@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,748 @@
1
+ /**
2
+ * The Add/Edit rule dialog: a guided, four-step flow for people who don't
3
+ * know robots.txt.
4
+ *
5
+ * 1. What do you want to do? Keep crawlers out / Let crawlers in
6
+ * 2. Which part of your site? Everything · A section · One page · A kind of file ·
7
+ * Links with tracking or extra parameters · (Advanced pattern)
8
+ * 3. Which crawlers? Everyone · Search engines · AI training · AI search and
9
+ * assistants · SEO tools · Pick specific bots
10
+ * 4. Review Consequences in plain English, "Except…", name, the
11
+ * exact lines, Try a URL, checks and the self-check
12
+ *
13
+ * Every step shows a live summary. Checks run on each change against the
14
+ * real matcher; errors block saving, warnings need "Add it anyway".
15
+ */
16
+ import { Badge, Banner, Button, Checkbox, Dialog, Input, InputArea } from "@cloudflare/kumo";
17
+ import { CheckCircle, Info, Plus, Trash, Warning, XCircle } from "@phosphor-icons/react";
18
+ import * as React from "react";
19
+
20
+ import type { BotEntry } from "../robots/bots.js";
21
+ import { describeRule, listPhrase } from "../robots/explain.js";
22
+ import { type Area, type Draft, FILE_TYPES, blankDraft, PARAMS, type Who, draftToRule, inferInput, matchExamples, ruleToDraft } from "../robots/guided.js";
23
+ import { evaluateParsed, matchRaw, parse } from "../robots/rep.js";
24
+ import { CRAWLER_GROUPS, PROBE_AGENT, type RobotsConfig, type RobotsRule, directives, generate, groupTokens, normalizePathInput } from "../robots/rules.js";
25
+ import { type Finding, analyzeRule, selfCheck } from "../robots/validate.js";
26
+ import { BotPicker } from "./robots-bots.js";
27
+
28
+ const STEPS = ["What to do", "Which part", "Which crawlers", "Review"];
29
+
30
+ const AREAS: Array<{ id: Area; label: string; example: string }> = [
31
+ { id: "everything", label: "Everything", example: "Every page and file on the site" },
32
+ { id: "section", label: "A section", example: "/recipes/ and everything in it, like /recipes/oats/" },
33
+ { id: "page", label: "One page", example: "Just /thank-you/" },
34
+ { id: "files", label: "A kind of file", example: "PDFs, Word documents, images…" },
35
+ { id: "params", label: "Links with tracking or extra parameters", example: "Like /post/?utm_source=newsletter" },
36
+ { id: "advanced", label: "Advanced: write a pattern", example: "For experts: * means anything, $ means “ends here”" },
37
+ ];
38
+
39
+ const WHO: Array<{ id: Who; label: string; hint: string }> = [
40
+ { id: "everyone", label: "Everyone", hint: "Every crawler (User-agent: *)." },
41
+ ...CRAWLER_GROUPS.map((g) => ({ id: g.id as Who, label: g.label, hint: g.description })),
42
+ { id: "specific", label: "Pick specific bots", hint: "Choose crawlers from the directory, or add one that isn't listed." },
43
+ ];
44
+
45
+ const THING: Record<Area, string> = {
46
+ everything: "your site",
47
+ section: "this section",
48
+ page: "this page",
49
+ files: "these files",
50
+ params: "these links",
51
+ advanced: "these addresses",
52
+ };
53
+
54
+ const PROMPTS: Record<Area, string> = {
55
+ everything: "",
56
+ section: "Enter a section address to see what it matches.",
57
+ page: "Enter a page address to see what it matches.",
58
+ files: "Pick a kind of file to see what it matches.",
59
+ params: "Pick a parameter to see what it matches.",
60
+ advanced: "Write a pattern to see what it matches.",
61
+ };
62
+
63
+ /** A preset's members, so people can see exactly who's included. */
64
+ function GroupMembers(props: { tokens: string[]; bots: BotEntry[] }) {
65
+ const byToken = new Map(props.bots.map((b) => [b.token.toLowerCase(), b]));
66
+ return (
67
+ <details className="text-xs">
68
+ <summary className="cursor-pointer text-kumo-subtle">
69
+ {props.tokens.length} crawler{props.tokens.length === 1 ? "" : "s"}: show who's included
70
+ </summary>
71
+ <ul className="mt-1 grid gap-x-4 gap-y-0.5 sm:grid-cols-2">
72
+ {props.tokens.map((t) => {
73
+ const b = byToken.get(t.toLowerCase());
74
+ return (
75
+ <li key={t}>
76
+ {b?.name ?? t} <code className="text-kumo-subtle">{t}</code>
77
+ {b?.operator ? <span className="text-kumo-subtle"> · {b.operator}</span> : null}
78
+ </li>
79
+ );
80
+ })}
81
+ </ul>
82
+ </details>
83
+ );
84
+ }
85
+
86
+ const capital = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
87
+
88
+ export interface WizardProps {
89
+ /** The rule being edited, or a new draft id. */
90
+ initial: { rule: RobotsRule | null; id: string } | null;
91
+ config: RobotsConfig;
92
+ bots: BotEntry[];
93
+ siteUrl: string;
94
+ sections: Array<{ label: string; path: string }>;
95
+ onClose: () => void;
96
+ onSave: (rule: RobotsRule, patch?: Partial<RobotsConfig>) => Promise<void>;
97
+ onEditRule: (ruleId: string) => void;
98
+ onMerge: (ruleId: string, agents: string[]) => Promise<void>;
99
+ onAddCustomBot: (token: string) => void;
100
+ }
101
+
102
+ function FindingRow(props: { finding: Finding; onFix?: () => void }) {
103
+ const f = props.finding;
104
+ const Icon = f.severity === "error" ? XCircle : f.severity === "warning" ? Warning : Info;
105
+ const color = f.severity === "error" ? "text-kumo-danger" : f.severity === "warning" ? "text-kumo-warning" : "text-kumo-subtle";
106
+ return (
107
+ <li className="flex items-start gap-2 text-sm">
108
+ <Icon className={`mt-0.5 shrink-0 ${color}`} aria-hidden="true" />
109
+ <span className="min-w-0 flex-1">
110
+ <span className="sr-only">{f.severity === "error" ? "Error: " : f.severity === "warning" ? "Warning: " : "Note: "}</span>
111
+ {f.message}
112
+ {f.fix && props.onFix && (
113
+ <>
114
+ {" "}
115
+ <button type="button" className="font-medium underline" onClick={props.onFix}>
116
+ {f.fix.label}
117
+ </button>
118
+ </>
119
+ )}
120
+ </span>
121
+ </li>
122
+ );
123
+ }
124
+
125
+ function Choice(props: { name: string; value: string; checked: boolean; onSelect: () => void; label: string; hint: string; children?: React.ReactNode }) {
126
+ const id = `cw-${props.name}-${props.value}`;
127
+ return (
128
+ <div className={`rounded-lg border px-3 py-2 ${props.checked ? "border-kumo-brand bg-kumo-tint/40" : "border-kumo-line"}`}>
129
+ <label htmlFor={id} className="flex cursor-pointer items-start gap-2">
130
+ <input id={id} type="radio" name={props.name} value={props.value} className="mt-1" checked={props.checked} onChange={props.onSelect} />
131
+ <span>
132
+ <span className="block text-sm font-medium">{props.label}</span>
133
+ <span className="block text-xs text-kumo-subtle">{props.hint}</span>
134
+ </span>
135
+ </label>
136
+ {props.checked && props.children && <div className="mt-3 space-y-3 cw-ps-6">{props.children}</div>}
137
+ </div>
138
+ );
139
+ }
140
+
141
+ function Chip(props: { on: boolean; onToggle: () => void; children: React.ReactNode }) {
142
+ return (
143
+ <button type="button" aria-pressed={props.on} onClick={props.onToggle} className={`rounded-full border px-3 py-1 text-sm ${props.on ? "border-kumo-brand bg-kumo-tint font-medium" : "border-kumo-line hover:bg-kumo-tint/50"}`}>
144
+ {props.on && <CheckCircle className="me-1 inline" aria-hidden="true" />}
145
+ {props.children}
146
+ </button>
147
+ );
148
+ }
149
+
150
+ export function RuleWizard(props: WizardProps) {
151
+ const startAtReview = Boolean(props.initial?.rule);
152
+ const isEdit = Boolean(props.initial?.rule && props.config.rules.some((r) => r.id === props.initial?.rule?.id));
153
+ const start = React.useMemo(() => {
154
+ if (!props.initial) return null;
155
+ if (props.initial.rule) return ruleToDraft(props.initial.rule);
156
+ return blankDraft(props.initial.id);
157
+ }, [props.initial]);
158
+ const [draft, setDraft] = React.useState<Draft | null>(start);
159
+ const [step, setStep] = React.useState(startAtReview ? 3 : 0);
160
+ const [ack, setAck] = React.useState(false);
161
+ const [saving, setSaving] = React.useState(false);
162
+ const [error, setError] = React.useState<string>();
163
+ const [patch, setPatch] = React.useState<Partial<RobotsConfig>>({});
164
+ const [tryUrl, setTryUrl] = React.useState("");
165
+ const headingRef = React.useRef<HTMLHeadingElement>(null);
166
+ React.useEffect(() => {
167
+ setDraft(start);
168
+ setStep(startAtReview ? 3 : 0);
169
+ setAck(false);
170
+ setError(undefined);
171
+ setPatch({});
172
+ setTryUrl("");
173
+ }, [start, startAtReview]);
174
+ React.useEffect(() => {
175
+ headingRef.current?.focus();
176
+ }, [step]);
177
+
178
+ const config = { ...props.config, ...patch };
179
+ const rule = React.useMemo(() => {
180
+ if (!draft) return null;
181
+ const r = draftToRule(draft, props.bots, props.siteUrl);
182
+ const sentence = capital(describeRule({ ...r }, nameOf(props.bots)));
183
+ return { ...r, name: draft.nameTouched && draft.name.trim() ? draft.name.trim() : sentence, description: draft.descriptionTouched ? draft.description.trim() || undefined : undefined };
184
+ }, [draft, props.bots, props.siteUrl]);
185
+
186
+ const findings = React.useMemo(() => {
187
+ if (!rule) return [];
188
+ const list = analyzeRule(config, rule, { siteUrl: props.siteUrl, bots: props.bots });
189
+ if (!list.some((f) => f.severity === "error")) {
190
+ const after = { ...config, rules: config.rules.some((r) => r.id === rule.id) ? config.rules.map((r) => (r.id === rule.id ? rule : r)) : [...config.rules, rule] };
191
+ const check = selfCheck(after, props.siteUrl || undefined, 1);
192
+ if (!check.ok) list.push({ code: "self-check", severity: "error", message: `Self-check: ${check.failures[0].reason} This rule can't be saved until that's fixed.` });
193
+ }
194
+ return list;
195
+ // eslint-disable-next-line react-hooks/exhaustive-deps
196
+ }, [rule, props.config, patch, props.siteUrl, props.bots]);
197
+
198
+ const dirty = JSON.stringify(draft) !== JSON.stringify(start);
199
+ const close = () => {
200
+ if (dirty && !window.confirm(isEdit ? "Discard your changes to this rule?" : "Discard this new rule?")) return;
201
+ props.onClose();
202
+ };
203
+
204
+ if (!draft || !rule) return null;
205
+ const set = (p: Partial<Draft>) => {
206
+ setDraft((d) => (d ? { ...d, ...p } : d));
207
+ setAck(false);
208
+ setError(undefined);
209
+ };
210
+
211
+ const targetErrors = findings.filter((f) => f.severity === "error" && !["bad-token", "duplicate", "contradiction", "self-check"].includes(f.code));
212
+ const errors = findings.filter((f) => f.severity === "error");
213
+ const warnings = findings.filter((f) => f.severity === "warning");
214
+ const infos = findings.filter((f) => f.severity === "info");
215
+ const agentsChosen = rule.agents.length > 0;
216
+ const canNext = step === 0 || (step === 1 && targetErrors.length === 0 && targetFilled(draft)) || (step === 2 && agentsChosen);
217
+ const canSave = errors.length === 0 && targetFilled(draft) && agentsChosen && (warnings.length === 0 || ack);
218
+
219
+ const applyFix = async (f: Finding) => {
220
+ const a = f.fix?.action;
221
+ if (!a) return;
222
+ if (a.type === "addAgents") {
223
+ const current = rule.agents;
224
+ set({ who: "specific", agents: [...new Set([...current, ...a.agents])] });
225
+ } else if (a.type === "turnOnInherit") setPatch((p) => ({ ...p, inheritGeneral: true }));
226
+ else if (a.type === "editRule") {
227
+ if (!dirty || window.confirm("Leave this rule and edit the other one? This rule won't be saved.")) props.onEditRule(a.ruleId);
228
+ } else if (a.type === "mergeInto") {
229
+ setSaving(true);
230
+ try {
231
+ await props.onMerge(a.ruleId, rule.agents);
232
+ } catch (cause) {
233
+ setError(cause instanceof Error ? cause.message : "Could not merge");
234
+ } finally {
235
+ setSaving(false);
236
+ }
237
+ }
238
+ };
239
+
240
+ const save = async () => {
241
+ if (!canSave) return;
242
+ setSaving(true);
243
+ setError(undefined);
244
+ try {
245
+ await props.onSave(rule, Object.keys(patch).length ? patch : undefined);
246
+ } catch (cause) {
247
+ setError(cause instanceof Error ? cause.message : "Could not save the rule");
248
+ } finally {
249
+ setSaving(false);
250
+ }
251
+ };
252
+
253
+ const examples = matchExamples(rule);
254
+ const lines = (() => {
255
+ try {
256
+ return directives(rule);
257
+ } catch {
258
+ return [];
259
+ }
260
+ })();
261
+
262
+ return (
263
+ <Dialog.Root open onOpenChange={(open) => !open && close()}>
264
+ <Dialog className="flex max-h-[90vh] flex-col p-0" size="xl">
265
+ <div className="border-b border-kumo-line px-6 pt-5 pb-3">
266
+ <Dialog.Title className="text-lg font-semibold">{isEdit ? "Edit rule" : "Add a rule"}</Dialog.Title>
267
+ <Dialog.Description className="sr-only">A guided form in four steps: what to do, which part of the site, which crawlers, and review.</Dialog.Description>
268
+ <ol className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs" aria-label="Steps">
269
+ {STEPS.map((s, i) => (
270
+ <li key={s} aria-current={i === step ? "step" : undefined} className={i === step ? "font-semibold" : "text-kumo-subtle"}>
271
+ {i < step ? (
272
+ <button type="button" className="underline" onClick={() => setStep(i)}>
273
+ {i + 1}. {s}
274
+ </button>
275
+ ) : (
276
+ `${i + 1}. ${s}`
277
+ )}
278
+ </li>
279
+ ))}
280
+ </ol>
281
+ </div>
282
+
283
+ <div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-4">
284
+ {step === 0 && (
285
+ <fieldset className="space-y-2">
286
+ <legend>
287
+ <h3 ref={headingRef} tabIndex={-1} className="mb-2 text-base font-semibold outline-none">
288
+ What do you want to do?
289
+ </h3>
290
+ </legend>
291
+ <Choice name="action" value="block" checked={draft.action === "block"} onSelect={() => set({ action: "block" })} label="Keep crawlers out" hint="Stop crawlers from fetching part (or all) of your site, like a members area, drafts, or PDFs." />
292
+ <Choice name="action" value="allow" checked={draft.action === "allow"} onSelect={() => set({ action: "allow" })} label="Let crawlers in" hint="Open up something that another rule blocks, for example one folder for AI crawlers that are otherwise blocked." />
293
+ </fieldset>
294
+ )}
295
+
296
+ {step === 1 && <AreaStep draft={draft} set={set} siteUrl={props.siteUrl} sections={props.sections} errors={targetErrors} headingRef={headingRef} />}
297
+
298
+ {step === 2 && (
299
+ <fieldset className="space-y-2">
300
+ <legend>
301
+ <h3 ref={headingRef} tabIndex={-1} className="mb-2 text-base font-semibold outline-none">
302
+ Which crawlers?
303
+ </h3>
304
+ </legend>
305
+ {WHO.map((w) => {
306
+ const group = CRAWLER_GROUPS.find((g) => g.id === w.id);
307
+ const count = group ? groupTokens(group, props.bots).length : 0;
308
+ return (
309
+ <Choice key={w.id} name="who" value={w.id} checked={draft.who === w.id} onSelect={() => set({ who: w.id, agents: w.id === "specific" && !draft.agents.length ? [] : draft.agents })} label={group ? `${w.label} (${count})` : w.label} hint={w.hint}>
310
+ {w.id === "specific" ? (
311
+ <BotPicker bots={props.bots} selected={draft.agents} onChange={(agents) => set({ agents })} onAddCustom={props.onAddCustomBot} />
312
+ ) : group ? (
313
+ <GroupMembers tokens={groupTokens(group, props.bots)} bots={props.bots} />
314
+ ) : null}
315
+ </Choice>
316
+ );
317
+ })}
318
+ </fieldset>
319
+ )}
320
+
321
+ {step === 3 && (
322
+ <ReviewStep
323
+ draft={draft}
324
+ rule={rule}
325
+ set={set}
326
+ config={config}
327
+ bots={props.bots}
328
+ siteUrl={props.siteUrl}
329
+ lines={lines}
330
+ headingRef={headingRef}
331
+ tryUrl={tryUrl}
332
+ setTryUrl={setTryUrl}
333
+ />
334
+ )}
335
+
336
+ {step >= 1 && (
337
+ <section className="space-y-2 rounded-lg border border-kumo-line bg-kumo-tint/30 p-3" aria-label="This rule so far" aria-live="polite">
338
+ {targetFilled(draft) && targetErrors.length === 0 ? (
339
+ <p className="text-sm font-medium">{capital(describeRule(rule, nameOf(props.bots)))}.</p>
340
+ ) : (
341
+ <p className="text-sm text-kumo-subtle">{PROMPTS[draft.area]}</p>
342
+ )}
343
+ {targetFilled(draft) && targetErrors.length === 0 && (examples.matches.length > 0 || examples.misses.length > 0) && (
344
+ <div className="grid gap-2 text-xs sm:grid-cols-2">
345
+ <div>
346
+ <p className="text-kumo-subtle">Will match</p>
347
+ <ul className="font-mono">{examples.matches.map((m) => <li key={m}>{m}</li>)}</ul>
348
+ </div>
349
+ <div>
350
+ <p className="text-kumo-subtle">Won't match</p>
351
+ <ul className="font-mono">{examples.misses.map((m) => <li key={m}>{m}</li>)}</ul>
352
+ </div>
353
+ </div>
354
+ )}
355
+ {step === 3 && findings.length > 0 && (
356
+ <ul className="space-y-1.5 pt-1">
357
+ {[...errors, ...warnings, ...infos].map((f, i) => (
358
+ <FindingRow key={`${f.code}-${i}`} finding={f} onFix={f.fix ? () => void applyFix(f) : undefined} />
359
+ ))}
360
+ </ul>
361
+ )}
362
+ {step === 3 && errors.length === 0 && (
363
+ <p className="flex items-center gap-1.5 text-xs text-kumo-subtle">
364
+ <CheckCircle className="text-kumo-success" aria-hidden="true" /> Self-check passed: the generated robots.txt does exactly what this rule says.
365
+ </p>
366
+ )}
367
+ {step < 3 && findings.some((f) => f.severity !== "info") && (
368
+ <p className="text-xs text-kumo-subtle">
369
+ {errors.length + warnings.length} thing{errors.length + warnings.length === 1 ? "" : "s"} to check on the Review step.
370
+ </p>
371
+ )}
372
+ </section>
373
+ )}
374
+ {patch.inheritGeneral && <Banner variant="default" title="Saving this rule also turns on “Named crawlers keep the rules for all crawlers”." />}
375
+ {step === 3 && warnings.length > 0 && errors.length === 0 && (
376
+ <Checkbox label={`I understand the warning${warnings.length === 1 ? "" : "s"} above. ${isEdit ? "Save" : "Add"} it anyway.`} checked={ack} onCheckedChange={(on: boolean) => setAck(on)} />
377
+ )}
378
+ {error && <Banner variant="error" role="alert" description={error} />}
379
+ </div>
380
+
381
+ <div className="flex flex-wrap items-center justify-end gap-2 border-t border-kumo-line px-6 py-3">
382
+ <Button type="button" variant="ghost" onClick={close}>
383
+ Cancel
384
+ </Button>
385
+ {step > 0 && (
386
+ <Button type="button" variant="secondary" onClick={() => setStep((s) => s - 1)}>
387
+ Back
388
+ </Button>
389
+ )}
390
+ {step < 3 ? (
391
+ <Button type="button" variant="primary" disabled={!canNext} onClick={() => setStep((s) => s + 1)}>
392
+ Next
393
+ </Button>
394
+ ) : (
395
+ <Button type="button" variant="primary" disabled={!canSave || saving} onClick={() => void save()}>
396
+ {saving ? "Saving…" : isEdit ? "Save changes" : "Add rule"}
397
+ </Button>
398
+ )}
399
+ </div>
400
+ </Dialog>
401
+ </Dialog.Root>
402
+ );
403
+ }
404
+
405
+ const nameOf = (bots: BotEntry[]) => {
406
+ const map = new Map(bots.map((b) => [b.token.toLowerCase(), b.name]));
407
+ return (token: string) => map.get(token.toLowerCase()) ?? token;
408
+ };
409
+
410
+ function targetFilled(d: Draft): boolean {
411
+ switch (d.area) {
412
+ case "section":
413
+ return Boolean(d.section.trim());
414
+ case "page":
415
+ return Boolean(d.page.trim());
416
+ case "files":
417
+ return d.fileTypes.length > 0 || Boolean(d.otherExt.trim());
418
+ case "params":
419
+ return d.anyQuery || d.params.length > 0 || Boolean(d.otherParam.trim());
420
+ case "advanced":
421
+ return Boolean(d.pattern.trim());
422
+ default:
423
+ return true;
424
+ }
425
+ }
426
+
427
+ function AreaStep(props: {
428
+ draft: Draft;
429
+ set: (p: Partial<Draft>) => void;
430
+ siteUrl: string;
431
+ sections: Array<{ label: string; path: string }>;
432
+ errors: Finding[];
433
+ headingRef: React.RefObject<HTMLHeadingElement | null>;
434
+ }) {
435
+ const { draft, set } = props;
436
+ const sectionPaths = props.sections.map((s) => s.path);
437
+ const reading = (value: string) => {
438
+ const r = inferInput(value, props.siteUrl, sectionPaths);
439
+ return r;
440
+ };
441
+ const firstError = props.errors[0]?.message;
442
+ const Suggest = (p: { value: string }) => {
443
+ const r = reading(p.value);
444
+ if (!r) return null;
445
+ const label = AREAS.find((a) => a.id === r.area)?.label;
446
+ return (
447
+ <div className="space-y-1 text-xs text-kumo-subtle" aria-live="polite">
448
+ {r.notes.map((n) => (
449
+ <p key={n}>{n}</p>
450
+ ))}
451
+ {r.area !== draft.area ? (
452
+ <p>
453
+ {r.message}{" "}
454
+ <button
455
+ type="button"
456
+ className="font-medium underline"
457
+ onClick={() => {
458
+ const patch: Partial<Draft> = { area: r.area };
459
+ if (r.area === "section") patch.section = r.value;
460
+ if (r.area === "page") patch.page = r.value;
461
+ if (r.area === "advanced") patch.pattern = r.value;
462
+ if (r.area === "params") patch.otherParam = r.value;
463
+ if (r.area === "files") patch.otherExt = r.value;
464
+ set(patch);
465
+ }}
466
+ >
467
+ Switch to “{label}”
468
+ </button>
469
+ </p>
470
+ ) : (
471
+ <p>{r.message}</p>
472
+ )}
473
+ </div>
474
+ );
475
+ };
476
+ const advancedExplain = (() => {
477
+ const v = draft.pattern.trim();
478
+ if (!v) return null;
479
+ const norm = normalizePathInput(v, props.siteUrl).value;
480
+ const parts: string[] = [];
481
+ parts.push(norm.startsWith("*") ? "Starts anywhere" : `Addresses starting with ${norm.split("*")[0].replace(/\$$/, "")}`);
482
+ if (norm.includes("*")) parts.push("* stands for any characters");
483
+ parts.push(norm.endsWith("$") ? "and they must end exactly there" : "followed by anything");
484
+ return `${parts.join(", ")}.`;
485
+ })();
486
+
487
+ return (
488
+ <fieldset className="space-y-2">
489
+ <legend>
490
+ <h3 ref={props.headingRef} tabIndex={-1} className="mb-2 text-base font-semibold outline-none">
491
+ Which part of your site?
492
+ </h3>
493
+ </legend>
494
+ {AREAS.map((a) => (
495
+ <Choice key={a.id} name="area" value={a.id} checked={draft.area === a.id} onSelect={() => set({ area: a.id })} label={a.label} hint={a.example}>
496
+ {a.id === "section" && (
497
+ <>
498
+ <Input
499
+ label="Section address"
500
+ labelTooltip="Paste any address from your site, or type a folder like /recipes/. Everything inside it is included."
501
+ placeholder={`/recipes/ or ${props.siteUrl || "https://example.com"}/recipes/`}
502
+ value={draft.section}
503
+ error={draft.section.trim() ? firstError : undefined}
504
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ section: e.target.value })}
505
+ />
506
+ {props.sections.length > 0 && (
507
+ <div className="flex flex-wrap items-center gap-1.5 text-xs">
508
+ <span className="text-kumo-subtle">Your sections:</span>
509
+ {props.sections.map((s) => (
510
+ <Chip key={s.path} on={normalizePathInput(draft.section).value.replace(/\/?$/, "/") === s.path} onToggle={() => set({ section: s.path })}>
511
+ {s.label} <span className="font-mono">{s.path}</span>
512
+ </Chip>
513
+ ))}
514
+ </div>
515
+ )}
516
+ {draft.section.trim() && <Suggest value={draft.section} />}
517
+ <Checkbox
518
+ label="Also wherever a folder with this name appears deeper in the site (like /en/recipes/)"
519
+ checked={draft.anyDepth}
520
+ onCheckedChange={(on: boolean) => set({ anyDepth: on })}
521
+ />
522
+ </>
523
+ )}
524
+ {a.id === "page" && (
525
+ <>
526
+ <Input
527
+ label="Page address"
528
+ placeholder={`/thank-you/ or ${props.siteUrl || "https://example.com"}/thank-you/`}
529
+ value={draft.page}
530
+ error={draft.page.trim() ? firstError : undefined}
531
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ page: e.target.value })}
532
+ />
533
+ {draft.page.trim() && <Suggest value={draft.page} />}
534
+ <Checkbox
535
+ label="Only this exact address (not addresses that continue it, like /thank-you/more or ?page=2)"
536
+ checked={draft.exact}
537
+ onCheckedChange={(on: boolean) => set({ exact: on })}
538
+ />
539
+ </>
540
+ )}
541
+ {a.id === "files" && (
542
+ <>
543
+ <div className="flex flex-wrap gap-1.5" role="group" aria-label="File types">
544
+ {FILE_TYPES.map((f) => (
545
+ <Chip key={f.id} on={draft.fileTypes.includes(f.id)} onToggle={() => set({ fileTypes: draft.fileTypes.includes(f.id) ? draft.fileTypes.filter((x) => x !== f.id) : [...draft.fileTypes, f.id] })}>
546
+ {f.label}
547
+ </Chip>
548
+ ))}
549
+ </div>
550
+ <Input label="Other extensions (optional)" placeholder="key, epub" value={draft.otherExt} error={firstError} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ otherExt: e.target.value })} />
551
+ <Input
552
+ label="Only inside a section (optional)"
553
+ placeholder="/downloads/"
554
+ description="Leave empty for files anywhere on the site."
555
+ value={draft.filesIn}
556
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ filesIn: e.target.value })}
557
+ />
558
+ </>
559
+ )}
560
+ {a.id === "params" && (
561
+ <>
562
+ <Checkbox label="Any address with a ? (every query string)" checked={draft.anyQuery} onCheckedChange={(on: boolean) => set({ anyQuery: on })} />
563
+ {!draft.anyQuery && (
564
+ <>
565
+ <div className="flex flex-wrap gap-1.5" role="group" aria-label="Common parameters">
566
+ {PARAMS.map((p) => (
567
+ <Chip key={p.id} on={draft.params.includes(p.id)} onToggle={() => set({ params: draft.params.includes(p.id) ? draft.params.filter((x) => x !== p.id) : [...draft.params, p.id] })}>
568
+ {p.label}
569
+ </Chip>
570
+ ))}
571
+ </div>
572
+ <Input
573
+ label="A parameter I name (optional)"
574
+ labelTooltip="The part between ? (or &) and =. In /shop?color=red it's color. End with * to cover every name that starts with it."
575
+ placeholder="color, campaign_*"
576
+ value={draft.otherParam}
577
+ error={firstError}
578
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ otherParam: e.target.value })}
579
+ />
580
+ </>
581
+ )}
582
+ </>
583
+ )}
584
+ {a.id === "advanced" && (
585
+ <>
586
+ <Input
587
+ label="Pattern"
588
+ labelTooltip="Starts with / (a path) or * (anything). * matches any characters; $ at the end means the address must end there. Examples: /tag-* (addresses starting with /tag-), /*preview= (any address containing preview=), /private (also /private-notes), /*/print/ (a print folder below the top level)."
589
+ placeholder="/tag-*"
590
+ value={draft.pattern}
591
+ error={draft.pattern.trim() ? firstError : undefined}
592
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ pattern: e.target.value })}
593
+ />
594
+ {advancedExplain && <p className="text-xs text-kumo-subtle">{advancedExplain}</p>}
595
+ <p className="text-xs text-kumo-subtle">
596
+ Examples: <code>/tag-*</code> starts with /tag- · <code>/*preview=</code> contains preview= · <code>/private</code> starts with /private (also /private-notes) ·{" "}
597
+ <code>/search$</code> exactly /search
598
+ </p>
599
+ </>
600
+ )}
601
+ </Choice>
602
+ ))}
603
+ </fieldset>
604
+ );
605
+ }
606
+
607
+ function ReviewStep(props: {
608
+ draft: Draft;
609
+ rule: RobotsRule;
610
+ set: (p: Partial<Draft>) => void;
611
+ config: RobotsConfig;
612
+ bots: BotEntry[];
613
+ siteUrl: string;
614
+ lines: ReturnType<typeof directives>;
615
+ headingRef: React.RefObject<HTMLHeadingElement | null>;
616
+ tryUrl: string;
617
+ setTryUrl: (v: string) => void;
618
+ }) {
619
+ const { draft, rule, set } = props;
620
+ const after = { ...props.config, rules: props.config.rules.some((r) => r.id === rule.id) ? props.config.rules.map((r) => (r.id === rule.id ? rule : r)) : [...props.config.rules, rule] };
621
+ const text = generate(after, { siteUrl: props.siteUrl });
622
+ const parsed = parse(text).directives;
623
+ const sample = matchExamples(rule).matches[0] ?? "/";
624
+ const thing = THING[draft.area];
625
+ const consequences: string[] = [];
626
+ const chosen = new Set(rule.agents.map((a) => a.toLowerCase()));
627
+ for (const g of CRAWLER_GROUPS) {
628
+ const tokens = groupTokens(g, props.bots);
629
+ const blocked = tokens.filter((t) => !evaluateParsed(parsed, [t], sample).allowed).length;
630
+ const name = g.phrase;
631
+ const involved = rule.agents.includes("*") || tokens.some((t) => chosen.has(t.toLowerCase()));
632
+ if (!involved && blocked === 0) continue;
633
+ if (blocked === 0) consequences.push(`${capital(name)} can fetch ${thing}.`);
634
+ else if (blocked === tokens.length) consequences.push(`${tokens.length} ${name} can't fetch ${thing}.`);
635
+ else consequences.push(`${blocked} of ${tokens.length} ${name} can't fetch ${thing}.`);
636
+ }
637
+ const other = evaluateParsed(parsed, [PROBE_AGENT], sample).allowed;
638
+ consequences.push(`Every other crawler ${other ? "can" : "can't"} fetch ${thing}.`);
639
+ const specific = rule.agents.filter((a) => a !== "*" && !CRAWLER_GROUPS.some((g) => groupTokens(g, props.bots).some((t) => t.toLowerCase() === a.toLowerCase())));
640
+ if (specific.length) {
641
+ const blocked = specific.filter((t) => !evaluateParsed(parsed, [t], sample).allowed);
642
+ consequences.unshift(blocked.length ? `${listPhrase(blocked)} can't fetch ${thing}.` : `${listPhrase(specific)} can fetch ${thing}.`);
643
+ }
644
+
645
+ const blockLine = props.lines.find((l) => l.directive === "Disallow");
646
+ const exceptionProblem = (value: string) => {
647
+ if (!value.trim() || !blockLine) return undefined;
648
+ const v = normalizePathInput(value, props.siteUrl).value;
649
+ const probe = v.replace(/\$$/, "").replace(/\*/g, "x");
650
+ return matchRaw(blockLine.value, probe) ? undefined : `That isn't inside ${blockLine.value}, so it's already allowed.`;
651
+ };
652
+
653
+ const tryVerdict = (() => {
654
+ const u = props.tryUrl.trim();
655
+ if (!u) return null;
656
+ const agents = rule.agents.includes("*") ? [PROBE_AGENT, ...rule.agents.filter((a) => a !== "*")] : rule.agents;
657
+ const yes: string[] = [];
658
+ const no: string[] = [];
659
+ for (const a of agents.slice(0, 200)) (evaluateParsed(parsed, [a], u, { encodePath: true }).allowed ? yes : no).push(a === PROBE_AGENT ? "other crawlers" : a);
660
+ return { yes, no, path: evaluateParsed(parsed, [agents[0]], u, { encodePath: true }).path };
661
+ })();
662
+
663
+ return (
664
+ <div className="space-y-4">
665
+ <h3 ref={props.headingRef} tabIndex={-1} className="text-base font-semibold outline-none">
666
+ Review
667
+ </h3>
668
+ <section aria-label="What this rule means">
669
+ <ul className="space-y-1 text-sm">
670
+ {consequences.map((c) => (
671
+ <li key={c}>{c}</li>
672
+ ))}
673
+ </ul>
674
+ <p className="mt-1 text-xs text-kumo-subtle">Tested on {sample}, with all your other rules applied.</p>
675
+ </section>
676
+
677
+ {draft.action === "block" && (draft.area === "section" || draft.area === "everything" || draft.area === "advanced") && (
678
+ <section className="space-y-2" aria-labelledby="cw-except">
679
+ <h4 id="cw-except" className="text-sm font-medium">
680
+ Except…
681
+ </h4>
682
+ <p className="text-xs text-kumo-subtle">Pages, folders or files inside the blocked area that crawlers may still fetch.</p>
683
+ {draft.except.map((e, i) => (
684
+ <div key={i} className="flex items-end gap-2">
685
+ <div className="flex-1">
686
+ <Input
687
+ label={`Exception ${i + 1}`}
688
+ placeholder={blockLine ? `${blockLine.value === "/" ? "/" : blockLine.value}welcome/` : "/members/welcome/"}
689
+ value={e}
690
+ error={exceptionProblem(e)}
691
+ onChange={(ev: React.ChangeEvent<HTMLInputElement>) => set({ except: draft.except.map((x, j) => (j === i ? ev.target.value : x)) })}
692
+ />
693
+ </div>
694
+ <Button type="button" variant="ghost" shape="square" icon={<Trash aria-hidden="true" />} aria-label={`Remove exception ${i + 1}`} onClick={() => set({ except: draft.except.filter((_, j) => j !== i) })} />
695
+ </div>
696
+ ))}
697
+ <Button type="button" variant="secondary" icon={<Plus />} onClick={() => set({ except: [...draft.except, ""] })}>
698
+ Add an exception
699
+ </Button>
700
+ </section>
701
+ )}
702
+
703
+ <section className="grid gap-3 sm:grid-cols-2">
704
+ <Input label="Rule name" value={draft.nameTouched ? draft.name : rule.name} onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ name: e.target.value, nameTouched: true })} description="Shown in your rules list and as a comment in robots.txt." />
705
+ <InputArea label="Note (optional)" rows={2} value={draft.description} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => set({ description: e.target.value, descriptionTouched: true })} />
706
+ </section>
707
+
708
+ <section className="space-y-2" aria-labelledby="cw-try">
709
+ <h4 id="cw-try" className="text-sm font-medium">
710
+ Try a URL
711
+ </h4>
712
+ <Input label="Address or path" placeholder={`${props.siteUrl || "https://example.com"}${sample}`} value={props.tryUrl} onChange={(e: React.ChangeEvent<HTMLInputElement>) => props.setTryUrl(e.target.value)} />
713
+ <div aria-live="polite" className="text-sm">
714
+ {tryVerdict && (
715
+ <p>
716
+ <code>{tryVerdict.path}</code>:{" "}
717
+ {tryVerdict.no.length === 0 ? (
718
+ <span><CheckCircle className="inline text-kumo-success" aria-hidden="true" /> allowed for the chosen crawlers.</span>
719
+ ) : tryVerdict.yes.length === 0 ? (
720
+ <span><XCircle className="inline text-kumo-danger" aria-hidden="true" /> blocked for the chosen crawlers.</span>
721
+ ) : (
722
+ <span>
723
+ blocked for {listPhrase(tryVerdict.no)}; allowed for {listPhrase(tryVerdict.yes)}.
724
+ </span>
725
+ )}
726
+ </p>
727
+ )}
728
+ </div>
729
+ </section>
730
+
731
+ <details className="rounded-lg border border-kumo-line px-3 py-2">
732
+ <summary className="cursor-pointer text-sm font-medium">Show robots.txt lines</summary>
733
+ <p className="mt-2 text-xs text-kumo-subtle">
734
+ This rule writes these lines for {rule.agents.includes("*") ? "every crawler (User-agent: *)" : `${rule.agents.length} crawler${rule.agents.length === 1 ? "" : "s"}`}. Crawlers with identical rules share one
735
+ group in the file.
736
+ </p>
737
+ <pre className="mt-1 overflow-x-auto font-mono text-xs">
738
+ {[...rule.agents.slice(0, 12).map((a) => `User-agent: ${a}`), ...(rule.agents.length > 12 ? [`# …and ${rule.agents.length - 12} more`] : []), ...props.lines.map((l) => `${l.directive}: ${l.value}`)].join("\n")}
739
+ </pre>
740
+ {rule.agents.length > 0 && (
741
+ <p className="mt-1 text-xs">
742
+ <Badge variant="secondary">{rule.agents.length}</Badge> crawler{rule.agents.length === 1 ? "" : "s"}
743
+ </p>
744
+ )}
745
+ </details>
746
+ </div>
747
+ );
748
+ }