htm-transform 0.1.6 → 0.1.7

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.
Files changed (4) hide show
  1. package/index.js +36 -5
  2. package/package.json +1 -1
  3. package/test.js +71 -19
  4. package/HostCard.js +0 -283
package/index.js CHANGED
@@ -68,7 +68,7 @@ export default function transform(code, options = {}) {
68
68
  * @param {any} loc - The loc object with line/column info
69
69
  */
70
70
  function setLocationRecursive(node, start, end, loc) {
71
- if (!node || typeof node !== 'object') return;
71
+ if (!node || typeof node !== "object" || !node.type) return;
72
72
 
73
73
  node.start = start;
74
74
  node.end = end;
@@ -81,17 +81,24 @@ export default function transform(code, options = {}) {
81
81
  const value = node[key];
82
82
  if (Array.isArray(value)) {
83
83
  for (const child of value) {
84
- if (child && typeof child === 'object' && child.type) {
84
+ if (child && typeof child === "object" && child.type) {
85
85
  setLocationRecursive(child, start, end, loc);
86
86
  }
87
87
  }
88
- } else if (value && typeof value === 'object' && value.type) {
88
+ } else if (value && typeof value === "object" && value.type) {
89
89
  setLocationRecursive(value, start, end, loc);
90
90
  }
91
91
  }
92
92
  }
93
93
 
94
94
  const traveler = makeTraveler({
95
+ PropertyDefinition(node, state) {
96
+ // Skip private fields with null values (astravel can't handle them)
97
+ if (node.value === null) {
98
+ return;
99
+ }
100
+ this.super.PropertyDefinition.call(this, node, state);
101
+ },
95
102
  TaggedTemplateExpression(node, state) {
96
103
  if (node.tag.type === "Identifier" && node.tag.name === tagName) {
97
104
  hasTransformation = true;
@@ -112,8 +119,12 @@ export default function transform(code, options = {}) {
112
119
  }
113
120
  Object.assign(node, transformed);
114
121
 
115
- // Continue traversing into the transformed node to handle nested templates
116
- this.go(node, state);
122
+ // Manually traverse child nodes for nested html templates
123
+ // We use the default traversal for the new node type
124
+ const newNodeType = transformed.type;
125
+ if (this.super[newNodeType]) {
126
+ this.super[newNodeType].call(this, node, state);
127
+ }
117
128
  return;
118
129
  }
119
130
  // Continue traversal for non-matching nodes
@@ -129,8 +140,28 @@ export default function transform(code, options = {}) {
129
140
  }
130
141
 
131
142
  // Attach comments to AST nodes
143
+ // Workaround: attachComments can't handle private fields with null values
144
+ // Temporarily mark them so we can restore them after
132
145
  if (comments.length > 0) {
146
+ const nullFields = [];
147
+ const markNullFields = makeTraveler({
148
+ PropertyDefinition(node) {
149
+ if (node.value === null) {
150
+ nullFields.push(node);
151
+ // Create a placeholder literal so astravel can traverse it
152
+ node.value = /** @type {any} */ ({ type: 'Literal', value: null, start: node.start, end: node.end, loc: node.loc });
153
+ }
154
+ this.super.PropertyDefinition.call(this, node);
155
+ },
156
+ });
157
+ markNullFields.go(ast);
158
+
133
159
  attachComments(ast, comments);
160
+
161
+ // Restore null values
162
+ for (const node of nullFields) {
163
+ node.value = null;
164
+ }
134
165
  }
135
166
 
136
167
  return generate(ast, { comments: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htm-transform",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Transform htm tagged templates into h function calls using acorn",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/test.js CHANGED
@@ -420,28 +420,80 @@ test("handles empty closing tag shorthand", () => {
420
420
  assert.strictEqual(normalize(output), normalize(expected));
421
421
  });
422
422
 
423
- test("handles html template in array with JSDoc comment", () => {
423
+ test("preserves location info when html template is in array with inline comment", () => {
424
+ const input = `const arr = [{ foo: html\`<span class=\${"hello"}>bar</span>\` }];
425
+ arr.push({
426
+ foo: "bar", // inline comment
427
+ });
428
+ `;
429
+ const output = transform(input);
430
+ const expected = `const arr = [{
431
+ foo: h("span", {
432
+ class: "hello"
433
+ }, "bar")
434
+ }];
435
+ arr.push({
436
+ // inline comment
437
+ foo: "bar"
438
+ });`;
439
+
440
+ assert.strictEqual(normalize(output), normalize(expected));
441
+ });
442
+
443
+ test("handles private class fields with html templates", () => {
444
+ const input = `class Alert {
445
+ #timeout;
446
+
447
+ render() {
448
+ return html\`<div>Test</div>\`;
449
+ }
450
+ }`;
451
+ const output = transform(input);
452
+ const expected = `class Alert {
453
+ #timeout;
454
+
455
+ render() {
456
+ return h("div", null, "Test");
457
+ }
458
+ }`;
459
+
460
+ assert.strictEqual(normalize(output), normalize(expected));
461
+ });
462
+
463
+ test("transforms html template in private field initializer", () => {
424
464
  const input = `class Foo {
425
- get actions() {
426
- /** @type {Action[]} */
427
- const actions = [
428
- {
429
- text: html\`<span>Test</span>\`,
430
- },
431
- ];
432
-
433
- actions.push({
434
- text: "Do the Thing",
435
- disabled: item.foo, // comment on same line
436
- });
437
-
438
- return actions;
465
+ #element = html\`<div>Test</div>\`;
466
+ }`;
467
+ const output = transform(input);
468
+ const expected = `class Foo {
469
+ #element = h("div", null, "Test");
470
+ }`;
471
+
472
+ assert.strictEqual(normalize(output), normalize(expected));
473
+ });
474
+
475
+ test("handles multiple private fields with JSDoc comments", () => {
476
+ const input = `class Foo {
477
+ /** @type {string | undefined} */
478
+ #timeout;
479
+ /** @type {number | undefined} */
480
+ #count;
481
+
482
+ render() {
483
+ return html\`<div>Test</div>\`;
439
484
  }
440
485
  }`;
486
+
441
487
  const output = transform(input);
488
+ const expected = `class Foo {
489
+ /** @type {string | undefined}*/
490
+ #timeout;
491
+ /** @type {number | undefined}*/
492
+ #count;
493
+ render() {
494
+ return h("div", null, "Test");
495
+ }
496
+ }`;
442
497
 
443
- // Should transform the html template without losing location info that causes attachComments to fail
444
- assert.ok(output.includes('h("span", null, "Test")'));
445
- assert.ok(output.includes("/** @type"));
446
- assert.ok(output.includes("// comment on same line"));
498
+ assert.strictEqual(normalize(output), normalize(expected));
447
499
  });
package/HostCard.js DELETED
@@ -1,283 +0,0 @@
1
- import { Component, createRef } from "preact";
2
- /** @import { RefObject } from "preact"; */
3
-
4
- import html from "~/util/preact/html.js";
5
-
6
- /** @import Core from "~/app/lib/core/core.js" */
7
- /** @import { Host } from "~/lib/types.js"; */
8
-
9
- import Button from "~/component/Button.js";
10
- import Icon from "~/component/Icon.js";
11
-
12
- import { signal } from "@preact/signals";
13
- import { ApiError, deleteMachine } from "~/lib/preact/api.js";
14
- import { alert } from "~/util/preact/alert.js";
15
- import { persist } from "~/util/preact/persist.js";
16
- import { PromiseSignal } from "~/util/preact/promise.js";
17
- import Dropdown from "~/component/Dropdown.js";
18
- /** @import { DropdownItem } from "~/component/Dropdown.js"; */
19
- import Modal from "~/component/Modal.js";
20
- import HelpModal, { promptOnConnection } from "~/app/component/HelpModal.js";
21
- import Tooltip from "~/component/Tooltip.js";
22
- /** @import { TriggerProps } from "~/component/Tooltip.js" */
23
-
24
- import button from "~/style/button.js";
25
- import util from "~/style/util.js";
26
- import { adopt } from "~/util/css.js";
27
- import sheet from "./HostCard.module.css" with { type: "css" };
28
- import { hosts, me } from "~/lib/preact/queries.js";
29
-
30
- const css = adopt(sheet);
31
-
32
- const lastConnected = signal(/** @type {Record<string, number>} */ ({}));
33
- persist(lastConnected, "last_connected");
34
-
35
- const dt = new Intl.DateTimeFormat("en-us", {
36
- dateStyle: "long",
37
- timeStyle: "short",
38
- });
39
-
40
- /**
41
- * @typedef {object} Props
42
- * @prop {Core} core
43
- * @prop {Host} host
44
- */
45
-
46
- /** @extends {Component<Props>} */
47
- export default class HostCard extends Component {
48
- me = me();
49
- hosts = hosts();
50
- deleting = signal(false);
51
-
52
- /** @type {RefObject<Modal>} */
53
- modalRef = createRef();
54
-
55
- onConnect() {
56
- if (promptOnConnection.value) {
57
- this.modalRef.current?.open();
58
- } else {
59
- this.connect();
60
- }
61
- }
62
-
63
- connect() {
64
- this.props.core.connect(this.props.host);
65
- lastConnected.value = {
66
- ...lastConnected.value,
67
- [this.props.host.id]: Date.now(),
68
- };
69
- }
70
-
71
- get lastConnectedAt() {
72
- const lastConnectedAt = lastConnected.value[this.props.host.id];
73
- if (lastConnectedAt) return `Last connected on ${dt.format(new Date(lastConnectedAt))}`;
74
- return "Never connected";
75
- }
76
-
77
- get actions() {
78
- const host = this.props.host;
79
-
80
- /** @type {DropdownItem[]} */
81
- const actions = [
82
- {
83
- text: html`Copy ID: <span class=${util.textId}>${host.id.slice(0, 24)}</span>`,
84
- onSelect: () => {
85
- navigator.clipboard.writeText(host.id);
86
- alert(`${host.name} ID copied to the clipboard!`);
87
- },
88
- },
89
- ];
90
-
91
- // we only show the delete button for your own computers
92
- const canDelete = this.me.data?.id === host.user_id;
93
- if (canDelete) {
94
- actions.push({
95
- text: "Delete",
96
- icon: "trash",
97
- disabled: host.is_online, // we only allow you to delete if it's offline
98
- onSelect: () => {
99
- this.deleting.value = true;
100
- },
101
- });
102
- }
103
-
104
- return actions;
105
- }
106
-
107
- render() {
108
- const { host, core } = this.props;
109
- const showBuild = core.debug && host.channel !== "" && !host.channel.startsWith("release");
110
-
111
- return html`
112
- <div class=${css.hostCard} data-id=${host.id}>
113
- <span class=${css.icon}>
114
- <${Icon} name=${host.is_online ? "windows-online" : "windows-offline"} size=${42} />
115
- </span>
116
- <div class=${css.info}>
117
- <div class=${css.title}>
118
- <h2>${host.name || html`<span style="opacity:0.5;">Unnamed Computer</span>`}</h2>
119
- <span class=${css.status} data-online=${host.is_online}>
120
- ${host.is_online ? "Online" : "Offline"}
121
- </span>
122
- </div>
123
- <div class=${css.hostinfo}>
124
- <span>${this.lastConnectedAt}</span>
125
- ${showBuild && html`<code>${host.channel}/${host.version}</code>`}
126
- </div>
127
- </div>
128
- <div class=${css.right}>
129
- ${this.renderConnectButton()}
130
- <${Dropdown}
131
- id="host-dropdown-${host.id}"
132
- placement="bottom-end"
133
- trigger=${html`
134
- <button
135
- type="button"
136
- class=${button.button}
137
- data-level="tertiary"
138
- data-size="small"
139
- popovertarget="host-dropdown-${host.id}"
140
- >
141
- <${Icon} name="menu" label="menu" size=${12} />
142
- </button>
143
- `}
144
- items=${this.actions}
145
- />
146
- </div>
147
- </div>
148
- <${HelpModal} modalRef=${this.modalRef} displayHideCheckbox onClose=${() => this.connect()} />
149
- <${DeleteModal}
150
- id=${host.id}
151
- name=${host.name}
152
- open=${this.deleting.value}
153
- onClose=${() => {
154
- this.deleting.value = false;
155
- }}
156
- onDelete=${() => this.hosts.fetch()}
157
- />
158
- `;
159
- }
160
-
161
- renderConnectButton() {
162
- const { host, core } = this.props;
163
- if (!host.is_online) return null;
164
-
165
- const clients = core.clients;
166
- const connectingToThisHost = clients[host.peer_id]?.status === "connecting";
167
-
168
- if (connectingToThisHost) {
169
- return html`
170
- <${Button}
171
- level="primary"
172
- kind="negative"
173
- onClick=${() => this.props.core.clientWithdrawOffer(host.peer_id)}
174
- >
175
- <${Icon} name="loader" />
176
- Cancel
177
- <//>
178
- `;
179
- }
180
-
181
- // ensure vanity and breaking versions match on both client and host
182
- const re = /(\d+)\.(\d+)\.\d+(?:-.+)?/;
183
- const [, clientVanity, clientBreaking] = core.info.version.match(re) ?? [];
184
- const [, hostVanity, hostBreaking] = host.version.match(re) ?? [];
185
- const versionsMatch = clientVanity === hostVanity && clientBreaking === hostBreaking;
186
-
187
- const connectingToAnyHost = Object.values(clients).some((c) => c.status === "connecting");
188
- const hosting = core.hostGuests.length > 0 && !core.connectWhileHosting;
189
-
190
- const disabled = !core.connected || hosting || connectingToAnyHost || !versionsMatch;
191
-
192
- let tooltip = "";
193
- if (hosting) {
194
- tooltip = "You can't connect to another computer while guests are connected to you.";
195
- } else if (!versionsMatch) {
196
- tooltip = `Your app's version is not compatible with ${host.name}.`;
197
- }
198
-
199
- return tooltip
200
- ? html`
201
- <${Tooltip} contents=${tooltip}>
202
- ${(/** @type {TriggerProps} */ { ref, ...trigger }) =>
203
- html`<${Button} disabled level="primary" innerRef=${ref} ...${trigger}>Connect<//>`}
204
- <//>
205
- `
206
- : html`
207
- <${Button}
208
- level="primary"
209
- loading=${connectingToThisHost}
210
- disabled=${disabled}
211
- onClick=${() => this.onConnect()}
212
- >
213
- Connect
214
- <//>
215
- `;
216
- }
217
- }
218
-
219
- class DeleteMachineError extends ApiError {
220
- /** @override */
221
- get messages() {
222
- return {
223
- [ApiError.noField]: {
224
- not_found: "Couldn't find this computer. It may have already been deleted.",
225
- },
226
- };
227
- }
228
- }
229
-
230
- /**
231
- * @typedef {object} DeleteModalProps
232
- * @prop {string} id
233
- * @prop {string} name
234
- * @prop {boolean} open
235
- * @prop {() => void} onClose
236
- * @prop {() => void} onDelete
237
- */
238
-
239
- /** @extends {Component<DeleteModalProps>} */
240
- class DeleteModal extends Component {
241
- deleteMachine = new PromiseSignal(async () => {
242
- await deleteMachine(this.props.id);
243
- alert(`Successfully deleted ${this.props.name}`, { type: "success" });
244
- this.props.onDelete();
245
- this.props.onClose();
246
- });
247
-
248
- render() {
249
- const { name, open, onClose } = this.props;
250
-
251
- const error = this.deleteMachine.error && DeleteMachineError.from(this.deleteMachine.error);
252
-
253
- return html`
254
- <${Modal} title="Delete ${name}?" open=${open} onClose=${onClose}>
255
- <div class=${css.deleteModal}>
256
- <p>You'll be able to add ${name} again by opening Phaze on that computer.</p>
257
- <p class=${css.error}>${error?.message}</p>
258
- <form
259
- class=${css.actions}
260
- onSubmit=${(/** @type {SubmitEvent} */ ev) => {
261
- if (ev.submitter?.getAttribute("formmethod") === "dialog") return;
262
- ev.preventDefault();
263
- this.deleteMachine.invoke();
264
- }}
265
- >
266
- <${Button}
267
- type="submit"
268
- level="primary"
269
- kind="negative"
270
- value="delete"
271
- loading=${this.deleteMachine.loading}
272
- >
273
- Delete
274
- <//>
275
- <${Button} type="submit" formmethod="dialog" disabled=${this.deleteMachine.loading}>
276
- Cancel
277
- <//>
278
- </form>
279
- </div>
280
- <//>
281
- `;
282
- }
283
- }