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 +75 -0
- package/index.js +288 -75
- package/package.json +7 -4
- package/test.js +72 -0
- package/tsconfig.json +15 -0
- package/jsconfig.json +0 -12
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 {
|
|
14
|
+
* @typedef {object} TextNode
|
|
15
15
|
* @property {'text'} type
|
|
16
16
|
* @property {string} value
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* @typedef {
|
|
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 {
|
|
27
|
+
* @typedef {object} Token
|
|
28
28
|
* @property {'openTag' | 'closeTag' | 'text' | 'expression'} type
|
|
29
29
|
* @property {string} [tag]
|
|
30
|
-
* @property {
|
|
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 {
|
|
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 {
|
|
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.
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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').
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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').
|
|
162
|
+
* @param {import('acorn').TaggedTemplateExpression} node - The tagged template expression node
|
|
127
163
|
* @param {string} pragma - The pragma function name
|
|
128
|
-
* @returns {import('acorn').
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
+
/** @type {Token} */
|
|
296
|
+
const closeToken = { type: "closeTag", tag: tagName };
|
|
297
|
+
tokens.push(closeToken);
|
|
248
298
|
} else {
|
|
249
299
|
const props = parseAttributes(attrsStr);
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
+
/** @type {Token} */
|
|
330
|
+
const exprToken = { type: "expression", value: exprMatch[0] };
|
|
331
|
+
tokens.push(exprToken);
|
|
276
332
|
} else if (text.trim()) {
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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').
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
553
|
+
if (key === "...spread" && typeof value === "object" && value.type === "expression") {
|
|
554
|
+
/** @type {import('acorn').SpreadElement} */
|
|
555
|
+
const spread = {
|
|
416
556
|
type: "SpreadElement",
|
|
417
|
-
|
|
557
|
+
start: 0,
|
|
558
|
+
end: 0,
|
|
559
|
+
argument: /** @type {import('acorn').Expression} */ (value.expr),
|
|
418
560
|
};
|
|
561
|
+
return spread;
|
|
419
562
|
}
|
|
420
|
-
|
|
563
|
+
/** @type {import('acorn').Property} */
|
|
564
|
+
const prop = {
|
|
421
565
|
type: "Property",
|
|
566
|
+
start: 0,
|
|
567
|
+
end: 0,
|
|
422
568
|
key: needsQuoting(key)
|
|
423
|
-
?
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
?
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
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
|
-
|
|
664
|
+
/** @type {import('acorn').CallExpression} */
|
|
665
|
+
const callExpr = {
|
|
464
666
|
type: "CallExpression",
|
|
465
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
7
|
+
"types": "index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
|
-
"types": "./index.
|
|
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
|
-
"
|
|
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