@dakaio/domainscout-mcp 1.3.0 → 1.5.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.
- package/README.md +29 -1
- package/dist/constants.js +25 -0
- package/dist/constants.js.map +1 -1
- package/dist/messages/tool.js +8 -1
- package/dist/messages/tool.js.map +1 -1
- package/dist/server/results-ui.js +71 -0
- package/dist/server/results-ui.js.map +1 -0
- package/dist/server/schemas.js +3 -1
- package/dist/server/schemas.js.map +1 -1
- package/dist/server/server.js +22 -6
- package/dist/server/server.js.map +1 -1
- package/dist/server/tool-list.js +42 -0
- package/dist/server/tool-list.js.map +1 -0
- package/package.json +2 -1
- package/ui/logo.png +0 -0
- package/ui/results.css +1452 -0
- package/ui/results.html +18 -0
- package/ui/results.js +2066 -0
package/ui/results.js
ADDED
|
@@ -0,0 +1,2066 @@
|
|
|
1
|
+
// DomainScout's results panel for hosts that support MCP Apps. The host renders ui/results.html
|
|
2
|
+
// in a sandboxed iframe under a check_domains call and passes the call's result in; the server
|
|
3
|
+
// inlines this script and results.css into that page, so nothing is loaded from elsewhere.
|
|
4
|
+
|
|
5
|
+
// ── The MCP Apps protocol: JSON-RPC 2.0 over postMessage with the host ─────────────────────────
|
|
6
|
+
|
|
7
|
+
/** The MCP Apps protocol version this panel speaks. */
|
|
8
|
+
const PROTOCOL_VERSION = '2026-01-26';
|
|
9
|
+
/** How this panel introduces itself to the host. */
|
|
10
|
+
const APP_INFO = { name: 'DomainScout results', version: '1.3.0' };
|
|
11
|
+
/** The JSON-RPC version of every message. */
|
|
12
|
+
const JSONRPC = '2.0';
|
|
13
|
+
/** Where messages go: the host is the frame that embeds this panel. */
|
|
14
|
+
const ANY_ORIGIN = '*';
|
|
15
|
+
/** How long a host gets to act on a request before the panel offers to copy instead. */
|
|
16
|
+
const HOST_TIMEOUT_MS = 3000;
|
|
17
|
+
|
|
18
|
+
/** The protocol's methods this panel sends or handles. */
|
|
19
|
+
const Method = {
|
|
20
|
+
Initialize: 'ui/initialize',
|
|
21
|
+
Initialized: 'ui/notifications/initialized',
|
|
22
|
+
ToolInput: 'ui/notifications/tool-input',
|
|
23
|
+
ToolResult: 'ui/notifications/tool-result',
|
|
24
|
+
ToolCancelled: 'ui/notifications/tool-cancelled',
|
|
25
|
+
HostContextChanged: 'ui/notifications/host-context-changed',
|
|
26
|
+
SizeChanged: 'ui/notifications/size-changed',
|
|
27
|
+
OpenLink: 'ui/open-link',
|
|
28
|
+
Message: 'ui/message',
|
|
29
|
+
UpdateModelContext: 'ui/update-model-context',
|
|
30
|
+
DownloadFile: 'ui/download-file',
|
|
31
|
+
CreateMessage: 'sampling/createMessage',
|
|
32
|
+
CallTool: 'tools/call',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Requests sent to the host that wait for an answer, by id. */
|
|
36
|
+
const pending = new Map();
|
|
37
|
+
let nextRequestId = 1;
|
|
38
|
+
|
|
39
|
+
function post(message) {
|
|
40
|
+
window.parent.postMessage({ jsonrpc: JSONRPC, ...message }, ANY_ORIGIN);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function request(method, params) {
|
|
44
|
+
const id = nextRequestId++;
|
|
45
|
+
post({ id, method, params });
|
|
46
|
+
return new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function notify(method, params) {
|
|
50
|
+
post({ method, params });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function settle(response) {
|
|
54
|
+
const waiting = pending.get(response.id);
|
|
55
|
+
if (!waiting) return;
|
|
56
|
+
pending.delete(response.id);
|
|
57
|
+
if (response.error) waiting.reject(response.error);
|
|
58
|
+
else waiting.resolve(response.result);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A request the host must answer in time and without an error; otherwise `fallback` runs. */
|
|
62
|
+
function requestOrFallback(method, params, fallback) {
|
|
63
|
+
const timeout = new Promise((_, reject) => window.setTimeout(reject, HOST_TIMEOUT_MS));
|
|
64
|
+
Promise.race([request(method, params), timeout])
|
|
65
|
+
.then((result) => {
|
|
66
|
+
if (result?.isError) fallback();
|
|
67
|
+
})
|
|
68
|
+
.catch(fallback);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Opens a registrar's page. The sandbox blocks navigation and pop-ups, so only the host can open
|
|
73
|
+
* it; when the host can't, refuses, or doesn't answer, the panel shows the link to copy.
|
|
74
|
+
*/
|
|
75
|
+
function openLink(url) {
|
|
76
|
+
if (!state.can.openLinks) {
|
|
77
|
+
showCopyFallback(Copy.LinkBlocked, url);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
requestOrFallback(Method.OpenLink, { url }, () => showCopyFallback(Copy.LinkBlocked, url));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The type of the text content blocks this panel sends and reads. */
|
|
84
|
+
const TEXT_BLOCK = 'text';
|
|
85
|
+
|
|
86
|
+
/** Sends a message to the chat as the user would; when the host can't, offers it to copy. */
|
|
87
|
+
function sendToChat(text) {
|
|
88
|
+
const params = { role: 'user', content: [{ type: TEXT_BLOCK, text }] };
|
|
89
|
+
requestOrFallback(Method.Message, params, () => showCopyFallback(Copy.MessageBlocked, text));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Words on the panel ────────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/** Every fixed text the panel shows or reads out. */
|
|
95
|
+
const Copy = {
|
|
96
|
+
ToolWho: 'DomainScout',
|
|
97
|
+
ToolName: 'check_domains',
|
|
98
|
+
Checking: 'Checking domains…',
|
|
99
|
+
Cancelled: 'The check was cancelled.',
|
|
100
|
+
NoResults: 'The check did not return results.',
|
|
101
|
+
NothingToCheck: 'No names to check.',
|
|
102
|
+
NoneFree: 'None of these are free.',
|
|
103
|
+
FreeDomains: 'Free domains',
|
|
104
|
+
Taken: 'Taken',
|
|
105
|
+
Reserved: 'Reserved',
|
|
106
|
+
Unknown: 'Not verified, try again later',
|
|
107
|
+
Available: 'Available',
|
|
108
|
+
Premium: 'Premium',
|
|
109
|
+
PremiumPrice: 'Premium price, set by the registry',
|
|
110
|
+
NoPrices: "Prices on the registrars' sites",
|
|
111
|
+
PriceOnSite: 'price on site',
|
|
112
|
+
Dropping: 'may free up soon',
|
|
113
|
+
DroppingNote: 'Being deleted: may become available soon',
|
|
114
|
+
Layout: 'Layout',
|
|
115
|
+
Theme: 'Theme',
|
|
116
|
+
TableCaption: 'Yearly price of each free domain at each registrar',
|
|
117
|
+
DomainHeading: 'Domain',
|
|
118
|
+
OthersHeading: 'Others',
|
|
119
|
+
SiteCell: 'site',
|
|
120
|
+
NotSold: 'not sold here',
|
|
121
|
+
AllTlds: 'All',
|
|
122
|
+
TldFilter: 'Show TLD',
|
|
123
|
+
Similar: 'Similar',
|
|
124
|
+
SimilarLabel: 'Use as an example: Regenerate brings names like this one',
|
|
125
|
+
NotSimilarLabel: 'No longer an example for Regenerate',
|
|
126
|
+
Saved: 'Saved',
|
|
127
|
+
Save: 'Save',
|
|
128
|
+
SaveLabel: 'Save',
|
|
129
|
+
Unsave: 'Remove from saved',
|
|
130
|
+
CompareSaved: 'Compare in chat',
|
|
131
|
+
RegenerateAll: 'Regenerate all',
|
|
132
|
+
RegenerateWith: 'Regenerate with',
|
|
133
|
+
SentToChat: 'Sent to chat',
|
|
134
|
+
Regenerating: 'Regenerating: new names on the way…',
|
|
135
|
+
NewBatchBelow: 'Asked the chat for a new batch: it will appear below this panel.',
|
|
136
|
+
NoNewNames: 'The model suggested no new names.',
|
|
137
|
+
RegenerateTipAll:
|
|
138
|
+
'A new batch of names in the same style, checked right away. Saved domains stay; names checked before never come back.',
|
|
139
|
+
RegenerateTipWith: (count) =>
|
|
140
|
+
`A new batch of names like the ${count} marked Similar, checked right away. Saved domains stay; names checked before never come back.`,
|
|
141
|
+
SavedTipNone:
|
|
142
|
+
'Save the domains you like: they stay through Regenerate, and the chat can compare them.',
|
|
143
|
+
SavedTipOne: 'Save one more domain to compare them in chat.',
|
|
144
|
+
SavedTipCompare: (count) =>
|
|
145
|
+
`Ask the chat to compare the ${count} saved domains and recommend one.`,
|
|
146
|
+
SavedTipKept: 'The domains you saved: they stay through Regenerate.',
|
|
147
|
+
RegenerateHint: 'Regenerate for a new batch.',
|
|
148
|
+
Settings: 'Settings for Regenerate',
|
|
149
|
+
SettingsTlds: 'TLDs to check',
|
|
150
|
+
SettingsSize: 'Names per batch',
|
|
151
|
+
SettingsAuto: 'Auto',
|
|
152
|
+
SettingsAutoLabel: 'As many as the last batch',
|
|
153
|
+
SettingsAddTld: 'Add a TLD',
|
|
154
|
+
SettingsAddPlaceholder: 'studio, co.uk…',
|
|
155
|
+
SettingsReset: 'Reset',
|
|
156
|
+
SettingsBatch: (names, tlds) =>
|
|
157
|
+
`Next batch: ${names} names × ${tlds} TLDs = ${names * tlds} domains.`,
|
|
158
|
+
SettingsCapped: (asked) =>
|
|
159
|
+
` ${asked} names would be more than the ${MAX_DOMAINS_PER_CHECK} domains a check takes.`,
|
|
160
|
+
OtherTlds: 'Try other TLDs',
|
|
161
|
+
DownloadCsv: 'Download CSV',
|
|
162
|
+
LinkBlocked: "Your chat app didn't open the link. Copy it:",
|
|
163
|
+
MessageBlocked: "Your chat app didn't take the message. Copy it into the chat:",
|
|
164
|
+
DownloadBlocked: "Your chat app didn't save the file. Copy the CSV:",
|
|
165
|
+
Close: 'Close',
|
|
166
|
+
Footnote:
|
|
167
|
+
'Registrars are listed cheapest first. Prices are standard yearly prices where a registrar publishes them; premium names cost more.',
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/** What a table cell shows for a registrar that does not sell the name. */
|
|
171
|
+
const NOT_SOLD_MARK = '—';
|
|
172
|
+
/** A hyphen and a space that never break a line: "2-year minimum" stays whole. */
|
|
173
|
+
const NO_BREAK_HYPHEN = '\u2011';
|
|
174
|
+
const NO_BREAK_SPACE = '\u00a0';
|
|
175
|
+
/** Between the lowest and the highest price: an en dash, as in "$10.46-$11.08". */
|
|
176
|
+
const RANGE_DASH = '\u2013';
|
|
177
|
+
|
|
178
|
+
// ── What the panel asks the chat ──────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/** A new batch is as big as the last one, within these bounds, unless the settings say otherwise. */
|
|
181
|
+
const REGENERATE_MIN = 20;
|
|
182
|
+
const REGENERATE_MAX = 200;
|
|
183
|
+
/** TLDs the settings offer for the next batch, besides those already checked. */
|
|
184
|
+
const TLD_CHOICES = [
|
|
185
|
+
'com',
|
|
186
|
+
'net',
|
|
187
|
+
'org',
|
|
188
|
+
'co',
|
|
189
|
+
'io',
|
|
190
|
+
'ai',
|
|
191
|
+
'app',
|
|
192
|
+
'dev',
|
|
193
|
+
'xyz',
|
|
194
|
+
'shop',
|
|
195
|
+
'store',
|
|
196
|
+
'tech',
|
|
197
|
+
'cafe',
|
|
198
|
+
'me',
|
|
199
|
+
];
|
|
200
|
+
/** Names per batch the settings offer, each twice the one before: 10, 20, 40 and 80. */
|
|
201
|
+
const SMALLEST_BATCH = 10;
|
|
202
|
+
const BATCH_SIZE_COUNT = 4;
|
|
203
|
+
const BATCH_SIZES = Array.from(
|
|
204
|
+
{ length: BATCH_SIZE_COUNT },
|
|
205
|
+
(_, step) => SMALLEST_BATCH * 2 ** step,
|
|
206
|
+
);
|
|
207
|
+
/** Domains a check takes by default (names times TLDs): a bigger batch is asked with fewer names. */
|
|
208
|
+
const MAX_DOMAINS_PER_CHECK = 500;
|
|
209
|
+
/** A TLD typed into the settings: "studio", or one with a second level such as "co.uk". */
|
|
210
|
+
const TLD_PATTERN = /^[a-z0-9-]{2,24}(.[a-z0-9-]{2,24})?$/;
|
|
211
|
+
/** Taken names passed on when asking for other TLDs: enough to go on, short enough to read. */
|
|
212
|
+
const TAKEN_SAMPLE = 30;
|
|
213
|
+
/** Free names given as examples of a style that works. */
|
|
214
|
+
const STYLE_SAMPLE = 5;
|
|
215
|
+
/** TLDs suggested for names taken in theirs. */
|
|
216
|
+
const OTHER_TLDS = ['io', 'app', 'dev', 'co'];
|
|
217
|
+
/** Fewer free names than this, and the panel offers to brainstorm more at the top. */
|
|
218
|
+
const FEW_FREE = 5;
|
|
219
|
+
|
|
220
|
+
/** The part of a domain before its TLD: "kettlecrate" for "kettlecrate.com". */
|
|
221
|
+
function label(item) {
|
|
222
|
+
const domain = item.display || item.domain;
|
|
223
|
+
return item.tld ? domain.slice(0, -(item.tld.length + 1)) : domain;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function tldList(tlds) {
|
|
227
|
+
return tlds.map((tld) => `.${tld}`).join(', ');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const Ask = {
|
|
231
|
+
otherTlds: (names) =>
|
|
232
|
+
`These names are taken: ${names.slice(0, TAKEN_SAMPLE).join(', ')}. Check the same names in other TLDs (${tldList(OTHER_TLDS)}) with DomainScout.`,
|
|
233
|
+
/**
|
|
234
|
+
* A new batch: as many names as last time, in the same style, none of them checked before. The
|
|
235
|
+
* names to leave out go in the model's context when the host takes it, so the chat stays short;
|
|
236
|
+
* otherwise they are spelled out. The saved domains go along in "saved", so they stay.
|
|
237
|
+
*/
|
|
238
|
+
regenerate: ({ names, saved, tlds, free, similar, count }) => {
|
|
239
|
+
const examples = [...new Set(free.map(label))].slice(0, STYLE_SAMPLE);
|
|
240
|
+
const liked = examples.length
|
|
241
|
+
? ` in the same style (names like ${examples.join(', ')} were free)`
|
|
242
|
+
: '';
|
|
243
|
+
const style = similar.length
|
|
244
|
+
? ` similar to ${similar.join(', ')} (the same style and length)`
|
|
245
|
+
: liked;
|
|
246
|
+
const avoid = state.can.updateModelContext
|
|
247
|
+
? `Leave out all ${names.length} names checked so far; they are in the panel's context.`
|
|
248
|
+
: `Leave out every name checked so far: ${names.join(', ')}.`;
|
|
249
|
+
const keep = saved.length
|
|
250
|
+
? ` Pass my saved domains in the saved parameter so they stay: ${saved.join(', ')}.`
|
|
251
|
+
: '';
|
|
252
|
+
return `Regenerate: brainstorm ${count} new domain names${style}. ${avoid} Check them all with DomainScout in ${tldList(tlds)}.${keep}`;
|
|
253
|
+
},
|
|
254
|
+
/** For the in-place path: the names only, to check right here. */
|
|
255
|
+
names: ({ names, saved, tlds, free, similar, count }) => {
|
|
256
|
+
const examples = [...new Set(free.map(label))].slice(0, STYLE_SAMPLE);
|
|
257
|
+
const style = similar.length
|
|
258
|
+
? `similar to ${similar.join(', ')} (the same style and length)`
|
|
259
|
+
: `in the same style as ${examples.join(', ')}`;
|
|
260
|
+
const keep = saved.length ? ` The user keeps ${saved.join(', ')}.` : '';
|
|
261
|
+
return `Brainstorm ${count} new domain names ${style}, for ${tldList(tlds)}.${keep} Leave out these names, already checked: ${names.join(', ')}. Reply with the names only, one per line, without a TLD.`;
|
|
262
|
+
},
|
|
263
|
+
compare: (items) =>
|
|
264
|
+
`Compare these saved domains and recommend one: ${items.map(describeForChat).join('; ')}.`,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/** "kettlecrate.com ($10.46/yr at Cloudflare, renews at $10.46)", for messages and context. */
|
|
268
|
+
function describeForChat(item) {
|
|
269
|
+
const lead = (item.buy ?? []).find((offer) => offer.price);
|
|
270
|
+
if (!lead) return item.display || item.domain;
|
|
271
|
+
const { price } = lead;
|
|
272
|
+
const renewal = money(price.renewal, price.currency);
|
|
273
|
+
const cost = `${money(price.registration, price.currency)}/yr at ${lead.registrar}, renews at ${renewal}`;
|
|
274
|
+
return `${item.display || item.domain} (${cost})`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Preferences: list, cards or table; light or dark ──────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
/** How the free domains are laid out. */
|
|
280
|
+
const View = {
|
|
281
|
+
List: 'list',
|
|
282
|
+
Grid: 'grid',
|
|
283
|
+
Table: 'table',
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/** The panel's color theme. */
|
|
287
|
+
const Theme = {
|
|
288
|
+
Light: 'light',
|
|
289
|
+
Dark: 'dark',
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
/** Where a visitor's choice is kept, when the sandbox allows storage at all. */
|
|
293
|
+
const StorageKey = {
|
|
294
|
+
View: 'domainscout.view',
|
|
295
|
+
Theme: 'domainscout.theme',
|
|
296
|
+
Tlds: 'domainscout.tlds',
|
|
297
|
+
BatchSize: 'domainscout.batch',
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/** The system's theme, until the host says otherwise. */
|
|
301
|
+
const LIGHT_SCHEME_QUERY = '(prefers-color-scheme: light)';
|
|
302
|
+
|
|
303
|
+
/** A stored choice; none when the sandbox blocks storage or nothing was chosen. */
|
|
304
|
+
function readChoice(key, allowed) {
|
|
305
|
+
try {
|
|
306
|
+
const value = window.localStorage.getItem(key);
|
|
307
|
+
return Object.values(allowed).includes(value) ? value : undefined;
|
|
308
|
+
} catch {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The TLDs kept in the settings; none when nothing was chosen or storage is blocked. */
|
|
314
|
+
function readTlds() {
|
|
315
|
+
try {
|
|
316
|
+
const value = JSON.parse(window.localStorage.getItem(StorageKey.Tlds) ?? 'null');
|
|
317
|
+
if (!Array.isArray(value)) return undefined;
|
|
318
|
+
const tlds = value.filter((tld) => typeof tld === 'string' && TLD_PATTERN.test(tld));
|
|
319
|
+
return tlds.length ? tlds : undefined;
|
|
320
|
+
} catch {
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The batch size kept in the settings; none for "Auto". */
|
|
326
|
+
function readBatchSize() {
|
|
327
|
+
try {
|
|
328
|
+
const value = Number(window.localStorage.getItem(StorageKey.BatchSize));
|
|
329
|
+
return BATCH_SIZES.includes(value) ? value : undefined;
|
|
330
|
+
} catch {
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Forgets a choice, so its default applies again. */
|
|
336
|
+
function dropChoice(key) {
|
|
337
|
+
try {
|
|
338
|
+
window.localStorage.removeItem(key);
|
|
339
|
+
} catch {
|
|
340
|
+
// A sandbox without storage: nothing was kept.
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function keepChoice(key, value) {
|
|
345
|
+
try {
|
|
346
|
+
window.localStorage.setItem(key, value);
|
|
347
|
+
} catch {
|
|
348
|
+
// A sandbox without storage: the choice lasts until the panel closes.
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** What a first-time visitor sees: the price table, dark like the landing page. */
|
|
353
|
+
const DEFAULT_VIEW = View.Table;
|
|
354
|
+
const DEFAULT_THEME = Theme.Dark;
|
|
355
|
+
|
|
356
|
+
/** Which way a table column is sorted. */
|
|
357
|
+
const SortDirection = {
|
|
358
|
+
Ascending: 'ascending',
|
|
359
|
+
Descending: 'descending',
|
|
360
|
+
};
|
|
361
|
+
/** The table's column that sorts by name rather than by a registrar's price. */
|
|
362
|
+
const DOMAIN_COLUMN = 'domain';
|
|
363
|
+
|
|
364
|
+
const state = {
|
|
365
|
+
view: readChoice(StorageKey.View, View) ?? DEFAULT_VIEW,
|
|
366
|
+
/** The panel's own theme: dark unless the visitor picked light. */
|
|
367
|
+
theme: readChoice(StorageKey.Theme, Theme) ?? DEFAULT_THEME,
|
|
368
|
+
/** The host's theme: only the page's color scheme follows it. */
|
|
369
|
+
hostTheme: window.matchMedia(LIGHT_SCHEME_QUERY).matches ? Theme.Light : Theme.Dark,
|
|
370
|
+
/** Whether the host said which theme it uses: only then does the canvas follow it. */
|
|
371
|
+
hostThemeKnown: false,
|
|
372
|
+
/** What the host offers to do for the panel; links are assumed until it says otherwise. */
|
|
373
|
+
can: {
|
|
374
|
+
openLinks: true,
|
|
375
|
+
message: false,
|
|
376
|
+
updateModelContext: false,
|
|
377
|
+
downloadFile: false,
|
|
378
|
+
serverTools: false,
|
|
379
|
+
sampling: false,
|
|
380
|
+
},
|
|
381
|
+
/** Every name checked in this panel so far, across regenerations, so none comes back. */
|
|
382
|
+
everChecked: new Set(),
|
|
383
|
+
/** The result on show, to go back to if a regeneration through the chat leaves this panel. */
|
|
384
|
+
lastResult: undefined,
|
|
385
|
+
/** The host's locale, for prices. */
|
|
386
|
+
locale: undefined,
|
|
387
|
+
/** The last result's TLDs, total and taken names, for what the panel asks the chat. */
|
|
388
|
+
check: { tlds: [], total: 0, taken: [], names: [] },
|
|
389
|
+
/** The last result's free domains, so a change of view, filter or sort keeps them. */
|
|
390
|
+
free: [],
|
|
391
|
+
/** The TLD the free domains are filtered to; all of them while unset. */
|
|
392
|
+
tld: undefined,
|
|
393
|
+
/** The table's sort: a registrar's prices or the names, and which way; the tool's order while unset. */
|
|
394
|
+
sort: { column: undefined, direction: SortDirection.Ascending },
|
|
395
|
+
/** The domains the visitor saved, in the order they were saved. */
|
|
396
|
+
saved: new Map(),
|
|
397
|
+
/** The names the visitor marked as examples for Regenerate. */
|
|
398
|
+
similar: new Map(),
|
|
399
|
+
/** The next batch's TLDs and size from the settings; the last check's while unset. */
|
|
400
|
+
settings: { tlds: readTlds(), size: readBatchSize() },
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
function currentTheme() {
|
|
404
|
+
return state.theme;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The panel's colors follow its own theme; the page's color scheme follows the host's, so the
|
|
409
|
+
* browser keeps the iframe's canvas transparent instead of painting it white or black.
|
|
410
|
+
*/
|
|
411
|
+
function applyTheme() {
|
|
412
|
+
const root = document.documentElement;
|
|
413
|
+
root.dataset.theme = state.theme;
|
|
414
|
+
if (state.hostThemeKnown) root.style.colorScheme = state.hostTheme;
|
|
415
|
+
syncToolbar();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── The host's look: its theme, font, locale and height cap ───────────────────────────────────
|
|
419
|
+
|
|
420
|
+
/** The class that lets the page scroll, when the host caps the frame's height. */
|
|
421
|
+
const CAPPED_CLASS = 'is-capped';
|
|
422
|
+
|
|
423
|
+
/** The one style element that holds the host's font faces. */
|
|
424
|
+
const hostFonts = document.createElement('style');
|
|
425
|
+
document.head.append(hostFonts);
|
|
426
|
+
|
|
427
|
+
function applyHostContext(context) {
|
|
428
|
+
if (!context) return;
|
|
429
|
+
if (Object.values(Theme).includes(context.theme)) {
|
|
430
|
+
state.hostTheme = context.theme;
|
|
431
|
+
state.hostThemeKnown = true;
|
|
432
|
+
}
|
|
433
|
+
if (context.locale) state.locale = context.locale;
|
|
434
|
+
const capped = Boolean(context.containerDimensions?.maxHeight);
|
|
435
|
+
document.documentElement.classList.toggle(CAPPED_CLASS, capped);
|
|
436
|
+
const font = context.styles?.variables?.['--font-sans'];
|
|
437
|
+
if (font) document.documentElement.style.setProperty('--font-sans', font);
|
|
438
|
+
const fonts = context.styles?.css?.fonts;
|
|
439
|
+
if (fonts) hostFonts.textContent = fonts;
|
|
440
|
+
applyTheme();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ── The panel's height follows its content ────────────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
/** How the page is measured at its natural height. */
|
|
446
|
+
const MEASURE_HEIGHT = 'max-content';
|
|
447
|
+
|
|
448
|
+
let reportedWidth = 0;
|
|
449
|
+
let reportedHeight = 0;
|
|
450
|
+
|
|
451
|
+
function reportSize() {
|
|
452
|
+
const root = document.documentElement;
|
|
453
|
+
const previous = root.style.height;
|
|
454
|
+
root.style.height = MEASURE_HEIGHT;
|
|
455
|
+
const height = Math.ceil(root.getBoundingClientRect().height);
|
|
456
|
+
root.style.height = previous;
|
|
457
|
+
const width = Math.ceil(window.innerWidth);
|
|
458
|
+
if (width === reportedWidth && height === reportedHeight) return;
|
|
459
|
+
reportedWidth = width;
|
|
460
|
+
reportedHeight = height;
|
|
461
|
+
notify(Method.SizeChanged, { width, height });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── Building blocks ───────────────────────────────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
/** The SVG namespace, for the icons. */
|
|
467
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
468
|
+
/** The icons' drawing area. */
|
|
469
|
+
const ICON_VIEW_BOX = '0 0 24 24';
|
|
470
|
+
|
|
471
|
+
/** The panel's icons. */
|
|
472
|
+
const Icon = {
|
|
473
|
+
List: 'list',
|
|
474
|
+
Grid: 'grid',
|
|
475
|
+
Table: 'table',
|
|
476
|
+
Sun: 'sun',
|
|
477
|
+
Moon: 'moon',
|
|
478
|
+
External: 'external',
|
|
479
|
+
Chevron: 'chevron',
|
|
480
|
+
Star: 'star',
|
|
481
|
+
Sparkles: 'sparkles',
|
|
482
|
+
Download: 'download',
|
|
483
|
+
Close: 'close',
|
|
484
|
+
Sort: 'sort',
|
|
485
|
+
Refresh: 'refresh',
|
|
486
|
+
Settings: 'settings',
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
/** Icons as SVG path data: drawn with strokes, in the text's color. */
|
|
490
|
+
const ICON_PATHS = new Map()
|
|
491
|
+
.set(Icon.List, ['M9 6h11', 'M9 12h11', 'M9 18h11', 'M4.5 6h.01', 'M4.5 12h.01', 'M4.5 18h.01'])
|
|
492
|
+
.set(Icon.Grid, ['M4 4h6v6H4z', 'M14 4h6v6h-6z', 'M4 14h6v6H4z', 'M14 14h6v6h-6z'])
|
|
493
|
+
.set(Icon.Table, ['M4 5h16v14H4z', 'M4 10h16', 'M4 15h16', 'M10 5v14'])
|
|
494
|
+
.set(Icon.Sun, [
|
|
495
|
+
'M12 8a4 4 0 1 0 0 8a4 4 0 1 0 0-8z',
|
|
496
|
+
'M12 2v2',
|
|
497
|
+
'M12 20v2',
|
|
498
|
+
'M4.9 4.9l1.4 1.4',
|
|
499
|
+
'M17.7 17.7l1.4 1.4',
|
|
500
|
+
'M2 12h2',
|
|
501
|
+
'M20 12h2',
|
|
502
|
+
'M4.9 19.1l1.4-1.4',
|
|
503
|
+
'M17.7 6.3l1.4-1.4',
|
|
504
|
+
])
|
|
505
|
+
.set(Icon.Moon, ['M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z'])
|
|
506
|
+
.set(Icon.External, ['M7 17L17 7', 'M8 7h9v9'])
|
|
507
|
+
.set(Icon.Chevron, ['M6 9l6 6 6-6'])
|
|
508
|
+
.set(Icon.Star, ['M12 3.5l2.6 5.3 5.8.8-4.2 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.2-4.1 5.8-.8z'])
|
|
509
|
+
.set(Icon.Sparkles, [
|
|
510
|
+
'M12 4l1.8 4.6L18.5 10l-4.7 1.4L12 16l-1.8-4.6L5.5 10l4.7-1.4z',
|
|
511
|
+
'M19 15v4',
|
|
512
|
+
'M17 17h4',
|
|
513
|
+
])
|
|
514
|
+
.set(Icon.Download, ['M12 4v11', 'M7 10l5 5 5-5', 'M5 20h14'])
|
|
515
|
+
.set(Icon.Close, ['M6 6l12 12', 'M18 6L6 18'])
|
|
516
|
+
.set(Icon.Sort, ['M8 9l4-4 4 4', 'M8 15l4 4 4-4'])
|
|
517
|
+
.set(Icon.Refresh, ['M20 11a8 8 0 1 0-2.3 5.7', 'M20 4v7h-7'])
|
|
518
|
+
.set(Icon.Settings, [
|
|
519
|
+
'M4 6h9',
|
|
520
|
+
'M17 6h3',
|
|
521
|
+
'M15 4v4',
|
|
522
|
+
'M4 12h3',
|
|
523
|
+
'M11 12h9',
|
|
524
|
+
'M9 10v4',
|
|
525
|
+
'M4 18h11',
|
|
526
|
+
'M19 18h1',
|
|
527
|
+
'M17 16v4',
|
|
528
|
+
]);
|
|
529
|
+
|
|
530
|
+
/** Attribute values used as switches. */
|
|
531
|
+
const TRUE = 'true';
|
|
532
|
+
const FALSE = 'false';
|
|
533
|
+
/** Keys the settings answer to: Enter adds a typed TLD, Escape closes them. */
|
|
534
|
+
const ENTER_KEY = 'Enter';
|
|
535
|
+
const ESCAPE_KEY = 'Escape';
|
|
536
|
+
/** A dot typed before a TLD, as in ".studio". */
|
|
537
|
+
const LEADING_DOT = /^./;
|
|
538
|
+
/** How an inlined image's address starts. */
|
|
539
|
+
const DATA_URI_PREFIX = 'data:';
|
|
540
|
+
/** The icon's size beside the name, in CSS pixels; the image is drawn sharper than that. */
|
|
541
|
+
const LOGO_SIZE = 18;
|
|
542
|
+
|
|
543
|
+
function icon(name) {
|
|
544
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
545
|
+
svg.setAttribute('class', 'icon');
|
|
546
|
+
svg.setAttribute('viewBox', ICON_VIEW_BOX);
|
|
547
|
+
svg.setAttribute('aria-hidden', TRUE);
|
|
548
|
+
for (const data of ICON_PATHS.get(name)) {
|
|
549
|
+
const path = document.createElementNS(SVG_NS, 'path');
|
|
550
|
+
path.setAttribute('d', data);
|
|
551
|
+
svg.append(path);
|
|
552
|
+
}
|
|
553
|
+
return svg;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** An element with a class and, optionally, text: data always goes in as text, never as HTML. */
|
|
557
|
+
function element(tag, className, text) {
|
|
558
|
+
const node = document.createElement(tag);
|
|
559
|
+
if (className) node.className = className;
|
|
560
|
+
if (text !== undefined) node.textContent = text;
|
|
561
|
+
return node;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function button(className, text, onClick) {
|
|
565
|
+
const node = element('button', className, text);
|
|
566
|
+
node.type = 'button';
|
|
567
|
+
node.addEventListener('click', onClick);
|
|
568
|
+
return node;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Text only screen readers hear, such as which domain a button belongs to. */
|
|
572
|
+
function hiddenText(text) {
|
|
573
|
+
return element('span', 'visually-hidden', text);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function money(amount, currency) {
|
|
577
|
+
return new Intl.NumberFormat(state.locale, { style: 'currency', currency }).format(amount);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** A button that opens and closes the given element, which starts closed. */
|
|
581
|
+
function disclosure(className, text, target, hiddenLabel) {
|
|
582
|
+
const toggle = button(className, text, () => {
|
|
583
|
+
const open = toggle.getAttribute('aria-expanded') !== TRUE;
|
|
584
|
+
toggle.setAttribute('aria-expanded', String(open));
|
|
585
|
+
target.hidden = !open;
|
|
586
|
+
});
|
|
587
|
+
toggle.setAttribute('aria-expanded', FALSE);
|
|
588
|
+
toggle.setAttribute('aria-controls', target.id);
|
|
589
|
+
if (hiddenLabel) toggle.append(hiddenText(hiddenLabel));
|
|
590
|
+
toggle.append(icon(Icon.Chevron));
|
|
591
|
+
return toggle;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
let nextId = 1;
|
|
595
|
+
|
|
596
|
+
function uniqueId(prefix) {
|
|
597
|
+
return `${prefix}-${nextId++}`;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* DomainScout's icon before its name, as on the landing page. The server puts the image into the
|
|
602
|
+
* page as a data URI, so nothing is fetched; without one (a page opened on its own) it is left out.
|
|
603
|
+
*/
|
|
604
|
+
function logo() {
|
|
605
|
+
const source = document.documentElement.dataset.logo;
|
|
606
|
+
if (!source?.startsWith(DATA_URI_PREFIX)) return '';
|
|
607
|
+
const image = element('img', 'tool__logo');
|
|
608
|
+
image.src = source;
|
|
609
|
+
image.alt = '';
|
|
610
|
+
image.width = LOGO_SIZE;
|
|
611
|
+
image.height = LOGO_SIZE;
|
|
612
|
+
return image;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ── The panel's frame: built once, so the toolbar keeps focus while the content changes ──────
|
|
616
|
+
|
|
617
|
+
/** The groups' options: the value each sets, its icon and its name for screen readers. */
|
|
618
|
+
const VIEW_OPTIONS = [
|
|
619
|
+
{ value: View.List, icon: Icon.List, label: 'Show as a list' },
|
|
620
|
+
{ value: View.Grid, icon: Icon.Grid, label: 'Show as cards' },
|
|
621
|
+
{ value: View.Table, icon: Icon.Table, label: 'Show as a price table' },
|
|
622
|
+
];
|
|
623
|
+
const THEME_OPTIONS = [
|
|
624
|
+
{ value: Theme.Light, icon: Icon.Sun, label: 'Light theme' },
|
|
625
|
+
{ value: Theme.Dark, icon: Icon.Moon, label: 'Dark theme' },
|
|
626
|
+
];
|
|
627
|
+
|
|
628
|
+
/** The toolbar's buttons, so a change of state can update their pressed look. */
|
|
629
|
+
const toolbarButtons = [];
|
|
630
|
+
|
|
631
|
+
function segmented(groupLabel, options, current, choose) {
|
|
632
|
+
const group = element('div', 'segmented');
|
|
633
|
+
group.setAttribute('role', 'group');
|
|
634
|
+
group.setAttribute('aria-label', groupLabel);
|
|
635
|
+
for (const option of options) {
|
|
636
|
+
const node = button('segmented__option', undefined, () => choose(option.value));
|
|
637
|
+
node.title = option.label;
|
|
638
|
+
node.setAttribute('aria-label', option.label);
|
|
639
|
+
node.append(icon(option.icon));
|
|
640
|
+
toolbarButtons.push({ button: node, isPressed: () => current() === option.value });
|
|
641
|
+
group.append(node);
|
|
642
|
+
}
|
|
643
|
+
return group;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function syncToolbar() {
|
|
647
|
+
for (const { button: node, isPressed } of toolbarButtons) {
|
|
648
|
+
node.setAttribute('aria-pressed', String(isPressed()));
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const panel = element('section', 'panel');
|
|
653
|
+
const header = element('div', 'header');
|
|
654
|
+
const tool = element('p', 'tool');
|
|
655
|
+
tool.append(logo(), element('span', 'tool__who', Copy.ToolWho), Copy.ToolName);
|
|
656
|
+
const layoutGroup = segmented(Copy.Layout, VIEW_OPTIONS, () => state.view, chooseView);
|
|
657
|
+
const themeGroup = segmented(Copy.Theme, THEME_OPTIONS, currentTheme, chooseTheme);
|
|
658
|
+
/** The one line screen readers are told about: the progress, the summary or an error. */
|
|
659
|
+
const status = element('p', 'summary');
|
|
660
|
+
status.setAttribute('role', 'status');
|
|
661
|
+
/** The tool's name on the left, the summary on the right. */
|
|
662
|
+
header.append(tool, status);
|
|
663
|
+
/**
|
|
664
|
+
* "Regenerate" on the left; the TLD filter and the toolbar together on the right. The button
|
|
665
|
+
* sits apart from the status line: a status line is read out whole on every change.
|
|
666
|
+
*/
|
|
667
|
+
const controls = element('div', 'controls');
|
|
668
|
+
const regenerateSlot = element('span', 'controls__action');
|
|
669
|
+
const controlsEnd = element('div', 'controls__end');
|
|
670
|
+
const filterSlot = element('div', 'controls__filter');
|
|
671
|
+
/** The settings' switch: a group of one, like the view and theme switches beside it. */
|
|
672
|
+
const settingsGroup = element('div', 'segmented');
|
|
673
|
+
const settingsToggle = button('segmented__option', undefined, () =>
|
|
674
|
+
openSettings(settingsBox.hidden),
|
|
675
|
+
);
|
|
676
|
+
settingsToggle.title = Copy.Settings;
|
|
677
|
+
settingsToggle.setAttribute('aria-label', Copy.Settings);
|
|
678
|
+
settingsToggle.setAttribute('aria-expanded', FALSE);
|
|
679
|
+
settingsToggle.setAttribute('aria-controls', 'settings');
|
|
680
|
+
settingsToggle.append(icon(Icon.Settings));
|
|
681
|
+
settingsGroup.append(settingsToggle);
|
|
682
|
+
settingsGroup.hidden = true;
|
|
683
|
+
const toolbar = element('div', 'toolbar');
|
|
684
|
+
toolbar.append(layoutGroup, themeGroup, settingsGroup);
|
|
685
|
+
controlsEnd.append(filterSlot, toolbar);
|
|
686
|
+
controls.append(regenerateSlot, controlsEnd);
|
|
687
|
+
/** The settings for the next batch, under the controls while open. */
|
|
688
|
+
const settingsBox = element('section', 'settings');
|
|
689
|
+
settingsBox.id = 'settings';
|
|
690
|
+
settingsBox.setAttribute('aria-label', Copy.Settings);
|
|
691
|
+
settingsBox.hidden = true;
|
|
692
|
+
settingsBox.addEventListener('keydown', (event) => {
|
|
693
|
+
if (event.key !== ESCAPE_KEY) return;
|
|
694
|
+
openSettings(false);
|
|
695
|
+
settingsToggle.focus();
|
|
696
|
+
});
|
|
697
|
+
const fallbackBox = element('div', 'fallback');
|
|
698
|
+
fallbackBox.hidden = true;
|
|
699
|
+
const content = element('div', 'content');
|
|
700
|
+
panel.append(header, controls, settingsBox, fallbackBox, content);
|
|
701
|
+
document.getElementById('app').append(panel);
|
|
702
|
+
|
|
703
|
+
function chooseView(view) {
|
|
704
|
+
const wasTable = state.view === View.Table;
|
|
705
|
+
state.view = view;
|
|
706
|
+
keepChoice(StorageKey.View, view);
|
|
707
|
+
syncToolbar();
|
|
708
|
+
const list = content.querySelector('.results');
|
|
709
|
+
// List and cards are one list with a different class: open rows stay open.
|
|
710
|
+
if (list && !wasTable && view !== View.Table) {
|
|
711
|
+
list.classList.toggle('results--grid', view === View.Grid);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (state.free.length) renderFree();
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function chooseTheme(theme) {
|
|
718
|
+
state.theme = theme;
|
|
719
|
+
keepChoice(StorageKey.Theme, theme);
|
|
720
|
+
applyTheme();
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** How the status line looks. */
|
|
724
|
+
const StatusKind = {
|
|
725
|
+
Progress: 'progress',
|
|
726
|
+
Result: 'result',
|
|
727
|
+
Error: 'error',
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
function setStatus(text, kind) {
|
|
731
|
+
status.textContent = text;
|
|
732
|
+
status.className = kind ? `summary summary--${kind}` : 'summary';
|
|
733
|
+
status.setAttribute('role', kind === StatusKind.Error ? 'alert' : 'status');
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Something the host would not do, offered to copy instead: a link, a message or a CSV. */
|
|
737
|
+
function showCopyFallback(note, text) {
|
|
738
|
+
const multiline = text.includes('\n');
|
|
739
|
+
const field = element(multiline ? 'textarea' : 'input', 'fallback__text-field');
|
|
740
|
+
if (!multiline) field.type = 'text';
|
|
741
|
+
field.readOnly = true;
|
|
742
|
+
field.value = text;
|
|
743
|
+
field.setAttribute('aria-label', note);
|
|
744
|
+
const close = button('fallback__close', Copy.Close, () => {
|
|
745
|
+
fallbackBox.hidden = true;
|
|
746
|
+
});
|
|
747
|
+
fallbackBox.className = 'fallback';
|
|
748
|
+
fallbackBox.replaceChildren(element('p', 'fallback__note', note), field, close);
|
|
749
|
+
fallbackBox.hidden = false;
|
|
750
|
+
field.focus();
|
|
751
|
+
field.select();
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ── Rendering a check_domains result ─────────────────────────────────────────────────────────
|
|
755
|
+
|
|
756
|
+
/** The tool's domain statuses. */
|
|
757
|
+
const Status = {
|
|
758
|
+
Available: 'available',
|
|
759
|
+
LikelyAvailable: 'likely_available',
|
|
760
|
+
Taken: 'taken',
|
|
761
|
+
Reserved: 'reserved',
|
|
762
|
+
Unknown: 'unknown',
|
|
763
|
+
};
|
|
764
|
+
/** Names shown at a time, in each list and each "show more" step: about a screenful. */
|
|
765
|
+
const PAGE_SIZE = 24;
|
|
766
|
+
/** Characters of an ISO date that make the day: "2030-02-03". */
|
|
767
|
+
const DATE_LENGTH = 10;
|
|
768
|
+
/** Milliseconds in a second, for the elapsed time. */
|
|
769
|
+
const SECOND_MS = 1000;
|
|
770
|
+
/** Rows a "show more" button has revealed get focus, so the keyboard carries on from there. */
|
|
771
|
+
const FOCUSABLE_FROM_SCRIPT = -1;
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* The progress line and a skeleton of the results to come. The names may be a list or just how
|
|
775
|
+
* many there will be; a regeneration says so in its own words.
|
|
776
|
+
*/
|
|
777
|
+
function showChecking(input, text) {
|
|
778
|
+
state.free = [];
|
|
779
|
+
const count = Number(input?.domains?.length) || 0;
|
|
780
|
+
const tlds = Array.isArray(input?.tlds) && input.tlds.length ? input.tlds : undefined;
|
|
781
|
+
const where = tlds ? ` in ${tldList(tlds)}` : '';
|
|
782
|
+
const checking = count ? `Checking ${count} names${where}…` : Copy.Checking;
|
|
783
|
+
setStatus(text ?? checking, StatusKind.Progress);
|
|
784
|
+
fallbackBox.hidden = true;
|
|
785
|
+
content.replaceChildren(element('div', 'progress'), skeleton(count));
|
|
786
|
+
filterSlot.replaceChildren();
|
|
787
|
+
regenerateSlot.replaceChildren();
|
|
788
|
+
layoutGroup.hidden = false;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Rows the skeleton shows while checking: as many as the names, up to a screenful. */
|
|
792
|
+
const SKELETON_ROWS = 8;
|
|
793
|
+
/** The skeleton's cells: the name, the two buttons, two prices and "N more". */
|
|
794
|
+
const SKELETON_CELLS = ['name', 'button', 'button', 'price', 'price', 'more'];
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* The shape of the results to come, in shimmering placeholders: the panel already has its size
|
|
798
|
+
* while the check runs, and the table drops into place without a jump.
|
|
799
|
+
*/
|
|
800
|
+
function skeleton(count) {
|
|
801
|
+
const box = element('div', 'skeleton');
|
|
802
|
+
box.setAttribute('aria-hidden', TRUE);
|
|
803
|
+
const rows = Math.min(Math.max(count, 1), SKELETON_ROWS);
|
|
804
|
+
for (let index = 0; index < rows; index++) {
|
|
805
|
+
const row = element('div', 'skeleton__row');
|
|
806
|
+
for (const kind of SKELETON_CELLS)
|
|
807
|
+
row.append(element('span', `skeleton__bar skeleton__bar--${kind}`));
|
|
808
|
+
box.append(row);
|
|
809
|
+
}
|
|
810
|
+
return box;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function showMessage(text, kind) {
|
|
814
|
+
state.free = [];
|
|
815
|
+
setStatus(text, kind);
|
|
816
|
+
content.replaceChildren();
|
|
817
|
+
filterSlot.replaceChildren();
|
|
818
|
+
regenerateSlot.replaceChildren();
|
|
819
|
+
layoutGroup.hidden = true;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function showResult(result) {
|
|
823
|
+
try {
|
|
824
|
+
renderResult(result);
|
|
825
|
+
} catch {
|
|
826
|
+
// A result shaped unlike this panel expects: the tool's own text report still says it all.
|
|
827
|
+
showMessage(reportText(result) ?? Copy.NoResults, StatusKind.Error);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function reportText(result) {
|
|
832
|
+
return result?.content?.find((block) => block.type === TEXT_BLOCK)?.text;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function renderResult(result) {
|
|
836
|
+
const data = result?.structuredContent;
|
|
837
|
+
if (result?.isError || !Array.isArray(data?.results)) {
|
|
838
|
+
showMessage(reportText(result) ?? Copy.NoResults, StatusKind.Error);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (!data.results.length) {
|
|
842
|
+
showMessage(Copy.NothingToCheck);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const byStatus = (statuses) => data.results.filter((item) => statuses.includes(item.status));
|
|
846
|
+
const free = byStatus([Status.Available, Status.LikelyAvailable]);
|
|
847
|
+
const taken = byStatus([Status.Taken]);
|
|
848
|
+
const reserved = byStatus([Status.Reserved]);
|
|
849
|
+
const unknown = byStatus([Status.Unknown]);
|
|
850
|
+
const tlds = [...new Set(data.results.map((item) => item.tld).filter(Boolean))];
|
|
851
|
+
const total = data.summary?.total ?? data.results.length;
|
|
852
|
+
const names = [...new Set(data.results.map(label))];
|
|
853
|
+
state.free = free;
|
|
854
|
+
state.check = { tlds, total, taken, names };
|
|
855
|
+
for (const name of names) state.everChecked.add(name);
|
|
856
|
+
state.lastResult = result;
|
|
857
|
+
state.tld = undefined;
|
|
858
|
+
state.sort = { column: undefined, direction: SortDirection.Ascending };
|
|
859
|
+
// Domains saved on an earlier panel come back marked: they start saved here too.
|
|
860
|
+
state.saved = new Map(free.filter((item) => item.saved).map((item) => [domainKey(item), item]));
|
|
861
|
+
state.similar = new Map();
|
|
862
|
+
showSummary(data, { free, taken, reserved, unknown });
|
|
863
|
+
layoutGroup.hidden = !free.length;
|
|
864
|
+
settingsGroup.hidden = !state.can.message;
|
|
865
|
+
if (!settingsBox.hidden) renderSettings();
|
|
866
|
+
regenerateSlot.replaceChildren(...(state.can.message ? [regenerateButton()] : []), savedButton());
|
|
867
|
+
shareContext();
|
|
868
|
+
|
|
869
|
+
const top = [];
|
|
870
|
+
if (free.length < FEW_FREE) top.push(fewFreeNote(free, total));
|
|
871
|
+
const rest = [];
|
|
872
|
+
if (taken.length) rest.push(group(Copy.Taken, taken, takenChip, takenActions(taken)));
|
|
873
|
+
if (reserved.length) rest.push(group(Copy.Reserved, reserved, notedChip('chip--reserved')));
|
|
874
|
+
if (unknown.length) rest.push(group(Copy.Unknown, unknown, notedChip('chip--unknown')));
|
|
875
|
+
if (data.warnings?.length) rest.push(notes(data.warnings));
|
|
876
|
+
if (free.length) {
|
|
877
|
+
rest.push(bottomActions());
|
|
878
|
+
const disclosureText = data.disclosure ? ` ${data.disclosure}` : '';
|
|
879
|
+
rest.push(element('p', 'footnote', `${Copy.Footnote}${disclosureText}`));
|
|
880
|
+
}
|
|
881
|
+
const savedBox = element('div', 'shortlist');
|
|
882
|
+
const freeBox = element('div', 'free');
|
|
883
|
+
// The saved domains sit under the free ones: when they appear, nothing above moves under the pointer.
|
|
884
|
+
content.replaceChildren(...top, freeBox, savedBox, ...rest);
|
|
885
|
+
renderSaved();
|
|
886
|
+
renderFilters();
|
|
887
|
+
renderFree();
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** The counts in the summary, in order: each with its word and its color. */
|
|
891
|
+
const SUMMARY_COUNTS = [
|
|
892
|
+
{ key: 'free', word: 'free', modifier: 'stat--free' },
|
|
893
|
+
{ key: 'taken', word: 'taken', modifier: 'stat--taken' },
|
|
894
|
+
{ key: 'reserved', word: 'reserved', modifier: 'stat--reserved' },
|
|
895
|
+
{ key: 'unknown', word: 'not verified', modifier: 'stat--unknown' },
|
|
896
|
+
];
|
|
897
|
+
/** Free and taken always show in the summary; reserved and unverified only when there are any. */
|
|
898
|
+
const ALWAYS_COUNTED = 2;
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* "48 domains checked in 4.2s", then each count with a colored dot: "● 23 free .com", "● 25 taken".
|
|
902
|
+
* The TLD is named when there is only one. Screen readers hear one plain sentence.
|
|
903
|
+
*/
|
|
904
|
+
function showSummary(data, groups) {
|
|
905
|
+
const total = data.summary?.total ?? data.results.length;
|
|
906
|
+
const elapsed = data.summary?.elapsed_ms;
|
|
907
|
+
const time = Number.isFinite(elapsed) ? ` in ${(elapsed / SECOND_MS).toFixed(1)}s` : '';
|
|
908
|
+
const tlds = new Set(data.results.map((item) => item.tld));
|
|
909
|
+
const onlyTld = tlds.size === 1 ? ` .${[...tlds][0]}` : '';
|
|
910
|
+
const lead = element('span', 'summary__lead', `${total} domains checked${time}`);
|
|
911
|
+
const stats = SUMMARY_COUNTS.filter(
|
|
912
|
+
({ key }, index) => index < ALWAYS_COUNTED || groups[key].length > 0,
|
|
913
|
+
).map(({ key, word, modifier }) => {
|
|
914
|
+
const text = key === 'free' ? `${word}${onlyTld}` : word;
|
|
915
|
+
const stat = element('span', `stat ${modifier}`);
|
|
916
|
+
stat.append(
|
|
917
|
+
element('span', 'stat__dot'),
|
|
918
|
+
element('span', 'stat__value', String(groups[key].length)),
|
|
919
|
+
` ${text}`,
|
|
920
|
+
);
|
|
921
|
+
return stat;
|
|
922
|
+
});
|
|
923
|
+
setStatus('', StatusKind.Result);
|
|
924
|
+
status.append(lead, ...stats);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Few or no free names: says so, and offers a new batch right away. */
|
|
928
|
+
function fewFreeNote(free, total) {
|
|
929
|
+
const note = element('div', 'few-free');
|
|
930
|
+
const text = free.length ? `Only ${free.length} of ${total} are free.` : Copy.NoneFree;
|
|
931
|
+
const hint = state.can.message ? ` ${Copy.RegenerateHint}` : '';
|
|
932
|
+
note.append(element('p', 'few-free__text', `${text}${hint}`));
|
|
933
|
+
return note;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* "Regenerate": asks the chat for a new batch of names, none of those checked so far, with the
|
|
938
|
+
* saved domains kept. This panel stays as it is; the new check comes as a new one under it.
|
|
939
|
+
*/
|
|
940
|
+
function regenerateButton() {
|
|
941
|
+
const node = button('chip-button regenerate', undefined, () => {
|
|
942
|
+
// Sent a moment ago: a second click would only ask the chat the same thing again.
|
|
943
|
+
if (node.classList.contains(SENT_CLASS)) return;
|
|
944
|
+
const saved = [...state.saved.values()].map((item) => item.domain);
|
|
945
|
+
const similar = [...new Set([...state.similar.values()].map(label))];
|
|
946
|
+
const names = [...state.everChecked];
|
|
947
|
+
const batch = {
|
|
948
|
+
...state.check,
|
|
949
|
+
names,
|
|
950
|
+
saved,
|
|
951
|
+
free: state.free,
|
|
952
|
+
similar,
|
|
953
|
+
tlds: nextTlds(),
|
|
954
|
+
count: nextCount(),
|
|
955
|
+
};
|
|
956
|
+
if (state.can.sampling && state.can.serverTools) regenerateInPlace(batch);
|
|
957
|
+
else regenerateThroughChat(batch, node);
|
|
958
|
+
});
|
|
959
|
+
// "Regenerate all" and "Regenerate with 2" share one cell, one of them hidden: the button keeps
|
|
960
|
+
// the wider one's width, so marking the first name moves nothing and nothing wraps.
|
|
961
|
+
regenerateCount = element('span', 'regenerate__count');
|
|
962
|
+
const withCount = element('span', 'swap__on', `${Copy.RegenerateWith} `);
|
|
963
|
+
withCount.append(regenerateCount);
|
|
964
|
+
const words = element('span', 'swap');
|
|
965
|
+
const sent = element('span', 'swap__sent', Copy.SentToChat);
|
|
966
|
+
words.append(element('span', 'swap__off', Copy.RegenerateAll), withCount, sent);
|
|
967
|
+
node.append(icon(Icon.Refresh), words);
|
|
968
|
+
regenerateButtonNode = node;
|
|
969
|
+
syncRegenerateCount();
|
|
970
|
+
return node;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Says the request went to the chat, for a moment: the new check comes as a new panel under the
|
|
975
|
+
* message, not in this one, so this panel only confirms it was asked.
|
|
976
|
+
*/
|
|
977
|
+
function showSent(node) {
|
|
978
|
+
node.classList.add(SENT_CLASS);
|
|
979
|
+
window.clearTimeout(sentTimer);
|
|
980
|
+
sentTimer = window.setTimeout(() => node.classList.remove(SENT_CLASS), SENT_SHOWN_MS);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** The class that shows "Sent to chat" on Regenerate, and for how long. */
|
|
984
|
+
const SENT_CLASS = 'regenerate--sent';
|
|
985
|
+
const SENT_SHOWN_MS = 4000;
|
|
986
|
+
let sentTimer;
|
|
987
|
+
|
|
988
|
+
/** The skeleton of a batch of the same size, at once, while the new names are on their way. */
|
|
989
|
+
function showRegenerating(batch) {
|
|
990
|
+
showChecking({ domains: { length: batch.count }, tlds: batch.tlds }, Copy.Regenerating);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* In this panel, when the host lets it ask the model and call the tool: the skeleton at once,
|
|
995
|
+
* then the model's new names, checked with the saved domains, in place of the old ones. Anything
|
|
996
|
+
* that fails falls back to asking through the chat.
|
|
997
|
+
*/
|
|
998
|
+
async function regenerateInPlace(batch) {
|
|
999
|
+
showRegenerating(batch);
|
|
1000
|
+
try {
|
|
1001
|
+
const reply = await request(Method.CreateMessage, {
|
|
1002
|
+
messages: [{ role: 'user', content: { type: TEXT_BLOCK, text: Ask.names(batch) } }],
|
|
1003
|
+
maxTokens: NAMES_MAX_TOKENS,
|
|
1004
|
+
});
|
|
1005
|
+
const names = parseNames(reply?.content?.text, state.everChecked);
|
|
1006
|
+
if (!names.length) throw new Error(Copy.NoNewNames);
|
|
1007
|
+
showChecking({ domains: names, tlds: batch.tlds });
|
|
1008
|
+
const args = { domains: names, tlds: batch.tlds, saved: batch.saved };
|
|
1009
|
+
const result = await request(Method.CallTool, { name: Copy.ToolName, arguments: args });
|
|
1010
|
+
showResult(result);
|
|
1011
|
+
} catch {
|
|
1012
|
+
regenerateThroughChat(batch, regenerateButtonNode);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Through the chat: the skeleton at once, the request as a message, then this panel goes back
|
|
1018
|
+
* to its results with a note - the new batch comes as a new panel under the message.
|
|
1019
|
+
*/
|
|
1020
|
+
function regenerateThroughChat(batch, node) {
|
|
1021
|
+
showRegenerating(batch);
|
|
1022
|
+
sendToChat(Ask.regenerate(batch));
|
|
1023
|
+
window.setTimeout(() => {
|
|
1024
|
+
if (state.lastResult) showResult(state.lastResult);
|
|
1025
|
+
showNotice(Copy.NewBatchBelow);
|
|
1026
|
+
if (regenerateButtonNode) showSent(regenerateButtonNode);
|
|
1027
|
+
}, BACK_TO_RESULTS_MS);
|
|
1028
|
+
if (node) showSent(node);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/** How long the skeleton stays before a regeneration through the chat gives the panel back. */
|
|
1032
|
+
const BACK_TO_RESULTS_MS = 1500;
|
|
1033
|
+
/** Room for the model's reply: a hundred or two short names. */
|
|
1034
|
+
const NAMES_MAX_TOKENS = 2000;
|
|
1035
|
+
/** One name as the model may write it: letters, digits and hyphens, maybe with a TLD after it. */
|
|
1036
|
+
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
1037
|
+
/** List markers and punctuation around names in the model's reply. */
|
|
1038
|
+
const NAME_SEPARATORS = /[\s,;]+/;
|
|
1039
|
+
const LIST_MARKER = /^(\d+[.)]|[-*\u2022])/;
|
|
1040
|
+
|
|
1041
|
+
/** The new names in the model's reply: one each, none checked before, as many as a batch takes. */
|
|
1042
|
+
function parseNames(text, checked) {
|
|
1043
|
+
const names = (text ?? '')
|
|
1044
|
+
.toLowerCase()
|
|
1045
|
+
.split(NAME_SEPARATORS)
|
|
1046
|
+
.map((word) => word.replace(LIST_MARKER, '').split('.')[0])
|
|
1047
|
+
.filter((name) => NAME_PATTERN.test(name) && !checked.has(name));
|
|
1048
|
+
return [...new Set(names)].slice(0, REGENERATE_MAX);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** A one-line note under the summary, such as where the new batch will appear. */
|
|
1052
|
+
function showNotice(text) {
|
|
1053
|
+
const note = element('p', 'notice', text);
|
|
1054
|
+
fallbackBox.className = 'fallback fallback--notice';
|
|
1055
|
+
fallbackBox.replaceChildren(note);
|
|
1056
|
+
fallbackBox.hidden = false;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** Saved domains the chat compares: one alone has nothing to be compared with. */
|
|
1060
|
+
const MIN_TO_COMPARE = 2;
|
|
1061
|
+
|
|
1062
|
+
/** "Saved 2" beside Regenerate: how many domains are saved, and a request to compare them. */
|
|
1063
|
+
let savedCount;
|
|
1064
|
+
|
|
1065
|
+
function savedButton() {
|
|
1066
|
+
const node = button('chip-button saved-jump', undefined, () => {
|
|
1067
|
+
const items = [...state.saved.values()];
|
|
1068
|
+
if (state.can.message && items.length >= MIN_TO_COMPARE) sendToChat(Ask.compare(items));
|
|
1069
|
+
});
|
|
1070
|
+
savedCount = element('span', 'saved-jump__count');
|
|
1071
|
+
node.append(icon(Icon.Star), `${Copy.Saved} `, savedCount);
|
|
1072
|
+
syncSavedCount();
|
|
1073
|
+
return node;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function syncSavedCount() {
|
|
1077
|
+
if (!savedCount?.isConnected) return;
|
|
1078
|
+
const count = state.saved.size;
|
|
1079
|
+
const node = savedCount.parentElement;
|
|
1080
|
+
const canCompare = state.can.message && count >= MIN_TO_COMPARE;
|
|
1081
|
+
savedCount.textContent = String(count);
|
|
1082
|
+
node.classList.toggle('saved-jump--some', count > 0);
|
|
1083
|
+
// Not disabled outright: a disabled button gets no hover, and the tip says what to do.
|
|
1084
|
+
node.setAttribute('aria-disabled', String(!canCompare));
|
|
1085
|
+
let tip = Copy.SavedTipNone;
|
|
1086
|
+
if (canCompare) tip = Copy.SavedTipCompare(count);
|
|
1087
|
+
else if (count && state.can.message) tip = Copy.SavedTipOne;
|
|
1088
|
+
else if (count) tip = Copy.SavedTipKept;
|
|
1089
|
+
setTip(node, `${Copy.Saved} ${count}`, tip);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* A short tip under a button on hover and on keyboard focus, drawn by CSS from data-tip; screen
|
|
1094
|
+
* readers get it as the button's description.
|
|
1095
|
+
*/
|
|
1096
|
+
function setTip(node, name, tip) {
|
|
1097
|
+
node.dataset.tip = tip;
|
|
1098
|
+
node.setAttribute('aria-label', `${name}. ${tip}`);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** The Regenerate button and its count, updated as names are marked Similar. */
|
|
1102
|
+
let regenerateButtonNode;
|
|
1103
|
+
let regenerateCount;
|
|
1104
|
+
|
|
1105
|
+
function syncRegenerateCount() {
|
|
1106
|
+
if (!regenerateButtonNode) return;
|
|
1107
|
+
const count = state.similar.size;
|
|
1108
|
+
regenerateCount.textContent = String(count);
|
|
1109
|
+
regenerateButtonNode.classList.toggle('regenerate--with', count > 0);
|
|
1110
|
+
const spoken = count ? `${Copy.RegenerateWith} ${count}` : Copy.RegenerateAll;
|
|
1111
|
+
setTip(
|
|
1112
|
+
regenerateButtonNode,
|
|
1113
|
+
spoken,
|
|
1114
|
+
count ? Copy.RegenerateTipWith(count) : Copy.RegenerateTipAll,
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/** Under the domains: the domains as a file. */
|
|
1119
|
+
function bottomActions() {
|
|
1120
|
+
const bar = element('div', 'actions');
|
|
1121
|
+
if (state.can.downloadFile) {
|
|
1122
|
+
const download = button('action', Copy.DownloadCsv, downloadCsv);
|
|
1123
|
+
download.prepend(icon(Icon.Download));
|
|
1124
|
+
bar.append(download);
|
|
1125
|
+
}
|
|
1126
|
+
return bar;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/** The free domains the filter lets through, in the table's sort when there is one. */
|
|
1130
|
+
/**
|
|
1131
|
+
* The free domains the filter lets through, the saved ones first, the rest in the table's sort
|
|
1132
|
+
* when there is one. The order is set when the domains are drawn: saving one does not move its
|
|
1133
|
+
* row from under the pointer.
|
|
1134
|
+
*/
|
|
1135
|
+
function shownFree() {
|
|
1136
|
+
const isSaved = (item) => state.saved.has(domainKey(item));
|
|
1137
|
+
const filtered = state.tld ? state.free.filter((item) => item.tld === state.tld) : state.free;
|
|
1138
|
+
const ordered = state.view === View.Table ? sorted(filtered) : filtered;
|
|
1139
|
+
return [...ordered.filter(isSaved), ...ordered.filter((item) => !isSaved(item))];
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** The free domains in the chosen view, into the box made for them. */
|
|
1143
|
+
function renderFree() {
|
|
1144
|
+
const box = content.querySelector('.free');
|
|
1145
|
+
if (!box) return;
|
|
1146
|
+
saveButtons.clear();
|
|
1147
|
+
similarButtons.clear();
|
|
1148
|
+
const items = shownFree();
|
|
1149
|
+
const parts = state.view === View.Table ? priceTable(items) : freeList(items);
|
|
1150
|
+
box.replaceChildren(...parts);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* The first page of items, then a button that adds a page at a time and moves focus to the first
|
|
1155
|
+
* new item, so a keyboard user carries on where the button was.
|
|
1156
|
+
*/
|
|
1157
|
+
function paged(items, container, build, moreLabel) {
|
|
1158
|
+
let shown = Math.min(PAGE_SIZE, items.length);
|
|
1159
|
+
container.append(...items.slice(0, shown).flatMap(build));
|
|
1160
|
+
if (shown >= items.length) return [];
|
|
1161
|
+
const more = button('more', moreLabel(items.length - shown), () => {
|
|
1162
|
+
const page = items.slice(shown, shown + PAGE_SIZE).flatMap(build);
|
|
1163
|
+
container.append(...page);
|
|
1164
|
+
shown += PAGE_SIZE;
|
|
1165
|
+
page[0].tabIndex = FOCUSABLE_FROM_SCRIPT;
|
|
1166
|
+
page[0].focus();
|
|
1167
|
+
if (shown >= items.length) more.remove();
|
|
1168
|
+
else more.textContent = moreLabel(items.length - shown);
|
|
1169
|
+
});
|
|
1170
|
+
return [more];
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// ── Filter: one TLD or all ────────────────────────────────────────────────────────────────────
|
|
1174
|
+
|
|
1175
|
+
/** "All · .com · .ai" over the free domains, when they are in more than one TLD. */
|
|
1176
|
+
/** TLDs the filter shows as pills; with more, it is a menu, so the line stays one line. */
|
|
1177
|
+
const FILTER_PILLS_MAX = 5;
|
|
1178
|
+
|
|
1179
|
+
/** The filter as a menu: "All · 159", ".com · 5" and so on, in the pills' look. */
|
|
1180
|
+
function filterMenu(options, countOf) {
|
|
1181
|
+
const wrap = element('label', 'filter-menu');
|
|
1182
|
+
const select = element('select', 'filter-menu__select');
|
|
1183
|
+
select.setAttribute('aria-label', Copy.TldFilter);
|
|
1184
|
+
for (const option of options) {
|
|
1185
|
+
const item = element('option', undefined, `${option.text} · ${countOf(option.value)}`);
|
|
1186
|
+
item.value = option.value ?? '';
|
|
1187
|
+
item.selected = state.tld === option.value;
|
|
1188
|
+
select.append(item);
|
|
1189
|
+
}
|
|
1190
|
+
select.addEventListener('change', () => {
|
|
1191
|
+
state.tld = select.value || undefined;
|
|
1192
|
+
renderFree();
|
|
1193
|
+
});
|
|
1194
|
+
wrap.append(select, icon(Icon.Chevron));
|
|
1195
|
+
return wrap;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function renderFilters() {
|
|
1199
|
+
const box = filterSlot;
|
|
1200
|
+
const tlds = [...new Set(state.free.map((item) => item.tld))];
|
|
1201
|
+
if (tlds.length < 2) {
|
|
1202
|
+
box.replaceChildren();
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
const options = [
|
|
1206
|
+
{ value: undefined, text: Copy.AllTlds },
|
|
1207
|
+
...tlds.map((tld) => ({ value: tld, text: `.${tld}` })),
|
|
1208
|
+
];
|
|
1209
|
+
const countOf = (value) =>
|
|
1210
|
+
value ? state.free.filter((item) => item.tld === value).length : state.free.length;
|
|
1211
|
+
if (tlds.length > FILTER_PILLS_MAX) {
|
|
1212
|
+
box.replaceChildren(filterMenu(options, countOf));
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
const group = element('div', 'filter');
|
|
1216
|
+
group.setAttribute('role', 'group');
|
|
1217
|
+
group.setAttribute('aria-label', Copy.TldFilter);
|
|
1218
|
+
for (const option of options) {
|
|
1219
|
+
const count = countOf(option.value);
|
|
1220
|
+
const node = button('filter__option', option.text, () => {
|
|
1221
|
+
state.tld = option.value;
|
|
1222
|
+
renderFilters();
|
|
1223
|
+
renderFree();
|
|
1224
|
+
});
|
|
1225
|
+
node.append(element('span', 'filter__count', String(count)));
|
|
1226
|
+
node.setAttribute('aria-pressed', String(state.tld === option.value));
|
|
1227
|
+
group.append(node);
|
|
1228
|
+
}
|
|
1229
|
+
box.replaceChildren(group);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// ── Settings: the TLDs and the size of the next batch ─────────────────────────────────────────
|
|
1233
|
+
|
|
1234
|
+
/** The next batch's TLDs: the ones chosen in the settings, or the last check's. */
|
|
1235
|
+
function nextTlds() {
|
|
1236
|
+
return state.settings.tlds ?? state.check.tlds;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** As many names as fit the TLDs within one check, beside the saved domains checked with them. */
|
|
1240
|
+
function namesThatFit(tlds) {
|
|
1241
|
+
const room = MAX_DOMAINS_PER_CHECK - state.saved.size;
|
|
1242
|
+
return Math.max(1, Math.floor(room / Math.max(tlds.length, 1)));
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/** As many names as the last batch, within bounds: the size on "Auto". */
|
|
1246
|
+
function lastBatchSize() {
|
|
1247
|
+
return Math.min(Math.max(state.check.names.length, REGENERATE_MIN), REGENERATE_MAX);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/** The size asked for: chosen in the settings, or the last batch's. */
|
|
1251
|
+
function askedCount() {
|
|
1252
|
+
return state.settings.size ?? lastBatchSize();
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/** The next batch's size: as asked, but never more domains than one check takes. */
|
|
1256
|
+
function nextCount() {
|
|
1257
|
+
return Math.min(askedCount(), namesThatFit(nextTlds()));
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function openSettings(open) {
|
|
1261
|
+
settingsBox.hidden = !open;
|
|
1262
|
+
settingsToggle.setAttribute('aria-expanded', String(open));
|
|
1263
|
+
settingsToggle.setAttribute('aria-pressed', String(open));
|
|
1264
|
+
if (open) renderSettings();
|
|
1265
|
+
reportSize();
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function chooseTlds(tlds) {
|
|
1269
|
+
state.settings.tlds = tlds;
|
|
1270
|
+
keepChoice(StorageKey.Tlds, JSON.stringify(tlds));
|
|
1271
|
+
renderSettings();
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function chooseSize(size) {
|
|
1275
|
+
state.settings.size = size;
|
|
1276
|
+
if (size) keepChoice(StorageKey.BatchSize, String(size));
|
|
1277
|
+
else dropChoice(StorageKey.BatchSize);
|
|
1278
|
+
renderSettings();
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function resetSettings() {
|
|
1282
|
+
state.settings = { tlds: undefined, size: undefined };
|
|
1283
|
+
dropChoice(StorageKey.Tlds);
|
|
1284
|
+
dropChoice(StorageKey.BatchSize);
|
|
1285
|
+
renderSettings();
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/** A row of the settings: its name above, its options below. */
|
|
1289
|
+
function settingsRow(name, ...parts) {
|
|
1290
|
+
const row = element('div', 'settings__row');
|
|
1291
|
+
const options = element('div', 'settings__options');
|
|
1292
|
+
options.setAttribute('role', 'group');
|
|
1293
|
+
options.setAttribute('aria-label', name);
|
|
1294
|
+
options.append(...parts);
|
|
1295
|
+
row.append(element('p', 'settings__name', name), options);
|
|
1296
|
+
return row;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/** A pill that is on or off, as in the TLD filter. */
|
|
1300
|
+
function pill(text, pressed, choose, label) {
|
|
1301
|
+
const node = button('filter__option', text, choose);
|
|
1302
|
+
node.setAttribute('aria-pressed', String(pressed));
|
|
1303
|
+
if (label) node.title = label;
|
|
1304
|
+
return node;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function renderSettings() {
|
|
1308
|
+
const chosen = nextTlds();
|
|
1309
|
+
const offered = [...new Set([...state.check.tlds, ...TLD_CHOICES, ...chosen])];
|
|
1310
|
+
const toggle = (tld) => {
|
|
1311
|
+
const next = chosen.includes(tld) ? chosen.filter((each) => each !== tld) : [...chosen, tld];
|
|
1312
|
+
// One TLD at least: a batch checked nowhere is no batch.
|
|
1313
|
+
if (next.length) chooseTlds(next);
|
|
1314
|
+
};
|
|
1315
|
+
const tlds = offered.map((tld) => pill(`.${tld}`, chosen.includes(tld), () => toggle(tld)));
|
|
1316
|
+
|
|
1317
|
+
const add = element('input', 'settings__input');
|
|
1318
|
+
add.type = 'text';
|
|
1319
|
+
add.placeholder = Copy.SettingsAddPlaceholder;
|
|
1320
|
+
add.setAttribute('aria-label', Copy.SettingsAddTld);
|
|
1321
|
+
add.autocomplete = 'off';
|
|
1322
|
+
add.spellcheck = false;
|
|
1323
|
+
add.addEventListener('keydown', (event) => {
|
|
1324
|
+
if (event.key !== ENTER_KEY) return;
|
|
1325
|
+
const tld = add.value.trim().toLowerCase().replace(LEADING_DOT, '');
|
|
1326
|
+
if (!TLD_PATTERN.test(tld)) {
|
|
1327
|
+
add.setAttribute('aria-invalid', TRUE);
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
if (!chosen.includes(tld)) chooseTlds([...chosen, tld]);
|
|
1331
|
+
settingsBox.querySelector('.settings__input')?.focus();
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
const auto = pill(
|
|
1335
|
+
`${Copy.SettingsAuto} · ${lastBatchSize()}`,
|
|
1336
|
+
!state.settings.size,
|
|
1337
|
+
() => chooseSize(undefined),
|
|
1338
|
+
Copy.SettingsAutoLabel,
|
|
1339
|
+
);
|
|
1340
|
+
const sizes = BATCH_SIZES.map((size) =>
|
|
1341
|
+
pill(String(size), state.settings.size === size, () => chooseSize(size)),
|
|
1342
|
+
);
|
|
1343
|
+
|
|
1344
|
+
const count = nextCount();
|
|
1345
|
+
const capped = count < askedCount() ? Copy.SettingsCapped(askedCount()) : '';
|
|
1346
|
+
const summary = element(
|
|
1347
|
+
'p',
|
|
1348
|
+
'settings__summary',
|
|
1349
|
+
`${Copy.SettingsBatch(count, chosen.length)}${capped}`,
|
|
1350
|
+
);
|
|
1351
|
+
const reset = button('action', Copy.SettingsReset, resetSettings);
|
|
1352
|
+
const footer = element('div', 'settings__footer');
|
|
1353
|
+
footer.append(summary, reset);
|
|
1354
|
+
|
|
1355
|
+
// Rebuilt on each change, so keep the focus on the control that made it.
|
|
1356
|
+
const focused = document.activeElement;
|
|
1357
|
+
const focusedText = settingsBox.contains(focused) ? focused.textContent : undefined;
|
|
1358
|
+
settingsBox.replaceChildren(
|
|
1359
|
+
settingsRow(Copy.SettingsTlds, ...tlds, add),
|
|
1360
|
+
settingsRow(Copy.SettingsSize, auto, ...sizes),
|
|
1361
|
+
footer,
|
|
1362
|
+
);
|
|
1363
|
+
if (focusedText) {
|
|
1364
|
+
const again = [...settingsBox.querySelectorAll('button')].find(
|
|
1365
|
+
(node) => node.textContent === focusedText,
|
|
1366
|
+
);
|
|
1367
|
+
again?.focus();
|
|
1368
|
+
}
|
|
1369
|
+
reportSize();
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// ── Saved: the domains the visitor keeps, shared with the chat ───────────────────────────────
|
|
1373
|
+
|
|
1374
|
+
/** The save buttons of each domain, so saving one updates every view of it. */
|
|
1375
|
+
const saveButtons = new Map();
|
|
1376
|
+
|
|
1377
|
+
function domainKey(item) {
|
|
1378
|
+
return item.domain;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/** "☆ Save" / "★ Saved": keeps the domain, or lets it go. */
|
|
1382
|
+
function saveButton(item) {
|
|
1383
|
+
const key = domainKey(item);
|
|
1384
|
+
const node = button('chip-button save', undefined, () => toggleSaved(item));
|
|
1385
|
+
// Both words sit in one cell, one of them hidden: the button is as wide as "Saved" either way,
|
|
1386
|
+
// so pressing it moves nothing.
|
|
1387
|
+
const words = element('span', 'chip-button__label swap');
|
|
1388
|
+
words.append(element('span', 'swap__off', Copy.Save), element('span', 'swap__on', Copy.Saved));
|
|
1389
|
+
node.append(icon(Icon.Star), words);
|
|
1390
|
+
const buttons = saveButtons.get(key) ?? new Set();
|
|
1391
|
+
buttons.add(node);
|
|
1392
|
+
saveButtons.set(key, buttons);
|
|
1393
|
+
syncSave(node, state.saved.has(key), item);
|
|
1394
|
+
return node;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function syncSave(node, saved, item) {
|
|
1398
|
+
const domain = item.display || item.domain;
|
|
1399
|
+
node.setAttribute('aria-pressed', String(saved));
|
|
1400
|
+
node.setAttribute('aria-label', `${saved ? Copy.Unsave : Copy.SaveLabel}: ${domain}`);
|
|
1401
|
+
node.title = saved ? Copy.Unsave : Copy.SaveLabel;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function toggleSaved(item) {
|
|
1405
|
+
const key = domainKey(item);
|
|
1406
|
+
if (state.saved.has(key)) state.saved.delete(key);
|
|
1407
|
+
else state.saved.set(key, item);
|
|
1408
|
+
const saved = state.saved.has(key);
|
|
1409
|
+
for (const node of saveButtons.get(key) ?? []) {
|
|
1410
|
+
if (node.isConnected) syncSave(node, saved, item);
|
|
1411
|
+
}
|
|
1412
|
+
renderSaved();
|
|
1413
|
+
shareContext();
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/** The saved domains under the free ones: each with a way to let it go, and "Compare in chat". */
|
|
1417
|
+
function renderSaved() {
|
|
1418
|
+
syncSavedCount();
|
|
1419
|
+
const box = content.querySelector('.shortlist');
|
|
1420
|
+
if (!box) return;
|
|
1421
|
+
const items = [...state.saved.values()];
|
|
1422
|
+
if (!items.length) {
|
|
1423
|
+
box.replaceChildren();
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
const title = element('h2', 'shortlist__title', `${Copy.Saved} · ${items.length}`);
|
|
1427
|
+
const chips = element('ul', 'shortlist__items');
|
|
1428
|
+
for (const item of items) {
|
|
1429
|
+
const chip = element('li', 'shortlist__item', item.display || item.domain);
|
|
1430
|
+
const remove = button('shortlist__remove', undefined, () => toggleSaved(item));
|
|
1431
|
+
remove.setAttribute('aria-label', `${Copy.Unsave}: ${item.display || item.domain}`);
|
|
1432
|
+
remove.append(icon(Icon.Close));
|
|
1433
|
+
chip.append(remove);
|
|
1434
|
+
chips.append(chip);
|
|
1435
|
+
}
|
|
1436
|
+
// The title and the button share a line; the saved names go below, so the button stays put.
|
|
1437
|
+
const header = element('div', 'shortlist__header');
|
|
1438
|
+
header.append(title);
|
|
1439
|
+
if (state.can.message && items.length > 1) {
|
|
1440
|
+
const compare = button('action', Copy.CompareSaved, () => sendToChat(Ask.compare(items)));
|
|
1441
|
+
compare.prepend(icon(Icon.Sparkles));
|
|
1442
|
+
header.append(compare);
|
|
1443
|
+
}
|
|
1444
|
+
box.replaceChildren(header, chips);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Tells the model what the visitor saved and which names were checked, so the chat can talk
|
|
1449
|
+
* about the picks and never suggests a checked name again. One update carries both, as a newer
|
|
1450
|
+
* update replaces the older one.
|
|
1451
|
+
*/
|
|
1452
|
+
function shareContext() {
|
|
1453
|
+
if (!state.can.updateModelContext) return;
|
|
1454
|
+
const items = [...state.saved.values()];
|
|
1455
|
+
const savedText = items.length
|
|
1456
|
+
? `The user saved these domains in the DomainScout panel: ${items.map(describeForChat).join('; ')}.`
|
|
1457
|
+
: 'The user has saved no domains in the DomainScout panel.';
|
|
1458
|
+
const checkedText = `Names already checked, not to suggest again: ${state.check.names.join(', ')}.`;
|
|
1459
|
+
const saved = items.map((item) => ({ domain: item.domain, ...cheapest(item) }));
|
|
1460
|
+
request(Method.UpdateModelContext, {
|
|
1461
|
+
content: [{ type: TEXT_BLOCK, text: `${savedText} ${checkedText}` }],
|
|
1462
|
+
structuredContent: { saved, checked: state.check.names },
|
|
1463
|
+
}).catch(() => {
|
|
1464
|
+
// The chat simply won't know; saving still works on the panel.
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/** The cheapest priced offer's facts, for the shortlist and the CSV. */
|
|
1469
|
+
function cheapest(item) {
|
|
1470
|
+
const lead = (item.buy ?? []).find((offer) => offer.price) ?? item.buy?.[0];
|
|
1471
|
+
return {
|
|
1472
|
+
registrar: lead?.registrar,
|
|
1473
|
+
registration: lead?.price?.registration,
|
|
1474
|
+
renewal: lead?.price?.renewal,
|
|
1475
|
+
currency: lead?.price?.currency,
|
|
1476
|
+
url: lead?.url,
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// ── CSV: the free domains to take elsewhere ───────────────────────────────────────────────────
|
|
1481
|
+
|
|
1482
|
+
/** The file the free domains download as. */
|
|
1483
|
+
const CSV_FILE_URI = 'file:///domainscout-free-domains.csv';
|
|
1484
|
+
const CSV_MIME_TYPE = 'text/csv';
|
|
1485
|
+
/** A resource block holding the file's text, as ui/download-file takes it. */
|
|
1486
|
+
const RESOURCE_BLOCK = 'resource';
|
|
1487
|
+
const CSV_COLUMNS = [
|
|
1488
|
+
'domain',
|
|
1489
|
+
'tld',
|
|
1490
|
+
'premium',
|
|
1491
|
+
'min_years',
|
|
1492
|
+
'registrar',
|
|
1493
|
+
'registration',
|
|
1494
|
+
'renewal',
|
|
1495
|
+
'currency',
|
|
1496
|
+
'buy_url',
|
|
1497
|
+
'saved',
|
|
1498
|
+
];
|
|
1499
|
+
/** A CSV field with a comma, a quote or a line break goes in quotes, its quotes doubled. */
|
|
1500
|
+
const CSV_NEEDS_QUOTES = /[",\n]/;
|
|
1501
|
+
|
|
1502
|
+
function csvField(value) {
|
|
1503
|
+
const text = value === undefined || value === null ? '' : String(value);
|
|
1504
|
+
return CSV_NEEDS_QUOTES.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function csvText() {
|
|
1508
|
+
const rows = shownFree().map((item) => {
|
|
1509
|
+
const offer = cheapest(item);
|
|
1510
|
+
const minYears = (item.buy ?? []).find((each) => each.price)?.price.minYears ?? 1;
|
|
1511
|
+
return [
|
|
1512
|
+
item.domain,
|
|
1513
|
+
item.tld,
|
|
1514
|
+
Boolean(item.premium),
|
|
1515
|
+
minYears,
|
|
1516
|
+
offer.registrar,
|
|
1517
|
+
offer.registration,
|
|
1518
|
+
offer.renewal,
|
|
1519
|
+
offer.currency,
|
|
1520
|
+
offer.url,
|
|
1521
|
+
state.saved.has(domainKey(item)),
|
|
1522
|
+
];
|
|
1523
|
+
});
|
|
1524
|
+
return [CSV_COLUMNS, ...rows].map((row) => row.map(csvField).join(',')).join('\n');
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function downloadCsv() {
|
|
1528
|
+
const text = csvText();
|
|
1529
|
+
const contents = [
|
|
1530
|
+
{ type: RESOURCE_BLOCK, resource: { uri: CSV_FILE_URI, mimeType: CSV_MIME_TYPE, text } },
|
|
1531
|
+
];
|
|
1532
|
+
requestOrFallback(Method.DownloadFile, { contents }, () =>
|
|
1533
|
+
showCopyFallback(Copy.DownloadBlocked, text),
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// ── The list and the cards ────────────────────────────────────────────────────────────────────
|
|
1538
|
+
|
|
1539
|
+
/** The free domains as a list or as cards: the same list, a class apart. */
|
|
1540
|
+
function freeList(free) {
|
|
1541
|
+
const listClass = state.view === View.Grid ? 'results results--grid' : 'results';
|
|
1542
|
+
const list = element('ul', listClass);
|
|
1543
|
+
list.setAttribute('aria-label', Copy.FreeDomains);
|
|
1544
|
+
const more = paged(
|
|
1545
|
+
free,
|
|
1546
|
+
list,
|
|
1547
|
+
(item) => [freeDomain(item)],
|
|
1548
|
+
(left) => `…and ${left} more free`,
|
|
1549
|
+
);
|
|
1550
|
+
return [list, ...more];
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function freeDomain(item) {
|
|
1554
|
+
const domain = item.display || item.domain;
|
|
1555
|
+
const row = element('li', 'result');
|
|
1556
|
+
const name = element('span', 'result__domain');
|
|
1557
|
+
name.append(element('span', 'result__name', domain), saveButton(item));
|
|
1558
|
+
row.append(name);
|
|
1559
|
+
if (item.display && item.display !== item.domain) {
|
|
1560
|
+
// An internationalized name: show what is actually registered, too.
|
|
1561
|
+
name.append(element('span', 'result__ascii', item.domain));
|
|
1562
|
+
}
|
|
1563
|
+
const pill = item.premium
|
|
1564
|
+
? element('span', 'pill pill--premium', Copy.Premium)
|
|
1565
|
+
: element('span', 'pill', Copy.Available);
|
|
1566
|
+
if (item.confirmedBy) pill.append(hiddenText(`, confirmed by ${item.confirmedBy}`));
|
|
1567
|
+
row.append(pill);
|
|
1568
|
+
|
|
1569
|
+
// The tool lists offers cheapest first, priced ones before the rest: the range reads from the
|
|
1570
|
+
// priced ones, and the accordion keeps the tool's order.
|
|
1571
|
+
const offers = item.buy ?? [];
|
|
1572
|
+
const line = element('div', 'result__line');
|
|
1573
|
+
line.append(element('span', 'result__price', priceSummary(item, offers)));
|
|
1574
|
+
const actions = element('span', 'result__actions');
|
|
1575
|
+
if (state.can.message) actions.append(similarButton(item));
|
|
1576
|
+
row.append(line);
|
|
1577
|
+
line.append(actions);
|
|
1578
|
+
if (!offers.length) return row;
|
|
1579
|
+
const list = registrarList(offers, domain);
|
|
1580
|
+
actions.append(disclosure('toggle', `All ${offers.length} registrars`, list, ` for ${domain}`));
|
|
1581
|
+
row.append(list);
|
|
1582
|
+
return row;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/** "✦ Similar": asks the chat for more names like this one, checked in the same TLDs. */
|
|
1586
|
+
function similarButton(item) {
|
|
1587
|
+
const key = domainKey(item);
|
|
1588
|
+
const node = button('chip-button similar', undefined, () => toggleSimilar(item));
|
|
1589
|
+
node.append(icon(Icon.Sparkles), element('span', 'chip-button__label', Copy.Similar));
|
|
1590
|
+
const buttons = similarButtons.get(key) ?? new Set();
|
|
1591
|
+
buttons.add(node);
|
|
1592
|
+
similarButtons.set(key, buttons);
|
|
1593
|
+
syncSimilar(node, state.similar.has(key), item);
|
|
1594
|
+
return node;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/** The Similar buttons of each domain, so marking one updates every view of it. */
|
|
1598
|
+
const similarButtons = new Map();
|
|
1599
|
+
|
|
1600
|
+
function syncSimilar(node, marked, item) {
|
|
1601
|
+
const text = marked ? Copy.NotSimilarLabel : Copy.SimilarLabel;
|
|
1602
|
+
node.setAttribute('aria-pressed', String(marked));
|
|
1603
|
+
node.setAttribute('aria-label', `${Copy.Similar}: ${item.display || item.domain}`);
|
|
1604
|
+
node.title = text;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/** Marks a name as an example for Regenerate, or unmarks it. */
|
|
1608
|
+
function toggleSimilar(item) {
|
|
1609
|
+
const key = domainKey(item);
|
|
1610
|
+
if (state.similar.has(key)) state.similar.delete(key);
|
|
1611
|
+
else state.similar.set(key, item);
|
|
1612
|
+
const marked = state.similar.has(key);
|
|
1613
|
+
for (const node of similarButtons.get(key) ?? []) {
|
|
1614
|
+
if (node.isConnected) syncSimilar(node, marked, item);
|
|
1615
|
+
}
|
|
1616
|
+
syncRegenerateCount();
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* "$10.46–$11.08/yr" from the cheapest registrar to the dearest one that publishes a price, in
|
|
1621
|
+
* the cheapest one's currency, then what renewing costs when it differs from the first year,
|
|
1622
|
+
* and the minimum term with what it costs upfront. A premium name has its own price, so it says
|
|
1623
|
+
* so instead of showing the standard one.
|
|
1624
|
+
*/
|
|
1625
|
+
function priceSummary(item, offers) {
|
|
1626
|
+
const tld = item.tld ? `.${item.tld}` : '';
|
|
1627
|
+
if (!offers.length) return `No registrar in our list sells ${tld}`;
|
|
1628
|
+
if (item.premium) return Copy.PremiumPrice;
|
|
1629
|
+
const lead = offers.find((offer) => offer.price)?.price;
|
|
1630
|
+
if (!lead) return Copy.NoPrices;
|
|
1631
|
+
const prices = offers
|
|
1632
|
+
.map((offer) => offer.price)
|
|
1633
|
+
.filter((price) => price?.currency === lead.currency);
|
|
1634
|
+
const parts = [
|
|
1635
|
+
`${range(
|
|
1636
|
+
prices.map((price) => price.registration),
|
|
1637
|
+
lead.currency,
|
|
1638
|
+
)}/yr`,
|
|
1639
|
+
];
|
|
1640
|
+
if (prices.some((price) => price.renewal !== price.registration)) {
|
|
1641
|
+
parts.push(
|
|
1642
|
+
`renews at ${range(
|
|
1643
|
+
prices.map((price) => price.renewal),
|
|
1644
|
+
lead.currency,
|
|
1645
|
+
)}`,
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1648
|
+
if (lead.minYears > 1) {
|
|
1649
|
+
const upfront = money(lead.registration * lead.minYears, lead.currency);
|
|
1650
|
+
parts.push(`${minimumTerm(lead.minYears)} (${upfront} upfront)`);
|
|
1651
|
+
}
|
|
1652
|
+
return parts.join(', ');
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/** "$10.46" or "$10.46–$11.08". */
|
|
1656
|
+
function range(amounts, currency) {
|
|
1657
|
+
const low = money(Math.min(...amounts), currency);
|
|
1658
|
+
const high = money(Math.max(...amounts), currency);
|
|
1659
|
+
return low === high ? low : `${low}${RANGE_DASH}${high}`;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function minimumTerm(years) {
|
|
1663
|
+
return `${years}${NO_BREAK_HYPHEN}year${NO_BREAK_SPACE}minimum`;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
/** "$28.12/yr, renews at $51.80" or "$10.46/yr": a registrar's own price. */
|
|
1667
|
+
function offerPrice(price) {
|
|
1668
|
+
const first = `${money(price.registration, price.currency)}/yr`;
|
|
1669
|
+
if (price.renewal === price.registration) return first;
|
|
1670
|
+
return `${first}, renews at ${money(price.renewal, price.currency)}`;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
/** A link to a registrar's page for the name; the host opens it, as the sandbox blocks navigation. */
|
|
1674
|
+
function registrarLink(offer, className) {
|
|
1675
|
+
const link = element('a', className);
|
|
1676
|
+
link.href = offer.url;
|
|
1677
|
+
link.rel = 'noopener';
|
|
1678
|
+
link.addEventListener('click', (event) => {
|
|
1679
|
+
event.preventDefault();
|
|
1680
|
+
openLink(offer.url);
|
|
1681
|
+
});
|
|
1682
|
+
return link;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* Every registrar that sells the name, in the tool's order. Each row is one link, the whole row
|
|
1687
|
+
* clickable: the registrar, its price and the arrow in columns that line up from row to row.
|
|
1688
|
+
*/
|
|
1689
|
+
function registrarList(offers, domain) {
|
|
1690
|
+
const list = element('ul', 'registrars');
|
|
1691
|
+
list.id = uniqueId('registrars');
|
|
1692
|
+
list.hidden = true;
|
|
1693
|
+
list.setAttribute('aria-label', `Registrars for ${domain}`);
|
|
1694
|
+
for (const offer of offers) {
|
|
1695
|
+
const link = registrarLink(offer, 'registrar');
|
|
1696
|
+
const price = offer.price ? offerPrice(offer.price) : Copy.PriceOnSite;
|
|
1697
|
+
link.append(
|
|
1698
|
+
element('span', 'registrar__name', offer.registrar),
|
|
1699
|
+
element('span', 'registrar__price', price),
|
|
1700
|
+
icon(Icon.External),
|
|
1701
|
+
);
|
|
1702
|
+
const item = element('li');
|
|
1703
|
+
item.append(link);
|
|
1704
|
+
list.append(item);
|
|
1705
|
+
}
|
|
1706
|
+
return list;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// ── The price table: domains down the side, registrars across the top ───────────────────────
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* The registrars across the top: only those with a public price for at least one of the names,
|
|
1713
|
+
* in one order for every row - by the place each one takes in the tool's price-sorted lists on
|
|
1714
|
+
* average, so the usually cheapest come first. The rest go to the "Others" column.
|
|
1715
|
+
*/
|
|
1716
|
+
function pricedColumns(free) {
|
|
1717
|
+
const places = new Map();
|
|
1718
|
+
for (const item of free) {
|
|
1719
|
+
(item.buy ?? []).forEach((offer, place) => {
|
|
1720
|
+
if (!offer.price) return;
|
|
1721
|
+
const seen = places.get(offer.registrar) ?? [];
|
|
1722
|
+
places.set(offer.registrar, [...seen, place]);
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
const average = (list) => list.reduce((sum, place) => sum + place, 0) / list.length;
|
|
1726
|
+
return [...places.entries()]
|
|
1727
|
+
.sort(([, a], [, b]) => average(a) - average(b))
|
|
1728
|
+
.map(([registrar]) => registrar);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
/** A name's registrars that have no column of their own, in the tool's order. */
|
|
1732
|
+
function otherOffers(item, columns) {
|
|
1733
|
+
return (item.buy ?? []).filter((offer) => !columns.includes(offer.registrar));
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/** A name's first-year price at a registrar, for sorting; none when it has no public price. */
|
|
1737
|
+
function priceAt(item, registrar) {
|
|
1738
|
+
return (item.buy ?? []).find((offer) => offer.registrar === registrar)?.price?.registration;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/** The free domains in the table's sort; names without a price at the sorted registrar go last. */
|
|
1742
|
+
function sorted(items) {
|
|
1743
|
+
const { column, direction } = state.sort;
|
|
1744
|
+
if (!column) return items;
|
|
1745
|
+
const sign = direction === SortDirection.Ascending ? 1 : -1;
|
|
1746
|
+
const byName = (a, b) => (a.display || a.domain).localeCompare(b.display || b.domain);
|
|
1747
|
+
if (column === DOMAIN_COLUMN) return [...items].sort((a, b) => sign * byName(a, b));
|
|
1748
|
+
return [...items].sort((a, b) => {
|
|
1749
|
+
const priceA = priceAt(a, column);
|
|
1750
|
+
const priceB = priceAt(b, column);
|
|
1751
|
+
if (priceA === undefined || priceB === undefined) {
|
|
1752
|
+
return (priceA === undefined) - (priceB === undefined) || byName(a, b);
|
|
1753
|
+
}
|
|
1754
|
+
return sign * (priceA - priceB) || byName(a, b);
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/** A click on a column's heading sorts by it; a second click turns the order around. */
|
|
1759
|
+
function sortBy(column) {
|
|
1760
|
+
const same = state.sort.column === column;
|
|
1761
|
+
const ascending = !same || state.sort.direction === SortDirection.Descending;
|
|
1762
|
+
state.sort = {
|
|
1763
|
+
column,
|
|
1764
|
+
direction: ascending ? SortDirection.Ascending : SortDirection.Descending,
|
|
1765
|
+
};
|
|
1766
|
+
renderFree();
|
|
1767
|
+
content.querySelector(`[data-column="${CSS.escape(column)}"] button`)?.focus();
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/** A heading that sorts its column, with aria-sort saying how the table is sorted now. */
|
|
1771
|
+
function sortableHeading(text, column) {
|
|
1772
|
+
const cell = element('th');
|
|
1773
|
+
cell.scope = 'col';
|
|
1774
|
+
cell.dataset.column = column;
|
|
1775
|
+
const sortedNow = state.sort.column === column;
|
|
1776
|
+
if (sortedNow) cell.setAttribute('aria-sort', state.sort.direction);
|
|
1777
|
+
const node = button('sort', text, () => sortBy(column));
|
|
1778
|
+
node.append(icon(Icon.Sort));
|
|
1779
|
+
if (sortedNow) node.classList.add(`sort--${state.sort.direction}`);
|
|
1780
|
+
cell.append(node);
|
|
1781
|
+
return cell;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/** The free domains as a table of prices: every price a link to that registrar. */
|
|
1785
|
+
function priceTable(free) {
|
|
1786
|
+
const columns = pricedColumns(free);
|
|
1787
|
+
const hasOthers = free.some((item) => otherOffers(item, columns).length > 0);
|
|
1788
|
+
const table = element('table', 'matrix');
|
|
1789
|
+
const caption = element('caption', 'visually-hidden', Copy.TableCaption);
|
|
1790
|
+
const headRow = element('tr');
|
|
1791
|
+
headRow.append(sortableHeading(Copy.DomainHeading, DOMAIN_COLUMN));
|
|
1792
|
+
// The buttons' columns: no visible heading, a spoken one.
|
|
1793
|
+
for (const text of actionHeadings()) {
|
|
1794
|
+
const cell = element('th', 'matrix__action');
|
|
1795
|
+
cell.scope = 'col';
|
|
1796
|
+
cell.append(hiddenText(text));
|
|
1797
|
+
headRow.append(cell);
|
|
1798
|
+
}
|
|
1799
|
+
for (const registrar of columns) headRow.append(sortableHeading(registrar, registrar));
|
|
1800
|
+
if (hasOthers) {
|
|
1801
|
+
const others = element('th', '', Copy.OthersHeading);
|
|
1802
|
+
others.scope = 'col';
|
|
1803
|
+
headRow.append(others);
|
|
1804
|
+
}
|
|
1805
|
+
const head = element('thead');
|
|
1806
|
+
head.append(headRow);
|
|
1807
|
+
const body = element('tbody');
|
|
1808
|
+
table.append(caption, head, body);
|
|
1809
|
+
const wrap = element('div', 'matrix-wrap');
|
|
1810
|
+
wrap.append(table);
|
|
1811
|
+
const more = paged(
|
|
1812
|
+
free,
|
|
1813
|
+
body,
|
|
1814
|
+
(item) => tableRows(item, columns, hasOthers),
|
|
1815
|
+
(left) => `…and ${left} more free`,
|
|
1816
|
+
);
|
|
1817
|
+
watchOverflow(wrap);
|
|
1818
|
+
return [wrap, ...more];
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
/** Marks the table's frame while it can scroll sideways, so a fade shows there is more. */
|
|
1822
|
+
function watchOverflow(wrap) {
|
|
1823
|
+
const update = () => {
|
|
1824
|
+
const more = wrap.scrollWidth - wrap.clientWidth - wrap.scrollLeft > 1;
|
|
1825
|
+
wrap.classList.toggle('matrix-wrap--more', more);
|
|
1826
|
+
};
|
|
1827
|
+
wrap.addEventListener('scroll', update, { passive: true });
|
|
1828
|
+
new ResizeObserver(update).observe(wrap);
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/** A name's row and, when it has other registrars, the row that opens under it with their links. */
|
|
1832
|
+
function tableRows(item, columns, hasOthers) {
|
|
1833
|
+
const domain = item.display || item.domain;
|
|
1834
|
+
const row = element('tr');
|
|
1835
|
+
row.append(domainCell(item), actionCell(saveButton(item)));
|
|
1836
|
+
if (state.can.message) row.append(actionCell(similarButton(item)));
|
|
1837
|
+
const offers = new Map((item.buy ?? []).map((offer) => [offer.registrar, offer]));
|
|
1838
|
+
for (const registrar of columns) row.append(priceCell(offers.get(registrar), domain));
|
|
1839
|
+
if (!hasOthers) return [row];
|
|
1840
|
+
const others = otherOffers(item, columns);
|
|
1841
|
+
if (!others.length) {
|
|
1842
|
+
row.append(noneCell());
|
|
1843
|
+
return [row];
|
|
1844
|
+
}
|
|
1845
|
+
const span = 1 + actionHeadings().length + columns.length + 1;
|
|
1846
|
+
const extra = otherLinksRow(others, span, domain);
|
|
1847
|
+
const cell = element('td');
|
|
1848
|
+
cell.append(
|
|
1849
|
+
disclosure('toggle matrix__toggle', `${others.length} more`, extra, ` for ${domain}`),
|
|
1850
|
+
);
|
|
1851
|
+
row.append(cell);
|
|
1852
|
+
return [row, extra];
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/** The headings of the buttons' columns: Save, and Similar when the host takes messages. */
|
|
1856
|
+
function actionHeadings() {
|
|
1857
|
+
return state.can.message ? [Copy.Save, Copy.Similar] : [Copy.Save];
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
/** A column of its own for one button, so the buttons line up row after row. */
|
|
1861
|
+
function actionCell(node) {
|
|
1862
|
+
const cell = element('td', 'matrix__action');
|
|
1863
|
+
cell.append(node);
|
|
1864
|
+
return cell;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function domainCell(item) {
|
|
1868
|
+
const cell = element('th', 'matrix__domain');
|
|
1869
|
+
cell.scope = 'row';
|
|
1870
|
+
cell.append(element('span', 'result__domain', item.display || item.domain));
|
|
1871
|
+
if (item.premium)
|
|
1872
|
+
cell.append(element('span', 'matrix__note matrix__note--premium', Copy.Premium));
|
|
1873
|
+
const minYears = (item.buy ?? []).find((offer) => offer.price)?.price.minYears ?? 1;
|
|
1874
|
+
if (minYears > 1) cell.append(element('span', 'matrix__note', minimumTerm(minYears)));
|
|
1875
|
+
return cell;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
/**
|
|
1879
|
+
* A price that links to the registrar, with what renewing costs under it when that differs;
|
|
1880
|
+
* "site" where the registrar publishes no price, a dash where it does not sell the name.
|
|
1881
|
+
*/
|
|
1882
|
+
function priceCell(offer, domain) {
|
|
1883
|
+
if (!offer) return noneCell();
|
|
1884
|
+
const cell = element('td');
|
|
1885
|
+
const className = offer.price ? 'matrix__link' : 'matrix__link matrix__link--site';
|
|
1886
|
+
const link = registrarLink(offer, className);
|
|
1887
|
+
if (offer.price) {
|
|
1888
|
+
const { price } = offer;
|
|
1889
|
+
link.append(money(price.registration, price.currency));
|
|
1890
|
+
if (price.renewal !== price.registration) {
|
|
1891
|
+
link.append(
|
|
1892
|
+
element('span', 'matrix__renewal', `renews ${money(price.renewal, price.currency)}`),
|
|
1893
|
+
);
|
|
1894
|
+
}
|
|
1895
|
+
} else {
|
|
1896
|
+
link.append(Copy.SiteCell);
|
|
1897
|
+
}
|
|
1898
|
+
link.append(hiddenText(` at ${offer.registrar} for ${domain}`));
|
|
1899
|
+
cell.append(link);
|
|
1900
|
+
return cell;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
function noneCell() {
|
|
1904
|
+
const cell = element('td');
|
|
1905
|
+
const mark = element('span', 'matrix__none', NOT_SOLD_MARK);
|
|
1906
|
+
mark.setAttribute('aria-hidden', TRUE);
|
|
1907
|
+
cell.append(mark, hiddenText(Copy.NotSold));
|
|
1908
|
+
return cell;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/** The row under a name: its other registrars as links, prices on their own sites. */
|
|
1912
|
+
function otherLinksRow(offers, span, domain) {
|
|
1913
|
+
const row = element('tr', 'matrix__extra');
|
|
1914
|
+
row.id = uniqueId('others');
|
|
1915
|
+
row.hidden = true;
|
|
1916
|
+
const cell = element('td');
|
|
1917
|
+
cell.colSpan = span;
|
|
1918
|
+
const caption = element('span', 'matrix__extra-label', `Prices on their sites for ${domain}:`);
|
|
1919
|
+
const links = element('span', 'matrix__extra-links');
|
|
1920
|
+
for (const offer of offers) {
|
|
1921
|
+
const link = registrarLink(offer, 'matrix__chip');
|
|
1922
|
+
link.append(offer.registrar, icon(Icon.External));
|
|
1923
|
+
links.append(link);
|
|
1924
|
+
}
|
|
1925
|
+
cell.append(caption, links);
|
|
1926
|
+
row.append(cell);
|
|
1927
|
+
return row;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// ── Taken, reserved and unverified names ─────────────────────────────────────────────────────
|
|
1931
|
+
|
|
1932
|
+
function group(title, items, chip, actions) {
|
|
1933
|
+
const box = element('section', 'group');
|
|
1934
|
+
const heading = element('div', 'group__header');
|
|
1935
|
+
heading.append(element('h2', 'group__label', `${title} · ${items.length}`));
|
|
1936
|
+
if (actions) heading.append(actions);
|
|
1937
|
+
const chips = element('ul', 'chips');
|
|
1938
|
+
chips.setAttribute('aria-label', title);
|
|
1939
|
+
const more = paged(
|
|
1940
|
+
items,
|
|
1941
|
+
chips,
|
|
1942
|
+
(item) => [chip(item)],
|
|
1943
|
+
(left) => `Show ${left} more`,
|
|
1944
|
+
);
|
|
1945
|
+
box.append(heading, chips, ...more);
|
|
1946
|
+
return box;
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
/**
|
|
1950
|
+
* "Try other TLDs": asks the chat to check the taken names in other TLDs - those not already
|
|
1951
|
+
* free in one of the checked ones.
|
|
1952
|
+
*/
|
|
1953
|
+
function takenActions(taken) {
|
|
1954
|
+
if (!state.can.message) return undefined;
|
|
1955
|
+
const freeNames = new Set(state.free.map(label));
|
|
1956
|
+
const names = [...new Set(taken.map(label))].filter((name) => !freeNames.has(name));
|
|
1957
|
+
if (!names.length) return undefined;
|
|
1958
|
+
const ask = button('toggle', Copy.OtherTlds, () => sendToChat(Ask.otherTlds(names)));
|
|
1959
|
+
ask.prepend(icon(Icon.Sparkles));
|
|
1960
|
+
return ask;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
/** A taken name; one being deleted says so, visibly, as it may be free soon. */
|
|
1964
|
+
function takenChip(item) {
|
|
1965
|
+
const dropping = item.registration?.dropping;
|
|
1966
|
+
const chip = element(
|
|
1967
|
+
'li',
|
|
1968
|
+
dropping ? 'chip chip--dropping' : 'chip',
|
|
1969
|
+
item.display || item.domain,
|
|
1970
|
+
);
|
|
1971
|
+
if (dropping) {
|
|
1972
|
+
chip.append(element('span', 'chip__note', Copy.Dropping));
|
|
1973
|
+
chip.title = Copy.DroppingNote;
|
|
1974
|
+
}
|
|
1975
|
+
const expires = item.registration?.expires?.slice(0, DATE_LENGTH);
|
|
1976
|
+
if (!dropping && expires) {
|
|
1977
|
+
chip.title = `Registered until ${expires}`;
|
|
1978
|
+
chip.append(hiddenText(`, registered until ${expires}`));
|
|
1979
|
+
}
|
|
1980
|
+
return chip;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
/** A chip in the given color, with the tool's note for screen readers and as a tooltip. */
|
|
1984
|
+
function notedChip(modifier) {
|
|
1985
|
+
return (item) => {
|
|
1986
|
+
const chip = element('li', `chip ${modifier}`, item.display || item.domain);
|
|
1987
|
+
if (item.note) {
|
|
1988
|
+
chip.title = item.note;
|
|
1989
|
+
chip.append(hiddenText(`: ${item.note}`));
|
|
1990
|
+
}
|
|
1991
|
+
return chip;
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function notes(warnings) {
|
|
1996
|
+
const list = element('ul', 'notes');
|
|
1997
|
+
for (const warning of warnings) list.append(element('li', '', warning));
|
|
1998
|
+
return list;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// ── Messages from the host ────────────────────────────────────────────────────────────────────
|
|
2002
|
+
|
|
2003
|
+
/** Handlers of the host's notifications. */
|
|
2004
|
+
const notificationHandlers = new Map()
|
|
2005
|
+
.set(Method.ToolInput, (params) => showChecking(params?.arguments))
|
|
2006
|
+
.set(Method.ToolResult, (params) => showResult(params))
|
|
2007
|
+
.set(Method.ToolCancelled, () => showMessage(Copy.Cancelled))
|
|
2008
|
+
.set(Method.HostContextChanged, (params) => applyHostContext(params));
|
|
2009
|
+
|
|
2010
|
+
window.addEventListener('message', (event) => {
|
|
2011
|
+
if (event.source !== window.parent) return;
|
|
2012
|
+
const message = event.data;
|
|
2013
|
+
if (message?.jsonrpc !== JSONRPC) return;
|
|
2014
|
+
if (message.method === undefined) {
|
|
2015
|
+
settle(message);
|
|
2016
|
+
} else if (message.id !== undefined) {
|
|
2017
|
+
// A request from the host (a teardown, a ping): nothing to clean up, so agree at once.
|
|
2018
|
+
post({ id: message.id, result: {} });
|
|
2019
|
+
} else {
|
|
2020
|
+
notificationHandlers.get(message.method)?.(message.params);
|
|
2021
|
+
}
|
|
2022
|
+
});
|
|
2023
|
+
|
|
2024
|
+
// ── Start: introduce the panel, take the host's look, then follow the content's size ──────────
|
|
2025
|
+
|
|
2026
|
+
/** The class that shows the panel, once it has the host's theme (or after a short wait). */
|
|
2027
|
+
const READY_CLASS = 'is-ready';
|
|
2028
|
+
/** How long the panel waits for the host's theme before showing itself anyway. */
|
|
2029
|
+
const SHOW_ANYWAY_MS = 150;
|
|
2030
|
+
|
|
2031
|
+
function showPanel() {
|
|
2032
|
+
document.documentElement.classList.add(READY_CLASS);
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
/** What the host said it can do, in the shape the panel checks. */
|
|
2036
|
+
function readCapabilities(capabilities) {
|
|
2037
|
+
return {
|
|
2038
|
+
openLinks: Boolean(capabilities?.openLinks),
|
|
2039
|
+
message: Boolean(capabilities?.message),
|
|
2040
|
+
updateModelContext: Boolean(capabilities?.updateModelContext),
|
|
2041
|
+
downloadFile: Boolean(capabilities?.downloadFile),
|
|
2042
|
+
serverTools: Boolean(capabilities?.serverTools),
|
|
2043
|
+
sampling: Boolean(capabilities?.sampling),
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
async function start() {
|
|
2048
|
+
applyTheme();
|
|
2049
|
+
showChecking();
|
|
2050
|
+
window.setTimeout(showPanel, SHOW_ANYWAY_MS);
|
|
2051
|
+
const observer = new ResizeObserver(() => window.requestAnimationFrame(reportSize));
|
|
2052
|
+
observer.observe(document.documentElement);
|
|
2053
|
+
observer.observe(document.body);
|
|
2054
|
+
const result = await request(Method.Initialize, {
|
|
2055
|
+
appInfo: APP_INFO,
|
|
2056
|
+
appCapabilities: {},
|
|
2057
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
2058
|
+
});
|
|
2059
|
+
state.can = readCapabilities(result?.hostCapabilities);
|
|
2060
|
+
applyHostContext(result?.hostContext);
|
|
2061
|
+
showPanel();
|
|
2062
|
+
notify(Method.Initialized);
|
|
2063
|
+
reportSize();
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
start();
|