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.
@@ -0,0 +1,10 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "WebFetch(domain:github.com)",
5
+ "Bash(npm test)",
6
+ "Bash(npm uninstall:*)",
7
+ "Bash(npm install)"
8
+ ]
9
+ }
10
+ }
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
+ });