@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,263 @@
1
+ /**
2
+ * Turn the robots.txt EmDash was serving into structured rules, and prove
3
+ * the result is equivalent: for every crawler named in either file (plus a
4
+ * few well-known ones and a stand-in for "any other crawler") and every path
5
+ * the files' lines target, both files must give the same verdict. When the
6
+ * rules can't reproduce the file exactly, the original is kept as Extra lines.
7
+ *
8
+ * One deliberate difference: media in EmDash's library (/_emdash/api/media/)
9
+ * is always opened to crawlers, so images can show up in image search. The
10
+ * equivalence check leaves media paths out, and the import summary says so.
11
+ */
12
+ import { extractProductToken, parse } from "./rep.js";
13
+ import { describeRule } from "./explain.js";
14
+ import {
15
+ CONFIG_VERSION,
16
+ DEFAULT_CONFIG,
17
+ EMDASH_ADMIN_PATH,
18
+ EMDASH_MEDIA_PATH,
19
+ PROBE_AGENT,
20
+ type RobotsConfig,
21
+ WELL_KNOWN_PATH,
22
+ type RobotsRule,
23
+ type RuleKind,
24
+ directives,
25
+ generate,
26
+ isValidToken,
27
+ matchGroup,
28
+ samplePaths,
29
+ } from "./rules.js";
30
+ import { evaluateParsed } from "./rep.js";
31
+
32
+ const MEDIA_NOTE = "We added one improvement: images and files in your media library can now be crawled, so they can appear in image search.";
33
+ const DISCOVERY_NOTE = "We now keep /.well-known/ and /llms.txt (while Discovery's llms.txt is on) reachable for crawlers you block, so AI agents and tools can find them. Change this in Settings and history.";
34
+ /** What Coywolf Pack 0.7.0 adds on purpose, shown once to sites that saved rules earlier. */
35
+ export const ADDITIONS_NOTES = ["Updated for Coywolf Pack 0.7.0. " + DISCOVERY_NOTE];
36
+
37
+ /** EmDash's own robots.txt when no custom one is set (emdash/src/astro/routes/robots.txt.ts). */
38
+ export function emdashDefaultRobots(siteUrl: string): string {
39
+ return ["User-agent: *", "Allow: /", "", "# Disallow admin and API routes", "Disallow: /_emdash/", "", `Sitemap: ${siteUrl}/sitemap.xml`, ""].join("\n");
40
+ }
41
+
42
+ /** The robots.txt EmDash serves: its custom file (with the sitemap appended when missing) or its default. */
43
+ export function emdashRobots(custom: string | null | undefined, siteUrl: string): string {
44
+ const site = siteUrl.replace(/\/$/, "");
45
+ if (custom) {
46
+ let content = custom;
47
+ if (!content.toLowerCase().includes("sitemap:")) content = `${content.trimEnd()}\n\nSitemap: ${site}/sitemap.xml\n`;
48
+ return content;
49
+ }
50
+ return emdashDefaultRobots(site);
51
+ }
52
+
53
+ interface RawGroup {
54
+ /** Lowercased token → written token; "*" for the global group. */
55
+ agents: string[];
56
+ rules: Array<{ directive: "Allow" | "Disallow"; value: string }>;
57
+ /** Lines that aren't Allow/Disallow (Crawl-delay, Content-Signal…), as written. */
58
+ other: string[];
59
+ uaLines: string[];
60
+ }
61
+
62
+ /** Google's grouping: a User-agent line after a rule line starts a new group. */
63
+ function groupsOf(text: string): { groups: RawGroup[]; sitemaps: string[]; orphans: string[] } {
64
+ const raw = text.replace(/^/, "").split(/\r\n|\r|\n/);
65
+ const { directives: dirs } = parse(text);
66
+ const groups: RawGroup[] = [];
67
+ const sitemaps: string[] = [];
68
+ const orphans: string[] = [];
69
+ let current: RawGroup | null = null;
70
+ let sawRule = false;
71
+ for (const d of dirs) {
72
+ const line = (raw[d.line - 1] ?? "").replace(/#.*$/, "").trim();
73
+ if (d.type === "sitemap") {
74
+ sitemaps.push(d.value);
75
+ continue;
76
+ }
77
+ if (d.type === "user-agent") {
78
+ if (!current || sawRule) {
79
+ current = { agents: [], rules: [], other: [], uaLines: [] };
80
+ groups.push(current);
81
+ sawRule = false;
82
+ }
83
+ current.uaLines.push(line);
84
+ const v = d.value;
85
+ if (v.length >= 1 && v[0] === "*" && (v.length === 1 || /\s/.test(v[1]))) current.agents.push("*");
86
+ else {
87
+ const token = extractProductToken(v);
88
+ if (token) current.agents.push(token);
89
+ }
90
+ continue;
91
+ }
92
+ if (d.type === "allow" || d.type === "disallow") {
93
+ if (!current) continue; // Google ignores rules before any User-agent.
94
+ sawRule = true;
95
+ current.rules.push({ directive: d.type === "allow" ? "Allow" : "Disallow", value: d.value });
96
+ continue;
97
+ }
98
+ if (current) current.other.push(line);
99
+ else orphans.push(line);
100
+ }
101
+ return { groups, sitemaps, orphans };
102
+ }
103
+
104
+ /** The stored rule that writes exactly `value` (or a custom rule when nothing simpler does). */
105
+ export function classify(directive: "Allow" | "Disallow", value: string): Pick<RobotsRule, "kind" | "path" | "exts" | "strict" | "directive"> {
106
+ const dir = directive === "Allow" ? "allow" : "disallow";
107
+ const candidates: Array<Pick<RobotsRule, "kind" | "path" | "exts" | "strict">> = [];
108
+ let m: RegExpExecArray | null;
109
+ if (value === "/") candidates.push({ kind: "entire_site" });
110
+ if (value === "/*?") candidates.push({ kind: "query_any" });
111
+ if ((m = /^\/\*\.([A-Za-z0-9]+)\$$/.exec(value))) candidates.push({ kind: "filetype", exts: [m[1].toLowerCase()] });
112
+ if ((m = /^(\/[^*$]*\/)\*\.([A-Za-z0-9]+)\$$/.exec(value))) candidates.push({ kind: "filetype_in_folder", path: m[1], exts: [m[2].toLowerCase()] });
113
+ if ((m = /^\/\*\/([^*$/]+)\/$/.exec(value))) candidates.push({ kind: "any_depth", path: m[1] });
114
+ if (!/[*$]/.test(value)) {
115
+ if (value.endsWith("/")) candidates.push({ kind: "folder", path: value });
116
+ else if (/\/[^/]*\.[^/]+$/.test(value)) candidates.push({ kind: "single_page", path: value });
117
+ else candidates.push({ kind: "prefix", path: value });
118
+ }
119
+ if ((m = /^([^*$]+)\$$/.exec(value))) candidates.push({ kind: "exact_url", path: m[1] });
120
+ if ((m = /^\/\*([^*?/$][^*$]*)$/.exec(value))) candidates.push({ kind: "contains", path: m[1] });
121
+ if ((m = /^(\/[^*$]+)\*$/.exec(value))) candidates.push({ kind: "wildcard_prefix", path: m[1] });
122
+ for (const c of candidates) {
123
+ const probe = { id: "x", name: "x", enabled: true, agents: ["*"], directive: dir, ...c } as RobotsRule;
124
+ const lines = directives(probe);
125
+ if (lines.length === 1 && lines[0].value === value) return { directive: dir, ...c };
126
+ }
127
+ return { directive: dir, kind: "custom" as RuleKind, path: value };
128
+ }
129
+
130
+ export interface ImportResult {
131
+ config: RobotsConfig;
132
+ mode: "rules" | "verbatim";
133
+ /** Plain-English notes for the admin. */
134
+ notes: string[];
135
+ }
136
+
137
+ const WELL_KNOWN = ["Googlebot", "bingbot", "GPTBot", "ClaudeBot", PROBE_AGENT];
138
+
139
+ /** Same verdicts for every crawler on every path, ignoring media paths (opened on purpose). */
140
+ export function equivalent(original: string, generated: string, agents: string[]): boolean {
141
+ const a = parse(original).directives;
142
+ const b = parse(generated).directives;
143
+ const paths = new Set(["/", "/a-post/", "/x.pdf", "/?q=1", "/_emdash/", "/_emdash/admin", "/_emdash/api/content/posts", "/index.html", "/sitemap.xml"]);
144
+ for (const d of [...a, ...b]) {
145
+ if (d.type === "allow" || d.type === "disallow") {
146
+ for (const p of samplePaths(d.value)) paths.add(p);
147
+ paths.add(`${d.value.replace(/\$$/, "").replace(/\*/g, "")}`.replace(/^([^/])/, "/$1") || "/");
148
+ }
149
+ }
150
+ for (const p of paths) {
151
+ // Opened on purpose: media, and discovery files (see discoveryPathsFor).
152
+ if (p.startsWith(EMDASH_MEDIA_PATH) || p.startsWith(WELL_KNOWN_PATH) || p.startsWith("/llms")) continue;
153
+ for (const agent of agents) {
154
+ if (evaluateParsed(a, [agent], p).allowed !== evaluateParsed(b, [agent], p).allowed) return false;
155
+ }
156
+ }
157
+ return true;
158
+ }
159
+
160
+ /** Turn a robots.txt into a config that serves an equivalent file (see the file comment). */
161
+ export function importRobots(original: string, siteUrl: string, now = new Date().toISOString()): ImportResult {
162
+ const site = siteUrl.replace(/\/+$/, "");
163
+ const { groups, sitemaps, orphans } = groupsOf(original);
164
+ const tokens = [...new Set(groups.flatMap((g) => g.agents).filter((a) => a !== "*"))];
165
+ const agents = [...new Set([...tokens.filter(isValidToken), ...WELL_KNOWN])];
166
+ const mediaAlreadyOpen = evaluateParsed(parse(original).directives, [PROBE_AGENT], `${EMDASH_MEDIA_PATH}file/a.jpg`).allowed;
167
+
168
+ const sitemapSettings = (() => {
169
+ const own = `${site}/sitemap.xml`;
170
+ const list = [...new Set(sitemaps)];
171
+ return { includeSitemap: list.includes(own), sitemaps: list.filter((s) => s !== own) };
172
+ })();
173
+
174
+ const build = (inheritGeneral: boolean, emdashLines: boolean): RobotsConfig | null => {
175
+ const byLine = new Map<string, RobotsRule>();
176
+ const extra: string[] = [...orphans];
177
+ let n = 0;
178
+ for (const g of groups) {
179
+ const named = g.agents.filter((a) => a === "*" || isValidToken(a));
180
+ if (!named.length) continue;
181
+ if (g.other.length) extra.push([...g.uaLines, ...g.other].join("\n"));
182
+ // Google: Allow wins ties, so a value both allowed and blocked is allowed.
183
+ const lines = g.rules.filter((r) => r.value !== "" && !(r.directive === "Disallow" && g.rules.some((o) => o.directive === "Allow" && o.value === r.value)));
184
+ // Explicit lines (including EmDash's harmless `Allow: /`) are kept as rules.
185
+ let effective = lines;
186
+ if (emdashLines && named.includes("*")) {
187
+ effective = effective.filter((l) => !(l.directive === "Disallow" && l.value === EMDASH_ADMIN_PATH) && !(l.directive === "Allow" && l.value === EMDASH_MEDIA_PATH));
188
+ }
189
+ // A group whose lines all drop out still names its crawlers (they ignore `*`): keep it as "allow everything".
190
+ if (!effective.length && g.rules.length && !named.includes("*")) effective = [{ directive: "Allow", value: "/" }];
191
+ for (const l of effective) {
192
+ const k = `${l.directive}|${l.value}`;
193
+ let rule = byLine.get(k);
194
+ if (!rule) {
195
+ rule = { id: `imp${++n}`, name: "", enabled: true, agents: [], ...classify(l.directive, l.value), source: "import" };
196
+ byLine.set(k, rule);
197
+ }
198
+ for (const a of named) if (!rule.agents.some((x) => x.toLowerCase() === a.toLowerCase())) rule.agents.push(a);
199
+ }
200
+ }
201
+ const rules = [...byLine.values()];
202
+ for (const rule of rules) {
203
+ const group = matchGroup(rule.agents);
204
+ if (group && rule.agents.length === group.tokens.length) rule.group = group.id;
205
+ rule.name = describeRule(rule);
206
+ }
207
+ const config: RobotsConfig = {
208
+ ...DEFAULT_CONFIG,
209
+ version: CONFIG_VERSION,
210
+ rules,
211
+ ...sitemapSettings,
212
+ allowMedia: true,
213
+ extra: extra.join("\n\n"),
214
+ inheritGeneral,
215
+ emdashLines,
216
+ importedAt: now,
217
+ importMode: "rules",
218
+ };
219
+ return equivalent(original, generate(config, { siteUrl: site }), agents) ? config : null;
220
+ };
221
+
222
+ const notes: string[] = [];
223
+ const mediaNote = [...(mediaAlreadyOpen ? [] : [MEDIA_NOTE]), DISCOVERY_NOTE];
224
+ for (const [inherit, emdash] of [
225
+ [true, true],
226
+ [false, true],
227
+ [true, false],
228
+ [false, false],
229
+ ] as const) {
230
+ const config = build(inherit, emdash);
231
+ if (!config) continue;
232
+ notes.push(
233
+ config.rules.length
234
+ ? `We turned EmDash's robots.txt into ${config.rules.length} rule${config.rules.length === 1 ? "" : "s"}. Crawlers see exactly the same rules as before.`
235
+ : "EmDash's robots.txt had no rules of its own beyond keeping its admin private. Crawlers see exactly the same rules as before.",
236
+ ...mediaNote,
237
+ );
238
+ if (!emdash) notes.push("EmDash's admin wasn't blocked for every crawler in the old file, so we kept it that way. Turn on “Keep EmDash's admin private” in Settings to block it.");
239
+ config.importNotes = notes;
240
+ return { config, mode: "rules", notes };
241
+ }
242
+
243
+ // Fallback: serve the original lines unchanged (minus sitemaps, which are settings).
244
+ const body = original
245
+ .replace(/^/, "")
246
+ .split(/\r\n|\r|\n/)
247
+ .filter((l) => !/^\s*site-?map\s*:/i.test(l))
248
+ .join("\n")
249
+ .trim();
250
+ const config: RobotsConfig = {
251
+ ...DEFAULT_CONFIG,
252
+ rules: [],
253
+ ...sitemapSettings,
254
+ allowMedia: true,
255
+ extra: mediaAlreadyOpen || !evaluateParsed(parse(original).directives, [PROBE_AGENT], "/some-page/").allowed ? body : `${body}\n\n# Added by Coywolf Pack: let crawlers see your media library\nUser-agent: *\nAllow: ${EMDASH_MEDIA_PATH}`,
256
+ emdashLines: false,
257
+ importedAt: now,
258
+ importMode: "verbatim",
259
+ };
260
+ notes.push("EmDash's robots.txt uses lines that can't be turned into rules exactly, so it's kept as written under Extra lines. Crawlers see the same rules as before.", ...(config.extra.includes("Added by Coywolf Pack") ? [MEDIA_NOTE] : []), DISCOVERY_NOTE);
261
+ config.importNotes = notes;
262
+ return { config, mode: "verbatim", notes };
263
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Serves /robots.txt from the saved rules (through the pack middleware), so
3
+ * turning the feature off falls straight back to EmDash's own robots.txt
4
+ * (the saved rules stay for next time).
5
+ *
6
+ * Turning the feature on takes over seamlessly: if nothing is saved yet, the
7
+ * first request (or the first admin page load, whichever comes first) turns
8
+ * the robots.txt EmDash was serving into equivalent rules and saves them
9
+ * with an insert-if-absent, so concurrent first requests can't import twice.
10
+ */
11
+ import { PLUGIN_ID, siteFeatures } from "../core/features.js";
12
+ import { noteReadFailure } from "../core/read-failures.js";
13
+ import { emdashRobots, importRobots } from "./importer.js";
14
+ import { type RobotsConfig, automaticFrom, generate, normalizeConfig } from "./rules.js";
15
+
16
+ export const CONFIG_SETTING = "robots.config";
17
+ export const CONFIG_OPTION = `plugin:${PLUGIN_ID}:settings:${CONFIG_SETTING}`;
18
+ const TTL_MS = 60_000;
19
+
20
+ let cache: { config: RobotsConfig | null; siteUrl: string | null; aiTraining: boolean; at: number } | null = null;
21
+ let importing: Promise<RobotsConfig | null> | null = null;
22
+
23
+ /** Drop the cached rules so the next request reloads them (called after saving). */
24
+ export function invalidateRobotsCache(): void {
25
+ cache = null;
26
+ }
27
+
28
+ async function readOption<T>(db: D1Database, name: string): Promise<T | null> {
29
+ const row = await db.prepare("SELECT value FROM options WHERE name = ?").bind(name).first<{ value: string }>();
30
+ return row?.value ? (JSON.parse(row.value) as T) : null;
31
+ }
32
+
33
+ /**
34
+ * EmDash's custom robots.txt (SEO settings), or null when it serves its
35
+ * default. Throws when the setting can't be read, so a transient database
36
+ * error never makes the takeover import EmDash's default file instead.
37
+ */
38
+ export async function readEmdashCustomRobots(db: D1Database): Promise<string | null> {
39
+ const seo = await readOption<{ robotsTxt?: string }>(db, "site:seo");
40
+ return seo?.robotsTxt?.trim() ? seo.robotsTxt : null;
41
+ }
42
+
43
+ /** Whether EmDash's Settings > SEO "Disallow AI training" is on (EmDash 1.3+); off when it can't be read. */
44
+ export async function readEmdashAiTraining(db: D1Database): Promise<boolean> {
45
+ try {
46
+ return (await readOption<{ disallowAiTraining?: boolean }>(db, "site:seo"))?.disallowAiTraining === true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /** Site origin the way EmDash's robots.txt computes it: the Site URL setting, else the request origin. */
53
+ export function siteOrigin(settingUrl: string | null | undefined, requestUrl: URL): string {
54
+ return (settingUrl?.trim() || requestUrl.origin).replace(/\/+$/, "");
55
+ }
56
+
57
+ /** Insert the config only if no row exists yet; returns whatever is stored afterwards. */
58
+ async function insertIfAbsent(db: D1Database, config: RobotsConfig): Promise<RobotsConfig> {
59
+ const value = JSON.stringify(config);
60
+ try {
61
+ await db.prepare("INSERT INTO options (name, value, revision) VALUES (?, ?, ?) ON CONFLICT(name) DO NOTHING").bind(CONFIG_OPTION, value, crypto.randomUUID()).run();
62
+ } catch {
63
+ // Databases from before EmDash's revision column.
64
+ await db.prepare("INSERT INTO options (name, value) VALUES (?, ?) ON CONFLICT(name) DO NOTHING").bind(CONFIG_OPTION, value).run();
65
+ }
66
+ const stored = await readOption<Partial<RobotsConfig>>(db, CONFIG_OPTION);
67
+ return stored ? normalizeConfig(stored) : config;
68
+ }
69
+
70
+ /** Import EmDash's robots.txt once (per isolate, and atomically in D1). */
71
+ async function importOnce(db: D1Database, siteUrl: string): Promise<RobotsConfig | null> {
72
+ if (!importing) {
73
+ importing = (async () => {
74
+ let custom: string | null;
75
+ try {
76
+ custom = await readEmdashCustomRobots(db);
77
+ } catch (error) {
78
+ // Can't tell what EmDash serves: keep serving EmDash's file and try again on a later request.
79
+ console.error("coywolf robots: could not read EmDash's robots.txt; not importing yet", error);
80
+ noteReadFailure("robots.txt import");
81
+ return null;
82
+ }
83
+ const { config } = importRobots(emdashRobots(custom, siteUrl), siteUrl);
84
+ try {
85
+ return await insertIfAbsent(db, config);
86
+ } catch (error) {
87
+ console.error("coywolf robots: could not save the imported rules", error);
88
+ noteReadFailure("robots.txt import");
89
+ return null;
90
+ }
91
+ })().finally(() => {
92
+ importing = null;
93
+ });
94
+ }
95
+ return importing;
96
+ }
97
+
98
+ export async function serveRobots(url: URL, method: string, env: Record<string, unknown>, database = "DB"): Promise<Response | undefined> {
99
+ if (url.pathname !== "/robots.txt" || (method !== "GET" && method !== "HEAD")) return undefined;
100
+ const db = env[database] as D1Database | undefined;
101
+ if (!db) return undefined;
102
+
103
+ if (!cache || Date.now() - cache.at > TTL_MS) {
104
+ try {
105
+ // Both rows in one query. The rules can be large and are only needed here, so they
106
+ // stay out of the feature switches' per-request read (core/features.ts).
107
+ const { results } = await db
108
+ .prepare("SELECT name, value FROM options WHERE name IN (?, ?, ?)")
109
+ .bind(CONFIG_OPTION, "site:url", "site:seo")
110
+ .all<{ name: string; value: string }>();
111
+ const parse = (name: string): unknown => {
112
+ const value = results.find((r) => r.name === name)?.value;
113
+ if (!value) return null;
114
+ try {
115
+ return JSON.parse(value);
116
+ } catch (error) {
117
+ // An unreadable Site URL falls back to the request origin; unreadable rules fail the load.
118
+ if (name === CONFIG_OPTION) throw error;
119
+ return null;
120
+ }
121
+ };
122
+ const stored = parse(CONFIG_OPTION) as Partial<RobotsConfig> | null;
123
+ const siteUrl = parse("site:url");
124
+ const site = typeof siteUrl === "string" ? siteUrl : null;
125
+ // EmDash's "Disallow AI training" (Settings > SEO) adds its lines here too, as it does to EmDash's own file.
126
+ const seo = parse("site:seo") as { disallowAiTraining?: boolean } | null;
127
+ const config = stored ? normalizeConfig(stored) : await importOnce(db, siteOrigin(site, url));
128
+ // Import deferred (it failed, and said so): EmDash's robots.txt is served, and nothing is cached, here or at the edge.
129
+ if (!config) return undefined;
130
+ cache = { config, siteUrl: site, aiTraining: seo?.disallowAiTraining === true, at: Date.now() };
131
+ } catch (error) {
132
+ console.error("coywolf robots: could not load rules", error);
133
+ // EmDash's own robots.txt answers instead: keep it out of the edge cache.
134
+ noteReadFailure("robots.txt rules");
135
+ return undefined;
136
+ }
137
+ }
138
+ if (!cache.config) return undefined;
139
+
140
+ // Discovery and Videos add lines (llms.txt/Markdown allowances, their sitemaps) while their features are on.
141
+ const automatic = automaticFrom(await siteFeatures(database).catch(() => ({})), cache.aiTraining);
142
+ const body = generate({ ...cache.config, automatic }, { siteUrl: siteOrigin(cache.siteUrl, url) });
143
+ return new Response(method === "HEAD" ? null : body, {
144
+ status: 200,
145
+ headers: {
146
+ "Content-Type": "text/plain; charset=utf-8",
147
+ "Cache-Control": "public, max-age=3600",
148
+ "X-Robots-Tag": "noindex",
149
+ },
150
+ });
151
+ }