@coywolf/emdash 0.0.0-stage → 0.35.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +203 -2
- package/docs/README.md +45 -0
- package/docs/ai.md +39 -0
- package/docs/backups.md +88 -0
- package/docs/breadcrumbs.md +59 -0
- package/docs/code-blocks.md +21 -0
- package/docs/content-urls.md +41 -0
- package/docs/custom-blocks.md +27 -0
- package/docs/discovery.md +32 -0
- package/docs/files.md +44 -0
- package/docs/form-uploads.md +59 -0
- package/docs/headings.md +37 -0
- package/docs/images.md +141 -0
- package/docs/internals.md +13 -0
- package/docs/links.md +34 -0
- package/docs/performance.md +72 -0
- package/docs/redirects.md +84 -0
- package/docs/releasing.md +21 -0
- package/docs/reviews.md +89 -0
- package/docs/robots.md +81 -0
- package/docs/schema.md +79 -0
- package/docs/search.md +114 -0
- package/docs/videos.md +48 -0
- package/docs/wordpress-import.md +198 -0
- package/package.json +79 -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.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/routes.ts +359 -0
- package/src/ai/store.ts +301 -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.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -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.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.ts +60 -0
- package/src/files/export.ts +44 -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.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.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/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.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.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.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.ts +362 -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.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,883 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robots.txt rules: the stored model, rendering each rule to directive lines,
|
|
3
|
+
* resolving which lines each crawler ends up with (RFC 9309 group selection),
|
|
4
|
+
* and generating the served robots.txt with EmDash's own lines kept intact.
|
|
5
|
+
*
|
|
6
|
+
* Only imports ./rep.ts (no runtime dependencies), so the tests run it under
|
|
7
|
+
* plain Node.
|
|
8
|
+
*/
|
|
9
|
+
import { type Directive, escapePattern, evaluateParsed, matchRaw, parse, toBytes } from "./rep.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stored rule kinds. The admin's guided flow offers a smaller set of plain
|
|
13
|
+
* choices (everything / a section / one page / a kind of file / links with
|
|
14
|
+
* parameters / an advanced pattern) that map onto these; the rest stay for
|
|
15
|
+
* rules saved by earlier versions and for imported files.
|
|
16
|
+
*/
|
|
17
|
+
export const RULE_KINDS = [
|
|
18
|
+
"entire_site",
|
|
19
|
+
"folder",
|
|
20
|
+
"prefix",
|
|
21
|
+
"single_page",
|
|
22
|
+
"exact_url",
|
|
23
|
+
"filetype",
|
|
24
|
+
"filetype_in_folder",
|
|
25
|
+
"contains",
|
|
26
|
+
"any_depth",
|
|
27
|
+
"query_any",
|
|
28
|
+
"query_param",
|
|
29
|
+
"wildcard_prefix",
|
|
30
|
+
"allow_exception",
|
|
31
|
+
"custom",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export type RuleKind = (typeof RULE_KINDS)[number];
|
|
35
|
+
|
|
36
|
+
export interface RobotsRule {
|
|
37
|
+
id: string;
|
|
38
|
+
/** Plain-English name, written as a comment above the group. */
|
|
39
|
+
name: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
/** robots.txt product tokens ("*" for every crawler). */
|
|
43
|
+
agents: string[];
|
|
44
|
+
/** Disallow (block) or Allow. `allow_exception` always writes both. */
|
|
45
|
+
directive: "allow" | "disallow";
|
|
46
|
+
kind: RuleKind;
|
|
47
|
+
path?: string;
|
|
48
|
+
/** Legacy single extension (filetype kinds). */
|
|
49
|
+
ext?: string;
|
|
50
|
+
/** Extensions for the filetype kinds (wins over `ext`). */
|
|
51
|
+
exts?: string[];
|
|
52
|
+
/** Query parameter names for `query_param` (wins over `path`); a trailing `*` matches any name starting with it. */
|
|
53
|
+
params?: string[];
|
|
54
|
+
/** For `allow_exception`: the item to allow inside the blocked folder. */
|
|
55
|
+
allow?: string;
|
|
56
|
+
/** For `single_page`: append `$` so nothing past the path matches. */
|
|
57
|
+
strict?: boolean;
|
|
58
|
+
/** For `folder`: also match a folder with this name at any depth (`/*\/name/`). */
|
|
59
|
+
anyDepth?: boolean;
|
|
60
|
+
/** For blocking rules: paths inside the blocked area that stay allowed (written as more specific Allow lines). */
|
|
61
|
+
except?: string[];
|
|
62
|
+
/** The crawler group picked in the guided flow (see CRAWLER_GROUPS), for plain-English summaries. */
|
|
63
|
+
group?: string;
|
|
64
|
+
/** Where the rule came from. */
|
|
65
|
+
source?: "user" | "import" | "template" | "legacy";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RobotsConfig {
|
|
69
|
+
/** 2 since Coywolf Pack 0.7.0; missing on configs saved by earlier versions. */
|
|
70
|
+
version?: number;
|
|
71
|
+
rules: RobotsRule[];
|
|
72
|
+
/** Write EmDash's `Sitemap: <site>/sitemap.xml` line. */
|
|
73
|
+
includeSitemap: boolean;
|
|
74
|
+
/** More sitemap URLs (absolute, or site-relative paths). */
|
|
75
|
+
sitemaps: string[];
|
|
76
|
+
/** Let crawlers fetch media files, which EmDash serves from /_emdash/api/media/. */
|
|
77
|
+
allowMedia: boolean;
|
|
78
|
+
/** Write each rule's name as a comment. */
|
|
79
|
+
comments: boolean;
|
|
80
|
+
/** Raw lines appended before the sitemaps (e.g. a Content-Signal line). */
|
|
81
|
+
extra: string;
|
|
82
|
+
/**
|
|
83
|
+
* Crawlers named in a rule also keep the rules for all crawlers (copied
|
|
84
|
+
* into their group), except where their own rules say otherwise. Without
|
|
85
|
+
* this, RFC 9309 makes a named crawler ignore every `User-agent: *` rule.
|
|
86
|
+
*/
|
|
87
|
+
inheritGeneral: boolean;
|
|
88
|
+
/** Keep EmDash's admin and API (/_emdash/) out for every crawler. */
|
|
89
|
+
emdashLines: boolean;
|
|
90
|
+
/** Set when the rules were created from EmDash's robots.txt. */
|
|
91
|
+
importedAt?: string;
|
|
92
|
+
importMode?: "rules" | "verbatim";
|
|
93
|
+
/** What the import did, in plain English (shown with "What's being served"). */
|
|
94
|
+
importNotes?: string[];
|
|
95
|
+
/** Keep discovery files (/.well-known/, llms.txt, and `discoveryPaths`) reachable for every crawler. */
|
|
96
|
+
discoveryAllowances: boolean;
|
|
97
|
+
/** More discovery paths to keep reachable (site-relative). */
|
|
98
|
+
discoveryPaths: string[];
|
|
99
|
+
/**
|
|
100
|
+
* Not stored: what other modules' features add, filled in at generation
|
|
101
|
+
* time from the feature switches (see automaticFrom).
|
|
102
|
+
*/
|
|
103
|
+
automatic?: AutomaticInfo;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface AutomaticInfo {
|
|
107
|
+
/** discovery.llms is on (Discovery serves /llms.txt). */
|
|
108
|
+
llms: boolean;
|
|
109
|
+
/** EmDash's Settings > SEO "Disallow AI training" is on: its AI-training lines are added (EMDASH_AI_TRAINING). */
|
|
110
|
+
aiTraining?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Machine-discovery locations kept reachable by default (RFC 8615 well-known URIs cover security.txt, ai-plugin.json and future manifests). */
|
|
114
|
+
export const WELL_KNOWN_PATH = "/.well-known/";
|
|
115
|
+
/** Only llms.txt by default: llms-full.txt carries the full site text, so adding it is the owner's call (a discovery path). */
|
|
116
|
+
export const LLMS_PATHS = ["/llms.txt"];
|
|
117
|
+
|
|
118
|
+
/** What the pack's other features (and EmDash's "Disallow AI training" setting) add to robots.txt. */
|
|
119
|
+
export function automaticFrom(features: Record<string, boolean | undefined>, aiTraining = false): AutomaticInfo {
|
|
120
|
+
return { llms: Boolean(features["discovery.llms"]), aiTraining };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** AI training crawlers and opt-out tokens EmDash's "Disallow AI training" names (emdash/src/astro/routes/robots.txt.ts, 1.3.0). */
|
|
124
|
+
export const EMDASH_AI_TRAINING_CRAWLERS = ["Amazonbot", "Applebot-Extended", "Bytespider", "CCBot", "ClaudeBot", "Google-Extended", "GPTBot", "meta-externalagent"];
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The lines EmDash adds to its robots.txt when "Disallow AI training" is on,
|
|
128
|
+
* word for word, so the setting does the same while the pack serves the file.
|
|
129
|
+
* The empty Disallow closes the `*` group (Content-Signal isn't a rule).
|
|
130
|
+
*/
|
|
131
|
+
export const EMDASH_AI_TRAINING = [
|
|
132
|
+
"# Disallow AI training",
|
|
133
|
+
"User-agent: *",
|
|
134
|
+
"Content-Signal: search=yes, ai-train=no",
|
|
135
|
+
"Disallow:",
|
|
136
|
+
...EMDASH_AI_TRAINING_CRAWLERS.flatMap((agent) => ["", `User-agent: ${agent}`, "Disallow: /"]),
|
|
137
|
+
].join("\n");
|
|
138
|
+
|
|
139
|
+
/** The discovery paths every crawler keeps (empty when the allowance is off). */
|
|
140
|
+
export function discoveryPathsFor(config: Pick<RobotsConfig, "discoveryAllowances" | "discoveryPaths" | "automatic">): string[] {
|
|
141
|
+
if (!config.discoveryAllowances) return [];
|
|
142
|
+
const own = (config.discoveryPaths ?? []).map((p) => encodeValue(clean(p))).filter((p) => p.startsWith("/") && p.length > 1);
|
|
143
|
+
return [...new Set([WELL_KNOWN_PATH, ...(config.automatic?.llms ? LLMS_PATHS : []), ...own])];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const CONFIG_VERSION = 2;
|
|
147
|
+
|
|
148
|
+
export const DEFAULT_CONFIG: RobotsConfig = {
|
|
149
|
+
version: CONFIG_VERSION,
|
|
150
|
+
rules: [],
|
|
151
|
+
includeSitemap: true,
|
|
152
|
+
sitemaps: [],
|
|
153
|
+
allowMedia: true,
|
|
154
|
+
comments: true,
|
|
155
|
+
extra: "",
|
|
156
|
+
inheritGeneral: true,
|
|
157
|
+
emdashLines: true,
|
|
158
|
+
discoveryAllowances: true,
|
|
159
|
+
discoveryPaths: [],
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const EMDASH_ADMIN_PATH = "/_emdash/";
|
|
163
|
+
export const EMDASH_MEDIA_PATH = "/_emdash/api/media/";
|
|
164
|
+
/** Longest Allow/Disallow value accepted (longer than any real URL). */
|
|
165
|
+
export const MAX_VALUE_LENGTH = 2083;
|
|
166
|
+
|
|
167
|
+
/** A robots.txt product token: letters, digits, `.`, `_`, `-` (or `*`). */
|
|
168
|
+
const TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
169
|
+
|
|
170
|
+
export function isValidToken(token: string): boolean {
|
|
171
|
+
return token === "*" || TOKEN_RE.test(token);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export class RobotsValidationError extends Error {}
|
|
175
|
+
|
|
176
|
+
const lead = (p: string) => {
|
|
177
|
+
const t = p.trim();
|
|
178
|
+
return !t || t.startsWith("/") || t.startsWith("*") ? t : `/${t}`;
|
|
179
|
+
};
|
|
180
|
+
const dirPath = (p: string) => {
|
|
181
|
+
const t = lead(p);
|
|
182
|
+
if (!t) return "/";
|
|
183
|
+
return t.endsWith("/") ? t : `${t}/`;
|
|
184
|
+
};
|
|
185
|
+
const ensureStar = (p: string) => (p.endsWith("*") ? p : `${p}*`);
|
|
186
|
+
/** robots.txt values can't contain whitespace or comments. */
|
|
187
|
+
const clean = (v: string) => v.replace(/[\s#]+/g, "");
|
|
188
|
+
/** Percent-encode non-ASCII bytes and uppercase %xx escapes, as crawlers compare them. */
|
|
189
|
+
export const encodeValue = (v: string) => escapePattern(toBytes(v));
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Normalize what a person typed or pasted as a path: strip a full URL down to
|
|
193
|
+
* its path and query, drop a #fragment, encode spaces and non-ASCII, and add
|
|
194
|
+
* the leading slash. Keeps `*` and `$` (pattern characters).
|
|
195
|
+
*/
|
|
196
|
+
export function normalizePathInput(input: string, siteUrl = ""): { value: string; notes: string[] } {
|
|
197
|
+
const notes: string[] = [];
|
|
198
|
+
let v = input.trim();
|
|
199
|
+
const m = /^(https?:)?\/\/([^/?#]+)(.*)$/i.exec(v);
|
|
200
|
+
if (m) {
|
|
201
|
+
const host = m[2].toLowerCase();
|
|
202
|
+
const siteHost = (/^https?:\/\/([^/?#]+)/i.exec(siteUrl)?.[1] ?? "").toLowerCase();
|
|
203
|
+
const bare = (h: string) => h.replace(/^www\./, "");
|
|
204
|
+
if (siteHost && bare(host) !== bare(siteHost)) notes.push(`That address is on ${host}, not this site; only its path is used.`);
|
|
205
|
+
v = m[3] || "/";
|
|
206
|
+
}
|
|
207
|
+
const hash = v.indexOf("#");
|
|
208
|
+
if (hash !== -1) {
|
|
209
|
+
v = v.slice(0, hash);
|
|
210
|
+
notes.push("The #part of an address is never sent to servers, so it was left out.");
|
|
211
|
+
}
|
|
212
|
+
if (/\s/.test(v)) {
|
|
213
|
+
v = v.replace(/\s/g, "%20");
|
|
214
|
+
notes.push("Spaces are written as %20, the way browsers send them.");
|
|
215
|
+
}
|
|
216
|
+
const encoded = encodeValue(v);
|
|
217
|
+
if (encoded !== v && /[^\x00-\x7f]/.test(v)) notes.push(`Written as ${encoded} (the way browsers send it).`);
|
|
218
|
+
return { value: lead(encoded), notes };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface DirectiveLine {
|
|
222
|
+
directive: "Allow" | "Disallow";
|
|
223
|
+
value: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** File extensions for the filetype kinds. */
|
|
227
|
+
export function ruleExts(rule: Pick<RobotsRule, "ext" | "exts">): string[] {
|
|
228
|
+
const list = rule.exts?.length ? rule.exts : rule.ext ? [rule.ext] : [];
|
|
229
|
+
return [...new Set(list.map((e) => clean(e).replace(/^\.+/, "").toLowerCase()).filter(Boolean))];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Query parameter names for `query_param`. */
|
|
233
|
+
export function ruleParams(rule: Pick<RobotsRule, "params" | "path">): string[] {
|
|
234
|
+
const list = rule.params?.length ? rule.params : rule.path ? [rule.path] : [];
|
|
235
|
+
return [...new Set(list.map((p) => clean(p).replace(/^[?&]+/, "").replace(/=+$/, "")).filter(Boolean))];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The directive line(s) a rule writes (no User-agent lines). */
|
|
239
|
+
export function directives(rule: RobotsRule): DirectiveLine[] {
|
|
240
|
+
const path = clean(rule.path ?? "");
|
|
241
|
+
const allow = clean(rule.allow ?? "");
|
|
242
|
+
const dir: DirectiveLine["directive"] = rule.directive === "allow" ? "Allow" : "Disallow";
|
|
243
|
+
const line = (value: string, d = dir): DirectiveLine => ({ directive: d, value: encodeValue(value) });
|
|
244
|
+
let out: DirectiveLine[];
|
|
245
|
+
switch (rule.kind) {
|
|
246
|
+
case "entire_site":
|
|
247
|
+
out = [line("/")];
|
|
248
|
+
break;
|
|
249
|
+
case "folder": {
|
|
250
|
+
const folder = dirPath(path);
|
|
251
|
+
out = [line(folder)];
|
|
252
|
+
if (rule.anyDepth && folder !== "/") out.push(line(`/*${folder}`));
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
case "prefix":
|
|
256
|
+
out = [line(lead(path))];
|
|
257
|
+
break;
|
|
258
|
+
case "single_page":
|
|
259
|
+
out = [line(lead(path).replace(/\$+$/, "") + (rule.strict ? "$" : ""))];
|
|
260
|
+
break;
|
|
261
|
+
case "exact_url":
|
|
262
|
+
out = [line(`${lead(path).replace(/\$+$/, "")}$`)];
|
|
263
|
+
break;
|
|
264
|
+
case "filetype":
|
|
265
|
+
out = ruleExts(rule).map((ext) => line(`/*.${ext}$`));
|
|
266
|
+
break;
|
|
267
|
+
case "filetype_in_folder":
|
|
268
|
+
out = ruleExts(rule).map((ext) => line(`${dirPath(path)}*.${ext}$`));
|
|
269
|
+
break;
|
|
270
|
+
case "contains":
|
|
271
|
+
out = [line(`/*${path.replace(/^\/+/, "")}`)];
|
|
272
|
+
break;
|
|
273
|
+
case "any_depth":
|
|
274
|
+
out = [line(`/*/${path.replace(/^\/+|\/+$/g, "")}/`)];
|
|
275
|
+
break;
|
|
276
|
+
case "query_any":
|
|
277
|
+
out = [line("/*?")];
|
|
278
|
+
break;
|
|
279
|
+
case "query_param":
|
|
280
|
+
out = ruleParams(rule).flatMap((p) => {
|
|
281
|
+
const name = p.endsWith("*") ? p.replace(/\*+$/, "") : `${p}=`;
|
|
282
|
+
return [line(`/*?${name}`), line(`/*&${name}`)];
|
|
283
|
+
});
|
|
284
|
+
break;
|
|
285
|
+
case "wildcard_prefix":
|
|
286
|
+
out = [line(ensureStar(lead(path)))];
|
|
287
|
+
break;
|
|
288
|
+
case "allow_exception":
|
|
289
|
+
out = allow ? [line(dirPath(path), "Disallow"), line(lead(allow), "Allow")] : [line(dirPath(path), "Disallow")];
|
|
290
|
+
break;
|
|
291
|
+
default:
|
|
292
|
+
out = [line(path)];
|
|
293
|
+
}
|
|
294
|
+
if (rule.directive === "disallow" && rule.except?.length) {
|
|
295
|
+
for (const e of rule.except) {
|
|
296
|
+
const v = clean(e);
|
|
297
|
+
if (v) out.push(line(lead(v), "Allow"));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const seen = new Set<string>();
|
|
301
|
+
return out.filter((l) => {
|
|
302
|
+
const k = `${l.directive}:${l.value}`;
|
|
303
|
+
if (seen.has(k)) return false;
|
|
304
|
+
seen.add(k);
|
|
305
|
+
return true;
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Throws RobotsValidationError when a rule can't produce a sensible directive. */
|
|
310
|
+
export function validateRule(rule: RobotsRule): void {
|
|
311
|
+
if (!rule.name.trim()) throw new RobotsValidationError("Give the rule a name.");
|
|
312
|
+
if (!rule.agents.length) throw new RobotsValidationError("Pick at least one crawler (or * for all).");
|
|
313
|
+
for (const agent of rule.agents) {
|
|
314
|
+
if (!isValidToken(agent)) {
|
|
315
|
+
throw new RobotsValidationError(
|
|
316
|
+
`"${agent}" isn't a robots.txt user-agent token (letters, digits, dot, dash and underscore, no spaces).`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const needsPath = !["entire_site", "query_any", "filetype", "query_param"].includes(rule.kind);
|
|
321
|
+
if (needsPath && !clean(rule.path ?? "")) throw new RobotsValidationError("This rule type needs a path.");
|
|
322
|
+
if (rule.kind === "filetype" || rule.kind === "filetype_in_folder") {
|
|
323
|
+
const exts = ruleExts(rule);
|
|
324
|
+
if (!exts.length || exts.some((e) => !/^[A-Za-z0-9]+$/.test(e))) throw new RobotsValidationError("Enter a file extension such as pdf.");
|
|
325
|
+
}
|
|
326
|
+
if (rule.kind === "query_param") {
|
|
327
|
+
const params = ruleParams(rule);
|
|
328
|
+
if (!params.length) throw new RobotsValidationError("Enter a parameter name such as utm_source.");
|
|
329
|
+
for (const p of params) {
|
|
330
|
+
if (!/^[A-Za-z0-9._~%[\]-]+\*?$/.test(p)) throw new RobotsValidationError(`"${p}" isn't a query parameter name (letters, digits, . _ ~ - and %, optionally ending in *).`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (rule.kind === "custom" && !/^[/*]/.test(clean(rule.path ?? ""))) {
|
|
334
|
+
throw new RobotsValidationError("A custom value must start with / or *.");
|
|
335
|
+
}
|
|
336
|
+
for (const d of directives(rule)) {
|
|
337
|
+
if (d.value.length > MAX_VALUE_LENGTH) {
|
|
338
|
+
throw new RobotsValidationError(`"${d.value.slice(0, 40)}…" is ${d.value.length} characters long; the most is ${MAX_VALUE_LENGTH}.`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const oneLine = (s: string | undefined) => (s ?? "").replace(/\s+/g, " ").trim();
|
|
344
|
+
/** Values that must stay on one line and can't contain spaces (URLs, tokens): drop all whitespace, including CR/LF. */
|
|
345
|
+
const singleLine = (s: string | undefined) => (s ?? "").replace(/\s+/g, "");
|
|
346
|
+
|
|
347
|
+
function uniq(list: string[]): string[] {
|
|
348
|
+
const seen = new Set<string>();
|
|
349
|
+
const out: string[] = [];
|
|
350
|
+
for (const item of list) {
|
|
351
|
+
const key = item.toLowerCase();
|
|
352
|
+
if (!seen.has(key)) {
|
|
353
|
+
seen.add(key);
|
|
354
|
+
out.push(item);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Paths a whole-site block must cover: the home page, any page, and EmDash media. */
|
|
361
|
+
const WHOLE_SITE_PROBES = ["/", "/any/page.html?x=1", `${EMDASH_MEDIA_PATH}file/a.jpg`];
|
|
362
|
+
|
|
363
|
+
/** Whether a Disallow value blocks every URL (`/`, `/*`, `*`, `/**`…), unlike e.g. `/$` (home page only). */
|
|
364
|
+
export function blocksWholeSite(value: string): boolean {
|
|
365
|
+
return value !== "" && WHOLE_SITE_PROBES.every((p) => matchRaw(value, p));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* ------------------------------------------------------------------ *
|
|
369
|
+
* Sample URLs: paths a pattern is meant to match (used by the
|
|
370
|
+
* inheritance logic, the validator and the self-check).
|
|
371
|
+
* ------------------------------------------------------------------ */
|
|
372
|
+
|
|
373
|
+
/** Concrete paths that a (normalized) pattern matches, a few per pattern. */
|
|
374
|
+
export function samplePaths(pattern: string): string[] {
|
|
375
|
+
if (!pattern) return [];
|
|
376
|
+
const anchored = pattern.endsWith("$");
|
|
377
|
+
const body = anchored ? pattern.slice(0, -1) : pattern;
|
|
378
|
+
const out = new Set<string>();
|
|
379
|
+
for (const fill of ["x", "a/b", ""]) {
|
|
380
|
+
let p = body.replace(/\*/g, fill);
|
|
381
|
+
if (!p.startsWith("/")) p = `/${p}`;
|
|
382
|
+
out.add(p);
|
|
383
|
+
if (anchored) continue;
|
|
384
|
+
out.add(p.endsWith("/") ? `${p}deep/page.html` : `${p}/deep/page.html`);
|
|
385
|
+
out.add(`${p}${p.includes("?") ? "&" : "?"}q=1`);
|
|
386
|
+
}
|
|
387
|
+
return [...out].filter((p) => !p.startsWith("//") && matchRaw(pattern, p)).slice(0, 8);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/* ------------------------------------------------------------------ *
|
|
391
|
+
* Which lines each crawler ends up with.
|
|
392
|
+
* ------------------------------------------------------------------ */
|
|
393
|
+
|
|
394
|
+
export interface ResolvedLine extends DirectiveLine {
|
|
395
|
+
/** Rule ids that produced this line; empty for EmDash's own lines. */
|
|
396
|
+
ruleIds: string[];
|
|
397
|
+
system?: boolean;
|
|
398
|
+
/** Copied from the rules for all crawlers. */
|
|
399
|
+
inherited?: boolean;
|
|
400
|
+
/** Added because another feature is on (Discovery's llms.txt and Markdown). */
|
|
401
|
+
auto?: boolean;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export interface ResolvedGroup {
|
|
405
|
+
agents: string[];
|
|
406
|
+
lines: ResolvedLine[];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** A token no rule names, standing in for "any other crawler". */
|
|
410
|
+
export const PROBE_AGENT = "CoywolfPackProbeBot";
|
|
411
|
+
|
|
412
|
+
const priority = (value: string) => value.length;
|
|
413
|
+
|
|
414
|
+
/** Longest match wins; Allow wins ties (Google). Lines must be normalized. */
|
|
415
|
+
export function decide(lines: DirectiveLine[], path: string): { allowed: boolean; line: DirectiveLine | null } {
|
|
416
|
+
let best: DirectiveLine | null = null;
|
|
417
|
+
for (const l of lines) {
|
|
418
|
+
if (!l.value || !matchRaw(l.value, path)) continue;
|
|
419
|
+
if (
|
|
420
|
+
!best ||
|
|
421
|
+
priority(l.value) > priority(best.value) ||
|
|
422
|
+
(priority(l.value) === priority(best.value) && l.directive === "Allow" && best.directive === "Disallow")
|
|
423
|
+
) {
|
|
424
|
+
best = l;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return { allowed: !best || best.directive === "Allow", line: best };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Whether line `s` matches every sample path of line `g` (so it governs where g would apply). */
|
|
431
|
+
function covers(s: DirectiveLine, g: DirectiveLine): boolean {
|
|
432
|
+
const samples = samplePaths(g.value);
|
|
433
|
+
return samples.length > 0 && samples.every((p) => matchRaw(s.value, p));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function enabledRules(config: RobotsConfig) {
|
|
437
|
+
return config.rules
|
|
438
|
+
.filter((r) => r.enabled)
|
|
439
|
+
.map((r) => ({ ...r, agents: r.agents.map(singleLine).filter(isValidToken) }))
|
|
440
|
+
.filter((r) => r.agents.length);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function addLine(list: ResolvedLine[], line: DirectiveLine, ruleId: string | null, extra: Partial<ResolvedLine> = {}) {
|
|
444
|
+
const have = list.find((l) => l.directive === line.directive && l.value === line.value);
|
|
445
|
+
if (have) {
|
|
446
|
+
if (ruleId && !have.ruleIds.includes(ruleId)) have.ruleIds.push(ruleId);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
list.push({ ...line, ruleIds: ruleId ? [ruleId] : [], ...extra });
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The lines each crawler follows. `*` gets the rules for all crawlers; each
|
|
454
|
+
* named crawler gets its own rules plus (when `inheritGeneral` is on) the
|
|
455
|
+
* general rules its own rules don't already decide; EmDash's admin block
|
|
456
|
+
* (and the media Allow) go to everyone not blocked from the whole site.
|
|
457
|
+
* Returns a map keyed by lowercased token ("*" for the general group).
|
|
458
|
+
*/
|
|
459
|
+
export function resolveAgents(config: RobotsConfig): Map<string, { token: string; lines: ResolvedLine[]; order: number }> {
|
|
460
|
+
const rules = enabledRules(config);
|
|
461
|
+
const out = new Map<string, { token: string; lines: ResolvedLine[]; order: number }>();
|
|
462
|
+
const general: ResolvedLine[] = [];
|
|
463
|
+
let order = 0;
|
|
464
|
+
rules.forEach((rule) => {
|
|
465
|
+
const lines = directives(rule);
|
|
466
|
+
for (const agent of uniq(rule.agents)) {
|
|
467
|
+
const key = agent.toLowerCase();
|
|
468
|
+
if (key === "*") {
|
|
469
|
+
for (const l of lines) addLine(general, l, rule.id);
|
|
470
|
+
if (!out.has("*")) out.set("*", { token: "*", lines: general, order: order++ });
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
let entry = out.get(key);
|
|
474
|
+
if (!entry) {
|
|
475
|
+
entry = { token: agent, lines: [], order: order++ };
|
|
476
|
+
out.set(key, entry);
|
|
477
|
+
}
|
|
478
|
+
for (const l of lines) addLine(entry.lines, l, rule.id);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
if (config.inheritGeneral) {
|
|
483
|
+
for (const [key, entry] of out) {
|
|
484
|
+
if (key === "*") continue;
|
|
485
|
+
const own = [...entry.lines];
|
|
486
|
+
const ownAllows = own.some((l) => l.directive === "Allow");
|
|
487
|
+
for (const g of general) {
|
|
488
|
+
if (own.some((s) => s.value === g.value)) continue;
|
|
489
|
+
const decided = own.some((s) => covers(s, g) && (s.directive !== g.directive || !ownAllows));
|
|
490
|
+
if (decided) continue;
|
|
491
|
+
entry.lines.push({ ...g, ruleIds: [...g.ruleIds], inherited: true });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (config.emdashLines && !out.has("*")) out.set("*", { token: "*", lines: general, order: order++ });
|
|
497
|
+
for (const entry of out.values()) {
|
|
498
|
+
const blocked = WHOLE_SITE_PROBES.slice(0, 2).every((p) => !decide(entry.lines, p).allowed);
|
|
499
|
+
if (blocked) continue;
|
|
500
|
+
const mediaBlocked = !decide(entry.lines, WHOLE_SITE_PROBES[2]).allowed;
|
|
501
|
+
if (config.allowMedia && (config.emdashLines || mediaBlocked)) addLine(entry.lines, { directive: "Allow", value: EMDASH_MEDIA_PATH }, null, { system: true });
|
|
502
|
+
if (config.emdashLines) addLine(entry.lines, { directive: "Disallow", value: EMDASH_ADMIN_PATH }, null, { system: true });
|
|
503
|
+
}
|
|
504
|
+
// Discovery files: any crawler that would be blocked from one gets an Allow for it.
|
|
505
|
+
const discovery = discoveryPathsFor(config);
|
|
506
|
+
for (const entry of out.values()) {
|
|
507
|
+
for (const value of discovery) {
|
|
508
|
+
const probe = samplePaths(value)[0] ?? value;
|
|
509
|
+
if (!decide(entry.lines, probe).allowed) addLine(entry.lines, { directive: "Allow", value }, null, { system: true, auto: true });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return out;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** The lines a crawler follows according to the rules (the model the self-check compares the file against). */
|
|
516
|
+
export function linesForAgent(resolved: ReturnType<typeof resolveAgents>, token: string): ResolvedLine[] {
|
|
517
|
+
return resolved.get(token.toLowerCase())?.lines ?? resolved.get("*")?.lines ?? [];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Most specific lines first (so first-match crawlers agree with longest-match ones); Allow first on ties. */
|
|
521
|
+
function sortLines(lines: ResolvedLine[]): ResolvedLine[] {
|
|
522
|
+
return lines
|
|
523
|
+
.map((l, i) => ({ l, i }))
|
|
524
|
+
.sort((a, b) => priority(b.l.value) - priority(a.l.value) || (a.l.directive === b.l.directive ? 0 : a.l.directive === "Allow" ? -1 : 1) || a.i - b.i)
|
|
525
|
+
.map((x) => x.l);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Crawlers with identical lines share one group, in the order they first appear in the rules. */
|
|
529
|
+
export function resolveGroups(config: RobotsConfig): ResolvedGroup[] {
|
|
530
|
+
const resolved = resolveAgents(config);
|
|
531
|
+
const groups = new Map<string, ResolvedGroup & { order: number }>();
|
|
532
|
+
for (const entry of [...resolved.values()].sort((a, b) => a.order - b.order)) {
|
|
533
|
+
if (!entry.lines.length) continue;
|
|
534
|
+
const lines = sortLines(entry.lines);
|
|
535
|
+
const key = lines.map((l) => `${l.directive}:${l.value}`).join("\n");
|
|
536
|
+
const g = groups.get(key);
|
|
537
|
+
if (g) {
|
|
538
|
+
g.agents.push(entry.token);
|
|
539
|
+
for (const l of lines) {
|
|
540
|
+
const mine = g.lines.find((x) => x.directive === l.directive && x.value === l.value);
|
|
541
|
+
if (mine) for (const id of l.ruleIds) if (!mine.ruleIds.includes(id)) mine.ruleIds.push(id);
|
|
542
|
+
}
|
|
543
|
+
} else groups.set(key, { agents: [entry.token], lines: lines.map((l) => ({ ...l, ruleIds: [...l.ruleIds] })), order: entry.order });
|
|
544
|
+
}
|
|
545
|
+
return [...groups.values()].sort((a, b) => a.order - b.order).map(({ agents, lines }) => ({ agents, lines }));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export interface GenerateOptions {
|
|
549
|
+
/** Site origin without a trailing slash, e.g. "https://wellbeing.io". */
|
|
550
|
+
siteUrl: string;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Build robots.txt: one group per set of crawlers that follow the same lines
|
|
555
|
+
* (most specific line first), then Extra lines, then sitemaps.
|
|
556
|
+
*/
|
|
557
|
+
export function generate(config: RobotsConfig, options: GenerateOptions): string {
|
|
558
|
+
const siteUrl = singleLine(options.siteUrl).replace(/\/+$/, "");
|
|
559
|
+
const rules = enabledRules(config);
|
|
560
|
+
const names = new Map(rules.map((r) => [r.id, oneLine(r.name)]));
|
|
561
|
+
const out: string[] = config.comments ? ["# robots.txt managed by Coywolf Pack (Robots.txt Rules): https://github.com/coywolf-llc/coywolf-pack", ""] : [];
|
|
562
|
+
|
|
563
|
+
for (const group of resolveGroups(config)) {
|
|
564
|
+
if (config.comments) {
|
|
565
|
+
const labels = uniq(group.lines.filter((l) => !l.inherited).flatMap((l) => l.ruleIds.map((id) => names.get(id) ?? "")).filter(Boolean));
|
|
566
|
+
if (group.lines.some((l) => l.inherited)) labels.push("rules for all crawlers");
|
|
567
|
+
if (group.lines.some((l) => l.auto)) labels.push("discovery files stay readable");
|
|
568
|
+
if (group.lines.some((l) => l.system && !l.auto)) labels.push(config.allowMedia ? "EmDash admin and API (media stays crawlable)" : "EmDash admin and API");
|
|
569
|
+
if (labels.length) out.push(`# ${labels.join("; ")}`);
|
|
570
|
+
}
|
|
571
|
+
for (const agent of group.agents) out.push(`User-agent: ${agent}`);
|
|
572
|
+
for (const l of group.lines) out.push(`${l.directive}: ${l.value}`);
|
|
573
|
+
out.push("");
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const extra = config.extra.trim();
|
|
577
|
+
if (extra) out.push(extra, "");
|
|
578
|
+
|
|
579
|
+
// After everything else, as EmDash adds it after its whole file: Extra lines without a User-agent of their own stay with the group they follow.
|
|
580
|
+
if (config.automatic?.aiTraining) out.push(EMDASH_AI_TRAINING, "");
|
|
581
|
+
|
|
582
|
+
for (const s of sitemapUrls(config, siteUrl)) out.push(`Sitemap: ${s}`);
|
|
583
|
+
|
|
584
|
+
return `${out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** The Sitemap URLs a config writes. */
|
|
588
|
+
export function sitemapUrls(config: Pick<RobotsConfig, "includeSitemap" | "sitemaps">, siteUrl: string): string[] {
|
|
589
|
+
const site = singleLine(siteUrl).replace(/\/+$/, "");
|
|
590
|
+
const sitemaps: string[] = [];
|
|
591
|
+
if (config.includeSitemap) sitemaps.push(`${site}/sitemap.xml`);
|
|
592
|
+
for (const s of config.sitemaps) {
|
|
593
|
+
const t = singleLine(s);
|
|
594
|
+
if (!t) continue;
|
|
595
|
+
sitemaps.push(/^https?:\/\//i.test(t) ? t : `${site}${lead(t)}`);
|
|
596
|
+
}
|
|
597
|
+
return uniq(sitemaps);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/* ------------------------------------------------------------------ *
|
|
601
|
+
* Stored config: normalizing and migrating.
|
|
602
|
+
* ------------------------------------------------------------------ */
|
|
603
|
+
|
|
604
|
+
const EQUIV_PATHS = ["/", "/a-post/", "/private/x", "/_emdash/admin", "/_emdash/api/content/x", `${EMDASH_MEDIA_PATH}file/a.jpg`, "/x.pdf", "/?q=1"];
|
|
605
|
+
|
|
606
|
+
/** Whether two robots.txt bodies give every listed crawler the same verdict on every listed path (plus each line's samples). */
|
|
607
|
+
export function sameVerdicts(a: string, b: string, agents: string[], extraPaths: string[] = []): boolean {
|
|
608
|
+
const da = parse(a).directives;
|
|
609
|
+
const db = parse(b).directives;
|
|
610
|
+
const paths = new Set([...EQUIV_PATHS, ...extraPaths]);
|
|
611
|
+
for (const d of [...da, ...db]) if (d.type === "allow" || d.type === "disallow") for (const p of samplePaths(d.value)) paths.add(p);
|
|
612
|
+
for (const agent of agents) {
|
|
613
|
+
for (const p of paths) {
|
|
614
|
+
if (evaluateParsed(da, [agent], p).allowed !== evaluateParsed(db, [agent], p).allowed) return false;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Normalize untrusted stored/posted config (missing fields get defaults).
|
|
622
|
+
* Configs saved before 0.7.0 (no `version`) never copied general rules into
|
|
623
|
+
* named crawlers' groups; they turn that on only when it changes nothing.
|
|
624
|
+
*/
|
|
625
|
+
export function normalizeConfig(input: Partial<RobotsConfig> | null | undefined): RobotsConfig {
|
|
626
|
+
const config: RobotsConfig = {
|
|
627
|
+
version: CONFIG_VERSION,
|
|
628
|
+
rules: Array.isArray(input?.rules) ? input.rules.map(normalizeRule) : [],
|
|
629
|
+
includeSitemap: input?.includeSitemap ?? DEFAULT_CONFIG.includeSitemap,
|
|
630
|
+
sitemaps: Array.isArray(input?.sitemaps) ? input.sitemaps.filter((s) => typeof s === "string") : [],
|
|
631
|
+
allowMedia: input?.allowMedia ?? DEFAULT_CONFIG.allowMedia,
|
|
632
|
+
comments: input?.comments ?? DEFAULT_CONFIG.comments,
|
|
633
|
+
extra: typeof input?.extra === "string" ? input.extra : "",
|
|
634
|
+
inheritGeneral: typeof input?.inheritGeneral === "boolean" ? input.inheritGeneral : true,
|
|
635
|
+
emdashLines: typeof input?.emdashLines === "boolean" ? input.emdashLines : true,
|
|
636
|
+
discoveryAllowances: typeof input?.discoveryAllowances === "boolean" ? input.discoveryAllowances : true,
|
|
637
|
+
discoveryPaths: Array.isArray(input?.discoveryPaths) ? input.discoveryPaths.filter((p) => typeof p === "string") : [],
|
|
638
|
+
...(input?.importedAt ? { importedAt: input.importedAt } : {}),
|
|
639
|
+
...(input?.importMode ? { importMode: input.importMode } : {}),
|
|
640
|
+
...(Array.isArray(input?.importNotes) ? { importNotes: input.importNotes.filter((n) => typeof n === "string") } : {}),
|
|
641
|
+
};
|
|
642
|
+
if (input && !input.version && typeof input.inheritGeneral !== "boolean") {
|
|
643
|
+
const legacy = { ...config, inheritGeneral: false, comments: false };
|
|
644
|
+
const modern = { ...legacy, inheritGeneral: true };
|
|
645
|
+
const site = { siteUrl: "https://example.com" };
|
|
646
|
+
const agents = [PROBE_AGENT, ...new Set(config.rules.flatMap((r) => r.agents))].filter((a) => a !== "*");
|
|
647
|
+
config.inheritGeneral = sameVerdicts(generate(legacy, site), generate(modern, site), agents);
|
|
648
|
+
}
|
|
649
|
+
return config;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function normalizeRule(rule: RobotsRule): RobotsRule {
|
|
653
|
+
return {
|
|
654
|
+
...rule,
|
|
655
|
+
name: typeof rule.name === "string" ? rule.name : "",
|
|
656
|
+
enabled: rule.enabled !== false,
|
|
657
|
+
agents: Array.isArray(rule.agents) ? rule.agents.filter((a) => typeof a === "string") : [],
|
|
658
|
+
directive: rule.directive === "allow" ? "allow" : "disallow",
|
|
659
|
+
kind: (RULE_KINDS as readonly string[]).includes(rule.kind) ? rule.kind : "custom",
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/* ------------------------------------------------------------------ *
|
|
664
|
+
* Crawler groups, presets and templates.
|
|
665
|
+
* ------------------------------------------------------------------ */
|
|
666
|
+
|
|
667
|
+
export interface CrawlerGroup {
|
|
668
|
+
id: string;
|
|
669
|
+
label: string;
|
|
670
|
+
/** Lowercase phrase for sentences ("AI training crawlers"). */
|
|
671
|
+
phrase: string;
|
|
672
|
+
description: string;
|
|
673
|
+
/**
|
|
674
|
+
* Members: bundled bots whose documented purpose matches and whose token
|
|
675
|
+
* is verified (see src/robots/data/verified.json → purposes; a test keeps
|
|
676
|
+
* this list and the data in step). Radar-only bots never join a group.
|
|
677
|
+
*/
|
|
678
|
+
tokens: string[];
|
|
679
|
+
/** Purposes this group stands for; verified custom bots with one of these join it too. */
|
|
680
|
+
purposes: string[];
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export const CRAWLER_GROUPS: CrawlerGroup[] = [
|
|
684
|
+
{
|
|
685
|
+
id: "search_engines",
|
|
686
|
+
label: "Search engines",
|
|
687
|
+
phrase: "search engines",
|
|
688
|
+
description: "Google, Bing, Apple, DuckDuckGo, Yandex, Baidu and Huawei Petal Search. Blocking them removes pages from search results.",
|
|
689
|
+
tokens: ["Googlebot", "Googlebot-Image", "Googlebot-Video", "Googlebot-News", "bingbot", "Applebot", "DuckDuckBot", "YandexBot", "Baiduspider", "PetalBot"],
|
|
690
|
+
purposes: ["search-engine"],
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
id: "ai_training",
|
|
694
|
+
label: "AI training",
|
|
695
|
+
phrase: "AI training crawlers",
|
|
696
|
+
description: "Crawlers whose operators say they collect pages to train AI models (GPTBot, ClaudeBot, Google-Extended, CCBot, Amazonbot…). Blocking them doesn't affect search.",
|
|
697
|
+
tokens: [
|
|
698
|
+
"GPTBot",
|
|
699
|
+
"ClaudeBot",
|
|
700
|
+
"Google-Extended",
|
|
701
|
+
"Applebot-Extended",
|
|
702
|
+
"CCBot",
|
|
703
|
+
"meta-externalagent",
|
|
704
|
+
"Bytespider",
|
|
705
|
+
"Amazonbot",
|
|
706
|
+
"MistralAI-Training",
|
|
707
|
+
"Webzio-Extended",
|
|
708
|
+
],
|
|
709
|
+
purposes: ["training"],
|
|
710
|
+
},
|
|
711
|
+
{
|
|
712
|
+
id: "ai_search",
|
|
713
|
+
label: "AI search and assistants",
|
|
714
|
+
phrase: "AI search and assistant crawlers",
|
|
715
|
+
description: "AI answer engines and assistants that fetch pages to answer questions and cite them (ChatGPT search, Perplexity, Claude…). Their operators say they don't train on them.",
|
|
716
|
+
tokens: [
|
|
717
|
+
"OAI-SearchBot",
|
|
718
|
+
"Claude-SearchBot",
|
|
719
|
+
"PerplexityBot",
|
|
720
|
+
"Amzn-SearchBot",
|
|
721
|
+
"MistralAI-Index",
|
|
722
|
+
"ChatGPT-User",
|
|
723
|
+
"Claude-User",
|
|
724
|
+
"Perplexity-User",
|
|
725
|
+
"meta-externalfetcher",
|
|
726
|
+
"MistralAI-User",
|
|
727
|
+
"DuckAssistBot",
|
|
728
|
+
"Amzn-User",
|
|
729
|
+
],
|
|
730
|
+
purposes: ["ai-search", "ai-assistant"],
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
id: "seo_tools",
|
|
734
|
+
label: "SEO tools",
|
|
735
|
+
phrase: "SEO tool crawlers",
|
|
736
|
+
description: "Backlink and site-audit crawlers (Ahrefs, Semrush, Majestic, Moz…). They don't send visitors.",
|
|
737
|
+
tokens: ["AhrefsBot", "SemrushBot", "MJ12bot", "DotBot", "BLEXBot", "DataForSeoBot", "Barkrowler"],
|
|
738
|
+
purposes: ["seo"],
|
|
739
|
+
},
|
|
740
|
+
];
|
|
741
|
+
|
|
742
|
+
export interface DirectoryBot {
|
|
743
|
+
token: string;
|
|
744
|
+
category: string;
|
|
745
|
+
status: string;
|
|
746
|
+
purpose?: string;
|
|
747
|
+
origin?: string;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** The tokens in a crawler group: its curated members plus verified custom bots (added on this site) with a matching purpose. */
|
|
751
|
+
export function groupTokens(group: CrawlerGroup, bots: DirectoryBot[] = []): string[] {
|
|
752
|
+
const custom = bots.filter((b) => b.origin === "custom" && b.status === "verified" && b.purpose && group.purposes.includes(b.purpose)).map((b) => b.token);
|
|
753
|
+
return uniq([...group.tokens, ...custom]).filter(isValidToken);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** Which crawler group a token list covers (all of the group's documented tokens), if any. */
|
|
757
|
+
export function matchGroup(agents: string[]): CrawlerGroup | undefined {
|
|
758
|
+
const set = new Set(agents.map((a) => a.toLowerCase()));
|
|
759
|
+
return CRAWLER_GROUPS.find((g) => g.tokens.every((t) => set.has(t.toLowerCase())));
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Crawler lists the presets wrote before 0.7.0, so rules made from them can follow the presets now. */
|
|
763
|
+
const LEGACY_PRESET_TOKENS: Record<string, string[]> = {
|
|
764
|
+
ai_training: ["GPTBot", "ClaudeBot", "Google-Extended", "Applebot-Extended", "CCBot", "meta-externalagent", "Bytespider", "Amazonbot", "MistralAI-Training", "Webzio-Extended", "ImagesiftBot"],
|
|
765
|
+
ai_search: ["OAI-SearchBot", "ChatGPT-User", "Claude-SearchBot", "Claude-User", "PerplexityBot", "Perplexity-User", "meta-externalfetcher", "MistralAI-User", "DuckAssistBot", "Amzn-User"],
|
|
766
|
+
search_engines: ["Googlebot", "bingbot", "Applebot", "DuckDuckBot", "YandexBot", "Baiduspider"],
|
|
767
|
+
seo_tools: ["AhrefsBot", "SemrushBot", "MJ12bot", "DotBot", "BLEXBot", "DataForSeoBot", "Barkrowler"],
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
export interface GroupChange {
|
|
771
|
+
ruleId: string;
|
|
772
|
+
ruleName: string;
|
|
773
|
+
group: string;
|
|
774
|
+
added: string[];
|
|
775
|
+
removed: string[];
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Rules whose crawlers came from a preset (they carry its id, or match a
|
|
780
|
+
* pre-0.7.0 preset list exactly) get the preset's current members.
|
|
781
|
+
*/
|
|
782
|
+
export function refreshGroups(config: RobotsConfig, bots: DirectoryBot[] = []): { config: RobotsConfig; changes: GroupChange[] } {
|
|
783
|
+
const changes: GroupChange[] = [];
|
|
784
|
+
const rules = config.rules.map((rule) => {
|
|
785
|
+
let groupId = rule.group;
|
|
786
|
+
if (!groupId) {
|
|
787
|
+
const set = new Set(rule.agents.map((a) => a.toLowerCase()));
|
|
788
|
+
groupId = Object.keys(LEGACY_PRESET_TOKENS).find((id) => {
|
|
789
|
+
const list = LEGACY_PRESET_TOKENS[id];
|
|
790
|
+
return list.length === set.size && list.every((t) => set.has(t.toLowerCase()));
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
const group = groupId ? CRAWLER_GROUPS.find((g) => g.id === groupId) : undefined;
|
|
794
|
+
if (!group) return rule;
|
|
795
|
+
const next = groupTokens(group, bots);
|
|
796
|
+
const have = new Set(rule.agents.map((a) => a.toLowerCase()));
|
|
797
|
+
const want = new Set(next.map((a) => a.toLowerCase()));
|
|
798
|
+
const added = next.filter((t) => !have.has(t.toLowerCase()));
|
|
799
|
+
const removed = rule.agents.filter((t) => !want.has(t.toLowerCase()));
|
|
800
|
+
if (!added.length && !removed.length && rule.group === group.id) return rule;
|
|
801
|
+
if (added.length || removed.length) changes.push({ ruleId: rule.id, ruleName: rule.name, group: group.id, added, removed });
|
|
802
|
+
return { ...rule, agents: next, group: group.id };
|
|
803
|
+
});
|
|
804
|
+
return { config: { ...config, rules }, changes };
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const groupById = (id: string) => CRAWLER_GROUPS.find((g) => g.id === id) as CrawlerGroup;
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Starting points offered in the admin ("named rules"). Tokens are checked against operators' documentation.
|
|
811
|
+
* No AI-training preset: EmDash's Settings > SEO "Disallow AI training" covers it (see EMDASH_AI_TRAINING).
|
|
812
|
+
*/
|
|
813
|
+
export const PRESETS: Array<Omit<RobotsRule, "id" | "enabled">> = [
|
|
814
|
+
{
|
|
815
|
+
name: "Block AI search and assistants",
|
|
816
|
+
description: "Keep AI answer engines and on-demand AI fetchers out. Your pages won't be cited in their answers.",
|
|
817
|
+
directive: "disallow",
|
|
818
|
+
kind: "entire_site",
|
|
819
|
+
agents: [...groupById("ai_search").tokens],
|
|
820
|
+
group: "ai_search",
|
|
821
|
+
},
|
|
822
|
+
{
|
|
823
|
+
name: "Allow search engines",
|
|
824
|
+
description: "Explicitly allow the major search engines everywhere EmDash allows them.",
|
|
825
|
+
directive: "allow",
|
|
826
|
+
kind: "entire_site",
|
|
827
|
+
agents: [...groupById("search_engines").tokens],
|
|
828
|
+
group: "search_engines",
|
|
829
|
+
},
|
|
830
|
+
{
|
|
831
|
+
name: "Block SEO tool crawlers",
|
|
832
|
+
description: "Stop backlink and SEO-audit crawlers that don't send visitors.",
|
|
833
|
+
directive: "disallow",
|
|
834
|
+
kind: "entire_site",
|
|
835
|
+
agents: [...groupById("seo_tools").tokens],
|
|
836
|
+
group: "seo_tools",
|
|
837
|
+
},
|
|
838
|
+
];
|
|
839
|
+
|
|
840
|
+
export interface Template {
|
|
841
|
+
id: string;
|
|
842
|
+
name: string;
|
|
843
|
+
description: string;
|
|
844
|
+
rules: (bots: DirectoryBot[]) => Array<Omit<RobotsRule, "id">>;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** One-click starting points. They replace the rule list and go through the same checks as any change. */
|
|
848
|
+
export const TEMPLATES: Template[] = [
|
|
849
|
+
{
|
|
850
|
+
id: "allow-everything",
|
|
851
|
+
name: "Allow everything",
|
|
852
|
+
description: "Every crawler can crawl every page. EmDash's admin stays private and media stays crawlable.",
|
|
853
|
+
rules: () => [],
|
|
854
|
+
},
|
|
855
|
+
{
|
|
856
|
+
id: "search-only",
|
|
857
|
+
name: "Block everything except search engines",
|
|
858
|
+
description: "Only search engines can crawl the site. Every other crawler, including AI crawlers and SEO tools, is blocked.",
|
|
859
|
+
rules: (bots) => [
|
|
860
|
+
{
|
|
861
|
+
name: "Block all crawlers",
|
|
862
|
+
description: "Everything is closed to crawlers that aren't search engines.",
|
|
863
|
+
enabled: true,
|
|
864
|
+
directive: "disallow",
|
|
865
|
+
kind: "entire_site",
|
|
866
|
+
agents: ["*"],
|
|
867
|
+
source: "template",
|
|
868
|
+
},
|
|
869
|
+
{
|
|
870
|
+
name: "Let search engines in",
|
|
871
|
+
description: "Search engines can crawl the whole site.",
|
|
872
|
+
enabled: true,
|
|
873
|
+
directive: "allow",
|
|
874
|
+
kind: "entire_site",
|
|
875
|
+
agents: groupTokens(groupById("search_engines"), bots),
|
|
876
|
+
group: "search_engines",
|
|
877
|
+
source: "template",
|
|
878
|
+
},
|
|
879
|
+
],
|
|
880
|
+
},
|
|
881
|
+
];
|
|
882
|
+
|
|
883
|
+
export type { Directive };
|