@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.
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/package.json +70 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.test.ts +187 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.test.mjs +287 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.test.mjs +103 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.test.ts +152 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.test.ts +30 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.test.ts +64 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/headings.test.ts +159 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.test.mjs +224 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.test.mjs +670 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.test.ts +50 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.test.mjs +522 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- 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
|
+
}
|