@coywolf/emdash 0.0.0-stage → 0.35.13

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 (261) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -2
  3. package/package.json +70 -5
  4. package/src/admin/admin-css.ts +57 -0
  5. package/src/admin/ai.tsx +915 -0
  6. package/src/admin/backups.tsx +613 -0
  7. package/src/admin/breadcrumbs.tsx +542 -0
  8. package/src/admin/codeBlocks.tsx +204 -0
  9. package/src/admin/customBlocks.tsx +321 -0
  10. package/src/admin/dirty.ts +20 -0
  11. package/src/admin/discovery.tsx +463 -0
  12. package/src/admin/download.ts +28 -0
  13. package/src/admin/feature-switches.tsx +124 -0
  14. package/src/admin/features.tsx +142 -0
  15. package/src/admin/files.tsx +791 -0
  16. package/src/admin/formUploads.tsx +445 -0
  17. package/src/admin/guides.tsx +18 -0
  18. package/src/admin/headings.tsx +262 -0
  19. package/src/admin/images.tsx +825 -0
  20. package/src/admin/index.tsx +62 -0
  21. package/src/admin/links.tsx +980 -0
  22. package/src/admin/performance.tsx +330 -0
  23. package/src/admin/preview.tsx +63 -0
  24. package/src/admin/redirects-removed.tsx +266 -0
  25. package/src/admin/redirects.tsx +470 -0
  26. package/src/admin/reviews.tsx +232 -0
  27. package/src/admin/robots-bots.tsx +647 -0
  28. package/src/admin/robots-shared.tsx +109 -0
  29. package/src/admin/robots-wizard.tsx +748 -0
  30. package/src/admin/robots.tsx +911 -0
  31. package/src/admin/save-bar.tsx +134 -0
  32. package/src/admin/schema.tsx +1187 -0
  33. package/src/admin/search.tsx +355 -0
  34. package/src/admin/settings-ui.tsx +87 -0
  35. package/src/admin/trash-prompt-core.ts +135 -0
  36. package/src/admin/trash-prompt.tsx +156 -0
  37. package/src/admin/usage.tsx +301 -0
  38. package/src/admin/videos-display.tsx +248 -0
  39. package/src/admin/videos.tsx +1202 -0
  40. package/src/admin/wpImport.tsx +1516 -0
  41. package/src/ai/entities.ts +59 -0
  42. package/src/ai/export.ts +61 -0
  43. package/src/ai/logic.test.ts +187 -0
  44. package/src/ai/logic.ts +606 -0
  45. package/src/ai/pack.ts +121 -0
  46. package/src/ai/prompts.ts +87 -0
  47. package/src/ai/providers.ts +276 -0
  48. package/src/ai/queue.test.ts +97 -0
  49. package/src/ai/routes.ts +359 -0
  50. package/src/ai/store.ts +301 -0
  51. package/src/ai/wikidata.test.ts +184 -0
  52. package/src/ai/wikidata.ts +87 -0
  53. package/src/ai/worker.ts +530 -0
  54. package/src/astro/astro-modules.d.ts +6 -0
  55. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  56. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  57. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  58. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  59. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  60. package/src/astro/customBlocks/Details.astro +8 -0
  61. package/src/astro/customBlocks/Disclosure.astro +8 -0
  62. package/src/astro/customBlocks/Note.astro +8 -0
  63. package/src/astro/customBlocks/Podcast.astro +8 -0
  64. package/src/astro/customBlocks/Quote.astro +8 -0
  65. package/src/astro/customBlocks/Testimonial.astro +8 -0
  66. package/src/astro/files/FileCard.astro +103 -0
  67. package/src/astro/headings/HeadingBlock.astro +129 -0
  68. package/src/astro/headings/TableOfContents.astro +45 -0
  69. package/src/astro/headings/request.ts +19 -0
  70. package/src/astro/images/ResponsiveImage.astro +60 -0
  71. package/src/astro/index.ts +84 -0
  72. package/src/astro/reviews/CoywolfReview.astro +27 -0
  73. package/src/astro/reviews/Review.astro +49 -0
  74. package/src/astro/search/SearchBox.astro +61 -0
  75. package/src/astro/search/SearchBoxForm.astro +509 -0
  76. package/src/astro/tables/CaptionedTable.astro +72 -0
  77. package/src/astro/videos/CoywolfVideo.astro +448 -0
  78. package/src/backups/download.ts +112 -0
  79. package/src/backups/dump.test.mjs +287 -0
  80. package/src/backups/dump.ts +288 -0
  81. package/src/backups/module.ts +276 -0
  82. package/src/backups/pack.ts +33 -0
  83. package/src/backups/restore.test.mjs +103 -0
  84. package/src/backups/restore.ts +209 -0
  85. package/src/backups/store.ts +409 -0
  86. package/src/breadcrumbs/pack.ts +113 -0
  87. package/src/breadcrumbs/settings.ts +102 -0
  88. package/src/breadcrumbs/stamp.ts +40 -0
  89. package/src/breadcrumbs/trail.ts +123 -0
  90. package/src/codeBlocks/contrast.ts +179 -0
  91. package/src/codeBlocks/hljs.d.ts +5 -0
  92. package/src/codeBlocks/pack.ts +110 -0
  93. package/src/codeBlocks/render.test.ts +152 -0
  94. package/src/codeBlocks/render.ts +304 -0
  95. package/src/codeBlocks/settings.ts +18 -0
  96. package/src/codeBlocks/themes.generated.ts +304 -0
  97. package/src/codeBlocks/themes.ts +114 -0
  98. package/src/core/compose.ts +244 -0
  99. package/src/core/content-url.ts +888 -0
  100. package/src/core/csv.ts +119 -0
  101. package/src/core/d1-batch.ts +65 -0
  102. package/src/core/entry-docs.ts +175 -0
  103. package/src/core/features-module.ts +48 -0
  104. package/src/core/features.ts +392 -0
  105. package/src/core/generation.ts +88 -0
  106. package/src/core/guide-content.ts +166 -0
  107. package/src/core/module.ts +104 -0
  108. package/src/core/read-failures.ts +57 -0
  109. package/src/core/request-memo.ts +71 -0
  110. package/src/core/secrets.ts +83 -0
  111. package/src/core/settings-enhance.ts +74 -0
  112. package/src/core/site.ts +36 -0
  113. package/src/core/storage.ts +27 -0
  114. package/src/customBlocks/pack.ts +260 -0
  115. package/src/customBlocks/render.ts +424 -0
  116. package/src/customBlocks/rich.ts +313 -0
  117. package/src/customBlocks/settings.ts +46 -0
  118. package/src/discovery/build.ts +252 -0
  119. package/src/discovery/feed-build.ts +217 -0
  120. package/src/discovery/feed.ts +172 -0
  121. package/src/discovery/indexnow.ts +114 -0
  122. package/src/discovery/llms.ts +79 -0
  123. package/src/discovery/markdown.ts +294 -0
  124. package/src/discovery/memory-cache.ts +97 -0
  125. package/src/discovery/middleware.ts +186 -0
  126. package/src/discovery/module.ts +353 -0
  127. package/src/discovery/news.ts +99 -0
  128. package/src/discovery/pack.ts +58 -0
  129. package/src/discovery/settings.ts +91 -0
  130. package/src/discovery/urls.ts +35 -0
  131. package/src/env.d.ts +10 -0
  132. package/src/files/card.ts +231 -0
  133. package/src/files/counts.test.ts +114 -0
  134. package/src/files/counts.ts +60 -0
  135. package/src/files/export.ts +44 -0
  136. package/src/files/format.test.ts +93 -0
  137. package/src/files/format.ts +163 -0
  138. package/src/files/module.ts +596 -0
  139. package/src/files/pack.ts +92 -0
  140. package/src/files/r2-rules.test.ts +30 -0
  141. package/src/files/r2-rules.ts +41 -0
  142. package/src/files/range.ts +50 -0
  143. package/src/files/s3.ts +114 -0
  144. package/src/files/serve.ts +141 -0
  145. package/src/files/sigv4.test.ts +64 -0
  146. package/src/files/sigv4.ts +160 -0
  147. package/src/files/site.ts +137 -0
  148. package/src/files/walker.ts +58 -0
  149. package/src/formUploads/lib.ts +266 -0
  150. package/src/formUploads/pack.ts +55 -0
  151. package/src/formUploads/wrap.ts +592 -0
  152. package/src/forms.ts +16 -0
  153. package/src/headings/headings.test.ts +159 -0
  154. package/src/headings/pack.ts +154 -0
  155. package/src/headings/render.ts +139 -0
  156. package/src/headings/settings.ts +117 -0
  157. package/src/headings/slug.ts +68 -0
  158. package/src/headings/stamp.ts +142 -0
  159. package/src/headings/test-hooks.mjs +14 -0
  160. package/src/headings/toc.ts +48 -0
  161. package/src/images/cloudflare.ts +329 -0
  162. package/src/images/lib.ts +221 -0
  163. package/src/images/module.ts +372 -0
  164. package/src/images/pack.ts +804 -0
  165. package/src/images/pending.ts +37 -0
  166. package/src/images/settings.ts +48 -0
  167. package/src/images/setup.ts +192 -0
  168. package/src/images/variants-estimate.ts +162 -0
  169. package/src/images/variants-store.ts +475 -0
  170. package/src/images/variants.ts +311 -0
  171. package/src/index.ts +143 -0
  172. package/src/links/check.ts +101 -0
  173. package/src/links/classify.ts +270 -0
  174. package/src/links/export.ts +59 -0
  175. package/src/links/module.ts +652 -0
  176. package/src/links/pack.ts +43 -0
  177. package/src/links/pt.ts +325 -0
  178. package/src/links/store.ts +704 -0
  179. package/src/middleware.ts +287 -0
  180. package/src/modules.ts +53 -0
  181. package/src/options.ts +66 -0
  182. package/src/pageCache/lib.test.mjs +224 -0
  183. package/src/pageCache/lib.ts +274 -0
  184. package/src/pageCache/pack.ts +240 -0
  185. package/src/pageCache/usage-core.ts +152 -0
  186. package/src/pageCache/usage.ts +262 -0
  187. package/src/pageCache/warm.test.mjs +670 -0
  188. package/src/pageCache/warm.ts +722 -0
  189. package/src/redirects/middleware.ts +169 -0
  190. package/src/redirects/module.ts +124 -0
  191. package/src/redirects/pack.ts +89 -0
  192. package/src/redirects/removed-core.ts +151 -0
  193. package/src/redirects/removed.ts +293 -0
  194. package/src/redirects/rules.ts +355 -0
  195. package/src/redirects/slug-changes.ts +207 -0
  196. package/src/redirects/slug-core.test.ts +50 -0
  197. package/src/redirects/slug-core.ts +49 -0
  198. package/src/redirects/transfer.ts +92 -0
  199. package/src/reviews/lib.ts +624 -0
  200. package/src/reviews/pack.ts +135 -0
  201. package/src/reviews/schema.ts +128 -0
  202. package/src/reviews/settings.ts +26 -0
  203. package/src/robots/bots.ts +321 -0
  204. package/src/robots/data/bots.json +696 -0
  205. package/src/robots/data/verified.json +582 -0
  206. package/src/robots/directory.ts +26 -0
  207. package/src/robots/explain.ts +191 -0
  208. package/src/robots/guided.ts +300 -0
  209. package/src/robots/importer.ts +263 -0
  210. package/src/robots/middleware.ts +151 -0
  211. package/src/robots/module.ts +446 -0
  212. package/src/robots/pack.ts +69 -0
  213. package/src/robots/radar.ts +129 -0
  214. package/src/robots/rep.test.ts +335 -0
  215. package/src/robots/rep.ts +362 -0
  216. package/src/robots/rules.test.ts +251 -0
  217. package/src/robots/rules.ts +883 -0
  218. package/src/robots/transfer.ts +45 -0
  219. package/src/robots/validate.ts +524 -0
  220. package/src/schema/catalog.ts +146 -0
  221. package/src/schema/graph.test.mjs +522 -0
  222. package/src/schema/graph.ts +791 -0
  223. package/src/schema/module.ts +1138 -0
  224. package/src/schema/og-image.ts +100 -0
  225. package/src/schema/pack.ts +58 -0
  226. package/src/search/engine.ts +578 -0
  227. package/src/search/fallback.ts +80 -0
  228. package/src/search/live-cache.ts +116 -0
  229. package/src/search/live-client.ts +770 -0
  230. package/src/search/live-serve.ts +282 -0
  231. package/src/search/module.ts +261 -0
  232. package/src/search/pack.ts +57 -0
  233. package/src/search/query.ts +132 -0
  234. package/src/search/ratelimit-core.ts +118 -0
  235. package/src/search/ratelimit.ts +79 -0
  236. package/src/search/snippet.ts +135 -0
  237. package/src/search/title-index.ts +139 -0
  238. package/src/search/title-match.ts +192 -0
  239. package/src/shared.ts +71 -0
  240. package/src/version.ts +2 -0
  241. package/src/videos/date.ts +40 -0
  242. package/src/videos/engagement.ts +75 -0
  243. package/src/videos/lib.ts +679 -0
  244. package/src/videos/module.ts +1160 -0
  245. package/src/videos/pack.ts +187 -0
  246. package/src/videos/pause.ts +42 -0
  247. package/src/videos/poster.ts +324 -0
  248. package/src/videos/render.ts +336 -0
  249. package/src/videos/store.ts +551 -0
  250. package/src/videos/stream.ts +134 -0
  251. package/src/wpImport/convert.ts +702 -0
  252. package/src/wpImport/guests.ts +329 -0
  253. package/src/wpImport/gutenberg.ts +157 -0
  254. package/src/wpImport/markers.ts +66 -0
  255. package/src/wpImport/pack.ts +451 -0
  256. package/src/wpImport/parents.ts +202 -0
  257. package/src/wpImport/prepare.ts +592 -0
  258. package/src/wpImport/redirects.ts +358 -0
  259. package/src/wpImport/sources.ts +67 -0
  260. package/src/wpImport/stream.ts +160 -0
  261. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,911 @@
1
+ /**
2
+ * Robots.txt admin page: a plain-English summary of the whole file, then
3
+ * tabs for Rules (list, URL tester, what's being served), Bots (the crawler
4
+ * directory) and Settings & history. Rules are added and edited in a guided
5
+ * dialog (./robots-wizard.tsx); every change is checked in the browser and
6
+ * again on the server (including the self-check) before it's saved.
7
+ */
8
+ import { Badge, Banner, Button, Checkbox, Dialog, DropdownMenu, Input, InputArea, Loader, Switch, Tabs } from "@cloudflare/kumo";
9
+ import { CheckCircle, Copy, DotsThree, DownloadSimple, FileArrowUp, Info, PencilSimple, Plus, Robot, Sparkle, Trash, Warning, X, XCircle } from "@phosphor-icons/react";
10
+ import * as React from "react";
11
+
12
+ import type { BotEntry } from "../robots/bots.js";
13
+ import { describeRule, summarize } from "../robots/explain.js";
14
+ import { evaluate } from "../robots/rep.js";
15
+ import { EMDASH_AI_TRAINING_CRAWLERS, type RobotsConfig, type RobotsRule, TEMPLATES, directives, resolveAgents } from "../robots/rules.js";
16
+ import { exportRobotsRules, readRobotsImport } from "../robots/transfer.js";
17
+ import { type Finding, analyzeConfigChange, checkConfig } from "../robots/validate.js";
18
+ import { BotsTab } from "./robots-bots.js";
19
+ import { dateTimeFormat, errorText, get, newId, post, tokenIndex, useCopy } from "./robots-shared.js";
20
+ import { saveFile, siteSlug, today } from "./download.js";
21
+ import { RuleWizard } from "./robots-wizard.js";
22
+ import { SaveBar, isDirty } from "./save-bar.js";
23
+ import { PartSwitches } from "./feature-switches.js";
24
+
25
+ interface SyncState {
26
+ at: string;
27
+ ok: boolean;
28
+ total?: number;
29
+ added?: number;
30
+ updated?: number;
31
+ delisted?: number;
32
+ error?: string;
33
+ }
34
+
35
+ interface HistoryItem {
36
+ id: string;
37
+ at: string;
38
+ by?: string;
39
+ label: string;
40
+ rules: number;
41
+ }
42
+
43
+ interface PageData {
44
+ config: RobotsConfig;
45
+ siteUrl: string;
46
+ preview: string;
47
+ emdash: { text: string; custom: boolean };
48
+ history: HistoryItem[];
49
+ sitemapNoteDismissed: boolean;
50
+ warnings: Finding[];
51
+ groupChanges: Array<{ ruleId: string; ruleName: string; group: string; added: string[]; removed: string[] }>;
52
+ sections: Array<{ label: string; path: string }>;
53
+ radar: { tokenConfigured: boolean; tokenSource: "settings" | "env" | null; state: SyncState | null; baselineDate: string };
54
+ }
55
+
56
+ interface Confirm {
57
+ title: string;
58
+ body: string;
59
+ findings?: Finding[];
60
+ confirmLabel: string;
61
+ destructive?: boolean;
62
+ onConfirm: () => Promise<void>;
63
+ }
64
+
65
+ const capital = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
66
+
67
+ function ConfirmDialog(props: { confirm: Confirm | null; onClose: () => void }) {
68
+ const [busy, setBusy] = React.useState(false);
69
+ const [error, setError] = React.useState<string>();
70
+ React.useEffect(() => setError(undefined), [props.confirm]);
71
+ const c = props.confirm;
72
+ if (!c) return null;
73
+ return (
74
+ <Dialog.Root open onOpenChange={(open) => !open && !busy && props.onClose()}>
75
+ <Dialog className="max-h-[90vh] overflow-y-auto p-6" size="lg">
76
+ <Dialog.Title className="text-lg font-semibold">{c.title}</Dialog.Title>
77
+ <Dialog.Description className="mt-1 text-sm text-kumo-subtle">{c.body}</Dialog.Description>
78
+ {c.findings && c.findings.length > 0 && (
79
+ <ul className="mt-3 space-y-1.5">
80
+ {c.findings.map((f, i) => (
81
+ <li key={`${f.code}-${i}`} className="flex items-start gap-2 text-sm">
82
+ <Warning className="mt-0.5 shrink-0 text-kumo-warning" aria-hidden="true" />
83
+ <span>{f.message}</span>
84
+ </li>
85
+ ))}
86
+ </ul>
87
+ )}
88
+ {error && (
89
+ <div className="mt-3">
90
+ <Banner variant="error" role="alert" description={error} />
91
+ </div>
92
+ )}
93
+ <div className="mt-5 flex justify-end gap-2">
94
+ <Button type="button" variant="secondary" disabled={busy} onClick={props.onClose}>
95
+ Cancel
96
+ </Button>
97
+ <Button
98
+ type="button"
99
+ variant={c.destructive ? "destructive" : "primary"}
100
+ disabled={busy}
101
+ onClick={async () => {
102
+ setBusy(true);
103
+ setError(undefined);
104
+ try {
105
+ await c.onConfirm();
106
+ props.onClose();
107
+ } catch (cause) {
108
+ setError(errorText(cause, "That didn't work"));
109
+ } finally {
110
+ setBusy(false);
111
+ }
112
+ }}
113
+ >
114
+ {busy ? "Working…" : c.confirmLabel}
115
+ </Button>
116
+ </div>
117
+ </Dialog>
118
+ </Dialog.Root>
119
+ );
120
+ }
121
+
122
+ function Tester(props: { robotsTxt: string; bots: BotEntry[]; siteUrl: string }) {
123
+ const [agent, setAgent] = React.useState("Googlebot");
124
+ const [url, setUrl] = React.useState("/");
125
+ const tokens = React.useMemo(() => [...new Set(props.bots.map((b) => b.token))].sort((a, b) => a.localeCompare(b)), [props.bots]);
126
+ const token = agent.trim();
127
+ const verdict = token && url.trim() ? evaluate(props.robotsTxt, [token], url.trim(), { encodePath: true }) : null;
128
+ const lines = props.robotsTxt.split("\n");
129
+ return (
130
+ <section className="space-y-3 rounded-lg border p-4" aria-labelledby="robots-tester">
131
+ <h2 id="robots-tester" className="text-base font-semibold">
132
+ Can a crawler fetch a URL?
133
+ </h2>
134
+ <p className="text-sm text-kumo-subtle">Tests the robots.txt being served, the way Google reads it.</p>
135
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-end">
136
+ <div className="sm:w-64">
137
+ <Input label="Crawler token" list="cw-robots-tokens" value={agent} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAgent(e.target.value)} />
138
+ <datalist id="cw-robots-tokens">
139
+ {tokens.map((t) => (
140
+ <option key={t} value={t} />
141
+ ))}
142
+ </datalist>
143
+ </div>
144
+ <div className="flex-1">
145
+ <Input label="URL or path" placeholder={`${props.siteUrl || "https://example.com"}/some/page/`} value={url} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUrl(e.target.value)} />
146
+ </div>
147
+ </div>
148
+ <div aria-live="polite" className="text-sm">
149
+ {verdict && (
150
+ <p className="flex items-start gap-2">
151
+ {verdict.allowed ? <CheckCircle className="mt-0.5 shrink-0 text-kumo-success" aria-hidden="true" /> : <XCircle className="mt-0.5 shrink-0 text-kumo-danger" aria-hidden="true" />}
152
+ <span>
153
+ <strong>{verdict.allowed ? "Allowed" : "Blocked"}</strong> for <code>{token}</code> at <code>{verdict.path}</code>.{" "}
154
+ {verdict.matchedDirective === "none"
155
+ ? verdict.scope === "specific"
156
+ ? `${token} has its own rules and none of them match, so it may fetch it.`
157
+ : "No rule matches, so it may fetch it."
158
+ : `Decided by line ${verdict.matchedLine}: ${lines[verdict.matchedLine - 1]?.trim()} (${verdict.scope === "specific" ? `${token}'s own group` : "the rules for all crawlers"}).`}
159
+ </span>
160
+ </p>
161
+ )}
162
+ </div>
163
+ </section>
164
+ );
165
+ }
166
+
167
+ function ServedFile(props: { text: string; emdash: PageData["emdash"]; config: RobotsConfig; siteUrl: string }) {
168
+ const [status, copy] = useCopy();
169
+ const rows = props.text.split("\n").length;
170
+ return (
171
+ <section className="space-y-3" aria-labelledby="robots-served">
172
+ <div className="flex flex-wrap items-center justify-between gap-2">
173
+ <h2 id="robots-served" className="text-base font-semibold">
174
+ What's being served
175
+ </h2>
176
+ <div className="flex items-center gap-2">
177
+ <span className="text-xs text-kumo-subtle" role="status" aria-live="polite">
178
+ {status}
179
+ </span>
180
+ <Button variant="secondary" icon={<Copy />} onClick={() => copy(props.text)}>
181
+ Copy
182
+ </Button>
183
+ <Button variant="secondary" icon={<DownloadSimple />} onClick={() => saveFile(props.text, "robots.txt")}>
184
+ Download
185
+ </Button>
186
+ {props.siteUrl && (
187
+ <a className="text-sm underline" href={`${props.siteUrl}/robots.txt`} target="_blank" rel="noreferrer noopener">
188
+ Open /robots.txt
189
+ </a>
190
+ )}
191
+ </div>
192
+ </div>
193
+ {props.config.importNotes && props.config.importNotes.length > 0 && (
194
+ <div className="rounded-lg border border-kumo-line bg-kumo-tint/30 p-3 text-sm">
195
+ <p className="font-medium">
196
+ {props.config.importedAt ? `Set up from EmDash's robots.txt on ${dateTimeFormat.format(new Date(props.config.importedAt))}` : "What's new"}
197
+ </p>
198
+ <ul className="mt-1 list-disc space-y-0.5 ps-5 text-kumo-subtle">
199
+ {props.config.importNotes.map((n) => (
200
+ <li key={n}>{n}</li>
201
+ ))}
202
+ </ul>
203
+ </div>
204
+ )}
205
+ <textarea readOnly aria-label="robots.txt being served" className="w-full resize-none rounded-lg border bg-kumo-tint/40 p-3 font-mono text-xs" rows={rows} value={props.text} />
206
+ <p className="text-xs text-kumo-subtle">Turn the Robots.txt Rules feature off (on Plugins → Coywolf Pack) to serve EmDash's own robots.txt again. Your rules are kept for next time.</p>
207
+ <details className="rounded-lg border border-kumo-line px-3 py-2">
208
+ <summary className="cursor-pointer text-sm font-medium">EmDash's original robots.txt (for reference)</summary>
209
+ <p className="mt-2 text-xs text-kumo-subtle">
210
+ {props.emdash.custom ? "From EmDash's SEO settings." : "EmDash's built-in default."} This is what EmDash serves when this feature is off.
211
+ </p>
212
+ <pre className="mt-1 overflow-x-auto font-mono text-xs">{props.emdash.text}</pre>
213
+ </details>
214
+ </section>
215
+ );
216
+ }
217
+
218
+ /** "List the site's sitemap", up top, with a gentle dismissible note when it's off. */
219
+ function SitemapOption(props: { config: RobotsConfig; siteUrl: string; busy: boolean; noteDismissed: boolean; onChange: (on: boolean) => void; onDismiss: () => void }) {
220
+ return (
221
+ <section className="space-y-2 rounded-lg border border-kumo-line p-4" aria-labelledby="robots-sitemap">
222
+ <h2 id="robots-sitemap" className="sr-only">
223
+ Sitemap
224
+ </h2>
225
+ <Checkbox
226
+ label={`List the site's sitemap (${props.siteUrl || "your site"}/sitemap.xml)`}
227
+ checked={props.config.includeSitemap}
228
+ disabled={props.busy}
229
+ onCheckedChange={(on: boolean) => props.onChange(on)}
230
+ />
231
+ <p className="cw-ps-6 text-xs text-kumo-subtle">A Sitemap line tells every search engine where your sitemap is. Turn it off if you'd rather submit sitemaps yourself, for example in Google Search Console.</p>
232
+ {!props.config.includeSitemap && !props.noteDismissed && (
233
+ <div className="flex items-start gap-2 rounded bg-kumo-tint/40 px-3 py-2 text-sm" role="note">
234
+ <Info className="mt-0.5 shrink-0 text-kumo-subtle" aria-hidden="true" />
235
+ <span className="flex-1">
236
+ Your sitemap isn't listed in robots.txt. That's fine if you submit it in Search Console; other search engines may find it more slowly.{" "}
237
+ <button type="button" className="font-medium underline" disabled={props.busy} onClick={() => props.onChange(true)}>
238
+ List it
239
+ </button>
240
+ </span>
241
+ <Button type="button" variant="ghost" shape="square" size="sm" aria-label="Don't show this note again" onClick={props.onDismiss}>
242
+ <X aria-hidden="true" />
243
+ </Button>
244
+ </div>
245
+ )}
246
+ </section>
247
+ );
248
+ }
249
+
250
+ /** AI training: EmDash's own Settings > SEO switch, whose lines this robots.txt includes while it's on. */
251
+ function AiTraining(props: { config: RobotsConfig }) {
252
+ const on = props.config.automatic?.aiTraining === true;
253
+ const own = props.config.rules.filter((r) => r.enabled && r.group === "ai_training" && r.directive === "disallow");
254
+ // A site-wide Allow for one of EmDash's crawlers wins over its Disallow: / (the more specific of equal-length lines is Allow).
255
+ const named = new Set(EMDASH_AI_TRAINING_CRAWLERS.map((t) => t.toLowerCase()));
256
+ const allowed = props.config.rules.filter((r) => r.enabled && r.directive === "allow" && r.kind === "entire_site" && r.agents.some((a) => named.has(a.toLowerCase())));
257
+ return (
258
+ <section className="space-y-2 rounded-lg border border-kumo-line p-4" aria-labelledby="robots-ai-training">
259
+ <h2 id="robots-ai-training" className="flex items-center gap-2 text-base font-semibold">
260
+ AI training <Badge variant={on ? "primary" : "secondary"}>{on ? "Disallowed" : "Not set"}</Badge>
261
+ </h2>
262
+ <p className="text-sm text-kumo-subtle">
263
+ {on
264
+ ? `EmDash's "Disallow AI training" is on, so this robots.txt asks AI training crawlers to stay away: a Content-Signal line (search=yes, ai-train=no) and Disallow: / for ${EMDASH_AI_TRAINING_CRAWLERS.join(", ")}.`
265
+ : `To ask AI training crawlers to stay away, turn on "Disallow AI training" in EmDash's SEO settings. This robots.txt includes its lines while it's on.`}{" "}
266
+ To block them outright, use your Cloudflare zone's AI bot policies (Training: Block); robots.txt only asks.
267
+ </p>
268
+ {on && own.length > 0 && (
269
+ <p className="text-sm">
270
+ {own.length === 1 ? `Your rule “${own[0].name}” repeats` : `${own.length} of your rules repeat`} what EmDash's setting adds. You can delete {own.length === 1 ? "it" : "them"} unless {own.length === 1 ? "it names" : "they name"} crawlers you want to keep blocked.
271
+ </p>
272
+ )}
273
+ {on && allowed.length > 0 && (
274
+ <p className="text-sm">
275
+ {allowed.map((r) => `“${r.name}”`).join(", ")} {allowed.length === 1 ? "lets" : "let"} some of these crawlers in anyway: an Allow for the whole site wins over EmDash's Disallow.
276
+ </p>
277
+ )}
278
+ <a className="text-sm font-medium text-kumo-link underline" href="/_emdash/admin/settings/seo">
279
+ Open SEO settings
280
+ </a>
281
+ </section>
282
+ );
283
+ }
284
+
285
+ /** Lines added automatically so crawlers can always read discovery files (/.well-known/, llms.txt…). */
286
+ function AutomaticLines(props: { config: RobotsConfig }) {
287
+ const resolved = resolveAgents(props.config);
288
+ const byValue = new Map<string, string[]>();
289
+ for (const [k, entry] of resolved) {
290
+ for (const l of entry.lines.filter((x) => x.auto)) byValue.set(l.value, [...(byValue.get(l.value) ?? []), k === "*" ? "all other crawlers" : entry.token]);
291
+ }
292
+ if (!byValue.size) return null;
293
+ return (
294
+ <section className="space-y-2 rounded-lg border border-kumo-line p-4" aria-labelledby="robots-auto">
295
+ <h2 id="robots-auto" className="flex items-center gap-2 text-base font-semibold">
296
+ Automatic <Badge variant="secondary">{byValue.size}</Badge>
297
+ </h2>
298
+ <p className="text-sm text-kumo-subtle">
299
+ Added so crawlers you've blocked can still read your discovery files ({[...byValue.keys()].join(", ")}), which tell AI agents and other tools about your site
300
+ {props.config.automatic?.llms ? "; llms.txt is included because Discovery's llms.txt is on" : ""}. Change this under Settings and history.
301
+ </p>
302
+ <ul className="space-y-1 text-xs">
303
+ {[...byValue].map(([value, who]) => (
304
+ <li key={value}>
305
+ <code>Allow: {value}</code> <span className="text-kumo-subtle">for {who.length > 4 ? `${who.slice(0, 3).join(", ")} and ${who.length - 3} more` : who.join(", ")}</span>
306
+ </li>
307
+ ))}
308
+ </ul>
309
+ </section>
310
+ );
311
+ }
312
+
313
+ function RulesList(props: {
314
+ config: RobotsConfig;
315
+ bots: BotEntry[];
316
+ busy: boolean;
317
+ onEdit: (rule: RobotsRule) => void;
318
+ onToggle: (rule: RobotsRule, on: boolean) => void;
319
+ onDelete: (rule: RobotsRule) => void;
320
+ onDuplicate: (rule: RobotsRule) => void;
321
+ onAdd: () => void;
322
+ }) {
323
+ const byToken = tokenIndex(props.bots);
324
+ const nameOf = (t: string) => byToken.get(t.toLowerCase())?.name ?? t;
325
+ if (!props.config.rules.length) {
326
+ return (
327
+ <div className="rounded-lg border py-10 text-center text-kumo-subtle">
328
+ <Robot size={40} className="mx-auto mb-3 opacity-30" aria-hidden="true" />
329
+ <p className="text-base font-medium">No rules</p>
330
+ <p className="mt-1 text-sm">Every crawler can crawl everything{props.config.emdashLines ? " except EmDash's admin" : ""}.</p>
331
+ <div className="mt-3">
332
+ <Button variant="primary" icon={<Plus />} onClick={props.onAdd}>
333
+ Add a rule
334
+ </Button>
335
+ </div>
336
+ </div>
337
+ );
338
+ }
339
+ return (
340
+ <ul className="rounded-lg border">
341
+ {props.config.rules.map((rule) => {
342
+ const sentence = capital(describeRule(rule, nameOf));
343
+ const lines = (() => {
344
+ try {
345
+ return directives(rule);
346
+ } catch {
347
+ return [];
348
+ }
349
+ })();
350
+ const unverified = rule.agents.filter((a) => byToken.get(a.toLowerCase())?.status === "unverified");
351
+ return (
352
+ <li key={rule.id} className={`flex items-start gap-3 border-b px-4 py-3 text-sm last:border-0 ${rule.enabled ? "" : "opacity-60"}`}>
353
+ <Switch className="shrink-0" size="sm" aria-label={`${rule.name}: ${rule.enabled ? "on" : "off"}`} checked={rule.enabled} disabled={props.busy} onCheckedChange={(on) => props.onToggle(rule, on)} />
354
+ <div className="min-w-0 flex-1">
355
+ <div className="font-medium">{rule.name || sentence}</div>
356
+ {rule.name && rule.name !== sentence && <div className="text-xs">{sentence}</div>}
357
+ {rule.description && <div className="text-xs text-kumo-subtle">{rule.description}</div>}
358
+ <div className="mt-1 flex flex-wrap items-center gap-1">
359
+ <Badge variant={rule.directive === "allow" && rule.kind !== "allow_exception" ? "secondary" : "outline"}>{rule.directive === "allow" && rule.kind !== "allow_exception" ? "Allow" : "Block"}</Badge>
360
+ {rule.agents.includes("*") ? <Badge variant="secondary">all crawlers</Badge> : <Badge variant="secondary">{rule.agents.length} crawler{rule.agents.length === 1 ? "" : "s"}</Badge>}
361
+ {unverified.length > 0 && (
362
+ <span title={`Unverified tokens: ${unverified.join(", ")}`}>
363
+ <Badge variant="warning">{unverified.length} unverified</Badge>
364
+ </span>
365
+ )}
366
+ {!rule.enabled && <Badge variant="outline">off</Badge>}
367
+ </div>
368
+ <details className="mt-1">
369
+ <summary className="cursor-pointer text-xs text-kumo-subtle">robots.txt lines</summary>
370
+ <code className="block text-xs text-kumo-subtle">
371
+ {lines.map((d) => `${d.directive}: ${d.value}`).join(" · ")} — for {rule.agents.includes("*") ? "*" : rule.agents.slice(0, 6).join(", ")}
372
+ {rule.agents.length > 6 ? ` +${rule.agents.length - 6} more` : ""}
373
+ </code>
374
+ </details>
375
+ </div>
376
+ <DropdownMenu>
377
+ <DropdownMenu.Trigger render={<Button type="button" variant="ghost" shape="square" icon={<DotsThree aria-hidden="true" />} aria-label={`Actions for ${rule.name}`} />} />
378
+ <DropdownMenu.Content className="p-1">
379
+ <DropdownMenu.Item className="py-1 data-highlighted:bg-kumo-fill" icon={<PencilSimple className="me-1.5 size-3.5" aria-hidden="true" />} onClick={() => props.onEdit(rule)}>
380
+ Edit
381
+ </DropdownMenu.Item>
382
+ <DropdownMenu.Item className="py-1 data-highlighted:bg-kumo-fill" icon={<Plus className="me-1.5 size-3.5" aria-hidden="true" />} onClick={() => props.onDuplicate(rule)}>
383
+ Duplicate and edit
384
+ </DropdownMenu.Item>
385
+ <DropdownMenu.Separator className="my-0.5" />
386
+ <DropdownMenu.Item className="py-1 data-highlighted:bg-kumo-fill" icon={<Trash className="me-1.5 size-3.5" aria-hidden="true" />} onClick={() => props.onDelete(rule)}>
387
+ Delete
388
+ </DropdownMenu.Item>
389
+ </DropdownMenu.Content>
390
+ </DropdownMenu>
391
+ </li>
392
+ );
393
+ })}
394
+ </ul>
395
+ );
396
+ }
397
+
398
+ function SettingsTab(props: {
399
+ data: PageData;
400
+ config: RobotsConfig;
401
+ onSave: (config: RobotsConfig, label: string) => Promise<void>;
402
+ onRestore: (item: HistoryItem) => void;
403
+ onReset: () => void;
404
+ onImport: (config: RobotsConfig, filename: string) => void;
405
+ }) {
406
+ const fileRef = React.useRef<HTMLInputElement>(null);
407
+ const [fileError, setFileError] = React.useState<string>();
408
+ const [draft, setDraft] = React.useState(props.config);
409
+ const [saving, setSaving] = React.useState(false);
410
+ const [error, setError] = React.useState<string>();
411
+ React.useEffect(() => setDraft(props.config), [props.config]);
412
+ const dirty = isDirty(draft, props.config);
413
+ const update = (p: Partial<RobotsConfig>) => setDraft((d) => ({ ...d, ...p }));
414
+ const problem = dirty ? checkConfig(draft, props.data.siteUrl || undefined) : null;
415
+ const risks = dirty ? analyzeConfigChange(props.config, draft, props.data.siteUrl || undefined) : [];
416
+ const [ack, setAck] = React.useState(false);
417
+ React.useEffect(() => setAck(false), [draft]);
418
+ const canSave = !problem && (risks.length === 0 || ack);
419
+ const save = async () => {
420
+ if (!dirty || !canSave) return;
421
+ setSaving(true);
422
+ setError(undefined);
423
+ try {
424
+ await props.onSave(draft, "Changed settings");
425
+ } catch (cause) {
426
+ setError(errorText(cause, "Could not save"));
427
+ } finally {
428
+ setSaving(false);
429
+ }
430
+ };
431
+
432
+ return (
433
+ <div className="space-y-6">
434
+ <section className="space-y-3" aria-labelledby="robots-settings">
435
+ <h2 id="robots-settings" className="text-base font-semibold">
436
+ Settings
437
+ </h2>
438
+ <Checkbox
439
+ label={`Keep EmDash's admin private (Disallow: /_emdash/ for every crawler)`}
440
+ checked={draft.emdashLines}
441
+ onCheckedChange={(on: boolean) => update({ emdashLines: on })}
442
+ />
443
+ <Checkbox
444
+ label="Keep media crawlable (Allow: /_emdash/api/media/, so your images can appear in image search)"
445
+ checked={draft.allowMedia}
446
+ onCheckedChange={(on: boolean) => update({ allowMedia: on })}
447
+ />
448
+ <Checkbox
449
+ label="Crawlers named in a rule also keep the rules for all crawlers (recommended)"
450
+ checked={draft.inheritGeneral}
451
+ onCheckedChange={(on: boolean) => update({ inheritGeneral: on })}
452
+ />
453
+ <p className="-mt-2 cw-ps-6 text-xs text-kumo-subtle">
454
+ In robots.txt, a crawler that's named anywhere ignores every rule for “all crawlers” (RFC 9309). With this on, Coywolf Pack copies those rules into its group, except where its own
455
+ rules say otherwise.
456
+ </p>
457
+ <Checkbox
458
+ label="Keep discovery files readable for every crawler (/.well-known/, and llms.txt while Discovery's llms.txt is on)"
459
+ checked={draft.discoveryAllowances}
460
+ onCheckedChange={(on: boolean) => update({ discoveryAllowances: on })}
461
+ />
462
+ {draft.discoveryAllowances && (
463
+ <InputArea
464
+ label="More discovery paths to keep readable (one per line)"
465
+ rows={2}
466
+ placeholder="/agents.json"
467
+ value={draft.discoveryPaths.join("\n")}
468
+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => update({ discoveryPaths: e.target.value.split("\n") })}
469
+ className="font-mono text-xs"
470
+ />
471
+ )}
472
+ <Checkbox label="Write rule names as comments" checked={draft.comments} onCheckedChange={(on: boolean) => update({ comments: on })} />
473
+ <InputArea
474
+ label="More sitemaps (one URL or path per line)"
475
+ rows={3}
476
+ value={draft.sitemaps.join("\n")}
477
+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => update({ sitemaps: e.target.value.split("\n") })}
478
+ className="font-mono text-xs"
479
+ />
480
+ <InputArea
481
+ label="Extra lines (added as written, e.g. Content-Signal or Crawl-delay; for experts)"
482
+ rows={5}
483
+ value={draft.extra}
484
+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => update({ extra: e.target.value })}
485
+ className="font-mono text-xs"
486
+ />
487
+ <div aria-live="polite" className="space-y-2">
488
+ {problem && <Banner variant="error" role="alert" description={problem} />}
489
+ {!problem && risks.length > 0 && (
490
+ <>
491
+ <ul className="space-y-1">
492
+ {risks.map((f, i) => (
493
+ <li key={`${f.code}-${i}`} className="flex items-start gap-2 text-sm">
494
+ <Warning className="mt-0.5 shrink-0 text-kumo-warning" aria-hidden="true" />
495
+ {f.message}
496
+ </li>
497
+ ))}
498
+ </ul>
499
+ <Checkbox label="I understand. Save anyway." checked={ack} onCheckedChange={(on: boolean) => setAck(on)} />
500
+ </>
501
+ )}
502
+ </div>
503
+ {error && <Banner variant="error" role="alert" description={error} />}
504
+ </section>
505
+
506
+ <section className="space-y-2" aria-labelledby="robots-transfer">
507
+ <h2 id="robots-transfer" className="text-base font-semibold">
508
+ Export and import
509
+ </h2>
510
+ <p className="text-sm text-kumo-subtle">
511
+ Save your rules and settings as a file to keep a copy or move them to another site. Importing a file replaces the current rules and settings; the current version stays in Version
512
+ history.
513
+ </p>
514
+ <div className="flex flex-wrap gap-2">
515
+ <Button
516
+ variant="secondary"
517
+ icon={<DownloadSimple />}
518
+ onClick={() => saveFile(exportRobotsRules(props.config, props.data.siteUrl || undefined), `robots-rules-${siteSlug() || "site"}-${today()}.json`, "application/json")}
519
+ >
520
+ Export rules
521
+ </Button>
522
+ <Button variant="secondary" icon={<FileArrowUp />} onClick={() => fileRef.current?.click()}>
523
+ Import rules…
524
+ </Button>
525
+ <input
526
+ ref={fileRef}
527
+ type="file"
528
+ accept=".json,application/json"
529
+ className="sr-only"
530
+ tabIndex={-1}
531
+ aria-hidden="true"
532
+ onChange={(e) => {
533
+ const file = e.target.files?.[0];
534
+ e.target.value = "";
535
+ if (!file) return;
536
+ setFileError(undefined);
537
+ file
538
+ .text()
539
+ .then((text) => props.onImport(readRobotsImport(text), file.name))
540
+ .catch((cause) => setFileError(errorText(cause, "Could not read that file.")));
541
+ }}
542
+ />
543
+ </div>
544
+ {fileError && <Banner variant="error" role="alert" description={fileError} />}
545
+ </section>
546
+
547
+ <section className="space-y-2" aria-labelledby="robots-history">
548
+ <div className="flex flex-wrap items-center justify-between gap-2">
549
+ <h2 id="robots-history" className="text-base font-semibold">
550
+ Version history
551
+ </h2>
552
+ <Button variant="secondary" onClick={props.onReset}>
553
+ Reset to EmDash's original
554
+ </Button>
555
+ </div>
556
+ <p className="text-sm text-kumo-subtle">The last 20 saved versions. Restoring one saves it as a new version, so you can always go back.</p>
557
+ {props.data.history.length === 0 ? (
558
+ <p className="text-sm text-kumo-subtle">No versions yet.</p>
559
+ ) : (
560
+ <ul className="rounded-lg border">
561
+ {props.data.history.map((h, i) => (
562
+ <li key={h.id} className="flex flex-wrap items-center gap-3 border-b px-4 py-2 text-sm last:border-0">
563
+ <div className="min-w-0 flex-1">
564
+ <div className="font-medium">
565
+ {h.label} {i === 0 && <Badge variant="secondary">current</Badge>}
566
+ </div>
567
+ <div className="text-xs text-kumo-subtle">
568
+ {dateTimeFormat.format(new Date(h.at))}
569
+ {h.by ? ` · ${h.by}` : ""} · {h.rules} rule{h.rules === 1 ? "" : "s"}
570
+ </div>
571
+ </div>
572
+ {i > 0 && (
573
+ <Button variant="secondary" size="sm" onClick={() => props.onRestore(h)}>
574
+ Restore
575
+ </Button>
576
+ )}
577
+ </li>
578
+ ))}
579
+ </ul>
580
+ )}
581
+ </section>
582
+
583
+ <SaveBar
584
+ dirty={dirty}
585
+ saving={saving}
586
+ canSave={canSave}
587
+ detail={problem ? "Fix the problem above to save" : !canSave ? "Confirm the warnings above to save" : undefined}
588
+ onSave={() => void save()}
589
+ onDiscard={() => setDraft(props.config)}
590
+ />
591
+ </div>
592
+ );
593
+ }
594
+
595
+ export function RobotsPage() {
596
+ const [data, setData] = React.useState<PageData>();
597
+ const [bots, setBots] = React.useState<BotEntry[]>([]);
598
+ const [error, setError] = React.useState<string>();
599
+ const [notice, setNotice] = React.useState<string>();
600
+ const [tab, setTab] = React.useState("rules");
601
+ const [busy, setBusy] = React.useState(false);
602
+ const [wizard, setWizard] = React.useState<{ rule: RobotsRule | null; id: string } | null>(null);
603
+ const [confirm, setConfirm] = React.useState<Confirm | null>(null);
604
+
605
+ const load = React.useCallback(async () => {
606
+ setError(undefined);
607
+ try {
608
+ const [page, directory] = await Promise.all([get<PageData>("get", "Could not load robots.txt rules"), get<{ bots: BotEntry[] }>("bots", "Could not load the crawler directory")]);
609
+ setData(page);
610
+ setBots(directory.bots);
611
+ } catch (cause) {
612
+ setError(errorText(cause, "Could not load robots.txt rules"));
613
+ }
614
+ }, []);
615
+ React.useEffect(() => {
616
+ void load();
617
+ }, [load]);
618
+
619
+ const config = data?.config;
620
+ const summary = React.useMemo(() => (config && data ? summarize(config, data.siteUrl, bots) : []), [config, data, bots]);
621
+
622
+ /** Save a whole config (the server re-checks it), then refresh history. */
623
+ const persist = async (next: RobotsConfig, label: string, message?: string) => {
624
+ setBusy(true);
625
+ try {
626
+ const result = await post<{ config: RobotsConfig; preview: string; warnings: Finding[] }>("save", { config: next, label });
627
+ const page = await get<PageData>("get", "Could not reload");
628
+ setData({ ...page, config: result.config, preview: result.preview, warnings: result.warnings ?? [] });
629
+ setNotice(message ?? "Saved. /robots.txt now serves this version (other Worker instances pick it up within a minute).");
630
+ setError(undefined);
631
+ } finally {
632
+ setBusy(false);
633
+ }
634
+ };
635
+
636
+ if (!data || !config) {
637
+ return error ? (
638
+ <Banner variant="error" role="alert" title="Something went wrong" description={error} />
639
+ ) : (
640
+ <div className="py-12 text-center text-kumo-subtle">
641
+ <Loader />
642
+ </div>
643
+ );
644
+ }
645
+
646
+ const withRule = (rule: RobotsRule, patch?: Partial<RobotsConfig>): RobotsConfig => ({
647
+ ...config,
648
+ ...patch,
649
+ rules: config.rules.some((r) => r.id === rule.id) ? config.rules.map((r) => (r.id === rule.id ? rule : r)) : [...config.rules, rule],
650
+ });
651
+
652
+ const applyTemplate = (id: string) => {
653
+ const t = TEMPLATES.find((x) => x.id === id);
654
+ if (!t) return;
655
+ const next: RobotsConfig = { ...config, rules: t.rules(bots).map((r) => ({ ...r, id: newId() })) };
656
+ const problem = checkConfig(next, data.siteUrl || undefined);
657
+ setConfirm({
658
+ title: `Use “${t.name}”?`,
659
+ body: `${t.description} This replaces your ${config.rules.length} rule${config.rules.length === 1 ? "" : "s"}; the current version stays in Version history.${problem ? ` It can't be used: ${problem}` : ""}`,
660
+ findings: analyzeConfigChange(config, next, data.siteUrl || undefined),
661
+ confirmLabel: "Use this template",
662
+ onConfirm: async () => {
663
+ if (problem) throw new Error(problem);
664
+ await persist(next, `Template: ${t.name}`, `Applied “${t.name}”.`);
665
+ },
666
+ });
667
+ };
668
+
669
+ const addCustomBot = (token: string) => {
670
+ if (bots.some((b) => b.token.toLowerCase() === token.toLowerCase())) return;
671
+ void post<{ bots: BotEntry[] }>("bot", { action: "save-custom", name: token, token, category: "OTHER" })
672
+ .then((r) => setBots(r.bots))
673
+ .catch(() => undefined);
674
+ };
675
+
676
+ return (
677
+ <div className="space-y-6">
678
+ <header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
679
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1">
680
+ <h1 className="flex min-h-9 min-w-0 items-center text-2xl font-semibold leading-tight">Robots.txt</h1>
681
+ <div className="flex shrink-0 justify-end gap-2">
682
+ <DropdownMenu>
683
+ <DropdownMenu.Trigger render={<Button variant="secondary" icon={<Sparkle />}>Templates</Button>} />
684
+ <DropdownMenu.Content className="max-w-sm p-1">
685
+ {TEMPLATES.map((t) => (
686
+ <DropdownMenu.Item key={t.id} className="flex-col items-start py-1.5 data-highlighted:bg-kumo-fill" onClick={() => applyTemplate(t.id)}>
687
+ <span className="font-medium">{t.name}</span>
688
+ <span className="text-xs text-kumo-subtle">{t.description}</span>
689
+ </DropdownMenu.Item>
690
+ ))}
691
+ </DropdownMenu.Content>
692
+ </DropdownMenu>
693
+ <Button variant="primary" icon={<Plus />} onClick={() => setWizard({ rule: null, id: newId() })}>
694
+ Add rule
695
+ </Button>
696
+ </div>
697
+ <p className="col-span-2 text-sm leading-5 text-pretty text-kumo-subtle">
698
+ Decide which crawlers can visit which parts of your site, in plain English. Every change is checked before it's saved, and the file below is what crawlers get.
699
+ </p>
700
+ </div>
701
+ </header>
702
+
703
+ <PartSwitches module="robots" />
704
+
705
+ {data.groupChanges.length > 0 && (
706
+ <Banner
707
+ variant="default"
708
+ title="Crawler presets were updated"
709
+ description={
710
+ <span>
711
+ Presets now include only crawlers whose operators document that purpose, with verified tokens. Rules made from a preset were updated:{" "}
712
+ {data.groupChanges.map((c) => `“${c.ruleName}”: ${c.added.length ? `added ${c.added.join(", ")}` : ""}${c.added.length && c.removed.length ? "; " : ""}${c.removed.length ? `removed ${c.removed.join(", ")}` : ""}`).join(". ")}. The previous
713
+ version is in Version history.
714
+ </span>
715
+ }
716
+ />
717
+ )}
718
+ {data.warnings.length > 0 && (
719
+ <Banner
720
+ variant="alert"
721
+ title="Check your Extra lines"
722
+ description={
723
+ <ul className="list-disc ps-5">
724
+ {data.warnings.map((w, i) => (
725
+ <li key={`${w.code}-${i}`}>{w.message}</li>
726
+ ))}
727
+ </ul>
728
+ }
729
+ />
730
+ )}
731
+ <div aria-live="polite">{notice && <Banner variant="default" role="status" title={notice} />}</div>
732
+ {error && <Banner variant="error" role="alert" title="Something went wrong" description={error} />}
733
+
734
+ <Tabs
735
+ value={tab}
736
+ onValueChange={setTab}
737
+ tabs={[
738
+ { value: "rules", label: `Rules (${config.rules.length})` },
739
+ { value: "bots", label: "Bots" },
740
+ { value: "settings", label: "Settings and history" },
741
+ ]}
742
+ />
743
+
744
+ {tab === "rules" && (
745
+ <div className="space-y-6">
746
+ <section aria-labelledby="robots-summary" className="rounded-lg border border-kumo-line p-4">
747
+ <h2 id="robots-summary" className="text-base font-semibold">
748
+ Rules summary
749
+ </h2>
750
+ <ul className="mt-2 space-y-1 text-sm">
751
+ {summary.map((s) => (
752
+ <li key={s.who} className="flex items-start gap-2">
753
+ {s.tone === "open" ? (
754
+ <CheckCircle className="mt-0.5 shrink-0 text-kumo-success" aria-hidden="true" />
755
+ ) : s.tone === "closed" ? (
756
+ <XCircle className="mt-0.5 shrink-0 text-kumo-danger" aria-hidden="true" />
757
+ ) : (
758
+ <Warning className="mt-0.5 shrink-0 text-kumo-warning" aria-hidden="true" />
759
+ )}
760
+ <span>
761
+ <strong>{s.who}</strong> {s.text}.
762
+ </span>
763
+ </li>
764
+ ))}
765
+ </ul>
766
+ {config.emdashLines && <p className="mt-2 text-xs text-kumo-subtle">EmDash's admin and API always stay private.</p>}
767
+ </section>
768
+ <SitemapOption
769
+ config={config}
770
+ siteUrl={data.siteUrl}
771
+ busy={busy}
772
+ noteDismissed={data.sitemapNoteDismissed}
773
+ onChange={(on) => void persist({ ...config, includeSitemap: on }, on ? "Listed the sitemap" : "Stopped listing the sitemap", on ? "The sitemap is listed in robots.txt." : "The sitemap is no longer listed.").catch((cause) => setError(errorText(cause, "Could not save")))}
774
+ onDismiss={() => {
775
+ setData((d) => (d ? { ...d, sitemapNoteDismissed: true } : d));
776
+ void post("dismiss-sitemap-note", {}).catch(() => undefined);
777
+ }}
778
+ />
779
+ <section className="space-y-2" aria-labelledby="robots-rules">
780
+ <h2 id="robots-rules" className="sr-only">
781
+ Rules
782
+ </h2>
783
+ <RulesList
784
+ config={config}
785
+ bots={bots}
786
+ busy={busy}
787
+ onAdd={() => setWizard({ rule: null, id: newId() })}
788
+ onEdit={(rule) => setWizard({ rule, id: rule.id })}
789
+ onDuplicate={(rule) => setWizard({ rule: { ...rule, id: newId(), name: `${rule.name} (copy)` }, id: "" })}
790
+ onToggle={(rule, on) => {
791
+ const next = withRule({ ...rule, enabled: on });
792
+ void persist(next, `${on ? "Turned on" : "Turned off"}: ${rule.name}`, `“${rule.name}” is ${on ? "on" : "off"}.`).catch((cause) => setError(errorText(cause, "Could not save")));
793
+ }}
794
+ onDelete={(rule) =>
795
+ setConfirm({
796
+ title: `Delete “${rule.name}”?`,
797
+ body: "Crawlers stop following it as soon as it's deleted. You can restore it from Version history.",
798
+ findings: analyzeConfigChange(config, { ...config, rules: config.rules.filter((r) => r.id !== rule.id) }, data.siteUrl || undefined),
799
+ confirmLabel: "Delete rule",
800
+ destructive: true,
801
+ onConfirm: () => persist({ ...config, rules: config.rules.filter((r) => r.id !== rule.id) }, `Deleted: ${rule.name}`, `Deleted “${rule.name}”.`),
802
+ })
803
+ }
804
+ />
805
+ {config.rules.length > 1 && (
806
+ <p className="text-xs text-kumo-subtle">
807
+ Order doesn't matter: crawlers follow the most specific matching line, whatever its position. Coywolf Pack writes the most specific lines first anyway, so older crawlers that read top to
808
+ bottom agree.
809
+ </p>
810
+ )}
811
+ </section>
812
+ <AutomaticLines config={config} />
813
+ <AiTraining config={config} />
814
+ <Tester robotsTxt={data.preview} bots={bots} siteUrl={data.siteUrl} />
815
+ <ServedFile text={data.preview} emdash={data.emdash} config={config} siteUrl={data.siteUrl} />
816
+ </div>
817
+ )}
818
+
819
+ {tab === "bots" && (
820
+ <BotsTab
821
+ bots={bots}
822
+ rules={config.rules}
823
+ radar={data.radar}
824
+ onBots={setBots}
825
+ onRadar={(r) => setData((d) => (d ? { ...d, radar: { ...d.radar, ...r } } : d))}
826
+ onReload={load}
827
+ />
828
+ )}
829
+
830
+ {tab === "settings" && (
831
+ <SettingsTab
832
+ data={data}
833
+ config={config}
834
+ onSave={(next, label) => persist(next, label)}
835
+ onRestore={(item) =>
836
+ setConfirm({
837
+ title: "Restore this version?",
838
+ body: `“${item.label}” from ${dateTimeFormat.format(new Date(item.at))} (${item.rules} rule${item.rules === 1 ? "" : "s"}) replaces the current rules and settings. The current version stays in history.`,
839
+ confirmLabel: "Restore",
840
+ onConfirm: async () => {
841
+ setBusy(true);
842
+ try {
843
+ await post("restore", { id: item.id });
844
+ await load();
845
+ setNotice("Restored. /robots.txt now serves that version.");
846
+ } finally {
847
+ setBusy(false);
848
+ }
849
+ },
850
+ })
851
+ }
852
+ onImport={(next, filename) => {
853
+ const problem = checkConfig(next, data.siteUrl || undefined);
854
+ setConfirm({
855
+ title: "Import these rules?",
856
+ body: `${filename} has ${next.rules.length} rule${next.rules.length === 1 ? "" : "s"}. They replace your ${config.rules.length} rule${config.rules.length === 1 ? "" : "s"} and the settings above; the current version stays in Version history.${problem ? ` It can't be used: ${problem}` : ""}`,
857
+ findings: analyzeConfigChange(config, next, data.siteUrl || undefined),
858
+ confirmLabel: "Import",
859
+ onConfirm: async () => {
860
+ if (problem) throw new Error(problem);
861
+ await persist(next, `Imported from ${filename}`.slice(0, 200), "Imported. /robots.txt now serves the imported rules.");
862
+ },
863
+ });
864
+ }}
865
+ onReset={() =>
866
+ setConfirm({
867
+ title: "Reset to EmDash's original?",
868
+ body: "Replaces your rules with ones converted from EmDash's own robots.txt (shown under What's being served), plus the media rule. The current version stays in history.",
869
+ confirmLabel: "Reset",
870
+ destructive: true,
871
+ onConfirm: async () => {
872
+ await post("reset", {});
873
+ await load();
874
+ setNotice("Reset to EmDash's original robots.txt.");
875
+ },
876
+ })
877
+ }
878
+ />
879
+ )}
880
+
881
+ {wizard && (
882
+ <RuleWizard
883
+ initial={wizard}
884
+ config={config}
885
+ bots={bots}
886
+ siteUrl={data.siteUrl}
887
+ sections={data.sections}
888
+ onClose={() => setWizard(null)}
889
+ onSave={async (rule, patch) => {
890
+ const isNew = !config.rules.some((r) => r.id === rule.id);
891
+ await persist(withRule(rule, patch), `${isNew ? "Added" : "Edited"}: ${rule.name}`, `${isNew ? "Added" : "Saved"} “${rule.name}”.`);
892
+ setWizard(null);
893
+ }}
894
+ onEditRule={(id) => {
895
+ const other = config.rules.find((r) => r.id === id);
896
+ setWizard(other ? { rule: other, id: other.id } : null);
897
+ }}
898
+ onMerge={async (id, agents) => {
899
+ const other = config.rules.find((r) => r.id === id);
900
+ if (!other) return;
901
+ const merged = { ...other, agents: [...new Set([...other.agents, ...agents])], group: undefined };
902
+ await persist(withRule(merged), `Edited: ${other.name}`, `Added the crawlers to “${other.name}”.`);
903
+ setWizard(null);
904
+ }}
905
+ onAddCustomBot={addCustomBot}
906
+ />
907
+ )}
908
+ <ConfirmDialog confirm={confirm} onClose={() => setConfirm(null)} />
909
+ </div>
910
+ );
911
+ }