@coywolf/emdash 0.0.0-stage → 0.35.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -2
  3. package/docs/README.md +45 -0
  4. package/docs/ai.md +39 -0
  5. package/docs/backups.md +88 -0
  6. package/docs/breadcrumbs.md +59 -0
  7. package/docs/code-blocks.md +21 -0
  8. package/docs/content-urls.md +41 -0
  9. package/docs/custom-blocks.md +27 -0
  10. package/docs/discovery.md +32 -0
  11. package/docs/files.md +44 -0
  12. package/docs/form-uploads.md +59 -0
  13. package/docs/headings.md +37 -0
  14. package/docs/images.md +141 -0
  15. package/docs/internals.md +13 -0
  16. package/docs/links.md +34 -0
  17. package/docs/performance.md +72 -0
  18. package/docs/redirects.md +84 -0
  19. package/docs/releasing.md +21 -0
  20. package/docs/reviews.md +89 -0
  21. package/docs/robots.md +81 -0
  22. package/docs/schema.md +79 -0
  23. package/docs/search.md +114 -0
  24. package/docs/videos.md +48 -0
  25. package/docs/wordpress-import.md +198 -0
  26. package/package.json +79 -5
  27. package/src/admin/admin-css.ts +57 -0
  28. package/src/admin/ai.tsx +915 -0
  29. package/src/admin/backups.tsx +613 -0
  30. package/src/admin/breadcrumbs.tsx +542 -0
  31. package/src/admin/codeBlocks.tsx +204 -0
  32. package/src/admin/customBlocks.tsx +321 -0
  33. package/src/admin/dirty.ts +20 -0
  34. package/src/admin/discovery.tsx +463 -0
  35. package/src/admin/download.ts +28 -0
  36. package/src/admin/feature-switches.tsx +124 -0
  37. package/src/admin/features.tsx +142 -0
  38. package/src/admin/files.tsx +791 -0
  39. package/src/admin/formUploads.tsx +445 -0
  40. package/src/admin/guides.tsx +18 -0
  41. package/src/admin/headings.tsx +262 -0
  42. package/src/admin/images.tsx +825 -0
  43. package/src/admin/index.tsx +62 -0
  44. package/src/admin/links.tsx +980 -0
  45. package/src/admin/performance.tsx +330 -0
  46. package/src/admin/preview.tsx +63 -0
  47. package/src/admin/redirects-removed.tsx +266 -0
  48. package/src/admin/redirects.tsx +470 -0
  49. package/src/admin/reviews.tsx +232 -0
  50. package/src/admin/robots-bots.tsx +647 -0
  51. package/src/admin/robots-shared.tsx +109 -0
  52. package/src/admin/robots-wizard.tsx +748 -0
  53. package/src/admin/robots.tsx +911 -0
  54. package/src/admin/save-bar.tsx +134 -0
  55. package/src/admin/schema.tsx +1187 -0
  56. package/src/admin/search.tsx +355 -0
  57. package/src/admin/settings-ui.tsx +87 -0
  58. package/src/admin/trash-prompt-core.ts +135 -0
  59. package/src/admin/trash-prompt.tsx +156 -0
  60. package/src/admin/usage.tsx +301 -0
  61. package/src/admin/videos-display.tsx +248 -0
  62. package/src/admin/videos.tsx +1202 -0
  63. package/src/admin/wpImport.tsx +1516 -0
  64. package/src/ai/entities.ts +59 -0
  65. package/src/ai/export.ts +61 -0
  66. package/src/ai/logic.ts +606 -0
  67. package/src/ai/pack.ts +121 -0
  68. package/src/ai/prompts.ts +87 -0
  69. package/src/ai/providers.ts +276 -0
  70. package/src/ai/routes.ts +359 -0
  71. package/src/ai/store.ts +301 -0
  72. package/src/ai/wikidata.ts +87 -0
  73. package/src/ai/worker.ts +530 -0
  74. package/src/astro/astro-modules.d.ts +6 -0
  75. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  76. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  77. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  78. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  79. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  80. package/src/astro/customBlocks/Details.astro +8 -0
  81. package/src/astro/customBlocks/Disclosure.astro +8 -0
  82. package/src/astro/customBlocks/Note.astro +8 -0
  83. package/src/astro/customBlocks/Podcast.astro +8 -0
  84. package/src/astro/customBlocks/Quote.astro +8 -0
  85. package/src/astro/customBlocks/Testimonial.astro +8 -0
  86. package/src/astro/files/FileCard.astro +103 -0
  87. package/src/astro/headings/HeadingBlock.astro +129 -0
  88. package/src/astro/headings/TableOfContents.astro +45 -0
  89. package/src/astro/headings/request.ts +19 -0
  90. package/src/astro/images/ResponsiveImage.astro +60 -0
  91. package/src/astro/index.ts +84 -0
  92. package/src/astro/reviews/CoywolfReview.astro +27 -0
  93. package/src/astro/reviews/Review.astro +49 -0
  94. package/src/astro/search/SearchBox.astro +61 -0
  95. package/src/astro/search/SearchBoxForm.astro +509 -0
  96. package/src/astro/tables/CaptionedTable.astro +72 -0
  97. package/src/astro/videos/CoywolfVideo.astro +448 -0
  98. package/src/backups/download.ts +112 -0
  99. package/src/backups/dump.ts +288 -0
  100. package/src/backups/module.ts +276 -0
  101. package/src/backups/pack.ts +33 -0
  102. package/src/backups/restore.ts +209 -0
  103. package/src/backups/store.ts +409 -0
  104. package/src/breadcrumbs/pack.ts +113 -0
  105. package/src/breadcrumbs/settings.ts +102 -0
  106. package/src/breadcrumbs/stamp.ts +40 -0
  107. package/src/breadcrumbs/trail.ts +123 -0
  108. package/src/codeBlocks/contrast.ts +179 -0
  109. package/src/codeBlocks/hljs.d.ts +5 -0
  110. package/src/codeBlocks/pack.ts +110 -0
  111. package/src/codeBlocks/render.ts +304 -0
  112. package/src/codeBlocks/settings.ts +18 -0
  113. package/src/codeBlocks/themes.generated.ts +304 -0
  114. package/src/codeBlocks/themes.ts +114 -0
  115. package/src/core/compose.ts +244 -0
  116. package/src/core/content-url.ts +888 -0
  117. package/src/core/csv.ts +119 -0
  118. package/src/core/d1-batch.ts +65 -0
  119. package/src/core/entry-docs.ts +175 -0
  120. package/src/core/features-module.ts +48 -0
  121. package/src/core/features.ts +392 -0
  122. package/src/core/generation.ts +88 -0
  123. package/src/core/guide-content.ts +166 -0
  124. package/src/core/module.ts +104 -0
  125. package/src/core/read-failures.ts +57 -0
  126. package/src/core/request-memo.ts +71 -0
  127. package/src/core/secrets.ts +83 -0
  128. package/src/core/settings-enhance.ts +74 -0
  129. package/src/core/site.ts +36 -0
  130. package/src/core/storage.ts +27 -0
  131. package/src/customBlocks/pack.ts +260 -0
  132. package/src/customBlocks/render.ts +424 -0
  133. package/src/customBlocks/rich.ts +313 -0
  134. package/src/customBlocks/settings.ts +46 -0
  135. package/src/discovery/build.ts +252 -0
  136. package/src/discovery/feed-build.ts +217 -0
  137. package/src/discovery/feed.ts +172 -0
  138. package/src/discovery/indexnow.ts +114 -0
  139. package/src/discovery/llms.ts +79 -0
  140. package/src/discovery/markdown.ts +294 -0
  141. package/src/discovery/memory-cache.ts +97 -0
  142. package/src/discovery/middleware.ts +186 -0
  143. package/src/discovery/module.ts +353 -0
  144. package/src/discovery/news.ts +99 -0
  145. package/src/discovery/pack.ts +58 -0
  146. package/src/discovery/settings.ts +91 -0
  147. package/src/discovery/urls.ts +35 -0
  148. package/src/env.d.ts +10 -0
  149. package/src/files/card.ts +231 -0
  150. package/src/files/counts.ts +60 -0
  151. package/src/files/export.ts +44 -0
  152. package/src/files/format.ts +163 -0
  153. package/src/files/module.ts +596 -0
  154. package/src/files/pack.ts +92 -0
  155. package/src/files/r2-rules.ts +41 -0
  156. package/src/files/range.ts +50 -0
  157. package/src/files/s3.ts +114 -0
  158. package/src/files/serve.ts +141 -0
  159. package/src/files/sigv4.ts +160 -0
  160. package/src/files/site.ts +137 -0
  161. package/src/files/walker.ts +58 -0
  162. package/src/formUploads/lib.ts +266 -0
  163. package/src/formUploads/pack.ts +55 -0
  164. package/src/formUploads/wrap.ts +592 -0
  165. package/src/forms.ts +16 -0
  166. package/src/headings/pack.ts +154 -0
  167. package/src/headings/render.ts +139 -0
  168. package/src/headings/settings.ts +117 -0
  169. package/src/headings/slug.ts +68 -0
  170. package/src/headings/stamp.ts +142 -0
  171. package/src/headings/test-hooks.mjs +14 -0
  172. package/src/headings/toc.ts +48 -0
  173. package/src/images/cloudflare.ts +329 -0
  174. package/src/images/lib.ts +221 -0
  175. package/src/images/module.ts +372 -0
  176. package/src/images/pack.ts +804 -0
  177. package/src/images/pending.ts +37 -0
  178. package/src/images/settings.ts +48 -0
  179. package/src/images/setup.ts +192 -0
  180. package/src/images/variants-estimate.ts +162 -0
  181. package/src/images/variants-store.ts +475 -0
  182. package/src/images/variants.ts +311 -0
  183. package/src/index.ts +143 -0
  184. package/src/links/check.ts +101 -0
  185. package/src/links/classify.ts +270 -0
  186. package/src/links/export.ts +59 -0
  187. package/src/links/module.ts +652 -0
  188. package/src/links/pack.ts +43 -0
  189. package/src/links/pt.ts +325 -0
  190. package/src/links/store.ts +704 -0
  191. package/src/middleware.ts +287 -0
  192. package/src/modules.ts +53 -0
  193. package/src/options.ts +66 -0
  194. package/src/pageCache/lib.ts +274 -0
  195. package/src/pageCache/pack.ts +240 -0
  196. package/src/pageCache/usage-core.ts +152 -0
  197. package/src/pageCache/usage.ts +262 -0
  198. package/src/pageCache/warm.ts +722 -0
  199. package/src/redirects/middleware.ts +169 -0
  200. package/src/redirects/module.ts +124 -0
  201. package/src/redirects/pack.ts +89 -0
  202. package/src/redirects/removed-core.ts +151 -0
  203. package/src/redirects/removed.ts +293 -0
  204. package/src/redirects/rules.ts +355 -0
  205. package/src/redirects/slug-changes.ts +207 -0
  206. package/src/redirects/slug-core.ts +49 -0
  207. package/src/redirects/transfer.ts +92 -0
  208. package/src/reviews/lib.ts +624 -0
  209. package/src/reviews/pack.ts +135 -0
  210. package/src/reviews/schema.ts +128 -0
  211. package/src/reviews/settings.ts +26 -0
  212. package/src/robots/bots.ts +321 -0
  213. package/src/robots/data/bots.json +696 -0
  214. package/src/robots/data/verified.json +582 -0
  215. package/src/robots/directory.ts +26 -0
  216. package/src/robots/explain.ts +191 -0
  217. package/src/robots/guided.ts +300 -0
  218. package/src/robots/importer.ts +263 -0
  219. package/src/robots/middleware.ts +151 -0
  220. package/src/robots/module.ts +446 -0
  221. package/src/robots/pack.ts +69 -0
  222. package/src/robots/radar.ts +129 -0
  223. package/src/robots/rep.ts +362 -0
  224. package/src/robots/rules.ts +883 -0
  225. package/src/robots/transfer.ts +45 -0
  226. package/src/robots/validate.ts +524 -0
  227. package/src/schema/catalog.ts +146 -0
  228. package/src/schema/graph.ts +791 -0
  229. package/src/schema/module.ts +1138 -0
  230. package/src/schema/og-image.ts +100 -0
  231. package/src/schema/pack.ts +58 -0
  232. package/src/search/engine.ts +578 -0
  233. package/src/search/fallback.ts +80 -0
  234. package/src/search/live-cache.ts +116 -0
  235. package/src/search/live-client.ts +770 -0
  236. package/src/search/live-serve.ts +282 -0
  237. package/src/search/module.ts +261 -0
  238. package/src/search/pack.ts +57 -0
  239. package/src/search/query.ts +132 -0
  240. package/src/search/ratelimit-core.ts +118 -0
  241. package/src/search/ratelimit.ts +79 -0
  242. package/src/search/snippet.ts +135 -0
  243. package/src/search/title-index.ts +139 -0
  244. package/src/search/title-match.ts +192 -0
  245. package/src/shared.ts +71 -0
  246. package/src/version.ts +2 -0
  247. package/src/videos/date.ts +40 -0
  248. package/src/videos/engagement.ts +75 -0
  249. package/src/videos/lib.ts +679 -0
  250. package/src/videos/module.ts +1160 -0
  251. package/src/videos/pack.ts +187 -0
  252. package/src/videos/pause.ts +42 -0
  253. package/src/videos/poster.ts +324 -0
  254. package/src/videos/render.ts +336 -0
  255. package/src/videos/store.ts +551 -0
  256. package/src/videos/stream.ts +134 -0
  257. package/src/wpImport/convert.ts +702 -0
  258. package/src/wpImport/guests.ts +329 -0
  259. package/src/wpImport/gutenberg.ts +157 -0
  260. package/src/wpImport/markers.ts +66 -0
  261. package/src/wpImport/pack.ts +451 -0
  262. package/src/wpImport/parents.ts +202 -0
  263. package/src/wpImport/prepare.ts +592 -0
  264. package/src/wpImport/redirects.ts +358 -0
  265. package/src/wpImport/sources.ts +67 -0
  266. package/src/wpImport/stream.ts +160 -0
  267. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Robots.txt rules export and import: the rules and settings as one JSON
3
+ * file, to keep a copy or move them to another site. Import goes through
4
+ * robots/save, so the server checks the file like any other change and the
5
+ * current version stays in Version history.
6
+ */
7
+ import { type RobotsConfig, normalizeConfig } from "./rules.js";
8
+
9
+ export const ROBOTS_EXPORT_FORMAT = "coywolf-pack/robots-rules";
10
+
11
+ export interface RobotsExport {
12
+ format: typeof ROBOTS_EXPORT_FORMAT;
13
+ version: 1;
14
+ exportedAt: string;
15
+ site?: string;
16
+ config: RobotsConfig;
17
+ }
18
+
19
+ /** The export file's text. `automatic` isn't stored (it comes from feature switches), so it's left out. */
20
+ export function exportRobotsRules(config: RobotsConfig, siteUrl?: string, now = new Date()): string {
21
+ const { automatic: _automatic, ...stored } = config;
22
+ const file: RobotsExport = { format: ROBOTS_EXPORT_FORMAT, version: 1, exportedAt: now.toISOString(), ...(siteUrl ? { site: siteUrl } : {}), config: stored };
23
+ return `${JSON.stringify(file, null, "\t")}\n`;
24
+ }
25
+
26
+ /**
27
+ * Read an export (or a bare config object) back into a config. Notes about
28
+ * where the original rules were imported from describe the other site, so
29
+ * they're dropped. Throws an Error with a readable message.
30
+ */
31
+ export function readRobotsImport(text: string): RobotsConfig {
32
+ let parsed: unknown;
33
+ try {
34
+ parsed = JSON.parse(text.replace(/^/, ""));
35
+ } catch {
36
+ throw new Error("That file isn't a Coywolf Pack robots.txt rules export (it isn't valid JSON).");
37
+ }
38
+ const object = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
39
+ const raw = object && object.format === ROBOTS_EXPORT_FORMAT ? object.config : object;
40
+ if (!raw || typeof raw !== "object" || !Array.isArray((raw as { rules?: unknown }).rules)) {
41
+ throw new Error("That file isn't a Coywolf Pack robots.txt rules export (it has no rules).");
42
+ }
43
+ const { importedAt: _at, importMode: _mode, importNotes: _notes, automatic: _automatic, ...config } = raw as RobotsConfig;
44
+ return normalizeConfig(config);
45
+ }
@@ -0,0 +1,524 @@
1
+ /**
2
+ * Real-time validation of a rule against the existing rules, and the
3
+ * self-check that proves the generated file does what the rules say.
4
+ *
5
+ * Findings have a severity:
6
+ * - error: can't be saved (the server refuses it too);
7
+ * - warning: valid but risky or surprising; the admin must confirm ("Add it anyway");
8
+ * - info: an explanation of how the rule interacts with others.
9
+ *
10
+ * Verdicts come from the real matcher (./rep.ts) on the generated text, so
11
+ * the checks see exactly what crawlers will see.
12
+ */
13
+ import { type Directive, evaluateParsed, matchRaw, parse } from "./rep.js";
14
+ import { describeRule, listPhrase } from "./explain.js";
15
+ import {
16
+ CRAWLER_GROUPS,
17
+ type DirectoryBot,
18
+ EMDASH_ADMIN_PATH,
19
+ EMDASH_MEDIA_PATH,
20
+ discoveryPathsFor,
21
+ MAX_VALUE_LENGTH,
22
+ PROBE_AGENT,
23
+ type RobotsConfig,
24
+ type RobotsRule,
25
+ RobotsValidationError,
26
+ blocksWholeSite,
27
+ decide,
28
+ directives,
29
+ generate,
30
+ isValidToken,
31
+ linesForAgent,
32
+ resolveAgents,
33
+ samplePaths,
34
+ validateRule,
35
+ } from "./rules.js";
36
+
37
+ export type Severity = "error" | "warning" | "info";
38
+
39
+ export type FixAction =
40
+ | { type: "addAgents"; agents: string[] }
41
+ | { type: "turnOnInherit" }
42
+ | { type: "editRule"; ruleId: string }
43
+ | { type: "mergeInto"; ruleId: string };
44
+
45
+ export interface Finding {
46
+ code: string;
47
+ severity: Severity;
48
+ message: string;
49
+ fix?: { label: string; action: FixAction };
50
+ }
51
+
52
+ /** Search engine tokens checked by the "you'd vanish from search" warnings. */
53
+ export const SEARCH_ENGINES = (CRAWLER_GROUPS.find((g) => g.id === "search_engines") as { tokens: string[] }).tokens;
54
+
55
+ const RENDER_ASSETS = ["/_astro/index.css", "/_astro/page.js", "/assets/site.css", "/scripts/app.js", "/images/photo.jpg", "/favicon.ico"];
56
+ const MEDIA_SAMPLE = `${EMDASH_MEDIA_PATH}file/photo.jpg`;
57
+ const ADMIN_SAMPLE = `${EMDASH_ADMIN_PATH}admin/`;
58
+ const SITEMAP_SAMPLES = ["/sitemap.xml", "/sitemap-index.xml"];
59
+
60
+ const key = (a: string) => a.toLowerCase();
61
+ const agentsOf = (rule: Pick<RobotsRule, "agents">) => [...new Set(rule.agents.map(key))];
62
+ /** The tokens to test a rule with: its own, with `*` standing in as a crawler no rule names. */
63
+ const testAgents = (rule: Pick<RobotsRule, "agents">) => rule.agents.map((a) => (a === "*" ? PROBE_AGENT : a));
64
+
65
+ class Verdicts {
66
+ private parsed: Directive[];
67
+ text: string;
68
+ constructor(text: string) {
69
+ this.text = text;
70
+ this.parsed = parse(text).directives;
71
+ }
72
+ allowed(agent: string, path: string): boolean {
73
+ return evaluateParsed(this.parsed, [agent], path).allowed;
74
+ }
75
+ line(agent: string, path: string) {
76
+ return evaluateParsed(this.parsed, [agent], path);
77
+ }
78
+ }
79
+
80
+ const SITE = { siteUrl: "https://example.com" };
81
+
82
+ /** Shape errors for one rule (empty when fine). */
83
+ export function shapeFindings(rule: RobotsRule): Finding[] {
84
+ const out: Finding[] = [];
85
+ const raw = [rule.path ?? "", rule.allow ?? "", ...(rule.except ?? []), ...(rule.params ?? []), ...(rule.exts ?? [])];
86
+ for (const v of raw) {
87
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: rejecting control characters.
88
+ if (/[\u0000-\u001f\u007f]/.test(v)) out.push({ code: "bad-chars", severity: "error", message: "Remove the line break or control character; each rule must stay on one line." });
89
+ else if (v.includes("#")) out.push({ code: "bad-chars", severity: "error", message: "Remove the #. Everything after # is a comment in robots.txt, and the #part of an address never reaches the server." });
90
+ }
91
+ for (const a of rule.agents) {
92
+ if (!isValidToken(a)) out.push({ code: "bad-token", severity: "error", message: `“${a}” isn't a crawler token. Tokens use letters, digits, dot, dash and underscore, with no spaces (like GPTBot).` });
93
+ }
94
+ if ((rule.kind === "custom" || rule.kind === "prefix" || rule.kind === "wildcard_prefix") && rule.path && !/^[/*]/.test(rule.path.trim())) {
95
+ out.push({ code: "leading-slash", severity: "error", message: "A pattern must start with / (a path on your site) or * (anything)." });
96
+ }
97
+ try {
98
+ validateRule(rule);
99
+ } catch (error) {
100
+ if (error instanceof RobotsValidationError && !out.length) out.push({ code: "invalid", severity: "error", message: error.message });
101
+ }
102
+ // "Except…" items must sit inside the area the rule blocks, and mustn't reopen all of it.
103
+ if (rule.directive === "disallow" && rule.except?.some((e) => e.trim())) {
104
+ const lines = safeDirectives({ ...rule, except: [] }).filter((d) => d.directive === "Disallow");
105
+ const blockSamples = [...new Set(lines.flatMap((d) => samplePaths(d.value)))];
106
+ for (const raw of rule.except.filter((e) => e.trim())) {
107
+ const e = safeDirectives({ ...rule, kind: "custom", path: "/x/", except: [raw] }).find((d) => d.directive === "Allow")?.value ?? raw;
108
+ const own = samplePaths(e);
109
+ const inside = own.length > 0 && own.every((p) => lines.some((d) => matchRaw(d.value, p)));
110
+ const neutralizes = blockSamples.length > 0 && blockSamples.every((p) => matchRaw(e, p));
111
+ if (neutralizes) out.push({ code: "except-everything", severity: "error", message: `The exception “${raw}” would reopen everything this rule blocks. Remove it, or turn the rule off instead.` });
112
+ else if (!inside) out.push({ code: "except-outside", severity: "error", message: `The exception “${raw}” isn't inside the area this rule blocks, so it would open something else. Exceptions must be inside ${lines.map((d) => d.value).join(" or ")}.` });
113
+ }
114
+ }
115
+ for (const d of safeDirectives(rule)) {
116
+ if (d.value.length > MAX_VALUE_LENGTH) out.push({ code: "too-long", severity: "error", message: `That pattern is ${d.value.length} characters long. The most is ${MAX_VALUE_LENGTH}; real addresses are shorter.` });
117
+ }
118
+ const seen = new Set<string>();
119
+ return out.filter((f) => (seen.has(f.message) ? false : (seen.add(f.message), true)));
120
+ }
121
+
122
+ function safeDirectives(rule: RobotsRule) {
123
+ try {
124
+ return directives(rule);
125
+ } catch {
126
+ return [];
127
+ }
128
+ }
129
+
130
+ const ruleName = (r: RobotsRule) => `“${r.name || describeRule(r)}”`;
131
+
132
+ /**
133
+ * Everything worth saying about adding (or editing) `draft` in `config`.
134
+ * `bots` (optional) adds token checks against the crawler directory.
135
+ */
136
+ export function analyzeRule(config: RobotsConfig, draft: RobotsRule, options: { siteUrl?: string; bots?: Array<DirectoryBot & { name?: string; custom?: boolean }> } = {}): Finding[] {
137
+ const findings = shapeFindings(draft);
138
+ if (findings.some((f) => f.severity === "error")) return findings;
139
+ const siteUrl = options.siteUrl || SITE.siteUrl;
140
+ const others = config.rules.filter((r) => r.id !== draft.id && r.enabled);
141
+ const before: RobotsConfig = { ...config, rules: config.rules.filter((r) => r.id !== draft.id) };
142
+ const after: RobotsConfig = { ...config, rules: [...before.rules, { ...draft, enabled: true }] };
143
+ const draftLines = directives(draft);
144
+ const draftAgents = agentsOf(draft);
145
+
146
+ // Duplicates and contradictions with rules for the same crawlers.
147
+ for (const other of others) {
148
+ const shared = agentsOf(other).filter((a) => draftAgents.includes(a));
149
+ if (!shared.length) continue;
150
+ const who = shared.includes("*") ? "all crawlers" : listPhrase(shared.map((s) => other.agents.find((a) => key(a) === s) ?? s));
151
+ const otherLines = directives(other);
152
+ const same = draftLines.filter((d) => otherLines.some((o) => o.value === d.value && o.directive === d.directive));
153
+ const opposite = draftLines.filter((d) => otherLines.some((o) => o.value === d.value && o.directive !== d.directive));
154
+ if (opposite.length) {
155
+ findings.push({
156
+ code: "contradiction",
157
+ severity: "error",
158
+ message: `${ruleName(other)} already ${opposite[0].directive === "Allow" ? "blocks" : "allows"} ${opposite[0].value} for ${who}. A rule can't both allow and block the same thing; change or turn off that rule instead.`,
159
+ fix: { label: `Edit ${ruleName(other)}`, action: { type: "editRule", ruleId: other.id } },
160
+ });
161
+ } else if (same.length === draftLines.length && shared.length === draftAgents.length) {
162
+ findings.push({
163
+ code: "duplicate",
164
+ severity: "error",
165
+ message: `${ruleName(other)} already does exactly this for ${who}.`,
166
+ fix: { label: `Edit ${ruleName(other)}`, action: { type: "editRule", ruleId: other.id } },
167
+ });
168
+ } else if (same.length === draftLines.length && other.directive === draft.directive && other.kind === draft.kind && (other.path ?? "") === (draft.path ?? "")) {
169
+ findings.push({
170
+ code: "duplicate-partial",
171
+ severity: "info",
172
+ message: `${ruleName(other)} already does this for ${who}. You could add the other crawlers to that rule instead of keeping two.`,
173
+ fix: { label: `Merge into ${ruleName(other)}`, action: { type: "mergeInto", ruleId: other.id } },
174
+ });
175
+ }
176
+ }
177
+ if (findings.some((f) => f.severity === "error")) return findings;
178
+
179
+ const vb = new Verdicts(generate(before, { siteUrl }));
180
+ const va = new Verdicts(generate(after, { siteUrl }));
181
+ const intended = draft.directive === "allow";
182
+ const resolvedBefore = resolveAgents(before);
183
+ const resolvedAfter = resolveAgents(after);
184
+ const agents = testAgents(draft);
185
+ const samples = [...new Set(draftLines.filter((d) => d.directive === (intended ? "Allow" : "Disallow")).flatMap((d) => samplePaths(d.value)))];
186
+
187
+ // What the rule actually changes for its own crawlers.
188
+ let changed = 0;
189
+ let unchanged = 0;
190
+ const overriddenBy = new Map<string, Set<string>>();
191
+ for (const agent of agents) {
192
+ for (const p of samples) {
193
+ const b = vb.allowed(agent, p);
194
+ const a = va.allowed(agent, p);
195
+ if (a !== b) changed++;
196
+ else unchanged++;
197
+ if (a !== intended) {
198
+ const d = decide(linesForAgent(resolvedAfter, agent), p).line as { ruleIds?: string[]; system?: boolean; directive?: string } | null;
199
+ const by = d?.system ? (d.directive === "Allow" ? "the “Keep media crawlable” setting" : "EmDash's admin protection") : (d?.ruleIds ?? []).map((id) => config.rules.find((r) => r.id === id)).filter(Boolean).map((r) => ruleName(r as RobotsRule))[0];
200
+ if (by) {
201
+ const set = overriddenBy.get(by) ?? new Set<string>();
202
+ set.add(p);
203
+ overriddenBy.set(by, set);
204
+ }
205
+ }
206
+ }
207
+ }
208
+ if (samples.length && !changed) {
209
+ if (intended) {
210
+ findings.push({
211
+ code: "allow-no-effect",
212
+ severity: "warning",
213
+ message: `Nothing blocks ${describeRule(draft).replace(/^Let /, "").replace(/ crawl /, " from ")} now, so this rule changes nothing today. It's harmless, and it can matter later if you block a wider area.`,
214
+ });
215
+ } else if (agents.some((a) => samples.some((p) => (decide(linesForAgent(resolvedAfter, a), p).line as { system?: boolean } | null)?.system))) {
216
+ findings.push({
217
+ code: "media",
218
+ severity: "warning",
219
+ message: `Your media library (${EMDASH_MEDIA_PATH}) stays crawlable because “Keep media crawlable” is on in Settings, so this rule changes nothing. Turn that setting off to block media.`,
220
+ });
221
+ } else {
222
+ const by = [...overriddenBy.keys()];
223
+ const cover = coveringRule(config, others, draft, samples, resolvedBefore);
224
+ findings.push({
225
+ code: "redundant-block",
226
+ severity: "warning",
227
+ message: cover ? `${ruleName(cover)} already blocks this for these crawlers, so this rule changes nothing.` : `These crawlers are already blocked here${by.length ? ` by ${by[0]}` : ""}, so this rule changes nothing.`,
228
+ ...(cover ? { fix: { label: `Edit ${ruleName(cover)}`, action: { type: "editRule" as const, ruleId: cover.id } } } : {}),
229
+ });
230
+ }
231
+ } else if (overriddenBy.size) {
232
+ for (const [by, paths] of overriddenBy) {
233
+ findings.push({
234
+ code: "overridden",
235
+ severity: "info",
236
+ message: `On ${listPhrase([...paths].slice(0, 2))}, ${by} still decides, because it's more specific (longer match wins in robots.txt).`,
237
+ });
238
+ }
239
+ }
240
+
241
+ // An allow carved out of a block: valid, explain it.
242
+ if (intended && changed) {
243
+ const blockers = new Set<string>();
244
+ for (const agent of agents) {
245
+ for (const p of samples) {
246
+ if (vb.allowed(agent, p)) continue;
247
+ const d = decide(linesForAgent(resolvedBefore, agent), p).line as { ruleIds?: string[] } | null;
248
+ for (const id of d?.ruleIds ?? []) {
249
+ const r = config.rules.find((x) => x.id === id);
250
+ if (r) blockers.add(ruleName(r));
251
+ }
252
+ }
253
+ }
254
+ if (blockers.size) {
255
+ findings.push({
256
+ code: "nested-allow",
257
+ severity: "info",
258
+ message: `This opens a gap inside ${listPhrase([...blockers])}. That's fine: the more specific line wins, so these crawlers can fetch this part and stay blocked from the rest.`,
259
+ });
260
+ }
261
+ }
262
+
263
+ // RFC 9309 group selection: naming a crawler takes it out of the `*` group.
264
+ const generalRules = others.filter((r) => r.agents.includes("*"));
265
+ const newlyNamed = draft.agents.filter((a) => a !== "*" && !resolvedBefore.has(key(a)));
266
+ if (newlyNamed.length && generalRules.length) {
267
+ const affected = newlyNamed.filter((a) => generalRules.some((g) => directives(g).some((d) => samplePaths(d.value).some((p) => vb.allowed(a, p) !== va.allowed(a, p) && !samples.includes(p)))));
268
+ if (affected.length) {
269
+ findings.push(
270
+ config.inheritGeneral
271
+ ? {
272
+ code: "group-selection",
273
+ severity: "info",
274
+ message: `Naming ${listPhrase(affected)} here gives ${affected.length === 1 ? "it its own group" : "them their own group"}; this rule decides where it overlaps the rules for all crawlers (${listPhrase(generalRules.map(ruleName))}).`,
275
+ }
276
+ : {
277
+ code: "group-selection",
278
+ severity: "warning",
279
+ message: `Crawlers named in a rule ignore every rule for all crawlers (RFC 9309). ${listPhrase(affected)} would stop following ${listPhrase(generalRules.map(ruleName))}.`,
280
+ fix: { label: "Keep the general rules for named crawlers", action: { type: "turnOnInherit" } },
281
+ },
282
+ );
283
+ }
284
+ }
285
+
286
+ // A rule for all crawlers that some named crawlers won't follow.
287
+ if (draft.agents.includes("*")) {
288
+ const skip: string[] = [];
289
+ for (const [k, entry] of resolvedAfter) {
290
+ if (k === "*" || draftAgents.includes(k)) continue;
291
+ if (samples.some((p) => va.allowed(entry.token, p) !== intended && va.allowed(PROBE_AGENT, p) === intended)) skip.push(entry.token);
292
+ }
293
+ if (skip.length) {
294
+ findings.push({
295
+ code: "general-overridden",
296
+ severity: "warning",
297
+ message: `${listPhrase(skip)} ${skip.length === 1 ? "has its own rule" : "have their own rules"} for this part of the site, so ${skip.length === 1 ? "it" : "they"} won't follow this one.`,
298
+ fix: { label: `Also apply it to ${skip.length === 1 ? skip[0] : `these ${skip.length} crawlers`}`, action: { type: "addAgents", agents: skip } },
299
+ });
300
+ }
301
+ }
302
+
303
+ // Discovery files stay readable; a rule aimed at one has no effect while the allowance is on.
304
+ if (!intended) {
305
+ const hit = discoveryPathsFor(config).filter((path) => {
306
+ const probe = samplePaths(path)[0] ?? path;
307
+ return draftLines.some((d) => d.directive === "Disallow" && !blocksWholeSite(d.value) && matchRaw(d.value, probe));
308
+ });
309
+ if (hit.length) {
310
+ findings.push({
311
+ code: "discovery-path",
312
+ severity: "warning",
313
+ message: `${listPhrase(hit)} ${hit.length === 1 ? "is a discovery file" : "are discovery files"} that Coywolf Pack keeps readable for every crawler, so this rule won't block ${hit.length === 1 ? "it" : "them"}. To block ${hit.length === 1 ? "it" : "them"}, turn off “Keep discovery files readable” in Settings and history.`,
314
+ });
315
+ }
316
+ }
317
+
318
+ findings.push(...riskFindings(vb, va, [...new Set([...agents, ...SEARCH_ENGINES, PROBE_AGENT])]));
319
+
320
+ if (options.bots) {
321
+ const byToken = new Map(options.bots.map((b) => [key(b.token), b]));
322
+ const unknown = draft.agents.filter((a) => a !== "*" && !byToken.has(key(a)));
323
+ const unverified = draft.agents.filter((a) => byToken.get(key(a))?.status === "unverified");
324
+ if (unknown.length) findings.push({ code: "unknown-token", severity: "info", message: `${listPhrase(unknown)} ${unknown.length === 1 ? "isn't" : "aren't"} in the crawler directory. A rule only works if the crawler uses exactly that token.` });
325
+ if (unverified.length) findings.push({ code: "unverified-token", severity: "info", message: `${listPhrase(unverified)}: the token isn't confirmed by the operator's documentation, so the crawler may not follow it.` });
326
+ }
327
+ if (draft.path && /[A-Z]/.test(draft.path) && draft.kind !== "custom") {
328
+ findings.push({ code: "case", severity: "info", message: "Addresses are case-sensitive: this won't cover the same address written in lowercase." });
329
+ }
330
+ return findings;
331
+ }
332
+
333
+ function coveringRule(config: RobotsConfig, others: RobotsRule[], draft: RobotsRule, samples: string[], resolved: ReturnType<typeof resolveAgents>): RobotsRule | undefined {
334
+ for (const agent of testAgents(draft)) {
335
+ const d = decide(linesForAgent(resolved, agent), samples[0] ?? "/").line as { ruleIds?: string[] } | null;
336
+ const id = d?.ruleIds?.[0];
337
+ const r = id ? others.find((x) => x.id === id) : undefined;
338
+ if (r) return r;
339
+ }
340
+ return config.rules.length ? undefined : undefined;
341
+ }
342
+
343
+ /** Consequences of a change that hurt sites in ways people don't expect. */
344
+ export function riskFindings(vb: { allowed(a: string, p: string): boolean }, va: { allowed(a: string, p: string): boolean }, agents: string[]): Finding[] {
345
+ const out: Finding[] = [];
346
+ const newly = (a: string, p: string) => vb.allowed(a, p) && !va.allowed(a, p);
347
+ const search = agents.filter((a) => SEARCH_ENGINES.includes(a) || a === PROBE_AGENT);
348
+ const goneFromSearch = search.filter((a) => newly(a, "/") || newly(a, "/some-page/"));
349
+ if (goneFromSearch.length) {
350
+ const named = goneFromSearch.filter((a) => a !== PROBE_AGENT);
351
+ out.push({
352
+ code: "search-whole-site",
353
+ severity: "warning",
354
+ message: named.length
355
+ ? `${listPhrase(named)} would be blocked from your pages, so your site would drop out of their search results.`
356
+ : "Every crawler without its own rule would be blocked from the whole site, including search engines that aren't named in a rule.",
357
+ });
358
+ }
359
+ const stillOpen = search.filter((a) => va.allowed(a, "/some-page/"));
360
+ const assets = stillOpen.filter((a) => RENDER_ASSETS.some((p) => newly(a, p)));
361
+ if (assets.length) {
362
+ out.push({
363
+ code: "render-assets",
364
+ severity: "warning",
365
+ message: "This blocks CSS, JavaScript or images that search engines need to render your pages. Pages may look broken to Google and rank worse.",
366
+ });
367
+ }
368
+ const media = stillOpen.filter((a) => newly(a, MEDIA_SAMPLE));
369
+ if (media.length) {
370
+ out.push({
371
+ code: "media",
372
+ severity: "warning",
373
+ message: `This blocks your media library (EmDash serves uploads from ${EMDASH_MEDIA_PATH}), so your images won't show up in image search.`,
374
+ });
375
+ }
376
+ if (agents.some((a) => SITEMAP_SAMPLES.some((p) => newly(a, p)) && va.allowed(a, "/some-page/"))) {
377
+ out.push({ code: "sitemap", severity: "warning", message: "This blocks your sitemap, which tells search engines what to crawl." });
378
+ }
379
+ if (agents.some((a) => !vb.allowed(a, ADMIN_SAMPLE) && va.allowed(a, ADMIN_SAMPLE))) {
380
+ out.push({ code: "admin", severity: "warning", message: `This opens EmDash's admin and API (${EMDASH_ADMIN_PATH}) to crawlers. They can't sign in, but there's nothing there worth crawling.` });
381
+ }
382
+ return out;
383
+ }
384
+
385
+ /** Warnings for replacing the whole config (templates, restores, resets). */
386
+ export function analyzeConfigChange(before: RobotsConfig, after: RobotsConfig, siteUrl = SITE.siteUrl): Finding[] {
387
+ const vb = new Verdicts(generate(before, { siteUrl }));
388
+ const va = new Verdicts(generate(after, { siteUrl }));
389
+ const agents = [...new Set([...SEARCH_ENGINES, PROBE_AGENT, ...after.rules.flatMap((r) => r.agents).filter((a) => a !== "*")])];
390
+ return riskFindings(vb, va, agents);
391
+ }
392
+
393
+ export interface SelfCheckFailure {
394
+ ruleId: string;
395
+ ruleName: string;
396
+ agent: string;
397
+ path: string;
398
+ expected: boolean;
399
+ actual: boolean;
400
+ /** The robots.txt line that decided it. */
401
+ decidedBy: string;
402
+ reason: string;
403
+ }
404
+
405
+ /**
406
+ * Proves the generated file does what the rules say. For every enabled rule:
407
+ * 1. each of its crawlers (a stand-in for `*`) gets, on URLs the rule targets,
408
+ * the verdict the rule model gives (the rule's action, unless a more
409
+ * specific rule decides);
410
+ * 2. crawlers the rule doesn't name get the same verdict on those URLs with
411
+ * and without the rule.
412
+ * The file is parsed and evaluated with the real matcher, so Extra lines,
413
+ * grouping and encoding are all covered.
414
+ */
415
+ export function selfCheck(config: RobotsConfig, siteUrl = SITE.siteUrl, limit = 5): { ok: boolean; failures: SelfCheckFailure[] } {
416
+ const text = generate(config, { siteUrl });
417
+ const v = new Verdicts(text);
418
+ const lines = text.split("\n");
419
+ const resolved = resolveAgents(config);
420
+ const failures: SelfCheckFailure[] = [];
421
+ const enabled = config.rules.filter((r) => r.enabled);
422
+ const bystanders = [PROBE_AGENT, "Googlebot", ...new Set(enabled.flatMap((r) => r.agents).filter((a) => a !== "*"))];
423
+
424
+ for (const rule of enabled) {
425
+ if (failures.length >= limit) break;
426
+ const ruleLines = safeDirectives(rule);
427
+ const samples = [...new Set(ruleLines.flatMap((d) => samplePaths(d.value)))];
428
+ const own = testAgents(rule).filter(isValidToken);
429
+ for (const agent of own) {
430
+ for (const p of samples) {
431
+ const expected = decide(linesForAgent(resolved, agent), p).allowed;
432
+ const verdict = v.line(agent, p);
433
+ if (verdict.allowed !== expected) {
434
+ const decidedBy = verdict.matchedLine ? (lines[verdict.matchedLine - 1] ?? "").trim() : "no line";
435
+ failures.push({
436
+ ruleId: rule.id,
437
+ ruleName: rule.name,
438
+ agent: agent === PROBE_AGENT ? "*" : agent,
439
+ path: p,
440
+ expected,
441
+ actual: verdict.allowed,
442
+ decidedBy,
443
+ reason: `${agent === PROBE_AGENT ? "Crawlers" : agent} should be ${expected ? "allowed" : "blocked"} on ${p}, but the file ${verdict.allowed ? "allows" : "blocks"} it (decided by “${decidedBy}”${config.extra.trim() ? "; check Extra lines" : ""}).`,
444
+ });
445
+ break;
446
+ }
447
+ }
448
+ if (failures.length >= limit) break;
449
+ }
450
+ const others = bystanders.filter((a) => !rule.agents.some((x) => key(x) === key(a)) && !(rule.agents.includes("*") && !resolved.has(key(a))));
451
+ if (!others.length || rule.agents.includes("*")) continue;
452
+ const without = new Verdicts(generate({ ...config, rules: config.rules.filter((r) => r.id !== rule.id) }, { siteUrl }));
453
+ for (const agent of others) {
454
+ const p = samples.find((s) => without.allowed(agent, s) !== v.allowed(agent, s));
455
+ if (p) {
456
+ failures.push({
457
+ ruleId: rule.id,
458
+ ruleName: rule.name,
459
+ agent: agent === PROBE_AGENT ? "*" : agent,
460
+ path: p,
461
+ expected: without.allowed(agent, p),
462
+ actual: v.allowed(agent, p),
463
+ decidedBy: "",
464
+ reason: `This rule is only for ${listPhrase(rule.agents)}, but it changes what ${agent === PROBE_AGENT ? "other crawlers" : agent} may fetch on ${p}.`,
465
+ });
466
+ break;
467
+ }
468
+ }
469
+ }
470
+ // Automatic lines (Discovery): every crawler that got one must really be able to fetch that file.
471
+ for (const [k, entry] of resolved) {
472
+ if (failures.length >= limit) break;
473
+ const agent = k === "*" ? PROBE_AGENT : entry.token;
474
+ for (const l of entry.lines.filter((x) => x.auto)) {
475
+ const p = samplePaths(l.value)[0];
476
+ if (p && !v.allowed(agent, p)) {
477
+ failures.push({
478
+ ruleId: "",
479
+ ruleName: "Discovery files",
480
+ agent: k === "*" ? "*" : entry.token,
481
+ path: p,
482
+ expected: true,
483
+ actual: false,
484
+ decidedBy: (lines[v.line(agent, p).matchedLine - 1] ?? "").trim(),
485
+ reason: `${k === "*" ? "Crawlers" : entry.token} should be able to read ${p} because it's a discovery file, but the file blocks it${config.extra.trim() ? "; check Extra lines" : ""}.`,
486
+ });
487
+ break;
488
+ }
489
+ }
490
+ }
491
+ return { ok: failures.length === 0, failures };
492
+ }
493
+
494
+ /** Risks that Extra lines add (e.g. a raw `Disallow: /` for everyone). Shown, but they don't block saving. */
495
+ export function extraLineWarnings(config: RobotsConfig, siteUrl = SITE.siteUrl): Finding[] {
496
+ if (!config.extra.trim()) return [];
497
+ return analyzeConfigChange({ ...config, extra: "" }, config, siteUrl).map((f) => ({ ...f, message: `Extra lines: ${f.message}` }));
498
+ }
499
+
500
+ /** Server-side gate before saving: shape errors, contradictions, and the self-check. */
501
+ export function checkConfig(config: RobotsConfig, siteUrl = SITE.siteUrl): string | null {
502
+ const enabled = config.rules.filter((r) => r.enabled);
503
+ for (const rule of enabled) {
504
+ const error = shapeFindings(rule).find((f) => f.severity === "error");
505
+ if (error) return `${ruleName(rule)}: ${error.message}`;
506
+ }
507
+ for (let i = 0; i < enabled.length; i++) {
508
+ for (let j = i + 1; j < enabled.length; j++) {
509
+ const a = enabled[i];
510
+ const b = enabled[j];
511
+ if (!agentsOf(a).some((x) => agentsOf(b).includes(x))) continue;
512
+ const la = directives(a);
513
+ const lb = directives(b);
514
+ const clash = la.find((d) => lb.some((o) => o.value === d.value && o.directive !== d.directive));
515
+ if (clash) return `${ruleName(a)} and ${ruleName(b)} both allow and block ${clash.value} for the same crawlers. Change or turn off one of them.`;
516
+ }
517
+ }
518
+ const check = selfCheck(config, siteUrl, 1);
519
+ if (!check.ok) {
520
+ const f = check.failures[0];
521
+ return `Self-check failed for “${f.ruleName}”: ${f.reason}`;
522
+ }
523
+ return null;
524
+ }