@gusnips/react 0.9.24 → 0.9.26
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 +173 -36
- package/dist/api-client.d.ts +51 -51
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +3 -3
- package/dist/api-error.d.ts +11 -11
- package/dist/api-error.js +11 -11
- package/dist/auth-email.d.ts +40 -0
- package/dist/auth-email.d.ts.map +1 -0
- package/dist/auth-email.js +106 -0
- package/dist/auth-email.js.map +1 -0
- package/dist/auth-store.d.ts +23 -23
- package/dist/auth-store.js +13 -13
- package/dist/commands.d.ts +80 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +192 -0
- package/dist/commands.js.map +1 -0
- package/dist/copy.d.ts +3 -3
- package/dist/copy.js +3 -3
- package/dist/deploy-recovery.d.ts +40 -40
- package/dist/deploy-recovery.js +40 -40
- package/dist/describe-error.d.ts +68 -68
- package/dist/describe-error.d.ts.map +1 -1
- package/dist/describe-error.js +5 -5
- package/dist/error-boundary.d.ts +12 -12
- package/dist/guards.d.ts +15 -15
- package/dist/guards.js +3 -3
- package/dist/hydrate.d.ts +15 -16
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +9 -9
- package/dist/hydrate.js.map +1 -1
- package/dist/i18n.d.ts +15 -16
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +5 -6
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/internal-path.d.ts +8 -7
- package/dist/internal-path.d.ts.map +1 -1
- package/dist/internal-path.js +8 -7
- package/dist/internal-path.js.map +1 -1
- package/dist/prerender-contract.d.ts +7 -7
- package/dist/prerender-contract.js +7 -7
- package/dist/query.d.ts +16 -17
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +2 -2
- package/dist/query.js.map +1 -1
- package/dist/sse.d.ts +19 -20
- package/dist/sse.d.ts.map +1 -1
- package/dist/sse.js +18 -19
- package/dist/sse.js.map +1 -1
- package/dist/states.d.ts +12 -12
- package/dist/states.d.ts.map +1 -1
- package/dist/supabase-session.d.ts +47 -48
- package/dist/supabase-session.d.ts.map +1 -1
- package/dist/supabase-session.js +35 -35
- package/dist/supabase-session.js.map +1 -1
- package/dist/theme.d.ts +4 -4
- package/dist/theme.js +4 -4
- package/dist/ui/class-name.d.ts +3 -4
- package/dist/ui/class-name.d.ts.map +1 -1
- package/dist/ui/class-name.js +3 -4
- package/dist/ui/class-name.js.map +1 -1
- package/dist/ui/combobox.d.ts +11 -11
- package/dist/ui/combobox.d.ts.map +1 -1
- package/dist/ui/dialog.d.ts +5 -5
- package/dist/ui/dialog.js +2 -2
- package/dist/ui/drawer.d.ts +10 -10
- package/dist/ui/drawer.d.ts.map +1 -1
- package/dist/ui/drawer.js +2 -2
- package/dist/ui/input.d.ts +2 -2
- package/dist/ui/input.js +2 -2
- package/dist/ui/menu.d.ts +9 -9
- package/dist/ui/menu.d.ts.map +1 -1
- package/dist/ui/menu.js +4 -4
- package/dist/ui/menu.js.map +1 -1
- package/dist/ui/select.d.ts +12 -12
- package/dist/ui/select.d.ts.map +1 -1
- package/dist/ui/select.js +3 -3
- package/dist/ui/tabs.d.ts +14 -15
- package/dist/ui/tabs.d.ts.map +1 -1
- package/dist/ui/tabs.js +13 -14
- package/dist/ui/tabs.js.map +1 -1
- package/package.json +5 -1
- package/src/api-client.ts +51 -51
- package/src/api-error.ts +11 -11
- package/src/auth-email.test.ts +145 -0
- package/src/auth-email.ts +112 -0
- package/src/auth-store.ts +23 -23
- package/src/commands-hook.test.ts +444 -0
- package/src/commands.test.ts +245 -0
- package/src/commands.ts +268 -0
- package/src/copy.ts +3 -3
- package/src/deploy-recovery.ts +40 -40
- package/src/describe-error.ts +68 -68
- package/src/error-boundary.tsx +12 -12
- package/src/guards.tsx +15 -15
- package/src/hydrate.ts +15 -16
- package/src/i18n.ts +15 -16
- package/src/index.ts +2 -0
- package/src/internal-path.ts +8 -7
- package/src/prerender-contract.ts +7 -7
- package/src/query.ts +16 -17
- package/src/sse.ts +19 -20
- package/src/states.ts +12 -12
- package/src/supabase-session.ts +46 -47
- package/src/theme.ts +4 -4
- package/src/ui/class-name.ts +3 -4
- package/src/ui/combobox.tsx +11 -11
- package/src/ui/dialog.tsx +5 -5
- package/src/ui/drawer.tsx +10 -10
- package/src/ui/input.tsx +2 -2
- package/src/ui/menu.tsx +8 -8
- package/src/ui/select.tsx +12 -12
- package/src/ui/tabs.tsx +14 -15
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { createElement } from "react";
|
|
3
|
+
import { renderToString } from "react-dom/server";
|
|
4
|
+
import { describe, expect, expectTypeOf, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
completeCommand,
|
|
7
|
+
matchCommands,
|
|
8
|
+
parseCommand,
|
|
9
|
+
resolveCommand,
|
|
10
|
+
useSlashCommands,
|
|
11
|
+
} from "./commands.ts";
|
|
12
|
+
|
|
13
|
+
const help = {
|
|
14
|
+
name: "help",
|
|
15
|
+
description: "Show available commands",
|
|
16
|
+
aliases: ["assist", "GUIDE"],
|
|
17
|
+
tags: ["manual"],
|
|
18
|
+
};
|
|
19
|
+
const schedule = {
|
|
20
|
+
name: "schedule",
|
|
21
|
+
description: "Run a task later",
|
|
22
|
+
aliases: ["later", "book"],
|
|
23
|
+
tags: ["timer"],
|
|
24
|
+
takesArg: true,
|
|
25
|
+
};
|
|
26
|
+
const commands = [help, schedule];
|
|
27
|
+
|
|
28
|
+
describe("parseCommand", () => {
|
|
29
|
+
it.each([
|
|
30
|
+
{ text: "/", expected: { name: "" } },
|
|
31
|
+
{ text: "/ ", expected: { name: "", arg: "" } },
|
|
32
|
+
{ text: "/HELP", expected: { name: "help" } },
|
|
33
|
+
{ text: "/help ", expected: { name: "help", arg: "" } },
|
|
34
|
+
{ text: " \t/HeLp", expected: { name: "help" } },
|
|
35
|
+
{
|
|
36
|
+
text: "/SCHEDULE Keep these spaces\tand case ",
|
|
37
|
+
expected: { name: "schedule", arg: "Keep these spaces\tand case " },
|
|
38
|
+
},
|
|
39
|
+
])("parses a single line: $text", ({ text, expected }) => {
|
|
40
|
+
expect(parseCommand(text)).toEqual(expected);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it.each(["/tmp/file", "/help/topic", "//help"])(
|
|
44
|
+
"keeps a slash path as an unknown command token: %s",
|
|
45
|
+
(text) => {
|
|
46
|
+
const name = text.slice(1);
|
|
47
|
+
expect(parseCommand(text)).toEqual({ name });
|
|
48
|
+
expect(resolveCommand(commands, name)).toEqual({ kind: "unknown", name });
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
it.each(["", " \t", "help", "Please /help", "https://example.test/help", "Hello /help there"])(
|
|
53
|
+
"ignores non-command prose: %s",
|
|
54
|
+
(text) => {
|
|
55
|
+
expect(parseCommand(text)).toBeNull();
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
it.each(["\n", "\r", "\r\n", "\u{2028}", "\u{2029}"])(
|
|
60
|
+
"rejects a line terminator anywhere: %j",
|
|
61
|
+
(separator) => {
|
|
62
|
+
for (const text of [`${separator}/help`, `/help${separator}`, `/help ${separator}topic`]) {
|
|
63
|
+
expect(parseCommand(text)).toBeNull();
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("resolveCommand", () => {
|
|
70
|
+
it("prefers an exact canonical name over an earlier alias", () => {
|
|
71
|
+
const alias = { ...schedule, aliases: ["help"] };
|
|
72
|
+
expect(resolveCommand([alias, help], "HELP")).toEqual({ kind: "matched", command: help });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it.each([
|
|
76
|
+
{ token: "guide", command: help },
|
|
77
|
+
{ token: "LATER", command: schedule },
|
|
78
|
+
])("matches an alias without case sensitivity: $token", ({ token, command }) => {
|
|
79
|
+
expect(resolveCommand(commands, token)).toEqual({ kind: "matched", command });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("reports aliases shared by different commands in registry order", () => {
|
|
83
|
+
const first = { ...schedule, aliases: ["shared", "SHARED"] };
|
|
84
|
+
const second = { ...help, aliases: ["shared"] };
|
|
85
|
+
expect(resolveCommand([first, second], "SHARED")).toEqual({
|
|
86
|
+
kind: "ambiguous",
|
|
87
|
+
name: "SHARED",
|
|
88
|
+
commands: [first, second],
|
|
89
|
+
});
|
|
90
|
+
expect(resolveCommand([first], "shared")).toEqual({ kind: "matched", command: first });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("reports duplicate canonical names even when an alias also matches", () => {
|
|
94
|
+
const duplicate = { ...schedule, name: "HELP" };
|
|
95
|
+
const alias = { ...schedule, aliases: ["help"] };
|
|
96
|
+
expect(resolveCommand([help, alias, duplicate], "help")).toEqual({
|
|
97
|
+
kind: "ambiguous",
|
|
98
|
+
name: "help",
|
|
99
|
+
commands: [help, duplicate],
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it.each(["manual", "timer", "Show available commands", "hel", "lat", "missing"])(
|
|
104
|
+
"does not execute a tag, description, prefix or unknown token: %s",
|
|
105
|
+
(name) => {
|
|
106
|
+
expect(resolveCommand(commands, name)).toEqual({ kind: "unknown", name });
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
it("never executes an empty token, even if the registry contains one", () => {
|
|
111
|
+
expect(resolveCommand([{ ...help, name: "", aliases: [""] }], "")).toEqual({
|
|
112
|
+
kind: "unknown",
|
|
113
|
+
name: "",
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("matchCommands", () => {
|
|
119
|
+
it("ranks names, aliases, prefixes, tags and descriptions with stable ties", () => {
|
|
120
|
+
const registry = [
|
|
121
|
+
{ name: "description", description: "Find a task", aliases: [], tags: [] },
|
|
122
|
+
{ name: "tag", description: "", aliases: [], tags: ["finder"] },
|
|
123
|
+
{ name: "shortcut", description: "", aliases: ["finder"], tags: [] },
|
|
124
|
+
{ name: "finding", description: "", aliases: [], tags: [] },
|
|
125
|
+
{ name: "alias", description: "", aliases: ["find"], tags: [] },
|
|
126
|
+
{ name: "find", description: "", aliases: [], tags: [] },
|
|
127
|
+
{ name: "second-tag", description: "", aliases: [], tags: ["find"] },
|
|
128
|
+
{ name: "second-description", description: "Find another task", aliases: [], tags: [] },
|
|
129
|
+
{ name: "unrelated", description: "", aliases: [], tags: [] },
|
|
130
|
+
];
|
|
131
|
+
expect(matchCommands(registry, "find").map((command) => command.name)).toEqual([
|
|
132
|
+
"find",
|
|
133
|
+
"alias",
|
|
134
|
+
"finding",
|
|
135
|
+
"shortcut",
|
|
136
|
+
"tag",
|
|
137
|
+
"second-tag",
|
|
138
|
+
"description",
|
|
139
|
+
"second-description",
|
|
140
|
+
]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const translated = [
|
|
144
|
+
{
|
|
145
|
+
name: "ação",
|
|
146
|
+
description: "A próxima revisão",
|
|
147
|
+
aliases: ["café"],
|
|
148
|
+
tags: ["rápido"],
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "mañana",
|
|
152
|
+
description: "Resumen de la reunión",
|
|
153
|
+
aliases: ["después"],
|
|
154
|
+
tags: ["configuración"],
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
it.each([
|
|
158
|
+
{ query: " ACAO ", name: "ação" },
|
|
159
|
+
{ query: "ac\u{0327}a\u{0303}o", name: "ação" },
|
|
160
|
+
{ query: "CAFE", name: "ação" },
|
|
161
|
+
{ query: "RAPIDO", name: "ação" },
|
|
162
|
+
{ query: "PROXIMA REVISAO", name: "ação" },
|
|
163
|
+
{ query: "MANANA", name: "mañana" },
|
|
164
|
+
{ query: "DESPUES", name: "mañana" },
|
|
165
|
+
{ query: "CONFIGURACION", name: "mañana" },
|
|
166
|
+
{ query: "REUNION", name: "mañana" },
|
|
167
|
+
])("matches case and accents across search fields: $query", ({ query, name }) => {
|
|
168
|
+
expect(matchCommands(translated, query).map((command) => command.name)).toEqual([name]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("returns only the first descriptor for each canonical name", () => {
|
|
172
|
+
const duplicate = { ...help, name: "HELP" };
|
|
173
|
+
const registry = [help, schedule, duplicate];
|
|
174
|
+
expect(matchCommands(registry, "help")).toEqual([help]);
|
|
175
|
+
expect(matchCommands(registry, "")).toEqual(commands);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("does not turn repeated or shared aliases into duplicate rows", () => {
|
|
179
|
+
const first = { ...help, aliases: ["shared", "shared", "SHARED"] };
|
|
180
|
+
const second = { ...schedule, aliases: ["shared"] };
|
|
181
|
+
expect(matchCommands([first, second], "shared")).toEqual([first, second]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("returns original descriptors and preserves their extra fields in the type", () => {
|
|
185
|
+
const custom = { ...help, execute: () => "done" };
|
|
186
|
+
const [matched] = matchCommands([custom], "assist");
|
|
187
|
+
expectTypeOf(matched).toEqualTypeOf<typeof custom | undefined>();
|
|
188
|
+
expect(matched).toBe(custom);
|
|
189
|
+
expect(matched?.execute()).toBe("done");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it.each(["", " \t "])("keeps registry order for an empty query: %j", (query) => {
|
|
193
|
+
expect(matchCommands([schedule, help], query)).toEqual([schedule, help]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("returns no rows for an unrelated query", () => {
|
|
197
|
+
expect(matchCommands(commands, "missing")).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("completeCommand", () => {
|
|
202
|
+
it.each([
|
|
203
|
+
["/later", "/schedule "],
|
|
204
|
+
["/sche", "/schedule "],
|
|
205
|
+
["/SCHEDULE", "/schedule "],
|
|
206
|
+
["/", "/schedule "],
|
|
207
|
+
["/later ", "/schedule "],
|
|
208
|
+
[" \t/later Keep these spaces\tand case ", "/schedule Keep these spaces\tand case "],
|
|
209
|
+
])("canonicalizes an argument-taking command: %s", (text, expected) => {
|
|
210
|
+
expect(completeCommand(schedule, text)).toBe(expected);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it.each([
|
|
214
|
+
["/assi", "/help"],
|
|
215
|
+
["/GUIDE", "/help"],
|
|
216
|
+
["/", "/help"],
|
|
217
|
+
["/assist topic with spaces ", "/help topic with spaces "],
|
|
218
|
+
])("canonicalizes a plain command without dropping its arguments: %s", (text, expected) => {
|
|
219
|
+
expect(completeCommand(help, text)).toBe(expected);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("useSlashCommands on the server", () => {
|
|
224
|
+
it("imports and renders in Node without browser globals or running callbacks", () => {
|
|
225
|
+
expect(typeof window).toBe("undefined");
|
|
226
|
+
expect(typeof document).toBe("undefined");
|
|
227
|
+
const onChange = vi.fn();
|
|
228
|
+
const onExecute = vi.fn();
|
|
229
|
+
const onError = vi.fn();
|
|
230
|
+
|
|
231
|
+
function Menu() {
|
|
232
|
+
const menu = useSlashCommands({ text: "/he", commands, onChange, onExecute, onError });
|
|
233
|
+
return createElement(
|
|
234
|
+
"output",
|
|
235
|
+
{ id: menu.listId },
|
|
236
|
+
`${menu.open}/${menu.active?.name}/${menu.items.length}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
expect(renderToString(createElement(Menu))).toContain("true/help/1");
|
|
241
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
242
|
+
expect(onExecute).not.toHaveBeenCalled();
|
|
243
|
+
expect(onError).not.toHaveBeenCalled();
|
|
244
|
+
});
|
|
245
|
+
});
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { useId, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface SlashCommand {
|
|
4
|
+
/** One token, without the leading slash. */
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
/** Other executable names. Tags only help search. */
|
|
8
|
+
aliases: readonly string[];
|
|
9
|
+
tags: readonly string[];
|
|
10
|
+
takesArg?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ParsedCommand {
|
|
14
|
+
name: string;
|
|
15
|
+
/** Undefined before a space; empty after a space with no argument yet. */
|
|
16
|
+
arg?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Multiline text and a slash inside a message are not commands. */
|
|
20
|
+
export function parseCommand(text: string): ParsedCommand | null {
|
|
21
|
+
if (/[\r\n\u{2028}\u{2029}]/u.test(text)) return null;
|
|
22
|
+
const match = /^\s*\/(\S*)(?:[^\S\r\n]+(.*))?$/u.exec(text);
|
|
23
|
+
if (!match) return null;
|
|
24
|
+
const name = (match[1] ?? "").toLowerCase();
|
|
25
|
+
return match[2] === undefined ? { name } : { name, arg: match[2] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type CommandResolution<T extends SlashCommand = SlashCommand> =
|
|
29
|
+
| { kind: "matched"; command: T }
|
|
30
|
+
| { kind: "unknown"; name: string }
|
|
31
|
+
| { kind: "ambiguous"; name: string; commands: readonly T[] };
|
|
32
|
+
|
|
33
|
+
/** Exact names win over aliases. Search tags never execute an action. */
|
|
34
|
+
export function resolveCommand<T extends SlashCommand>(
|
|
35
|
+
commands: readonly T[],
|
|
36
|
+
name: string,
|
|
37
|
+
): CommandResolution<T> {
|
|
38
|
+
const token = name.toLowerCase();
|
|
39
|
+
if (!token) return { kind: "unknown", name };
|
|
40
|
+
let matches = commands.filter((command) => command.name.toLowerCase() === token);
|
|
41
|
+
if (matches.length === 0) {
|
|
42
|
+
matches = commands.filter((command) =>
|
|
43
|
+
command.aliases.some((alias) => alias.toLowerCase() === token),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (matches.length > 1) return { kind: "ambiguous", name, commands: matches };
|
|
47
|
+
const command = matches[0];
|
|
48
|
+
return command ? { kind: "matched", command } : { kind: "unknown", name };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function searchText(text: string): string {
|
|
52
|
+
return text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function rank(command: SlashCommand, query: string): number {
|
|
56
|
+
if (!query) return 0;
|
|
57
|
+
const name = searchText(command.name);
|
|
58
|
+
const aliases = command.aliases.map(searchText);
|
|
59
|
+
if (name === query) return 0;
|
|
60
|
+
if (aliases.includes(query)) return 1;
|
|
61
|
+
if (name.startsWith(query)) return 2;
|
|
62
|
+
if (aliases.some((alias) => alias.startsWith(query))) return 3;
|
|
63
|
+
if (command.tags.some((tag) => searchText(tag).includes(query))) return 4;
|
|
64
|
+
if (searchText(command.description).includes(query)) return 5;
|
|
65
|
+
return Infinity;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One row per name, in registry order when two results have the same rank. */
|
|
69
|
+
export function matchCommands<T extends SlashCommand>(commands: readonly T[], query: string): T[] {
|
|
70
|
+
const token = searchText(query.trim());
|
|
71
|
+
const names = new Set<string>();
|
|
72
|
+
return commands
|
|
73
|
+
.filter((command) => {
|
|
74
|
+
const name = command.name.toLowerCase();
|
|
75
|
+
if (names.has(name)) return false;
|
|
76
|
+
names.add(name);
|
|
77
|
+
return true;
|
|
78
|
+
})
|
|
79
|
+
.map((command) => ({ command, rank: rank(command, token) }))
|
|
80
|
+
.filter((match) => Number.isFinite(match.rank))
|
|
81
|
+
.sort((a, b) => a.rank - b.rank)
|
|
82
|
+
.map((match) => match.command);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Replace only the name, keeping the argument already typed. */
|
|
86
|
+
export function completeCommand(command: SlashCommand, text: string): string {
|
|
87
|
+
const arg = parseCommand(text)?.arg;
|
|
88
|
+
if (arg !== undefined) return `/${command.name} ${arg}`;
|
|
89
|
+
return `/${command.name}${command.takesArg ? " " : ""}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type CommandProblem<T extends SlashCommand = SlashCommand> = Exclude<
|
|
93
|
+
CommandResolution<T>,
|
|
94
|
+
{ kind: "matched" }
|
|
95
|
+
>;
|
|
96
|
+
|
|
97
|
+
export interface CommandOptions<T extends SlashCommand> {
|
|
98
|
+
text: string;
|
|
99
|
+
/** Supply only commands allowed here, with current translated descriptions and tags. */
|
|
100
|
+
commands: readonly T[];
|
|
101
|
+
onChange: (text: string) => void;
|
|
102
|
+
/** The app owns authorization, errors, draft cleanup and attachments. */
|
|
103
|
+
onExecute: (command: T, arg: string | undefined, altKey: boolean) => void;
|
|
104
|
+
/** Required: show a way back to the available commands, never send this text to the model. */
|
|
105
|
+
onError: (problem: CommandProblem<T>) => void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Structural so React events and small test events both fit. */
|
|
109
|
+
export interface CommandKeyEvent {
|
|
110
|
+
key: string;
|
|
111
|
+
shiftKey?: boolean;
|
|
112
|
+
altKey?: boolean;
|
|
113
|
+
ctrlKey?: boolean;
|
|
114
|
+
metaKey?: boolean;
|
|
115
|
+
isComposing?: boolean;
|
|
116
|
+
keyCode?: number;
|
|
117
|
+
nativeEvent?: { isComposing?: boolean; keyCode?: number };
|
|
118
|
+
preventDefault: () => void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface SlashCommandMenu<T extends SlashCommand = SlashCommand> {
|
|
122
|
+
parsed: ParsedCommand | null;
|
|
123
|
+
open: boolean;
|
|
124
|
+
items: readonly T[];
|
|
125
|
+
index: number;
|
|
126
|
+
active: T | undefined;
|
|
127
|
+
listId: string;
|
|
128
|
+
itemId: (command: T) => string;
|
|
129
|
+
activeId: string | undefined;
|
|
130
|
+
highlight: (index: number) => void;
|
|
131
|
+
close: () => void;
|
|
132
|
+
showAll: () => void;
|
|
133
|
+
pick: (command: T, altKey?: boolean) => void;
|
|
134
|
+
submit: (altKey?: boolean) => boolean;
|
|
135
|
+
handleKeyDown: (event: CommandKeyEvent) => boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A controlled textarea menu. No UI, browser globals, timers or attachment state. */
|
|
139
|
+
export function useSlashCommands<T extends SlashCommand>({
|
|
140
|
+
text,
|
|
141
|
+
commands,
|
|
142
|
+
onChange,
|
|
143
|
+
onExecute,
|
|
144
|
+
onError,
|
|
145
|
+
}: CommandOptions<T>): SlashCommandMenu<T> {
|
|
146
|
+
const parsed = parseCommand(text);
|
|
147
|
+
const resolution = parsed ? resolveCommand(commands, parsed.name) : null;
|
|
148
|
+
const editingName =
|
|
149
|
+
parsed !== null && (parsed.arg === undefined || resolution?.kind !== "matched");
|
|
150
|
+
const items = editingName ? matchCommands(commands, parsed.name) : [];
|
|
151
|
+
const key = JSON.stringify([text, items.map((command) => command.name)]);
|
|
152
|
+
const [state, setState] = useState({ key, text, index: 0, deliberate: false, dismissed: false });
|
|
153
|
+
// Reset selection on a changed result set, but keep Escape's dismissal until the draft changes.
|
|
154
|
+
if (state.key !== key) {
|
|
155
|
+
setState({
|
|
156
|
+
key,
|
|
157
|
+
text,
|
|
158
|
+
index: 0,
|
|
159
|
+
deliberate: false,
|
|
160
|
+
dismissed: state.text === text && state.dismissed,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const listId = `${useId()}-commands`;
|
|
165
|
+
const open = editingName && !state.dismissed;
|
|
166
|
+
const active = open ? items[state.index] : undefined;
|
|
167
|
+
const itemId = (command: T) => `${listId}-${encodeURIComponent(command.name)}`;
|
|
168
|
+
|
|
169
|
+
function close(): void {
|
|
170
|
+
setState((current) => ({ ...current, dismissed: true }));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function showAll(): void {
|
|
174
|
+
setState((current) => ({ ...current, index: 0, deliberate: false, dismissed: false }));
|
|
175
|
+
onChange("/");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function highlight(index: number): void {
|
|
179
|
+
if (!items[index]) return;
|
|
180
|
+
setState((current) => ({ ...current, index, deliberate: true }));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function pick(command: T, altKey = false): void {
|
|
184
|
+
// Re-resolve against current capabilities, not a descriptor retained by a caller.
|
|
185
|
+
const selected = resolveCommand(commands, command.name);
|
|
186
|
+
if (selected.kind !== "matched") {
|
|
187
|
+
onError(selected);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (selected.command.takesArg || parsed?.arg) {
|
|
191
|
+
onChange(completeCommand(selected.command, text));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
onExecute(selected.command, parsed?.arg, altKey);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Call before the normal send/queue/quota path, including button and imperative submits. */
|
|
198
|
+
function submit(altKey = false): boolean {
|
|
199
|
+
if (!parsed || !resolution) return false;
|
|
200
|
+
if (resolution.kind !== "matched") {
|
|
201
|
+
onError(resolution);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
if (
|
|
205
|
+
resolution.command.takesArg &&
|
|
206
|
+
parsed.arg === undefined &&
|
|
207
|
+
parsed.name !== resolution.command.name.toLowerCase()
|
|
208
|
+
) {
|
|
209
|
+
onChange(completeCommand(resolution.command, text));
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
onExecute(resolution.command, parsed.arg, altKey);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Call first. True means skip your own key handler; IME still gets the browser's default. */
|
|
217
|
+
function handleKeyDown(event: CommandKeyEvent): boolean {
|
|
218
|
+
const native = event.nativeEvent ?? event;
|
|
219
|
+
if (native.isComposing || native.keyCode === 229) return true;
|
|
220
|
+
if (event.shiftKey) return false;
|
|
221
|
+
if (open && event.key === "Escape") {
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
close();
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
if (open && items.length > 0 && !event.ctrlKey && !event.metaKey) {
|
|
227
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
228
|
+
event.preventDefault();
|
|
229
|
+
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
230
|
+
highlight((state.index + step + items.length) % items.length);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
if (active && (event.key === "Enter" || event.key === "Tab")) {
|
|
234
|
+
event.preventDefault();
|
|
235
|
+
if (event.key === "Tab") {
|
|
236
|
+
onChange(completeCommand(active, text));
|
|
237
|
+
} else if (resolution?.kind === "ambiguous" && !state.deliberate) {
|
|
238
|
+
onError(resolution);
|
|
239
|
+
} else {
|
|
240
|
+
pick(active, event.altKey);
|
|
241
|
+
}
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (parsed && event.key === "Enter") {
|
|
246
|
+
event.preventDefault();
|
|
247
|
+
return submit(event.altKey);
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
parsed,
|
|
254
|
+
open,
|
|
255
|
+
items,
|
|
256
|
+
index: state.index,
|
|
257
|
+
active,
|
|
258
|
+
listId,
|
|
259
|
+
itemId,
|
|
260
|
+
activeId: active ? itemId(active) : undefined,
|
|
261
|
+
highlight,
|
|
262
|
+
close,
|
|
263
|
+
showAll,
|
|
264
|
+
pick,
|
|
265
|
+
submit,
|
|
266
|
+
handleKeyDown,
|
|
267
|
+
};
|
|
268
|
+
}
|
package/src/copy.ts
CHANGED
|
@@ -29,9 +29,9 @@ export interface CopyOptions {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* A copy with an outcome the reader can see.
|
|
33
|
-
* and copy it yourself", in a label or a toast
|
|
34
|
-
* needs `aria-live="polite"` to be heard.
|
|
32
|
+
* A copy with an outcome the reader can see. You choose what it says, such as "Copied" or "Select
|
|
33
|
+
* it and copy it yourself", in a label or a toast. You also choose how to announce it: a label that
|
|
34
|
+
* changes needs `aria-live="polite"` to be heard.
|
|
35
35
|
*
|
|
36
36
|
* The outcome goes back to `"idle"` on its own. A "Copied" that never clears makes the second copy
|
|
37
37
|
* look like it did not take.
|
package/src/deploy-recovery.ts
CHANGED
|
@@ -5,26 +5,26 @@
|
|
|
5
5
|
* every hashed asset underneath whatever tabs are already open, and those tabs go on running
|
|
6
6
|
* yesterday's bundle. It surfaces two ways, and only one of them looks like a missing file:
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* 1. The next navigation asks for a chunk whose hashed name is gone. React's lazy boundary throws,
|
|
9
|
+
* and the person gets a white screen on a click that worked a minute ago. 2. The old bundle reads a
|
|
10
|
+
* field the API has since renamed, and throws. That one arrives as a plain `TypeError` with nothing
|
|
11
|
+
* in it about a deploy: no file, no hash, no signal.
|
|
12
12
|
*
|
|
13
13
|
* So there are two detectors here and they are not interchangeable. {@link isChunkLoadError}
|
|
14
|
-
* matches the message, which is all you have for a failure that never reaches a boundary.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* matches the message, which is all you have for a failure that never reaches a boundary. {@link
|
|
15
|
+
* isStaleBuild} asks the server for evidence rather than a guess. It is the only one that catches
|
|
16
|
+
* case 2 at all.
|
|
17
17
|
*
|
|
18
18
|
* **The deepest fix is not in this file, and it is worth more than everything in it.** A static
|
|
19
|
-
* host answers a missing `/assets/x.js` with the SPA fallback
|
|
20
|
-
* `text/html
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
19
|
+
* host answers a missing `/assets/x.js` with the SPA fallback, `index.html`, at a 200, as
|
|
20
|
+
* `text/html`. That is what the browser's "MIME type text/html" module-script refusal actually is.
|
|
21
|
+
* Hosts then match cache rules against the REQUEST PATH rather than the outcome, so the `immutable,
|
|
22
|
+
* max-age=31536000` rule written for hashed assets gets applied to that HTML body: one poisoned
|
|
23
|
+
* asset URL, at the edge and in every browser that touched it, for a year. One adopter removed the
|
|
24
|
+
* cause instead of recovering from it: no blanket fallback over `/assets/`, and an edge function
|
|
25
|
+
* that refuses to answer an asset path with `text/html`. That turns a dead chunk into a real,
|
|
26
|
+
* uncacheable 404 and lets everything below be recovery rather than the plan. A package cannot ship
|
|
27
|
+
* that; it can refuse to let it go unwritten.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -48,14 +48,14 @@ const RELOAD_WINDOW_MS = 60_000;
|
|
|
48
48
|
const PROBE_TIMEOUT_MS = 4_000;
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* A lazy-chunk fetch failure
|
|
51
|
+
* A lazy-chunk fetch failure that a reload can fix.
|
|
52
52
|
*
|
|
53
|
-
* Matched by message because no browser gives it a shared type. Every phrasing in the wild is
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
53
|
+
* Matched by message because no browser gives it a shared type. Every phrasing in the wild is here:
|
|
54
|
+
* Chrome/Edge "Failed to fetch dynamically imported module", Firefox "error loading dynamically
|
|
55
|
+
* imported module", Safari "Importing a module script failed", webpack's ChunkLoadError and
|
|
56
|
+
* "Loading chunk N failed", and the SPA-fallback "MIME type" refusal. The last one that fires on a
|
|
57
|
+
* static host that has not fixed the cause above, because the missing `.js` is answered with
|
|
58
|
+
* `index.html`.
|
|
59
59
|
*
|
|
60
60
|
* A standalone predicate rather than a method on the boundary, so a global `unhandledrejection`
|
|
61
61
|
* handler can classify the same failure without importing a component.
|
|
@@ -93,13 +93,13 @@ function runningEntry(): string {
|
|
|
93
93
|
/**
|
|
94
94
|
* Is the build this tab is running still the one being served?
|
|
95
95
|
*
|
|
96
|
-
* Asked of the server, so the answer is evidence rather than a guess.
|
|
97
|
-
*
|
|
98
|
-
*
|
|
96
|
+
* Asked of the server, so the answer is evidence rather than a guess. An offline request, a non-200
|
|
97
|
+
* response, a captive portal or a timed-out probe resolves to `false`: dressing a genuine bug up as
|
|
98
|
+
* an update hides it from the user and from us.
|
|
99
99
|
*
|
|
100
100
|
* It asks for the PAGE, not for the asset, and that is not the obvious choice. A missing asset
|
|
101
101
|
* looks like the direct question, but a CDN commonly serves assets with a long `s-maxage`, so a
|
|
102
|
-
* bundle a deploy retired an hour ago still answers 200 from the edge
|
|
102
|
+
* bundle a deploy retired an hour ago still answers 200 from the edge. It would report "current"
|
|
103
103
|
* during exactly the window when skew is most likely. The page is the one URL that cannot lie: it
|
|
104
104
|
* ships `max-age=0, must-revalidate` and never enters the edge cache.
|
|
105
105
|
*/
|
|
@@ -124,9 +124,9 @@ export async function isStaleBuild(): Promise<boolean> {
|
|
|
124
124
|
* rather than watching the same attempt loop.
|
|
125
125
|
*
|
|
126
126
|
* The guard is the part that matters. Reloading on a failure is obvious; reloading on a failure
|
|
127
|
-
* that the reload does not fix is an infinite loop with the user inside it. At most one reload
|
|
128
|
-
*
|
|
129
|
-
*
|
|
127
|
+
* that the reload does not fix is an infinite loop with the user inside it. At most one reload per
|
|
128
|
+
* minute, so a genuinely broken deploy shows the error screen instead of flickering forever. That
|
|
129
|
+
* screen can at least say something.
|
|
130
130
|
*
|
|
131
131
|
* `sessionStorage` and not `localStorage`: the guard is about this tab's current predicament, and
|
|
132
132
|
* a stale stamp in another tab must not suppress a reload this one needs. Wrapped because storage
|
|
@@ -149,11 +149,11 @@ export function reloadOnce(): boolean {
|
|
|
149
149
|
/**
|
|
150
150
|
* Vite's *hint* failure, which is not the same thing and must not be treated as one.
|
|
151
151
|
*
|
|
152
|
-
* Vite injects a speculative `<link rel="stylesheet">` for a chunk's CSS before importing it.
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
152
|
+
* Vite injects a speculative `<link rel="stylesheet">` for a chunk's CSS before importing it. When
|
|
153
|
+
* that link fails, the import itself still runs right after and usually succeeds, so this one is
|
|
154
|
+
* worth swallowing, where a failure of the import is the real thing {@link isChunkLoadError}
|
|
155
|
+
* matches. Vite builds this message in exactly one place and only ever for a stylesheet link;
|
|
156
|
+
* script hints never reject.
|
|
157
157
|
*/
|
|
158
158
|
export function isPreloadHintFailure(error: unknown): boolean {
|
|
159
159
|
return error instanceof Error && error.message.startsWith("Unable to preload CSS for");
|
|
@@ -162,12 +162,12 @@ export function isPreloadHintFailure(error: unknown): boolean {
|
|
|
162
162
|
/**
|
|
163
163
|
* Install the `vite:preloadError` listener. Call once, from the browser entry.
|
|
164
164
|
*
|
|
165
|
-
* Vite offers `preventDefault()` here so an app can own a failed asset itself
|
|
166
|
-
*
|
|
165
|
+
* Vite offers `preventDefault()` here so an app can own a failed asset itself, but taking that deal
|
|
166
|
+
* unconditionally resolves the dynamic import with `undefined` (Vite's helper ends in
|
|
167
167
|
* `baseModule().catch(handlePreloadError)`), and `React.lazy` then reads `.default` off nothing.
|
|
168
|
-
* The user gets the crash screen and the developer gets a TypeError with only React frames in
|
|
169
|
-
*
|
|
170
|
-
*
|
|
168
|
+
* The user gets the crash screen and the developer gets a TypeError with only React frames in it,
|
|
169
|
+
* naming no chunk. So it is taken ONLY for a failed preload hint, where the import still runs. A
|
|
170
|
+
* real module failure is left to throw, where the error boundary can recognise it.
|
|
171
171
|
*/
|
|
172
172
|
export function installPreloadErrorHandler(): void {
|
|
173
173
|
window.addEventListener("vite:preloadError", (event) => {
|