htm-transform 0.1.5 → 0.1.6

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/HostCard.js +283 -0
  2. package/index.js +49 -0
  3. package/package.json +1 -1
  4. package/test.js +42 -0
package/HostCard.js ADDED
@@ -0,0 +1,283 @@
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
+ }
package/index.js CHANGED
@@ -60,18 +60,58 @@ export default function transform(code, options = {}) {
60
60
 
61
61
  let hasTransformation = false;
62
62
 
63
+ /**
64
+ * Recursively sets location information on all nodes in an AST subtree
65
+ * @param {any} node - The AST node to process
66
+ * @param {number} start - The start position
67
+ * @param {number} end - The end position
68
+ * @param {any} loc - The loc object with line/column info
69
+ */
70
+ function setLocationRecursive(node, start, end, loc) {
71
+ if (!node || typeof node !== 'object') return;
72
+
73
+ node.start = start;
74
+ node.end = end;
75
+ if (loc) {
76
+ node.loc = loc;
77
+ }
78
+
79
+ // Recursively set location for all child nodes
80
+ for (const key in node) {
81
+ const value = node[key];
82
+ if (Array.isArray(value)) {
83
+ for (const child of value) {
84
+ if (child && typeof child === 'object' && child.type) {
85
+ setLocationRecursive(child, start, end, loc);
86
+ }
87
+ }
88
+ } else if (value && typeof value === 'object' && value.type) {
89
+ setLocationRecursive(value, start, end, loc);
90
+ }
91
+ }
92
+ }
93
+
63
94
  const traveler = makeTraveler({
64
95
  TaggedTemplateExpression(node, state) {
65
96
  if (node.tag.type === "Identifier" && node.tag.name === tagName) {
66
97
  hasTransformation = true;
67
98
  const transformed = transformTaggedTemplate(node, pragma);
68
99
 
100
+ // Preserve original location information
101
+ const originalStart = node.start;
102
+ const originalEnd = node.end;
103
+ const originalLoc = node.loc;
104
+
105
+ // Set location info recursively on transformed tree
106
+ setLocationRecursive(transformed, originalStart, originalEnd, originalLoc);
107
+
69
108
  // Replace the node with the transformed version
70
109
  const mutableNode = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (node));
71
110
  for (const key in mutableNode) {
72
111
  delete mutableNode[key];
73
112
  }
74
113
  Object.assign(node, transformed);
114
+
75
115
  // Continue traversing into the transformed node to handle nested templates
76
116
  this.go(node, state);
77
117
  return;
@@ -300,6 +340,15 @@ function tokenize(template) {
300
340
  while (i < template.length) {
301
341
  // Check for tag
302
342
  if (template[i] === "<") {
343
+ // Check for empty closing tag <//>
344
+ if (template.slice(i, i + 4) === "<//>") {
345
+ /** @type {Token} */
346
+ const closeToken = { type: "closeTag", tag: "" };
347
+ tokens.push(closeToken);
348
+ i += 4;
349
+ continue;
350
+ }
351
+
303
352
  const tagMatch = template.slice(i).match(/^<(\/?)([a-zA-Z0-9_${}]+)([^>]*?)(\/?)>/);
304
353
 
305
354
  if (tagMatch) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htm-transform",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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
@@ -403,3 +403,45 @@ const y = 42;`;
403
403
 
404
404
  assert.strictEqual(normalize(output), normalize(expected));
405
405
  });
406
+
407
+ test("handles nested html in logical OR expression", () => {
408
+ const input = `const result = html\`<div>\${name || html\`<span>Default</span>\`}</div>\`;`;
409
+ const output = transform(input);
410
+ const expected = `const result = h("div", null, name || h("span", null, "Default"));`;
411
+
412
+ assert.strictEqual(normalize(output), normalize(expected));
413
+ });
414
+
415
+ test("handles empty closing tag shorthand", () => {
416
+ const input = `const result = html\`<\${Button}>Click<//>\`;`;
417
+ const output = transform(input);
418
+ const expected = `const result = h(Button, null, "Click");`;
419
+
420
+ assert.strictEqual(normalize(output), normalize(expected));
421
+ });
422
+
423
+ test("handles html template in array with JSDoc comment", () => {
424
+ 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;
439
+ }
440
+ }`;
441
+ const output = transform(input);
442
+
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"));
447
+ });