@coywolf/emdash 0.0.0-stage → 0.35.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -2
  3. package/docs/README.md +45 -0
  4. package/docs/ai.md +39 -0
  5. package/docs/backups.md +88 -0
  6. package/docs/breadcrumbs.md +59 -0
  7. package/docs/code-blocks.md +21 -0
  8. package/docs/content-urls.md +41 -0
  9. package/docs/custom-blocks.md +27 -0
  10. package/docs/discovery.md +32 -0
  11. package/docs/files.md +44 -0
  12. package/docs/form-uploads.md +59 -0
  13. package/docs/headings.md +37 -0
  14. package/docs/images.md +141 -0
  15. package/docs/internals.md +13 -0
  16. package/docs/links.md +34 -0
  17. package/docs/performance.md +72 -0
  18. package/docs/redirects.md +84 -0
  19. package/docs/releasing.md +21 -0
  20. package/docs/reviews.md +89 -0
  21. package/docs/robots.md +81 -0
  22. package/docs/schema.md +79 -0
  23. package/docs/search.md +114 -0
  24. package/docs/videos.md +48 -0
  25. package/docs/wordpress-import.md +198 -0
  26. package/package.json +79 -5
  27. package/src/admin/admin-css.ts +57 -0
  28. package/src/admin/ai.tsx +915 -0
  29. package/src/admin/backups.tsx +613 -0
  30. package/src/admin/breadcrumbs.tsx +542 -0
  31. package/src/admin/codeBlocks.tsx +204 -0
  32. package/src/admin/customBlocks.tsx +321 -0
  33. package/src/admin/dirty.ts +20 -0
  34. package/src/admin/discovery.tsx +463 -0
  35. package/src/admin/download.ts +28 -0
  36. package/src/admin/feature-switches.tsx +124 -0
  37. package/src/admin/features.tsx +142 -0
  38. package/src/admin/files.tsx +791 -0
  39. package/src/admin/formUploads.tsx +445 -0
  40. package/src/admin/guides.tsx +18 -0
  41. package/src/admin/headings.tsx +262 -0
  42. package/src/admin/images.tsx +825 -0
  43. package/src/admin/index.tsx +62 -0
  44. package/src/admin/links.tsx +980 -0
  45. package/src/admin/performance.tsx +330 -0
  46. package/src/admin/preview.tsx +63 -0
  47. package/src/admin/redirects-removed.tsx +266 -0
  48. package/src/admin/redirects.tsx +470 -0
  49. package/src/admin/reviews.tsx +232 -0
  50. package/src/admin/robots-bots.tsx +647 -0
  51. package/src/admin/robots-shared.tsx +109 -0
  52. package/src/admin/robots-wizard.tsx +748 -0
  53. package/src/admin/robots.tsx +911 -0
  54. package/src/admin/save-bar.tsx +134 -0
  55. package/src/admin/schema.tsx +1187 -0
  56. package/src/admin/search.tsx +355 -0
  57. package/src/admin/settings-ui.tsx +87 -0
  58. package/src/admin/trash-prompt-core.ts +135 -0
  59. package/src/admin/trash-prompt.tsx +156 -0
  60. package/src/admin/usage.tsx +301 -0
  61. package/src/admin/videos-display.tsx +248 -0
  62. package/src/admin/videos.tsx +1202 -0
  63. package/src/admin/wpImport.tsx +1516 -0
  64. package/src/ai/entities.ts +59 -0
  65. package/src/ai/export.ts +61 -0
  66. package/src/ai/logic.ts +606 -0
  67. package/src/ai/pack.ts +121 -0
  68. package/src/ai/prompts.ts +87 -0
  69. package/src/ai/providers.ts +276 -0
  70. package/src/ai/routes.ts +359 -0
  71. package/src/ai/store.ts +301 -0
  72. package/src/ai/wikidata.ts +87 -0
  73. package/src/ai/worker.ts +530 -0
  74. package/src/astro/astro-modules.d.ts +6 -0
  75. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  76. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  77. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  78. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  79. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  80. package/src/astro/customBlocks/Details.astro +8 -0
  81. package/src/astro/customBlocks/Disclosure.astro +8 -0
  82. package/src/astro/customBlocks/Note.astro +8 -0
  83. package/src/astro/customBlocks/Podcast.astro +8 -0
  84. package/src/astro/customBlocks/Quote.astro +8 -0
  85. package/src/astro/customBlocks/Testimonial.astro +8 -0
  86. package/src/astro/files/FileCard.astro +103 -0
  87. package/src/astro/headings/HeadingBlock.astro +129 -0
  88. package/src/astro/headings/TableOfContents.astro +45 -0
  89. package/src/astro/headings/request.ts +19 -0
  90. package/src/astro/images/ResponsiveImage.astro +60 -0
  91. package/src/astro/index.ts +84 -0
  92. package/src/astro/reviews/CoywolfReview.astro +27 -0
  93. package/src/astro/reviews/Review.astro +49 -0
  94. package/src/astro/search/SearchBox.astro +61 -0
  95. package/src/astro/search/SearchBoxForm.astro +509 -0
  96. package/src/astro/tables/CaptionedTable.astro +72 -0
  97. package/src/astro/videos/CoywolfVideo.astro +448 -0
  98. package/src/backups/download.ts +112 -0
  99. package/src/backups/dump.ts +288 -0
  100. package/src/backups/module.ts +276 -0
  101. package/src/backups/pack.ts +33 -0
  102. package/src/backups/restore.ts +209 -0
  103. package/src/backups/store.ts +409 -0
  104. package/src/breadcrumbs/pack.ts +113 -0
  105. package/src/breadcrumbs/settings.ts +102 -0
  106. package/src/breadcrumbs/stamp.ts +40 -0
  107. package/src/breadcrumbs/trail.ts +123 -0
  108. package/src/codeBlocks/contrast.ts +179 -0
  109. package/src/codeBlocks/hljs.d.ts +5 -0
  110. package/src/codeBlocks/pack.ts +110 -0
  111. package/src/codeBlocks/render.ts +304 -0
  112. package/src/codeBlocks/settings.ts +18 -0
  113. package/src/codeBlocks/themes.generated.ts +304 -0
  114. package/src/codeBlocks/themes.ts +114 -0
  115. package/src/core/compose.ts +244 -0
  116. package/src/core/content-url.ts +888 -0
  117. package/src/core/csv.ts +119 -0
  118. package/src/core/d1-batch.ts +65 -0
  119. package/src/core/entry-docs.ts +175 -0
  120. package/src/core/features-module.ts +48 -0
  121. package/src/core/features.ts +392 -0
  122. package/src/core/generation.ts +88 -0
  123. package/src/core/guide-content.ts +166 -0
  124. package/src/core/module.ts +104 -0
  125. package/src/core/read-failures.ts +57 -0
  126. package/src/core/request-memo.ts +71 -0
  127. package/src/core/secrets.ts +83 -0
  128. package/src/core/settings-enhance.ts +74 -0
  129. package/src/core/site.ts +36 -0
  130. package/src/core/storage.ts +27 -0
  131. package/src/customBlocks/pack.ts +260 -0
  132. package/src/customBlocks/render.ts +424 -0
  133. package/src/customBlocks/rich.ts +313 -0
  134. package/src/customBlocks/settings.ts +46 -0
  135. package/src/discovery/build.ts +252 -0
  136. package/src/discovery/feed-build.ts +217 -0
  137. package/src/discovery/feed.ts +172 -0
  138. package/src/discovery/indexnow.ts +114 -0
  139. package/src/discovery/llms.ts +79 -0
  140. package/src/discovery/markdown.ts +294 -0
  141. package/src/discovery/memory-cache.ts +97 -0
  142. package/src/discovery/middleware.ts +186 -0
  143. package/src/discovery/module.ts +353 -0
  144. package/src/discovery/news.ts +99 -0
  145. package/src/discovery/pack.ts +58 -0
  146. package/src/discovery/settings.ts +91 -0
  147. package/src/discovery/urls.ts +35 -0
  148. package/src/env.d.ts +10 -0
  149. package/src/files/card.ts +231 -0
  150. package/src/files/counts.ts +60 -0
  151. package/src/files/export.ts +44 -0
  152. package/src/files/format.ts +163 -0
  153. package/src/files/module.ts +596 -0
  154. package/src/files/pack.ts +92 -0
  155. package/src/files/r2-rules.ts +41 -0
  156. package/src/files/range.ts +50 -0
  157. package/src/files/s3.ts +114 -0
  158. package/src/files/serve.ts +141 -0
  159. package/src/files/sigv4.ts +160 -0
  160. package/src/files/site.ts +137 -0
  161. package/src/files/walker.ts +58 -0
  162. package/src/formUploads/lib.ts +266 -0
  163. package/src/formUploads/pack.ts +55 -0
  164. package/src/formUploads/wrap.ts +592 -0
  165. package/src/forms.ts +16 -0
  166. package/src/headings/pack.ts +154 -0
  167. package/src/headings/render.ts +139 -0
  168. package/src/headings/settings.ts +117 -0
  169. package/src/headings/slug.ts +68 -0
  170. package/src/headings/stamp.ts +142 -0
  171. package/src/headings/test-hooks.mjs +14 -0
  172. package/src/headings/toc.ts +48 -0
  173. package/src/images/cloudflare.ts +329 -0
  174. package/src/images/lib.ts +221 -0
  175. package/src/images/module.ts +372 -0
  176. package/src/images/pack.ts +804 -0
  177. package/src/images/pending.ts +37 -0
  178. package/src/images/settings.ts +48 -0
  179. package/src/images/setup.ts +192 -0
  180. package/src/images/variants-estimate.ts +162 -0
  181. package/src/images/variants-store.ts +475 -0
  182. package/src/images/variants.ts +311 -0
  183. package/src/index.ts +143 -0
  184. package/src/links/check.ts +101 -0
  185. package/src/links/classify.ts +270 -0
  186. package/src/links/export.ts +59 -0
  187. package/src/links/module.ts +652 -0
  188. package/src/links/pack.ts +43 -0
  189. package/src/links/pt.ts +325 -0
  190. package/src/links/store.ts +704 -0
  191. package/src/middleware.ts +287 -0
  192. package/src/modules.ts +53 -0
  193. package/src/options.ts +66 -0
  194. package/src/pageCache/lib.ts +274 -0
  195. package/src/pageCache/pack.ts +240 -0
  196. package/src/pageCache/usage-core.ts +152 -0
  197. package/src/pageCache/usage.ts +262 -0
  198. package/src/pageCache/warm.ts +722 -0
  199. package/src/redirects/middleware.ts +169 -0
  200. package/src/redirects/module.ts +124 -0
  201. package/src/redirects/pack.ts +89 -0
  202. package/src/redirects/removed-core.ts +151 -0
  203. package/src/redirects/removed.ts +293 -0
  204. package/src/redirects/rules.ts +355 -0
  205. package/src/redirects/slug-changes.ts +207 -0
  206. package/src/redirects/slug-core.ts +49 -0
  207. package/src/redirects/transfer.ts +92 -0
  208. package/src/reviews/lib.ts +624 -0
  209. package/src/reviews/pack.ts +135 -0
  210. package/src/reviews/schema.ts +128 -0
  211. package/src/reviews/settings.ts +26 -0
  212. package/src/robots/bots.ts +321 -0
  213. package/src/robots/data/bots.json +696 -0
  214. package/src/robots/data/verified.json +582 -0
  215. package/src/robots/directory.ts +26 -0
  216. package/src/robots/explain.ts +191 -0
  217. package/src/robots/guided.ts +300 -0
  218. package/src/robots/importer.ts +263 -0
  219. package/src/robots/middleware.ts +151 -0
  220. package/src/robots/module.ts +446 -0
  221. package/src/robots/pack.ts +69 -0
  222. package/src/robots/radar.ts +129 -0
  223. package/src/robots/rep.ts +362 -0
  224. package/src/robots/rules.ts +883 -0
  225. package/src/robots/transfer.ts +45 -0
  226. package/src/robots/validate.ts +524 -0
  227. package/src/schema/catalog.ts +146 -0
  228. package/src/schema/graph.ts +791 -0
  229. package/src/schema/module.ts +1138 -0
  230. package/src/schema/og-image.ts +100 -0
  231. package/src/schema/pack.ts +58 -0
  232. package/src/search/engine.ts +578 -0
  233. package/src/search/fallback.ts +80 -0
  234. package/src/search/live-cache.ts +116 -0
  235. package/src/search/live-client.ts +770 -0
  236. package/src/search/live-serve.ts +282 -0
  237. package/src/search/module.ts +261 -0
  238. package/src/search/pack.ts +57 -0
  239. package/src/search/query.ts +132 -0
  240. package/src/search/ratelimit-core.ts +118 -0
  241. package/src/search/ratelimit.ts +79 -0
  242. package/src/search/snippet.ts +135 -0
  243. package/src/search/title-index.ts +139 -0
  244. package/src/search/title-match.ts +192 -0
  245. package/src/shared.ts +71 -0
  246. package/src/version.ts +2 -0
  247. package/src/videos/date.ts +40 -0
  248. package/src/videos/engagement.ts +75 -0
  249. package/src/videos/lib.ts +679 -0
  250. package/src/videos/module.ts +1160 -0
  251. package/src/videos/pack.ts +187 -0
  252. package/src/videos/pause.ts +42 -0
  253. package/src/videos/poster.ts +324 -0
  254. package/src/videos/render.ts +336 -0
  255. package/src/videos/store.ts +551 -0
  256. package/src/videos/stream.ts +134 -0
  257. package/src/wpImport/convert.ts +702 -0
  258. package/src/wpImport/guests.ts +329 -0
  259. package/src/wpImport/gutenberg.ts +157 -0
  260. package/src/wpImport/markers.ts +66 -0
  261. package/src/wpImport/pack.ts +451 -0
  262. package/src/wpImport/parents.ts +202 -0
  263. package/src/wpImport/prepare.ts +592 -0
  264. package/src/wpImport/redirects.ts +358 -0
  265. package/src/wpImport/sources.ts +67 -0
  266. package/src/wpImport/stream.ts +160 -0
  267. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,647 @@
1
+ /**
2
+ * Robots.txt → Bots tab (the crawler directory: filters, verify, rename,
3
+ * add custom bots, Radar sync) and the searchable bot picker used by the
4
+ * rule dialog.
5
+ */
6
+ import { Badge, Banner, Button, Dialog, DropdownMenu, Input, InputArea, Select } from "@cloudflare/kumo";
7
+ import { ArrowsClockwise, CaretDown, CaretRight, DotsThree, MagnifyingGlass, Plus, XCircle } from "@phosphor-icons/react";
8
+ import * as React from "react";
9
+
10
+ import { type BotEntry, type BotPurpose, CATEGORY_LABELS, PURPOSE_LABELS, categoryLabel } from "../robots/bots.js";
11
+ import type { RobotsRule } from "../robots/rules.js";
12
+ import { isValidToken } from "../robots/rules.js";
13
+ import { EVIDENCE_LABELS, StatusBadge, TriCheckbox, UnverifiedIcon, dateFormat, errorText, post, tokenIndex } from "./robots-shared.js";
14
+ import { CredentialGuide } from "./guides.js";
15
+ import { SecretField, SetupCard } from "./settings-ui.js";
16
+
17
+ const catName = (c: string) => CATEGORY_LABELS[c] ?? categoryLabel(c);
18
+
19
+ /** Unique-by-token bots, verified first. */
20
+ function uniqueBots(bots: BotEntry[]): BotEntry[] {
21
+ return [...tokenIndex(bots).values()].sort((a, b) => a.name.localeCompare(b.name, "en", { sensitivity: "base" }));
22
+ }
23
+
24
+ /**
25
+ * Pick individual crawlers: search, then categories as an accordion with a
26
+ * tick-all checkbox each, plus "a token that isn't listed".
27
+ */
28
+ export function BotPicker(props: { bots: BotEntry[]; selected: string[]; onChange: (agents: string[]) => void; onAddCustom?: (token: string) => void }) {
29
+ const [query, setQuery] = React.useState("");
30
+ const [open, setOpen] = React.useState<Set<string>>(new Set());
31
+ const [custom, setCustom] = React.useState("");
32
+ const selected = new Set(props.selected.map((a) => a.toLowerCase()));
33
+ const set = (tokens: string[], on: boolean) => {
34
+ const lower = new Set(tokens.map((t) => t.toLowerCase()));
35
+ const rest = props.selected.filter((a) => !lower.has(a.toLowerCase()));
36
+ props.onChange(on ? [...rest, ...tokens] : rest);
37
+ };
38
+ const q = query.trim().toLowerCase();
39
+ const all = uniqueBots(props.bots).filter((b) => !q || `${b.name} ${b.token} ${b.operator}`.toLowerCase().includes(q));
40
+ const categories = [...new Set(all.map((b) => b.category))].sort((a, b) => catName(a).localeCompare(catName(b)));
41
+ const byToken = tokenIndex(props.bots);
42
+ const customOk = custom.trim() && isValidToken(custom.trim()) && custom.trim() !== "*";
43
+ const customBad = Boolean(custom.trim()) && !customOk;
44
+ const customErrorId = `${React.useId()}-custom-error`;
45
+
46
+ return (
47
+ <div className="space-y-3">
48
+ <div className="flex flex-wrap gap-1" aria-live="polite" aria-label="Picked crawlers">
49
+ {props.selected.length === 0 && <span className="text-sm text-kumo-subtle">No crawlers picked yet.</span>}
50
+ {props.selected.map((agent) => {
51
+ const bot = byToken.get(agent.toLowerCase());
52
+ return (
53
+ <button
54
+ key={agent}
55
+ type="button"
56
+ className="inline-flex items-center gap-1 rounded border border-kumo-line px-1.5 py-0.5 text-xs hover:bg-kumo-tint"
57
+ onClick={() => set([agent], false)}
58
+ aria-label={`Remove ${bot?.name ?? agent}`}
59
+ >
60
+ {agent === "*" ? "Everyone (*)" : (bot?.name ?? agent)}
61
+ {bot?.status === "unverified" && <UnverifiedIcon />}
62
+ <XCircle aria-hidden="true" />
63
+ </button>
64
+ );
65
+ })}
66
+ </div>
67
+ <div className="relative">
68
+ <Input
69
+ label="Search crawlers"
70
+ placeholder="Name, token or company, e.g. GPTBot or OpenAI"
71
+ value={query}
72
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
73
+ />
74
+ <MagnifyingGlass className="pointer-events-none absolute right-3 cw-field-icon text-kumo-subtle" aria-hidden="true" />
75
+ </div>
76
+ <div className="max-h-80 overflow-y-auto rounded border border-kumo-line">
77
+ {categories.map((cat) => {
78
+ const list = all.filter((b) => b.category === cat);
79
+ const on = list.filter((b) => selected.has(b.token.toLowerCase())).length;
80
+ const expanded = Boolean(q) || open.has(cat);
81
+ const panelId = `cw-bots-${cat}`;
82
+ return (
83
+ <div key={cat} className="border-b border-kumo-line last:border-0">
84
+ <div className="flex items-center gap-2 px-3 py-2">
85
+ <TriCheckbox
86
+ checked={on > 0 && on === list.length}
87
+ indeterminate={on > 0 && on < list.length}
88
+ onChange={(v) => set(list.map((b) => b.token), v)}
89
+ label={`All ${catName(cat)} crawlers`}
90
+ />
91
+ <button
92
+ type="button"
93
+ className="flex flex-1 items-center gap-1 text-left text-sm font-medium"
94
+ aria-expanded={expanded}
95
+ aria-controls={panelId}
96
+ onClick={() => setOpen((s) => (s.has(cat) ? new Set([...s].filter((x) => x !== cat)) : new Set([...s, cat])))}
97
+ >
98
+ {expanded ? <CaretDown aria-hidden="true" /> : <CaretRight aria-hidden="true" />}
99
+ {catName(cat)} <span className="font-normal text-kumo-subtle">({on ? `${on} of ` : ""}{list.length})</span>
100
+ </button>
101
+ </div>
102
+ {expanded && (
103
+ <ul id={panelId} className="pb-2">
104
+ {list.slice(0, 200).map((bot) => (
105
+ <li key={bot.slug}>
106
+ <label className="flex cursor-pointer items-start gap-2 ps-9 pe-9 py-1 text-sm hover:bg-kumo-tint/50">
107
+ <input type="checkbox" className="mt-1" checked={selected.has(bot.token.toLowerCase())} onChange={(e) => set([bot.token], e.target.checked)} />
108
+ <span className="min-w-0 flex-1">
109
+ <span className="flex flex-wrap items-center gap-1.5">
110
+ <span>{bot.name}</span>
111
+ <code className="text-xs text-kumo-subtle">{bot.token}</code>
112
+ <StatusBadge bot={bot} />
113
+ </span>
114
+ {bot.operator && <span className="block text-xs text-kumo-subtle">{bot.operator}</span>}
115
+ </span>
116
+ </label>
117
+ </li>
118
+ ))}
119
+ </ul>
120
+ )}
121
+ </div>
122
+ );
123
+ })}
124
+ {categories.length === 0 && <p className="px-3 py-4 text-center text-sm text-kumo-subtle">No crawlers match “{query}”.</p>}
125
+ </div>
126
+ <div className="flex items-end gap-2">
127
+ <div className="flex-1">
128
+ <Input
129
+ label="A crawler that isn't listed (its robots.txt token)"
130
+ placeholder="ExampleBot"
131
+ value={custom}
132
+ aria-invalid={customBad || undefined}
133
+ aria-describedby={customBad ? customErrorId : undefined}
134
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCustom(e.target.value)}
135
+ onKeyDown={(e: React.KeyboardEvent) => {
136
+ // The Enter that confirms an input-method candidate isn't "add".
137
+ if (e.nativeEvent.isComposing || e.keyCode === 229) return;
138
+ if (e.key === "Enter" && customOk) {
139
+ e.preventDefault();
140
+ set([custom.trim()], true);
141
+ props.onAddCustom?.(custom.trim());
142
+ setCustom("");
143
+ }
144
+ }}
145
+ />
146
+ </div>
147
+ <Button
148
+ type="button"
149
+ variant="secondary"
150
+ disabled={!customOk}
151
+ onClick={() => {
152
+ set([custom.trim()], true);
153
+ props.onAddCustom?.(custom.trim());
154
+ setCustom("");
155
+ }}
156
+ >
157
+ Add bot
158
+ </Button>
159
+ </div>
160
+ {customBad && (
161
+ <p id={customErrorId} className="text-sm text-kumo-danger">
162
+ Tokens use letters, digits, dot, dash and underscore, with no spaces.
163
+ </p>
164
+ )}
165
+ </div>
166
+ );
167
+ }
168
+
169
+ /* ------------------------------------------------------------------ */
170
+
171
+ type StatusFilter = "all" | "operator-docs" | "user-agent" | "manual" | "unverified" | "custom";
172
+
173
+ interface RadarInfo {
174
+ tokenConfigured: boolean;
175
+ tokenSource: "settings" | "env" | null;
176
+ state: { at: string; ok: boolean; total?: number; added?: number; updated?: number; error?: string } | null;
177
+ baselineDate: string;
178
+ }
179
+
180
+ function statusOf(b: BotEntry): StatusFilter {
181
+ if (b.origin === "custom" && b.status !== "verified") return "custom";
182
+ if (b.status !== "verified") return "unverified";
183
+ return b.evidence === "manual" ? "manual" : b.evidence === "user-agent" ? "user-agent" : "operator-docs";
184
+ }
185
+
186
+ type BotDialog = { kind: "verify"; bot: BotEntry } | { kind: "rename"; bot: BotEntry } | { kind: "custom"; bot?: BotEntry } | null;
187
+
188
+ export function BotsTab(props: {
189
+ bots: BotEntry[];
190
+ rules: RobotsRule[];
191
+ radar: RadarInfo;
192
+ onBots: (bots: BotEntry[]) => void;
193
+ onRadar: (radar: Partial<RadarInfo>) => void;
194
+ onReload: () => Promise<void>;
195
+ }) {
196
+ const [query, setQuery] = React.useState("");
197
+ const [category, setCategory] = React.useState("all");
198
+ const [operator, setOperator] = React.useState("all");
199
+ const [status, setStatus] = React.useState<StatusFilter>("all");
200
+ const [usedOnly, setUsedOnly] = React.useState(false);
201
+ const [purpose, setPurpose] = React.useState("all");
202
+ const [limit, setLimit] = React.useState(100);
203
+ const [dialog, setDialog] = React.useState<BotDialog>(null);
204
+ const [message, setMessage] = React.useState<string>();
205
+ const [error, setError] = React.useState<string>();
206
+
207
+ const usage = React.useMemo(() => {
208
+ const map = new Map<string, RobotsRule[]>();
209
+ for (const r of props.rules) for (const a of r.agents) map.set(a.toLowerCase(), [...(map.get(a.toLowerCase()) ?? []), r]);
210
+ return map;
211
+ }, [props.rules]);
212
+ const categories = [...new Set(props.bots.map((b) => b.category))].sort((a, b) => catName(a).localeCompare(catName(b)));
213
+ const operators = [...new Set(props.bots.map((b) => b.operator).filter(Boolean))].sort((a, b) => a.localeCompare(b));
214
+ const q = query.trim().toLowerCase();
215
+ const visible = props.bots.filter(
216
+ (b) =>
217
+ (category === "all" || b.category === category) &&
218
+ (operator === "all" || b.operator === operator) &&
219
+ (purpose === "all" || (purpose === "none" ? !b.purpose : b.purpose === purpose)) &&
220
+ (status === "all" || statusOf(b) === status) &&
221
+ (!usedOnly || usage.has(b.token.toLowerCase())) &&
222
+ (!q || `${b.name} ${b.token} ${b.operator} ${b.description}`.toLowerCase().includes(q)),
223
+ );
224
+
225
+ const act = async (body: Record<string, unknown>, done: string) => {
226
+ setError(undefined);
227
+ try {
228
+ const result = await post<{ bots: BotEntry[] }>("bot", body);
229
+ props.onBots(result.bots);
230
+ setMessage(done);
231
+ setDialog(null);
232
+ } catch (cause) {
233
+ setError(errorText(cause, "That didn't work"));
234
+ throw cause;
235
+ }
236
+ };
237
+
238
+ return (
239
+ <div className="space-y-4">
240
+ <div className="flex flex-wrap items-start justify-between gap-2">
241
+ <p className="max-w-2xl text-sm text-kumo-subtle">
242
+ {props.bots.length} crawlers from Cloudflare Radar's bot directory and operators' documentation. A crawler only follows a rule if it uses that exact
243
+ token, so verified tokens (checked against the operator's documentation or published user-agent) are safest.
244
+ </p>
245
+ <Button variant="secondary" icon={<Plus />} onClick={() => setDialog({ kind: "custom" })}>
246
+ Add bot
247
+ </Button>
248
+ </div>
249
+ <div aria-live="polite">{message && <Banner variant="default" role="status" title={message} />}</div>
250
+ {error && !dialog && <Banner variant="error" role="alert" description={error} />}
251
+
252
+ <div className="grid gap-2 sm:grid-cols-2 cw-lg-cols-6">
253
+ <div className="lg:col-span-2">
254
+ <Input label="Search" placeholder="Name, token, company or description" value={query} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)} />
255
+ </div>
256
+ <Select
257
+ label="Category"
258
+ value={category}
259
+ onValueChange={(v: string | null) => setCategory(v ?? "all")}
260
+ items={[{ value: "all", label: "All categories" }, ...categories.map((c) => ({ value: c, label: catName(c) }))]}
261
+ />
262
+ <Select
263
+ label="Operator"
264
+ value={operator}
265
+ onValueChange={(v: string | null) => setOperator(v ?? "all")}
266
+ items={[{ value: "all", label: "All operators" }, ...operators.map((o) => ({ value: o, label: o }))]}
267
+ />
268
+ <Select
269
+ label="Purpose"
270
+ value={purpose}
271
+ onValueChange={(v: string | null) => setPurpose(v ?? "all")}
272
+ items={[{ value: "all", label: "Any purpose" }, ...Object.entries(PURPOSE_LABELS).map(([value, label]) => ({ value, label })), { value: "none", label: "Not documented" }]}
273
+ />
274
+ <Select
275
+ label="Status"
276
+ value={status}
277
+ onValueChange={(v: string | null) => setStatus((v ?? "all") as StatusFilter)}
278
+ items={[
279
+ { value: "all", label: "Any status" },
280
+ { value: "operator-docs", label: "Verified: operator docs" },
281
+ { value: "user-agent", label: "Verified: user-agent" },
282
+ { value: "manual", label: "Verified on this site" },
283
+ { value: "unverified", label: "Unverified" },
284
+ { value: "custom", label: "Added by you" },
285
+ ]}
286
+ />
287
+ </div>
288
+ <label className="flex items-center gap-2 text-sm">
289
+ <input type="checkbox" checked={usedOnly} onChange={(e) => setUsedOnly(e.target.checked)} />
290
+ Only crawlers used in rules
291
+ </label>
292
+
293
+ <div className="overflow-x-auto rounded-lg border">
294
+ <table className="w-full text-left text-sm">
295
+ <caption className="sr-only">Crawler directory</caption>
296
+ <thead className="border-b bg-kumo-tint/40 text-xs text-kumo-subtle">
297
+ <tr>
298
+ <th scope="col" className="px-3 py-2 font-medium">Name</th>
299
+ <th scope="col" className="px-3 py-2 font-medium">Token</th>
300
+ <th scope="col" className="cw-md-table-cell px-3 py-2 font-medium">Operator</th>
301
+ <th scope="col" className="cw-md-table-cell px-3 py-2 font-medium">Category and purpose</th>
302
+ <th scope="col" className="px-3 py-2 font-medium">Status</th>
303
+ <th scope="col" className="hidden px-3 py-2 font-medium lg:table-cell">Rules</th>
304
+ <th scope="col" className="px-3 py-2"><span className="sr-only">Actions</span></th>
305
+ </tr>
306
+ </thead>
307
+ <tbody>
308
+ {visible.slice(0, limit).map((bot) => {
309
+ const used = usage.get(bot.token.toLowerCase()) ?? [];
310
+ return (
311
+ <tr key={bot.slug} className="border-b last:border-0 align-top">
312
+ <td className="px-3 py-2">
313
+ <div className="font-medium">{bot.name}</div>
314
+ {bot.originalName && <div className="text-xs text-kumo-subtle">Listed as {bot.originalName}</div>}
315
+ {bot.delisted && <Badge variant="outline">left Radar</Badge>}
316
+ </td>
317
+ <td className="px-3 py-2"><code className="text-xs">{bot.token}</code></td>
318
+ <td className="cw-md-table-cell px-3 py-2">{bot.operator || "—"}</td>
319
+ <td className="cw-md-table-cell px-3 py-2">
320
+ {catName(bot.category)}
321
+ <div className="text-xs text-kumo-subtle">{bot.purpose ? PURPOSE_LABELS[bot.purpose] : "Purpose not documented"}</div>
322
+ </td>
323
+ <td className="px-3 py-2">
324
+ <StatusBadge bot={bot} evidence={false} />
325
+ <div className="text-xs text-kumo-subtle">
326
+ {EVIDENCE_LABELS[bot.evidence] ?? ""}
327
+ {bot.verifiedAt ? ` · ${dateFormat.format(new Date(bot.verifiedAt.length === 10 ? `${bot.verifiedAt}T12:00:00Z` : bot.verifiedAt))}` : ""}
328
+ {bot.verifiedBy ? ` · ${bot.verifiedBy}` : ""}
329
+ </div>
330
+ {bot.sourceUrl && /^https?:\/\//i.test(bot.sourceUrl) && (
331
+ <a className="text-xs underline" href={bot.sourceUrl} target="_blank" rel="noreferrer noopener">
332
+ Source<span className="sr-only"> for {bot.name}</span>
333
+ </a>
334
+ )}
335
+ </td>
336
+ <td className="hidden px-3 py-2 text-xs lg:table-cell">{used.length ? used.map((r) => r.name).join(", ") : "—"}</td>
337
+ <td className="px-3 py-2 text-right">
338
+ <DropdownMenu>
339
+ <DropdownMenu.Trigger render={<Button type="button" variant="ghost" shape="square" icon={<DotsThree aria-hidden="true" />} aria-label={`Actions for ${bot.name}`} />} />
340
+ <DropdownMenu.Content className="p-1">
341
+ {bot.status !== "verified" && (
342
+ <DropdownMenu.Item className="py-1 data-highlighted:bg-kumo-fill" onClick={() => setDialog({ kind: "verify", bot })}>
343
+ Mark verified…
344
+ </DropdownMenu.Item>
345
+ )}
346
+ {bot.evidence === "manual" && (
347
+ <DropdownMenu.Item className="py-1 data-highlighted:bg-kumo-fill" onClick={() => void act({ action: "unverify", slug: bot.slug }, `${bot.name} is no longer marked verified.`).catch(() => undefined)}>
348
+ Remove my verification
349
+ </DropdownMenu.Item>
350
+ )}
351
+ {bot.origin === "custom" ? (
352
+ <>
353
+ <DropdownMenu.Item className="py-1 data-highlighted:bg-kumo-fill" onClick={() => setDialog({ kind: "custom", bot })}>
354
+ Edit…
355
+ </DropdownMenu.Item>
356
+ <DropdownMenu.Item
357
+ className="py-1 data-highlighted:bg-kumo-fill"
358
+ onClick={() => {
359
+ const inUse = used.length ? ` It's used in ${used.length} rule${used.length === 1 ? "" : "s"}, which keep the token.` : "";
360
+ if (window.confirm(`Delete ${bot.name}?${inUse}`)) void act({ action: "delete", slug: bot.slug }, `${bot.name} deleted.`).catch(() => undefined);
361
+ }}
362
+ >
363
+ Delete
364
+ </DropdownMenu.Item>
365
+ </>
366
+ ) : (
367
+ <>
368
+ <DropdownMenu.Item className="py-1 data-highlighted:bg-kumo-fill" onClick={() => setDialog({ kind: "rename", bot })}>
369
+ Rename…
370
+ </DropdownMenu.Item>
371
+ {(bot.originalName || bot.evidence === "manual") && (
372
+ <DropdownMenu.Item className="py-1 data-highlighted:bg-kumo-fill" onClick={() => void act({ action: "reset", slug: bot.slug }, `${bot.name}: your changes were undone.`).catch(() => undefined)}>
373
+ Undo my changes
374
+ </DropdownMenu.Item>
375
+ )}
376
+ </>
377
+ )}
378
+ </DropdownMenu.Content>
379
+ </DropdownMenu>
380
+ </td>
381
+ </tr>
382
+ );
383
+ })}
384
+ </tbody>
385
+ </table>
386
+ {visible.length === 0 && <p className="px-3 py-6 text-center text-sm text-kumo-subtle">No crawlers match these filters.</p>}
387
+ </div>
388
+ {visible.length > limit && (
389
+ <Button variant="secondary" onClick={() => setLimit((l) => l + 200)}>
390
+ Show more ({visible.length - limit} left)
391
+ </Button>
392
+ )}
393
+
394
+ <RadarCard radar={props.radar} onRadar={props.onRadar} onReload={props.onReload} />
395
+
396
+ {dialog && (
397
+ <BotDialogView
398
+ dialog={dialog}
399
+ bots={props.bots}
400
+ error={error}
401
+ onClose={() => {
402
+ setDialog(null);
403
+ setError(undefined);
404
+ }}
405
+ onSubmit={act}
406
+ />
407
+ )}
408
+ </div>
409
+ );
410
+ }
411
+
412
+ function BotDialogView(props: { dialog: NonNullable<BotDialog>; bots: BotEntry[]; error?: string; onClose: () => void; onSubmit: (body: Record<string, unknown>, done: string) => Promise<void> }) {
413
+ const d = props.dialog;
414
+ const bot = d.bot;
415
+ const [name, setName] = React.useState(bot?.name ?? "");
416
+ const [token, setToken] = React.useState(bot?.token ?? "");
417
+ const [category, setCategory] = React.useState(bot?.category ?? "AI_CRAWLER");
418
+ const [purpose, setPurpose] = React.useState<BotPurpose>(bot?.purpose ?? "other");
419
+ const [operator, setOperator] = React.useState(bot?.operator ?? "");
420
+ const [sourceUrl, setSourceUrl] = React.useState(d.kind === "verify" ? "" : (bot?.sourceUrl ?? ""));
421
+ const [notes, setNotes] = React.useState(d.kind === "custom" ? (bot?.description ?? "") : "");
422
+ const [busy, setBusy] = React.useState(false);
423
+ const urlOk = (u: string) => /^https?:\/\/\S+\.\S+/.test(u.trim());
424
+ const tokenProblem =
425
+ d.kind !== "custom"
426
+ ? null
427
+ : !token.trim()
428
+ ? "Enter the crawler's robots.txt token."
429
+ : !isValidToken(token.trim()) || token.trim() === "*"
430
+ ? "Tokens use letters, digits, dot, dash and underscore, with no spaces (like ExampleBot)."
431
+ : !bot && props.bots.some((b) => b.token.toLowerCase() === token.trim().toLowerCase())
432
+ ? `${token.trim()} is already in the directory.`
433
+ : null;
434
+ const problem =
435
+ d.kind === "verify"
436
+ ? !urlOk(sourceUrl) ? "Enter the web address of the page that documents this token." : null
437
+ : d.kind === "rename"
438
+ ? !name.trim() ? "Enter a name." : null
439
+ : tokenProblem ?? (!name.trim() ? "Enter a name." : sourceUrl.trim() && !urlOk(sourceUrl) ? "The source must be a web address (https://…)." : null);
440
+ const title = d.kind === "verify" ? `Verify ${bot?.name}` : d.kind === "rename" ? `Rename ${bot?.name}` : bot ? `Edit ${bot.name}` : "Add a bot";
441
+ const submit = async () => {
442
+ if (problem) return;
443
+ setBusy(true);
444
+ try {
445
+ if (d.kind === "verify") await props.onSubmit({ action: "verify", slug: bot?.slug, sourceUrl: sourceUrl.trim(), note: notes.trim() || undefined }, `${bot?.name} marked verified.`);
446
+ else if (d.kind === "rename") await props.onSubmit({ action: "rename", slug: bot?.slug, name: name.trim() }, `Renamed to ${name.trim()}. Its token is still ${bot?.token}.`);
447
+ else
448
+ await props.onSubmit(
449
+ { action: "save-custom", slug: bot?.slug, name: name.trim(), token: token.trim(), category, purpose, operator: operator.trim() || undefined, sourceUrl: sourceUrl.trim(), notes: notes.trim() || undefined },
450
+ bot ? `${name.trim()} saved.` : `${name.trim()} added. Pick it in any rule.`,
451
+ );
452
+ } catch {
453
+ // Shown in the dialog.
454
+ } finally {
455
+ setBusy(false);
456
+ }
457
+ };
458
+ const categories = [...new Set([...Object.keys(CATEGORY_LABELS), ...props.bots.map((b) => b.category)])].sort((a, b) => catName(a).localeCompare(catName(b)));
459
+
460
+ return (
461
+ <Dialog.Root open onOpenChange={(open) => !open && props.onClose()}>
462
+ <Dialog className="max-h-[90vh] overflow-y-auto p-6" size="lg">
463
+ <Dialog.Title className="text-lg font-semibold">{title}</Dialog.Title>
464
+ <Dialog.Description className="mt-1 text-sm text-kumo-subtle">
465
+ {d.kind === "verify"
466
+ ? `Confirm that ${bot?.token} is the token ${bot?.operator || "the operator"} documents for robots.txt. We store the source, your note, the date and your name.`
467
+ : d.kind === "rename"
468
+ ? `Changes the name shown in this admin only. The robots.txt token stays ${bot?.token}.`
469
+ : "Add a crawler the directory doesn't list. Use the exact token the operator documents for robots.txt."}
470
+ </Dialog.Description>
471
+ <form
472
+ className="mt-4 space-y-3"
473
+ onSubmit={(e) => {
474
+ e.preventDefault();
475
+ void submit();
476
+ }}
477
+ >
478
+ {(d.kind === "rename" || d.kind === "custom") && <Input label="Name" value={name} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)} />}
479
+ {d.kind === "custom" && (
480
+ <>
481
+ <Input label="robots.txt token" placeholder="ExampleBot" value={token} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setToken(e.target.value)} />
482
+ <Select label="Category" value={category} onValueChange={(v: string | null) => setCategory(v ?? "OTHER")} items={categories.map((c) => ({ value: c, label: catName(c) }))} />
483
+ <Select
484
+ label="Purpose"
485
+ labelTooltip="What the operator says it's for. Verified bots with a preset's purpose join that preset (for example, AI training)."
486
+ value={purpose}
487
+ onValueChange={(v: string | null) => setPurpose((v ?? "other") as BotPurpose)}
488
+ items={Object.entries(PURPOSE_LABELS).map(([value, label]) => ({ value, label }))}
489
+ />
490
+ <Input label="Operator (optional)" placeholder="Example Inc." value={operator} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setOperator(e.target.value)} />
491
+ </>
492
+ )}
493
+ {(d.kind === "verify" || d.kind === "custom") && (
494
+ <Input
495
+ label={d.kind === "verify" ? "Source (the page that documents the token)" : "Source (optional)"}
496
+ placeholder="https://example.com/docs/crawler"
497
+ value={sourceUrl}
498
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSourceUrl(e.target.value)}
499
+ />
500
+ )}
501
+ {(d.kind === "verify" || d.kind === "custom") && (
502
+ <InputArea label={d.kind === "verify" ? "Note (optional)" : "Notes (optional)"} rows={2} value={notes} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNotes(e.target.value)} />
503
+ )}
504
+ <p className="min-h-5 text-sm text-kumo-subtle" aria-live="polite">
505
+ {problem && (name || token || sourceUrl) ? problem : ""}
506
+ </p>
507
+ {props.error && <Banner variant="error" role="alert" description={props.error} />}
508
+ <div className="flex justify-end gap-2">
509
+ <Button type="button" variant="secondary" onClick={props.onClose}>
510
+ Cancel
511
+ </Button>
512
+ <Button type="submit" variant="primary" disabled={Boolean(problem) || busy}>
513
+ {busy ? "Saving…" : d.kind === "verify" ? "Mark verified" : d.kind === "rename" ? "Rename" : bot ? "Save" : "Add bot"}
514
+ </Button>
515
+ </div>
516
+ </form>
517
+ </Dialog>
518
+ </Dialog.Root>
519
+ );
520
+ }
521
+
522
+ function RadarCard(props: { radar: RadarInfo; onRadar: (r: Partial<RadarInfo>) => void; onReload: () => Promise<void> }) {
523
+ const [editing, setEditing] = React.useState(false);
524
+ const [syncing, setSyncing] = React.useState(false);
525
+ const [message, setMessage] = React.useState<string>();
526
+ const [error, setError] = React.useState<string>();
527
+ const r = props.radar;
528
+ const refresh = async () => {
529
+ setSyncing(true);
530
+ setError(undefined);
531
+ try {
532
+ const state = await post<NonNullable<RadarInfo["state"]>>("refresh", {});
533
+ if (!state.ok) throw new Error(state.error ?? "Radar sync failed");
534
+ setMessage(`Refreshed from Cloudflare Radar: ${state.total} bots, ${state.added} new, ${state.updated} updated.`);
535
+ await props.onReload();
536
+ } catch (cause) {
537
+ const m = errorText(cause, "Radar sync failed");
538
+ setError(/turned off/i.test(m) ? "Turn on “Weekly crawler list from Cloudflare Radar” on Plugins → Coywolf Pack first." : m);
539
+ } finally {
540
+ setSyncing(false);
541
+ }
542
+ };
543
+ const changed = (source: RadarInfo["tokenSource"], m: string) => {
544
+ props.onRadar({ tokenSource: source, tokenConfigured: Boolean(source) });
545
+ setEditing(false);
546
+ setMessage(m);
547
+ };
548
+ return (
549
+ <section className="space-y-2 rounded-lg border p-4" aria-labelledby="robots-radar">
550
+ <div className="flex flex-wrap items-start justify-between gap-2">
551
+ <h2 id="robots-radar" className="text-base font-semibold">
552
+ Cloudflare Radar sync
553
+ </h2>
554
+ {r.tokenConfigured && (
555
+ <div className="flex flex-wrap gap-2">
556
+ {r.tokenSource === "settings" && !editing && (
557
+ <Button variant="ghost" onClick={() => setEditing(true)}>
558
+ Change token
559
+ </Button>
560
+ )}
561
+ <Button variant="secondary" icon={<ArrowsClockwise />} disabled={syncing} onClick={() => void refresh()}>
562
+ {syncing ? "Refreshing…" : "Refresh from Radar"}
563
+ </Button>
564
+ </div>
565
+ )}
566
+ </div>
567
+ <p className="text-sm text-kumo-subtle">Bundled list from {dateFormat.format(new Date(`${r.baselineDate}T12:00:00Z`))}.</p>
568
+ <div aria-live="polite">{message && <p className="text-sm">{message}</p>}</div>
569
+ {error && <Banner variant="error" role="alert" description={error} />}
570
+ {r.tokenConfigured ? (
571
+ <>
572
+ <p className="text-sm text-kumo-subtle">
573
+ {r.state
574
+ ? r.state.ok
575
+ ? `Last sync ${dateFormat.format(new Date(r.state.at))}: ${r.state.total} bots, ${r.state.added} new.`
576
+ : `Last sync failed ${dateFormat.format(new Date(r.state.at))}: ${r.state.error}`
577
+ : "Token set; no sync has run yet."}{" "}
578
+ {r.tokenSource === "env" && "Using the RADAR_API_TOKEN Worker secret."} Turn on “Weekly crawler list from Cloudflare Radar” on Plugins → Coywolf Pack to refresh it every week.
579
+ </p>
580
+ {editing && <RadarTokenForm source={r.tokenSource} onChanged={changed} onCancel={() => setEditing(false)} />}
581
+ </>
582
+ ) : (
583
+ <SetupCard
584
+ title="Add a Cloudflare Radar API token to keep this list current"
585
+ description="Optional. With a token, Refresh from Radar updates the crawler list now, and the weekly sync (on Plugins → Coywolf Pack) keeps it current. Or set the RADAR_API_TOKEN Worker secret."
586
+ >
587
+ <RadarTokenForm source={r.tokenSource} onChanged={changed} />
588
+ </SetupCard>
589
+ )}
590
+ </section>
591
+ );
592
+ }
593
+
594
+ function RadarTokenForm(props: { source: RadarInfo["tokenSource"]; onChanged: (source: RadarInfo["tokenSource"], message: string) => void; onCancel?: () => void }) {
595
+ const [token, setToken] = React.useState("");
596
+ const [pending, setPending] = React.useState<"save" | "clear">();
597
+ const [error, setError] = React.useState<string>();
598
+ const send = async (body: { token?: string; clear?: boolean }) => {
599
+ setPending(body.clear ? "clear" : "save");
600
+ setError(undefined);
601
+ try {
602
+ const result = await post<{ tokenSource: RadarInfo["tokenSource"] }>("radar-token", body);
603
+ setToken("");
604
+ props.onChanged(result.tokenSource, body.clear ? "Radar token removed." : "Radar token saved.");
605
+ } catch (cause) {
606
+ setError(errorText(cause, "Could not save the token"));
607
+ } finally {
608
+ setPending(undefined);
609
+ }
610
+ };
611
+ return (
612
+ <form
613
+ className="space-y-3"
614
+ onSubmit={(e) => {
615
+ e.preventDefault();
616
+ if (token.trim()) void send({ token: token.trim() });
617
+ }}
618
+ >
619
+ <div className="sm:max-w-md">
620
+ <SecretField
621
+ label="Cloudflare Radar API token"
622
+ saved={props.source === "settings"}
623
+ value={token}
624
+ onChange={setToken}
625
+ description="Create a Custom Token with Account → Radar → Read. Stored encrypted."
626
+ onClear={() => void send({ clear: true })}
627
+ clearing={pending === "clear"}
628
+ disabled={Boolean(pending)}
629
+ />
630
+ </div>
631
+ <div className="sm:max-w-md">
632
+ <CredentialGuide id="radar" />
633
+ </div>
634
+ {error && <Banner variant="error" role="alert" description={error} />}
635
+ <div className="flex gap-2">
636
+ <Button type="submit" variant="primary" disabled={Boolean(pending) || !token.trim()}>
637
+ {pending === "save" ? "Saving…" : "Save token"}
638
+ </Button>
639
+ {props.onCancel && (
640
+ <Button type="button" variant="secondary" disabled={Boolean(pending)} onClick={props.onCancel}>
641
+ Cancel
642
+ </Button>
643
+ )}
644
+ </div>
645
+ </form>
646
+ );
647
+ }