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.
- package/HostCard.js +283 -0
- package/index.js +49 -0
- package/package.json +1 -1
- 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
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
|
+
});
|