htm-transform 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.
- package/.claude/settings.local.json +10 -0
- package/README.md +13 -0
- package/index.js +457 -0
- package/package.json +25 -0
- package/test.js +361 -0
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# htm-transform
|
|
2
|
+
|
|
3
|
+
Transform [htm](https://github.com/developit/htm) tagged templates into normal h function calls.
|
|
4
|
+
A small alternative to [babel-plugin-htm](https://www.npmjs.com/package/babel-plugin-htm) that doesn't depend on Babel.
|
|
5
|
+
|
|
6
|
+
## Quickstart
|
|
7
|
+
|
|
8
|
+
```js
|
|
9
|
+
import transform from "@jakelazaroff/htm-transform";
|
|
10
|
+
|
|
11
|
+
const result = transform("const hyperscript = html`<h1 id=hello>Hello world!</h1>`;");
|
|
12
|
+
console.assert(result === `const hyperscript = h("h1", { id: "hello" }, "Hello world!")`);
|
|
13
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import * as acorn from "acorn";
|
|
2
|
+
import { generate } from "astring";
|
|
3
|
+
import { simple } from "acorn-walk";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Object} ElementNode
|
|
7
|
+
* @property {'element'} type
|
|
8
|
+
* @property {string | {type: 'expression', expr: import('acorn').Node}} tag
|
|
9
|
+
* @property {Object<string, string | boolean | {type: 'expression', expr: import('acorn').Node}>} props
|
|
10
|
+
* @property {Array<ElementNode | TextNode | ExpressionNode>} children
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} TextNode
|
|
15
|
+
* @property {'text'} type
|
|
16
|
+
* @property {string} value
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} ExpressionNode
|
|
21
|
+
* @property {'expression'} type
|
|
22
|
+
* @property {string} value
|
|
23
|
+
* @property {import('acorn').Node} [expr]
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} Token
|
|
28
|
+
* @property {'openTag' | 'closeTag' | 'text' | 'expression'} type
|
|
29
|
+
* @property {string} [tag]
|
|
30
|
+
* @property {Object<string, string | boolean>} [props]
|
|
31
|
+
* @property {boolean} [selfClosing]
|
|
32
|
+
* @property {string} [value]
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Transforms htm tagged templates into h function calls
|
|
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
|
|
42
|
+
* @param {string} options.import.from - Module to import from (e.g., 'preact', 'react')
|
|
43
|
+
* @param {string} options.import.name - Export name to import (e.g., 'h', 'createElement')
|
|
44
|
+
* @returns {string} - Transformed code
|
|
45
|
+
*/
|
|
46
|
+
export default function transform(code, options = {}) {
|
|
47
|
+
const { pragma = "h", tag: tagName = "html", import: importConfig } = options;
|
|
48
|
+
|
|
49
|
+
const ast = acorn.parse(code, {
|
|
50
|
+
ecmaVersion: "latest",
|
|
51
|
+
sourceType: "module",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
let hasTransformation = false;
|
|
55
|
+
|
|
56
|
+
simple(ast, {
|
|
57
|
+
TaggedTemplateExpression(node) {
|
|
58
|
+
if (node.tag.type === "Identifier" && node.tag.name === tagName) {
|
|
59
|
+
hasTransformation = true;
|
|
60
|
+
const transformed = transformTaggedTemplate(node, pragma);
|
|
61
|
+
|
|
62
|
+
// Replace the node with the transformed version
|
|
63
|
+
for (const key in node) {
|
|
64
|
+
delete node[key];
|
|
65
|
+
}
|
|
66
|
+
Object.assign(node, transformed);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Add import statement if specified and transformation occurred
|
|
72
|
+
if (hasTransformation && importConfig?.from && importConfig?.name) {
|
|
73
|
+
addImportDeclaration(ast, importConfig.from, importConfig.name);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return generate(ast);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Adds an import declaration at the top of the AST
|
|
81
|
+
* @param {import('acorn').Node} ast - The AST to modify
|
|
82
|
+
* @param {string} moduleName - The module to import from
|
|
83
|
+
* @param {string} exportName - The export name to import
|
|
84
|
+
* @returns {void}
|
|
85
|
+
*/
|
|
86
|
+
function addImportDeclaration(ast, moduleName, exportName) {
|
|
87
|
+
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
|
+
|
|
96
|
+
if (hasImport) return;
|
|
97
|
+
|
|
98
|
+
// Create import declaration: import { exportName } from 'moduleName';
|
|
99
|
+
const importDeclaration = {
|
|
100
|
+
type: "ImportDeclaration",
|
|
101
|
+
specifiers: [
|
|
102
|
+
{
|
|
103
|
+
type: "ImportSpecifier",
|
|
104
|
+
imported: {
|
|
105
|
+
type: "Identifier",
|
|
106
|
+
name: exportName,
|
|
107
|
+
},
|
|
108
|
+
local: {
|
|
109
|
+
type: "Identifier",
|
|
110
|
+
name: exportName,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
source: {
|
|
115
|
+
type: "Literal",
|
|
116
|
+
value: moduleName,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Add to the beginning of the body
|
|
121
|
+
ast.body.unshift(importDeclaration);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Transforms a single tagged template expression
|
|
126
|
+
* @param {import('acorn').Node} node - The tagged template expression node
|
|
127
|
+
* @param {string} pragma - The pragma function name
|
|
128
|
+
* @returns {import('acorn').Node} The transformed AST node
|
|
129
|
+
*/
|
|
130
|
+
function transformTaggedTemplate(node, pragma) {
|
|
131
|
+
const quasi = node.quasi;
|
|
132
|
+
const { quasis, expressions } = quasi;
|
|
133
|
+
|
|
134
|
+
// Build the full template string with placeholders
|
|
135
|
+
let template = "";
|
|
136
|
+
const placeholders = [];
|
|
137
|
+
|
|
138
|
+
for (let i = 0; i < quasis.length; i++) {
|
|
139
|
+
template += quasis[i].value.raw;
|
|
140
|
+
if (i < expressions.length) {
|
|
141
|
+
const placeholder = `__EXPR_${i}__`;
|
|
142
|
+
placeholders.push({ placeholder, expression: expressions[i] });
|
|
143
|
+
template += placeholder;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Parse the HTML-like template
|
|
148
|
+
const elements = parseTemplate(template.trim(), placeholders);
|
|
149
|
+
|
|
150
|
+
// Convert to h() calls
|
|
151
|
+
if (elements.length === 0) {
|
|
152
|
+
return { type: "Literal", value: null };
|
|
153
|
+
} else if (elements.length === 1) {
|
|
154
|
+
return elementsToAST(elements[0], pragma);
|
|
155
|
+
} else {
|
|
156
|
+
// Multiple root elements - return array
|
|
157
|
+
return {
|
|
158
|
+
type: "ArrayExpression",
|
|
159
|
+
elements: elements.map((el) => elementsToAST(el, pragma)),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Parses the template string into element nodes
|
|
166
|
+
* @param {string} template - The template string to parse
|
|
167
|
+
* @param {Array<{placeholder: string, expression: import('acorn').Node}>} placeholders - Expression placeholders
|
|
168
|
+
* @returns {Array<ElementNode | TextNode | ExpressionNode>} Parsed element nodes
|
|
169
|
+
*/
|
|
170
|
+
function parseTemplate(template, placeholders) {
|
|
171
|
+
const tokens = tokenize(template);
|
|
172
|
+
const elements = [];
|
|
173
|
+
let i = 0;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Recursively parses tokens into element nodes
|
|
177
|
+
* @param {string | null} endTag - The closing tag to stop at
|
|
178
|
+
* @returns {Array<ElementNode | TextNode | ExpressionNode>} Parsed children
|
|
179
|
+
*/
|
|
180
|
+
function parse(endTag = null) {
|
|
181
|
+
const children = [];
|
|
182
|
+
|
|
183
|
+
while (i < tokens.length) {
|
|
184
|
+
const token = tokens[i];
|
|
185
|
+
|
|
186
|
+
if (token.type === "openTag") {
|
|
187
|
+
i++;
|
|
188
|
+
const element = {
|
|
189
|
+
type: "element",
|
|
190
|
+
tag: token.tag,
|
|
191
|
+
props: token.props,
|
|
192
|
+
children: [],
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
if (!token.selfClosing) {
|
|
196
|
+
element.children = parse(token.tag);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
children.push(element);
|
|
200
|
+
} else if (token.type === "closeTag") {
|
|
201
|
+
if (token.tag === endTag || token.tag === "") {
|
|
202
|
+
i++;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
i++;
|
|
206
|
+
} else if (token.type === "text") {
|
|
207
|
+
const text = token.value.trim();
|
|
208
|
+
if (text) {
|
|
209
|
+
children.push({ type: "text", value: text });
|
|
210
|
+
}
|
|
211
|
+
i++;
|
|
212
|
+
} else if (token.type === "expression") {
|
|
213
|
+
children.push({ type: "expression", value: token.value });
|
|
214
|
+
i++;
|
|
215
|
+
} else {
|
|
216
|
+
i++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return children;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const result = parse();
|
|
224
|
+
|
|
225
|
+
// Resolve placeholders back to expressions
|
|
226
|
+
return resolvePlaceholders(result, placeholders);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Tokenizes the template string
|
|
231
|
+
* @param {string} template - The template string to tokenize
|
|
232
|
+
* @returns {Array<Token>} Array of tokens
|
|
233
|
+
*/
|
|
234
|
+
function tokenize(template) {
|
|
235
|
+
const tokens = [];
|
|
236
|
+
let i = 0;
|
|
237
|
+
|
|
238
|
+
while (i < template.length) {
|
|
239
|
+
// Check for tag
|
|
240
|
+
if (template[i] === "<") {
|
|
241
|
+
const tagMatch = template.slice(i).match(/^<(\/?)([a-zA-Z0-9_${}]+)([^>]*?)(\/?)>/);
|
|
242
|
+
|
|
243
|
+
if (tagMatch) {
|
|
244
|
+
const [full, isClosing, tagName, attrsStr, selfClosing] = tagMatch;
|
|
245
|
+
|
|
246
|
+
if (isClosing) {
|
|
247
|
+
tokens.push({ type: "closeTag", tag: tagName });
|
|
248
|
+
} else {
|
|
249
|
+
const props = parseAttributes(attrsStr);
|
|
250
|
+
tokens.push({
|
|
251
|
+
type: "openTag",
|
|
252
|
+
tag: tagName,
|
|
253
|
+
props,
|
|
254
|
+
selfClosing: selfClosing === "/",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
i += full.length;
|
|
259
|
+
} else {
|
|
260
|
+
// Not a valid tag, treat as text
|
|
261
|
+
tokens.push({ type: "text", value: "<" });
|
|
262
|
+
i++;
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
// Text content or expression placeholder
|
|
266
|
+
let text = "";
|
|
267
|
+
while (i < template.length && template[i] !== "<") {
|
|
268
|
+
text += template[i];
|
|
269
|
+
i++;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Check if this is an expression placeholder
|
|
273
|
+
const exprMatch = text.match(/^(__EXPR_\d+__)/);
|
|
274
|
+
if (exprMatch && text === exprMatch[0]) {
|
|
275
|
+
tokens.push({ type: "expression", value: exprMatch[0] });
|
|
276
|
+
} else if (text.trim()) {
|
|
277
|
+
tokens.push({ type: "text", value: text });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return tokens;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Parses HTML attributes
|
|
287
|
+
* @param {string} attrsStr - The attribute string to parse
|
|
288
|
+
* @returns {Object<string, string | boolean>} Parsed attributes object
|
|
289
|
+
*/
|
|
290
|
+
function parseAttributes(attrsStr) {
|
|
291
|
+
const props = {};
|
|
292
|
+
const attrRegex = /([a-zA-Z0-9_:-]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
293
|
+
let match;
|
|
294
|
+
|
|
295
|
+
while ((match = attrRegex.exec(attrsStr))) {
|
|
296
|
+
const [, name, quoted1, quoted2, unquoted] = match;
|
|
297
|
+
const value = quoted1 ?? quoted2 ?? unquoted ?? true;
|
|
298
|
+
props[name] = value;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return props;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Resolves placeholders back to expression objects
|
|
306
|
+
* @param {Array<ElementNode | TextNode | ExpressionNode>} nodes - Nodes to resolve
|
|
307
|
+
* @param {Array<{placeholder: string, expression: import('acorn').Node}>} placeholders - Expression placeholders
|
|
308
|
+
* @returns {Array<ElementNode | TextNode | ExpressionNode>} Resolved nodes
|
|
309
|
+
*/
|
|
310
|
+
function resolvePlaceholders(nodes, placeholders) {
|
|
311
|
+
const placeholderMap = new Map(placeholders.map((p) => [p.placeholder, p.expression]));
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Resolves placeholders in a single node
|
|
315
|
+
* @param {ElementNode | TextNode | ExpressionNode} node - The node to resolve
|
|
316
|
+
* @returns {ElementNode | TextNode | ExpressionNode | Array<ElementNode | TextNode | ExpressionNode>} Resolved node(s)
|
|
317
|
+
*/
|
|
318
|
+
function resolve(node) {
|
|
319
|
+
if (node.type === "element") {
|
|
320
|
+
// Resolve tag name if it's a placeholder (component)
|
|
321
|
+
if (typeof node.tag === "string" && node.tag.startsWith("__EXPR_")) {
|
|
322
|
+
node.tag = { type: "expression", expr: placeholderMap.get(node.tag) };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Resolve props, preserving order
|
|
326
|
+
const newProps = {};
|
|
327
|
+
for (const [key, value] of Object.entries(node.props)) {
|
|
328
|
+
if (key.startsWith("__EXPR_")) {
|
|
329
|
+
// 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) };
|
|
333
|
+
} else {
|
|
334
|
+
newProps[key] = value;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
node.props = newProps;
|
|
338
|
+
|
|
339
|
+
// Resolve children
|
|
340
|
+
node.children = node.children.map(resolve);
|
|
341
|
+
} else if (node.type === "expression") {
|
|
342
|
+
const expr = placeholderMap.get(node.value);
|
|
343
|
+
if (expr) {
|
|
344
|
+
node.expr = expr;
|
|
345
|
+
}
|
|
346
|
+
} else if (node.type === "text") {
|
|
347
|
+
// Check if text contains placeholders
|
|
348
|
+
const parts = node.value.split(/(__EXPR_\d+__)/);
|
|
349
|
+
if (parts.length > 1) {
|
|
350
|
+
return parts
|
|
351
|
+
.filter((p) => p)
|
|
352
|
+
.map((p) => {
|
|
353
|
+
if (p.startsWith("__EXPR_")) {
|
|
354
|
+
return { type: "expression", expr: placeholderMap.get(p) };
|
|
355
|
+
}
|
|
356
|
+
return { type: "text", value: p };
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return node;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return nodes.map(resolve).flat();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Converts element nodes to AST nodes representing h() calls
|
|
369
|
+
* @param {ElementNode | TextNode | ExpressionNode} node - The node to convert
|
|
370
|
+
* @param {string} pragma - The pragma function name
|
|
371
|
+
* @returns {import('acorn').Node} The AST node
|
|
372
|
+
*/
|
|
373
|
+
function elementsToAST(node, pragma) {
|
|
374
|
+
if (node.type === "text") {
|
|
375
|
+
return { type: "Literal", value: node.value };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (node.type === "expression") {
|
|
379
|
+
return node.expr;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (node.type === "element") {
|
|
383
|
+
// Build h(tag, props, ...children)
|
|
384
|
+
const args = [];
|
|
385
|
+
|
|
386
|
+
// Tag argument
|
|
387
|
+
if (node.tag.type === "expression") {
|
|
388
|
+
args.push(node.tag.expr);
|
|
389
|
+
} else {
|
|
390
|
+
args.push({ type: "Literal", value: node.tag });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Props argument
|
|
394
|
+
const propsEntries = Object.entries(node.props);
|
|
395
|
+
if (propsEntries.length > 0) {
|
|
396
|
+
const spreadProp = node.props["...spread"];
|
|
397
|
+
|
|
398
|
+
if (spreadProp && propsEntries.length === 1) {
|
|
399
|
+
// Just spread props
|
|
400
|
+
args.push(spreadProp.expr);
|
|
401
|
+
} else if (spreadProp) {
|
|
402
|
+
// Merge spread with other props, preserving order
|
|
403
|
+
const properties = propsEntries.map(([key, value]) => {
|
|
404
|
+
if (key === "...spread") {
|
|
405
|
+
return {
|
|
406
|
+
type: "SpreadElement",
|
|
407
|
+
argument: value.expr,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
type: "Property",
|
|
412
|
+
key: { type: "Identifier", name: key },
|
|
413
|
+
value: value.type === "expression" ? value.expr : { type: "Literal", value },
|
|
414
|
+
kind: "init",
|
|
415
|
+
method: false,
|
|
416
|
+
shorthand: false,
|
|
417
|
+
computed: false,
|
|
418
|
+
};
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
args.push({
|
|
422
|
+
type: "ObjectExpression",
|
|
423
|
+
properties,
|
|
424
|
+
});
|
|
425
|
+
} else {
|
|
426
|
+
// Normal props
|
|
427
|
+
args.push({
|
|
428
|
+
type: "ObjectExpression",
|
|
429
|
+
properties: propsEntries.map(([key, value]) => ({
|
|
430
|
+
type: "Property",
|
|
431
|
+
key: { type: "Identifier", name: key },
|
|
432
|
+
value: value.type === "expression" ? value.expr : { type: "Literal", value },
|
|
433
|
+
kind: "init",
|
|
434
|
+
method: false,
|
|
435
|
+
shorthand: false,
|
|
436
|
+
computed: false,
|
|
437
|
+
})),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
} else {
|
|
441
|
+
args.push({ type: "Literal", value: null });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Children arguments
|
|
445
|
+
for (const child of node.children) {
|
|
446
|
+
args.push(elementsToAST(child, pragma));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
type: "CallExpression",
|
|
451
|
+
callee: { type: "Identifier", name: pragma },
|
|
452
|
+
arguments: args,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return { type: "Literal", value: null };
|
|
457
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "htm-transform",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Transform htm tagged templates into h function calls using acorn",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --test test.js",
|
|
9
|
+
"test:watch": "node --test --watch test.js"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"htm",
|
|
13
|
+
"hyperscript",
|
|
14
|
+
"transform",
|
|
15
|
+
"acorn",
|
|
16
|
+
"ast"
|
|
17
|
+
],
|
|
18
|
+
"author": "",
|
|
19
|
+
"license": "MPL-2.0",
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"acorn": "^8.11.3",
|
|
22
|
+
"acorn-walk": "^8.3.2",
|
|
23
|
+
"astring": "^1.8.6"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/test.js
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { test, describe } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import transform from "./index.js";
|
|
4
|
+
|
|
5
|
+
// Helper to normalize whitespace for comparison
|
|
6
|
+
function normalize(str) {
|
|
7
|
+
return str.replace(/\s+/g, " ").trim();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("htm-transform", () => {
|
|
11
|
+
test("transforms simple element", () => {
|
|
12
|
+
const input = `const result = html\`<h1 id=hello>Hello world!</h1>\`;`;
|
|
13
|
+
const output = transform(input);
|
|
14
|
+
const expected = `const result = h("h1", {
|
|
15
|
+
id: "hello"
|
|
16
|
+
}, "Hello world!");`;
|
|
17
|
+
|
|
18
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("transforms element with dynamic class and content", () => {
|
|
22
|
+
const input = `const result = html\`<div class=\${className}>\${content}</div>\`;`;
|
|
23
|
+
const output = transform(input);
|
|
24
|
+
const expected = `const result = h("div", {
|
|
25
|
+
class: className
|
|
26
|
+
}, content);`;
|
|
27
|
+
|
|
28
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("transforms component with props", () => {
|
|
32
|
+
const input = `const result = html\`<\${Header} name="ToDo's" />\`;`;
|
|
33
|
+
const output = transform(input);
|
|
34
|
+
const expected = `const result = h(Header, {
|
|
35
|
+
name: "ToDo's"
|
|
36
|
+
});`;
|
|
37
|
+
|
|
38
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("transforms multiple root elements into array", () => {
|
|
42
|
+
const input = `const result = html\`
|
|
43
|
+
<h1 id=hello>Hello</h1>
|
|
44
|
+
<div class=world>World!</div>
|
|
45
|
+
\`;`;
|
|
46
|
+
const output = transform(input);
|
|
47
|
+
const expected = `const result = [h("h1", {
|
|
48
|
+
id: "hello"
|
|
49
|
+
}, "Hello"), h("div", {
|
|
50
|
+
class: "world"
|
|
51
|
+
}, "World!")];`;
|
|
52
|
+
|
|
53
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("transforms nested elements", () => {
|
|
57
|
+
const input = `const result = html\`<div><p>Hello</p><p>World</p></div>\`;`;
|
|
58
|
+
const output = transform(input);
|
|
59
|
+
const expected = `const result = h("div", null, h("p", null, "Hello"), h("p", null, "World"));`;
|
|
60
|
+
|
|
61
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("transforms spread props", () => {
|
|
65
|
+
const input = `const result = html\`<div ...\${props}>Content</div>\`;`;
|
|
66
|
+
const output = transform(input);
|
|
67
|
+
const expected = `const result = h("div", props, "Content");`;
|
|
68
|
+
|
|
69
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("transforms mixed props and spread", () => {
|
|
73
|
+
const input = `const result = html\`<div class="test" ...\${props}>Content</div>\`;`;
|
|
74
|
+
const output = transform(input);
|
|
75
|
+
const expected = `const result = h("div", {
|
|
76
|
+
class: "test",
|
|
77
|
+
...props
|
|
78
|
+
}, "Content");`;
|
|
79
|
+
|
|
80
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("transforms self-closing tags", () => {
|
|
84
|
+
const input = `const result = html\`<img src="test.jpg" alt="Test" />\`;`;
|
|
85
|
+
const output = transform(input);
|
|
86
|
+
const expected = `const result = h("img", {
|
|
87
|
+
src: "test.jpg",
|
|
88
|
+
alt: "Test"
|
|
89
|
+
});`;
|
|
90
|
+
|
|
91
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("transforms elements with boolean attributes", () => {
|
|
95
|
+
const input = `const result = html\`<input type="checkbox" checked disabled />\`;`;
|
|
96
|
+
const output = transform(input);
|
|
97
|
+
const expected = `const result = h("input", {
|
|
98
|
+
type: "checkbox",
|
|
99
|
+
checked: true,
|
|
100
|
+
disabled: true
|
|
101
|
+
});`;
|
|
102
|
+
|
|
103
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("preserves other code unchanged", () => {
|
|
107
|
+
const input = `const foo = 'bar';\nconst result = html\`<div>Test</div>\`;\nconsole.log(foo);`;
|
|
108
|
+
const output = transform(input);
|
|
109
|
+
const expected = `const foo = 'bar';
|
|
110
|
+
const result = h("div", null, "Test");
|
|
111
|
+
console.log(foo);`;
|
|
112
|
+
|
|
113
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("handles custom pragma option", () => {
|
|
117
|
+
const input = `const result = html\`<div>Test</div>\`;`;
|
|
118
|
+
const output = transform(input, { pragma: "React.createElement" });
|
|
119
|
+
const expected = `const result = React.createElement("div", null, "Test");`;
|
|
120
|
+
|
|
121
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("handles custom tag name option", () => {
|
|
125
|
+
const input = `const result = htm\`<div>Test</div>\`;`;
|
|
126
|
+
const output = transform(input, { tag: "htm" });
|
|
127
|
+
const expected = `const result = h("div", null, "Test");`;
|
|
128
|
+
|
|
129
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("transforms element with multiple dynamic children", () => {
|
|
133
|
+
const input = `const result = html\`<ul>\${items.map(i => html\`<li>\${i}</li>\`)}</ul>\`;`;
|
|
134
|
+
const output = transform(input);
|
|
135
|
+
const expected = `const result = h("ul", null, items.map(i => h("li", null, i)));`;
|
|
136
|
+
|
|
137
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("transforms component with closing tag", () => {
|
|
141
|
+
const input = `const result = html\`<\${Wrapper}>Content</\${Wrapper}>\`;`;
|
|
142
|
+
const output = transform(input);
|
|
143
|
+
const expected = `const result = h(Wrapper, null, "Content");`;
|
|
144
|
+
|
|
145
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("transforms empty element", () => {
|
|
149
|
+
const input = `const result = html\`<div></div>\`;`;
|
|
150
|
+
const output = transform(input);
|
|
151
|
+
const expected = `const result = h("div", null);`;
|
|
152
|
+
|
|
153
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("does not add import when import option is omitted", () => {
|
|
157
|
+
const input = `const result = html\`<div>Test</div>\`;`;
|
|
158
|
+
const output = transform(input);
|
|
159
|
+
const expected = `const result = h("div", null, "Test");`;
|
|
160
|
+
|
|
161
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("adds import statement when import config is specified", () => {
|
|
165
|
+
const input = `const result = html\`<div>Test</div>\`;`;
|
|
166
|
+
const output = transform(input, {
|
|
167
|
+
import: { from: "preact", name: "h" },
|
|
168
|
+
});
|
|
169
|
+
const expected = `import {h} from "preact";
|
|
170
|
+
const result = h("div", null, "Test");`;
|
|
171
|
+
|
|
172
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("adds import for React.createElement", () => {
|
|
176
|
+
const input = `const result = html\`<div>Test</div>\`;`;
|
|
177
|
+
const output = transform(input, {
|
|
178
|
+
pragma: "createElement",
|
|
179
|
+
import: { from: "react", name: "createElement" },
|
|
180
|
+
});
|
|
181
|
+
const expected = `import {createElement} from "react";
|
|
182
|
+
const result = createElement("div", null, "Test");`;
|
|
183
|
+
|
|
184
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("does not add import when only from is specified", () => {
|
|
188
|
+
const input = `const result = html\`<div>Test</div>\`;`;
|
|
189
|
+
const output = transform(input, {
|
|
190
|
+
import: { from: "preact" },
|
|
191
|
+
});
|
|
192
|
+
const expected = `const result = h("div", null, "Test");`;
|
|
193
|
+
|
|
194
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("does not add import when only name is specified", () => {
|
|
198
|
+
const input = `const result = html\`<div>Test</div>\`;`;
|
|
199
|
+
const output = transform(input, {
|
|
200
|
+
import: { name: "h" },
|
|
201
|
+
});
|
|
202
|
+
const expected = `const result = h("div", null, "Test");`;
|
|
203
|
+
|
|
204
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("does not duplicate existing import", () => {
|
|
208
|
+
const input = `import { h } from 'preact';
|
|
209
|
+
const result = html\`<div>Test</div>\`;`;
|
|
210
|
+
const output = transform(input, {
|
|
211
|
+
import: { from: "preact", name: "h" },
|
|
212
|
+
});
|
|
213
|
+
const expected = `import {h} from 'preact';
|
|
214
|
+
const result = h("div", null, "Test");`;
|
|
215
|
+
|
|
216
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("adds import before existing imports", () => {
|
|
220
|
+
const input = `import { useState } from 'preact/hooks';
|
|
221
|
+
const result = html\`<div>Test</div>\`;`;
|
|
222
|
+
const output = transform(input, {
|
|
223
|
+
import: { from: "preact", name: "h" },
|
|
224
|
+
});
|
|
225
|
+
const expected = `import {h} from "preact";
|
|
226
|
+
import {useState} from 'preact/hooks';
|
|
227
|
+
const result = h("div", null, "Test");`;
|
|
228
|
+
|
|
229
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("adds import before code when no imports exist", () => {
|
|
233
|
+
const input = `const foo = 'bar';
|
|
234
|
+
const result = html\`<div>Test</div>\`;`;
|
|
235
|
+
const output = transform(input, {
|
|
236
|
+
import: { from: "preact", name: "h" },
|
|
237
|
+
});
|
|
238
|
+
const expected = `import {h} from "preact";
|
|
239
|
+
const foo = 'bar';
|
|
240
|
+
const result = h("div", null, "Test");`;
|
|
241
|
+
|
|
242
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("does not duplicate import when importing same export from same module", () => {
|
|
246
|
+
const input = `import { h } from 'preact';
|
|
247
|
+
import { render } from 'preact';
|
|
248
|
+
const result = html\`<div>Test</div>\`;`;
|
|
249
|
+
const output = transform(input, {
|
|
250
|
+
import: { from: "preact", name: "h" },
|
|
251
|
+
});
|
|
252
|
+
const expected = `import {h} from 'preact';
|
|
253
|
+
import {render} from 'preact';
|
|
254
|
+
const result = h("div", null, "Test");`;
|
|
255
|
+
|
|
256
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("adds import even if same module is imported with different exports", () => {
|
|
260
|
+
const input = `import { render } from 'preact';
|
|
261
|
+
const result = html\`<div>Test</div>\`;`;
|
|
262
|
+
const output = transform(input, {
|
|
263
|
+
import: { from: "preact", name: "h" },
|
|
264
|
+
});
|
|
265
|
+
const expected = `import {h} from "preact";
|
|
266
|
+
import {render} from 'preact';
|
|
267
|
+
const result = h("div", null, "Test");`;
|
|
268
|
+
|
|
269
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("handles multiple htm transformations with import", () => {
|
|
273
|
+
const input = `const a = html\`<div>A</div>\`;
|
|
274
|
+
const b = html\`<span>B</span>\`;`;
|
|
275
|
+
const output = transform(input, {
|
|
276
|
+
import: { from: "preact", name: "h" },
|
|
277
|
+
});
|
|
278
|
+
const expected = `import {h} from "preact";
|
|
279
|
+
const a = h("div", null, "A");
|
|
280
|
+
const b = h("span", null, "B");`;
|
|
281
|
+
|
|
282
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("works with different module paths", () => {
|
|
286
|
+
const input = `const result = html\`<div>Test</div>\`;`;
|
|
287
|
+
const output = transform(input, {
|
|
288
|
+
import: { from: "preact/compat", name: "h" },
|
|
289
|
+
});
|
|
290
|
+
const expected = `import {h} from "preact/compat";
|
|
291
|
+
const result = h("div", null, "Test");`;
|
|
292
|
+
|
|
293
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("works with scoped packages", () => {
|
|
297
|
+
const input = `const result = html\`<div>Test</div>\`;`;
|
|
298
|
+
const output = transform(input, {
|
|
299
|
+
import: { from: "@preact/signals", name: "h" },
|
|
300
|
+
});
|
|
301
|
+
const expected = `import {h} from "@preact/signals";
|
|
302
|
+
const result = h("div", null, "Test");`;
|
|
303
|
+
|
|
304
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("adds import with custom pragma matching name", () => {
|
|
308
|
+
const input = `const result = html\`<div>Test</div>\`;`;
|
|
309
|
+
const output = transform(input, {
|
|
310
|
+
pragma: "myH",
|
|
311
|
+
import: { from: "my-library", name: "myH" },
|
|
312
|
+
});
|
|
313
|
+
const expected = `import {myH} from "my-library";
|
|
314
|
+
const result = myH("div", null, "Test");`;
|
|
315
|
+
|
|
316
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("handles empty file with import config", () => {
|
|
320
|
+
const input = ``;
|
|
321
|
+
const output = transform(input, {
|
|
322
|
+
import: { from: "preact", name: "h" },
|
|
323
|
+
});
|
|
324
|
+
const expected = ``;
|
|
325
|
+
|
|
326
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("handles file with only imports", () => {
|
|
330
|
+
const input = `import { useState } from 'preact/hooks';`;
|
|
331
|
+
const output = transform(input, {
|
|
332
|
+
import: { from: "preact", name: "h" },
|
|
333
|
+
});
|
|
334
|
+
const expected = `import {useState} from 'preact/hooks';`;
|
|
335
|
+
|
|
336
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("spread at beginning means later props override", () => {
|
|
340
|
+
const input = `const result = html\`<div ...\${props} class="test">Content</div>\`;`;
|
|
341
|
+
const output = transform(input);
|
|
342
|
+
const expected = `const result = h("div", {
|
|
343
|
+
...props,
|
|
344
|
+
class: "test"
|
|
345
|
+
}, "Content");`;
|
|
346
|
+
|
|
347
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("spread between props preserves order", () => {
|
|
351
|
+
const input = `const result = html\`<div id="foo" ...\${props} class="test">Content</div>\`;`;
|
|
352
|
+
const output = transform(input);
|
|
353
|
+
const expected = `const result = h("div", {
|
|
354
|
+
id: "foo",
|
|
355
|
+
...props,
|
|
356
|
+
class: "test"
|
|
357
|
+
}, "Content");`;
|
|
358
|
+
|
|
359
|
+
assert.strictEqual(normalize(output), normalize(expected));
|
|
360
|
+
});
|
|
361
|
+
});
|