@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,446 @@
1
+ /**
2
+ * Robots.txt Rules: admin routes. The file itself is served by the pack
3
+ * middleware (./middleware.ts); the URL tester and the validator run in the
4
+ * browser against the generated text (./rep.ts), and the server re-checks
5
+ * every save (shape, contradictions and the self-check) before storing it.
6
+ */
7
+ import type { PluginContext } from "emdash";
8
+ import { PluginRouteError, definePluginRoute } from "emdash";
9
+ import { z } from "zod";
10
+
11
+ import { ctxFeatures, requireFeature } from "../core/features.js";
12
+ import { parseInput, workerEnv } from "../shared.js";
13
+ import { type BotOverride, applyOverrides, customSlug, mergeDirectory } from "./bots.js";
14
+ import { baselineBots, baselineDate } from "./directory.js";
15
+ import { ADDITIONS_NOTES, emdashRobots, importRobots } from "./importer.js";
16
+ import { CONFIG_SETTING, invalidateRobotsCache, readEmdashAiTraining, readEmdashCustomRobots } from "./middleware.js";
17
+ import { RADAR_TOKEN_SETTING, SYNC_STATE_KEY, type SyncState, radarTokenSource, readOverlays, syncRadar } from "./radar.js";
18
+ import { type GroupChange, PRESETS, RULE_KINDS, type RobotsConfig, automaticFrom, generate, isValidToken, normalizeConfig, refreshGroups } from "./rules.js";
19
+ import { checkConfig, extraLineWarnings } from "./validate.js";
20
+
21
+ export const HISTORY_COLLECTION = "robots_history";
22
+ export const BOT_OVERRIDES_COLLECTION = "robots_bot_overrides";
23
+ const HISTORY_LIMIT = 20;
24
+ /** KV flag: the admin closed the "your sitemap isn't listed" note. */
25
+ const SITEMAP_NOTE_KEY = "robots:sitemapNoteDismissed";
26
+
27
+ export interface RobotsOptions {
28
+ /** D1 binding of the site database. Default "DB". */
29
+ database?: string;
30
+ }
31
+
32
+ export interface HistoryEntry {
33
+ at: string;
34
+ by?: string;
35
+ label: string;
36
+ config: RobotsConfig;
37
+ }
38
+
39
+ const NO_NEWLINE = /^[^\r\n]*$/;
40
+ const oneLine = (max: number) => z.string().max(max).regex(NO_NEWLINE, "Line breaks aren't allowed here.");
41
+
42
+ const ruleInput = z.object({
43
+ id: z.string().min(1).max(64),
44
+ name: z.string().max(300),
45
+ description: z.string().max(500).optional(),
46
+ enabled: z.boolean(),
47
+ agents: z.array(oneLine(100)).max(1000),
48
+ directive: z.enum(["allow", "disallow"]),
49
+ kind: z.enum(RULE_KINDS),
50
+ path: oneLine(2100).optional(),
51
+ ext: oneLine(20).optional(),
52
+ exts: z.array(oneLine(20)).max(50).optional(),
53
+ params: z.array(oneLine(100)).max(50).optional(),
54
+ allow: oneLine(2100).optional(),
55
+ strict: z.boolean().optional(),
56
+ anyDepth: z.boolean().optional(),
57
+ except: z.array(oneLine(2100)).max(50).optional(),
58
+ group: z.string().max(40).optional(),
59
+ source: z.enum(["user", "import", "template", "legacy"]).optional(),
60
+ });
61
+
62
+ const configInput = z.object({
63
+ version: z.number().optional(),
64
+ rules: z.array(ruleInput).max(200),
65
+ includeSitemap: z.boolean(),
66
+ sitemaps: z.array(oneLine(2000)).max(50),
67
+ allowMedia: z.boolean(),
68
+ comments: z.boolean(),
69
+ extra: z.string().max(20_000),
70
+ inheritGeneral: z.boolean(),
71
+ emdashLines: z.boolean(),
72
+ discoveryAllowances: z.boolean().optional(),
73
+ discoveryPaths: z.array(oneLine(500)).max(50).optional(),
74
+ importedAt: z.string().max(40).optional(),
75
+ importMode: z.enum(["rules", "verbatim"]).optional(),
76
+ importNotes: z.array(z.string().max(500)).max(10).optional(),
77
+ });
78
+
79
+ const siteUrlOf = (ctx: PluginContext) => (ctx.site.url || "").replace(/\/+$/, "");
80
+ const who = (ctx: { user?: { name: string | null; email: string } }) => ctx.user?.name || ctx.user?.email || undefined;
81
+
82
+ async function siteDb(database: string): Promise<D1Database | undefined> {
83
+ try {
84
+ return (await workerEnv())[database] as D1Database | undefined;
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
89
+
90
+ /** The robots.txt EmDash itself would serve right now. */
91
+ async function emdashServed(database: string, siteUrl: string): Promise<{ text: string; custom: boolean }> {
92
+ const db = await siteDb(database);
93
+ let custom: string | null;
94
+ try {
95
+ if (!db) throw new Error(`no ${database} binding`);
96
+ custom = await readEmdashCustomRobots(db);
97
+ } catch (error) {
98
+ // Never fall back to EmDash's default here: importing or resetting from it could replace a custom file.
99
+ throw PluginRouteError.badRequest(`Couldn't read EmDash's robots.txt settings (${error instanceof Error ? error.message : "database error"}). Nothing was changed; try again.`);
100
+ }
101
+ return { text: emdashRobots(custom, siteUrl || "https://example.com"), custom: Boolean(custom) };
102
+ }
103
+
104
+ /** Top-level sections from EmDash collections' URL patterns (e.g. "/recipes/{slug}" → "/recipes/"), for the rule dialog. */
105
+ /** EmDash's "Disallow AI training" (Settings > SEO), which the served robots.txt includes. */
106
+ async function emdashAiTraining(database: string): Promise<boolean> {
107
+ const db = await siteDb(database);
108
+ return db ? readEmdashAiTraining(db) : false;
109
+ }
110
+
111
+ async function siteSections(database: string): Promise<Array<{ label: string; path: string }>> {
112
+ const db = await siteDb(database);
113
+ if (!db) return [];
114
+ try {
115
+ const { results } = await db.prepare("SELECT label, url_pattern FROM _emdash_collections WHERE url_pattern IS NOT NULL").all<{ label: string; url_pattern: string }>();
116
+ const out: Array<{ label: string; path: string }> = [];
117
+ for (const row of results ?? []) {
118
+ const head = row.url_pattern.split("{")[0];
119
+ const path = head.slice(0, head.lastIndexOf("/") + 1);
120
+ if (path.length > 1 && path.startsWith("/") && !out.some((o) => o.path === path)) out.push({ label: row.label, path });
121
+ }
122
+ return out.slice(0, 12);
123
+ } catch {
124
+ return [];
125
+ }
126
+ }
127
+
128
+ async function readHistory(ctx: PluginContext): Promise<Array<{ id: string; data: HistoryEntry }>> {
129
+ const collection = ctx.storage[HISTORY_COLLECTION];
130
+ if (!collection) return [];
131
+ const page = await collection.query({ limit: 100 });
132
+ return (page.items as Array<{ id: string; data: HistoryEntry }>).sort((a, b) => b.data.at.localeCompare(a.data.at));
133
+ }
134
+
135
+ async function pushHistory(ctx: PluginContext, entry: HistoryEntry): Promise<void> {
136
+ const collection = ctx.storage[HISTORY_COLLECTION];
137
+ if (!collection) return;
138
+ const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
139
+ await collection.put(id, entry);
140
+ const all = await readHistory(ctx);
141
+ const stale = all.slice(HISTORY_LIMIT).map((e) => e.id);
142
+ if (stale.length) await collection.deleteMany(stale);
143
+ }
144
+
145
+ async function readBotOverrides(ctx: PluginContext): Promise<BotOverride[]> {
146
+ const collection = ctx.storage[BOT_OVERRIDES_COLLECTION];
147
+ if (!collection) return [];
148
+ const out: BotOverride[] = [];
149
+ let cursor: string | undefined;
150
+ for (let i = 0; i < 20; i++) {
151
+ const page = await collection.query({ limit: 100, ...(cursor ? { cursor } : {}) });
152
+ for (const item of page.items) out.push(item.data as BotOverride);
153
+ if (!page.hasMore || !page.cursor) break;
154
+ cursor = page.cursor;
155
+ }
156
+ return out;
157
+ }
158
+
159
+ /**
160
+ * The saved config, importing EmDash's robots.txt first when nothing is saved
161
+ * yet. compareAndSet(null) only creates when absent, so a concurrent import
162
+ * from the middleware (or another admin tab) wins cleanly.
163
+ */
164
+ async function loadOrImport(ctx: PluginContext, database: string): Promise<RobotsConfig> {
165
+ const stored = await ctx.settings.get<Partial<RobotsConfig>>(CONFIG_SETTING);
166
+ if (stored && !stored.version) {
167
+ // Saved before 0.7.0: store the migrated shape once, and say what's new.
168
+ const migrated = { ...normalizeConfig(stored), importNotes: [...ADDITIONS_NOTES] };
169
+ await ctx.settings.set(CONFIG_SETTING, migrated);
170
+ invalidateRobotsCache();
171
+ return migrated;
172
+ }
173
+ if (stored) return normalizeConfig(stored);
174
+ const siteUrl = siteUrlOf(ctx);
175
+ const { text } = await emdashServed(database, siteUrl);
176
+ const { config } = importRobots(text, siteUrl || "https://example.com");
177
+ const result = await ctx.settings.compareAndSet(CONFIG_SETTING, null, config);
178
+ if (result.applied) {
179
+ invalidateRobotsCache();
180
+ await pushHistory(ctx, { at: new Date().toISOString(), label: "Imported from EmDash's robots.txt", config }).catch(() => undefined);
181
+ ctx.log.info("Robots: imported EmDash's robots.txt", { rules: config.rules.length, mode: config.importMode });
182
+ return config;
183
+ }
184
+ return normalizeConfig(await ctx.settings.get<Partial<RobotsConfig>>(CONFIG_SETTING));
185
+ }
186
+
187
+ async function persist(ctx: PluginContext, database: string, config: RobotsConfig, label: string, by: string | undefined) {
188
+ const siteUrl = siteUrlOf(ctx);
189
+ const { automatic: _ignored, ...stored } = config;
190
+ const live: RobotsConfig = { ...stored, automatic: automaticFrom(await ctxFeatures(ctx), await emdashAiTraining(database)) };
191
+ const problem = checkConfig(live, siteUrl || undefined);
192
+ if (problem) throw PluginRouteError.badRequest(problem);
193
+ const preview = generate(live, { siteUrl });
194
+ if (new TextEncoder().encode(preview).length > 500 * 1024) {
195
+ throw PluginRouteError.badRequest("That robots.txt would be over 500 KiB, the most Google reads. Remove some rules.");
196
+ }
197
+ await ctx.settings.set(CONFIG_SETTING, stored);
198
+ invalidateRobotsCache();
199
+ await pushHistory(ctx, { at: new Date().toISOString(), by, label, config: stored }).catch((error) => ctx.log.warn("Robots: history not saved", { error: String(error) }));
200
+ ctx.log.info("Robots.txt rules saved", { rules: config.rules.length, label });
201
+ return { config: live, preview, saved: true, warnings: extraLineWarnings(live, siteUrl || undefined) };
202
+ }
203
+
204
+ const httpUrl = z
205
+ .string()
206
+ .url()
207
+ .max(2000)
208
+ .refine((u) => /^https?:\/\//i.test(u), "Use a web address starting with https:// or http://.");
209
+
210
+ const botAction = z.discriminatedUnion("action", [
211
+ z.object({ action: z.literal("verify"), slug: z.string().min(1).max(120), sourceUrl: httpUrl, note: z.string().max(500).optional() }),
212
+ z.object({ action: z.literal("unverify"), slug: z.string().min(1).max(120) }),
213
+ z.object({ action: z.literal("rename"), slug: z.string().min(1).max(120), name: z.string().trim().min(1).max(120) }),
214
+ z.object({
215
+ action: z.literal("save-custom"),
216
+ slug: z.string().max(120).optional(),
217
+ name: z.string().trim().min(1).max(120),
218
+ token: z.string().trim().min(1).max(100),
219
+ category: z.string().min(1).max(60),
220
+ purpose: z.enum(["training", "ai-search", "ai-assistant", "search-engine", "seo", "other"]).optional(),
221
+ operator: z.string().max(120).optional(),
222
+ sourceUrl: z.union([httpUrl, z.literal("")]).optional(),
223
+ notes: z.string().max(500).optional(),
224
+ }),
225
+ z.object({ action: z.literal("delete"), slug: z.string().min(1).max(120) }),
226
+ z.object({ action: z.literal("reset"), slug: z.string().min(1).max(120) }),
227
+ ]);
228
+
229
+ export function robotsModule(options: RobotsOptions) {
230
+ const database = options.database ?? "DB";
231
+
232
+ const routes = {
233
+ /** Saved rules (importing EmDash's file on first load), the served file, EmDash's original, history and sync status. */
234
+ "robots/get": {
235
+ permission: "plugins:manage" as const,
236
+ handler: async (ctx: PluginContext) => {
237
+ await requireFeature(ctx, "robots");
238
+ const loaded = await loadOrImport(ctx, database);
239
+ // Rules made from a crawler preset follow the preset's current members.
240
+ const [overlays, overrides] = await Promise.all([readOverlays(ctx), readBotOverrides(ctx)]);
241
+ const refreshed = refreshGroups(loaded, applyOverrides(mergeDirectory(await baselineBots(), overlays), overrides));
242
+ let groupChanges: GroupChange[] = [];
243
+ if (JSON.stringify(refreshed.config.rules) !== JSON.stringify(loaded.rules)) {
244
+ await ctx.settings.set(CONFIG_SETTING, refreshed.config);
245
+ invalidateRobotsCache();
246
+ groupChanges = refreshed.changes;
247
+ if (groupChanges.length) {
248
+ await pushHistory(ctx, { at: new Date().toISOString(), label: "Updated crawler groups", config: refreshed.config }).catch(() => undefined);
249
+ ctx.log.info("Robots: rules updated to the current crawler groups", { rules: groupChanges.length });
250
+ }
251
+ }
252
+ const config: RobotsConfig = { ...refreshed.config, automatic: automaticFrom(await ctxFeatures(ctx), await emdashAiTraining(database)) };
253
+ const siteUrl = siteUrlOf(ctx);
254
+ const tokenSource = await radarTokenSource(ctx);
255
+ const emdash = await emdashServed(database, siteUrl).catch(() => ({ text: "EmDash's robots.txt couldn't be read right now.", custom: false }));
256
+ let history = await readHistory(ctx).catch(() => []);
257
+ if (!history.length) {
258
+ // Rules imported by the middleware (or saved before 0.7.0) get a first version to restore to.
259
+ await pushHistory(ctx, { at: new Date().toISOString(), label: config.importedAt ? "Imported from EmDash's robots.txt" : "Rules before version history", config }).catch(() => undefined);
260
+ history = await readHistory(ctx).catch(() => []);
261
+ }
262
+ return {
263
+ config,
264
+ saved: true,
265
+ siteUrl,
266
+ preview: generate(config, { siteUrl }),
267
+ emdash,
268
+ history: history.map((h) => ({ id: h.id, at: h.data.at, by: h.data.by, label: h.data.label, rules: h.data.config.rules.length })),
269
+ groupChanges,
270
+ warnings: extraLineWarnings(config, siteUrl || undefined),
271
+ sitemapNoteDismissed: (await ctx.kv.get<boolean>(SITEMAP_NOTE_KEY)) === true,
272
+ presets: PRESETS,
273
+ sections: await siteSections(database),
274
+ radar: {
275
+ tokenSource,
276
+ tokenConfigured: Boolean(tokenSource),
277
+ state: await ctx.kv.get<SyncState>(SYNC_STATE_KEY),
278
+ baselineDate: await baselineDate(),
279
+ },
280
+ };
281
+ },
282
+ },
283
+
284
+ /** Close the "your sitemap isn't listed" note for good. */
285
+ "robots/dismiss-sitemap-note": definePluginRoute({
286
+ permission: "plugins:manage",
287
+ methods: ["POST"],
288
+ request: { body: "json" },
289
+ handler: async (ctx) => {
290
+ await ctx.kv.set(SITEMAP_NOTE_KEY, true);
291
+ return { dismissed: true };
292
+ },
293
+ }),
294
+
295
+ /** The crawler directory (bundled baseline + Radar overlay + this site's verifications, renames and custom bots). */
296
+ "robots/bots": {
297
+ permission: "plugins:manage" as const,
298
+ handler: async (ctx: PluginContext) => {
299
+ await requireFeature(ctx, "robots");
300
+ const [overlays, overrides] = await Promise.all([readOverlays(ctx), readBotOverrides(ctx)]);
301
+ return { bots: applyOverrides(mergeDirectory(await baselineBots(), overlays), overrides) };
302
+ },
303
+ },
304
+
305
+ "robots/save": definePluginRoute({
306
+ permission: "plugins:manage",
307
+ methods: ["POST"],
308
+ request: { body: "json", maxBytes: 512 * 1024 },
309
+ handler: async (ctx) => {
310
+ await requireFeature(ctx, "robots");
311
+ const input = parseInput(z.object({ config: configInput, label: z.string().max(200).optional() }), ctx.input);
312
+ const config = normalizeConfig(input.config as RobotsConfig);
313
+ return persist(ctx, database, config, input.label?.trim() || "Saved", who(ctx));
314
+ },
315
+ }),
316
+
317
+ /** Restore a saved version (it becomes a new version, so restoring can be undone). */
318
+ "robots/restore": definePluginRoute({
319
+ permission: "plugins:manage",
320
+ methods: ["POST"],
321
+ request: { body: "json" },
322
+ handler: async (ctx) => {
323
+ await requireFeature(ctx, "robots");
324
+ const { id } = parseInput(z.object({ id: z.string().min(1).max(64) }), ctx.input);
325
+ const entry = (await ctx.storage[HISTORY_COLLECTION]?.get(id)) as HistoryEntry | null | undefined;
326
+ if (!entry) throw PluginRouteError.notFound("That version is no longer kept.");
327
+ const date = new Date(entry.at).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" });
328
+ return persist(ctx, database, normalizeConfig(entry.config), `Restored the version from ${date} UTC`, who(ctx));
329
+ },
330
+ }),
331
+
332
+ /** Start over from the robots.txt EmDash would serve now. */
333
+ "robots/reset": definePluginRoute({
334
+ permission: "plugins:manage",
335
+ methods: ["POST"],
336
+ request: { body: "json" },
337
+ handler: async (ctx) => {
338
+ await requireFeature(ctx, "robots");
339
+ const siteUrl = siteUrlOf(ctx);
340
+ const { text } = await emdashServed(database, siteUrl);
341
+ const { config } = importRobots(text, siteUrl || "https://example.com");
342
+ return persist(ctx, database, config, "Reset to EmDash's original", who(ctx));
343
+ },
344
+ }),
345
+
346
+ /** Verify, rename, add, edit or delete crawler directory entries for this site. */
347
+ "robots/bot": definePluginRoute({
348
+ permission: "plugins:manage",
349
+ methods: ["POST"],
350
+ request: { body: "json" },
351
+ handler: async (ctx) => {
352
+ await requireFeature(ctx, "robots");
353
+ const input = parseInput(botAction, ctx.input);
354
+ const collection = ctx.storage[BOT_OVERRIDES_COLLECTION];
355
+ if (!collection) throw PluginRouteError.badRequest("Plugin storage isn't available.");
356
+ const current = ((await collection.get(input.slug ?? "")) as BotOverride | null) ?? null;
357
+ const by = who(ctx);
358
+ const now = new Date().toISOString();
359
+ switch (input.action) {
360
+ case "verify":
361
+ await collection.put(input.slug, { ...(current ?? { slug: input.slug }), verified: { sourceUrl: input.sourceUrl, note: input.note?.trim() || undefined, at: now, by } });
362
+ break;
363
+ case "unverify":
364
+ if (current) await collection.put(input.slug, { ...current, verified: undefined });
365
+ break;
366
+ case "rename":
367
+ await collection.put(input.slug, { ...(current ?? { slug: input.slug }), name: input.name });
368
+ break;
369
+ case "save-custom": {
370
+ if (!isValidToken(input.token) || input.token === "*") {
371
+ throw PluginRouteError.badRequest("A robots.txt token uses only letters, digits, dot, underscore and dash (like ExampleBot).");
372
+ }
373
+ const slug = input.slug || customSlug(input.token);
374
+ const existing = ((await collection.get(slug)) as BotOverride | null) ?? null;
375
+ if (!input.slug && existing) throw PluginRouteError.badRequest(`You already added a bot with the token ${input.token}.`);
376
+ if (input.slug && !existing?.custom) throw PluginRouteError.badRequest("Only bots you added can be edited this way.");
377
+ const directory = mergeDirectory(await baselineBots(), await readOverlays(ctx));
378
+ const clash = directory.find((b) => b.token.toLowerCase() === input.token.toLowerCase());
379
+ if (clash && !input.slug) throw PluginRouteError.badRequest(`${input.token} is already in the directory as ${clash.name}.`);
380
+ await collection.put(slug, {
381
+ ...(existing ?? {}),
382
+ slug,
383
+ name: input.name,
384
+ custom: {
385
+ token: input.token,
386
+ category: input.category,
387
+ purpose: input.purpose,
388
+ operator: input.operator?.trim() || undefined,
389
+ sourceUrl: input.sourceUrl || undefined,
390
+ notes: input.notes?.trim() || undefined,
391
+ createdAt: existing?.custom?.createdAt ?? now,
392
+ createdBy: existing?.custom?.createdBy ?? by,
393
+ },
394
+ } satisfies BotOverride);
395
+ break;
396
+ }
397
+ case "delete":
398
+ if (!current?.custom) throw PluginRouteError.badRequest("Only bots you added can be deleted.");
399
+ await collection.delete(input.slug);
400
+ break;
401
+ case "reset":
402
+ await collection.delete(input.slug);
403
+ break;
404
+ }
405
+ ctx.log.info("Robots: crawler directory changed", { action: input.action, slug: input.slug ?? "" });
406
+ const [overlays, overrides] = await Promise.all([readOverlays(ctx), readBotOverrides(ctx)]);
407
+ return { bots: applyOverrides(mergeDirectory(await baselineBots(), overlays), overrides) };
408
+ },
409
+ }),
410
+
411
+ /** Save (or remove) the Radar API token. It's a secret setting, so EmDash stores it encrypted. */
412
+ "robots/radar-token": definePluginRoute({
413
+ permission: "plugins:manage",
414
+ methods: ["POST"],
415
+ request: { body: "json" },
416
+ handler: async (ctx) => {
417
+ await requireFeature(ctx, "robots");
418
+ const input = parseInput(z.object({ token: z.string().trim().max(500).optional(), clear: z.boolean().optional() }), ctx.input);
419
+ if (input.clear) await ctx.settings.delete(RADAR_TOKEN_SETTING);
420
+ else if (input.token) await ctx.settings.set(RADAR_TOKEN_SETTING, input.token);
421
+ else throw PluginRouteError.badRequest("Enter a Cloudflare Radar API token.");
422
+ ctx.log.info(input.clear ? "Radar token removed" : "Radar token saved");
423
+ const tokenSource = await radarTokenSource(ctx);
424
+ return { tokenSource, tokenConfigured: Boolean(tokenSource) };
425
+ },
426
+ }),
427
+
428
+ /** Refresh the crawler directory from Cloudflare Radar now. */
429
+ "robots/refresh": definePluginRoute({
430
+ permission: "plugins:manage",
431
+ methods: ["POST"],
432
+ request: { body: "json" },
433
+ handler: async (ctx) => {
434
+ await requireFeature(ctx, "robots.radarSync");
435
+ try {
436
+ return await syncRadar(ctx);
437
+ } catch (error) {
438
+ throw PluginRouteError.badRequest(error instanceof Error ? error.message : "Radar sync failed");
439
+ }
440
+ },
441
+ }),
442
+ };
443
+
444
+ return { routes };
445
+ }
446
+
@@ -0,0 +1,69 @@
1
+ import type { PluginContext } from "emdash";
2
+
3
+ import { registerFeatures } from "../core/features.js";
4
+ import { ROBOTS_TAG, keepAtEdge } from "../pageCache/lib.js";
5
+ import type { PackMiddleware, PackModule } from "../core/module.js";
6
+ import { serveRobots } from "./middleware.js";
7
+ import { BOT_OVERRIDES_COLLECTION, HISTORY_COLLECTION, type RobotsOptions, robotsModule } from "./module.js";
8
+ import { OVERLAY_COLLECTION, RADAR_HOST, syncRadar } from "./radar.js";
9
+
10
+ const FEATURES = [
11
+ {
12
+ id: "robots",
13
+ label: "Robots.txt Rules",
14
+ description:
15
+ "Manage robots.txt with plain-English rules: block AI training crawlers, keep sections private, with live checks and a URL tester. Turning it on takes over EmDash's robots.txt with equivalent rules; turning it off gives it back.",
16
+ default: false,
17
+ },
18
+ {
19
+ id: "robots.radarSync",
20
+ label: "Weekly crawler list from Cloudflare Radar",
21
+ description: "Refresh the crawler directory every week from the Cloudflare Radar API (needs a Radar API token).",
22
+ default: false,
23
+ },
24
+ ];
25
+ registerFeatures(FEATURES);
26
+
27
+ export const ROBOTS_TASK = "robots-refresh-bots";
28
+
29
+ export function robotsPack(options: RobotsOptions): PackModule {
30
+ return {
31
+ id: "robots",
32
+ label: "Robots.txt Rules",
33
+ features: FEATURES,
34
+ routes: robotsModule(options).routes,
35
+ tasks: [
36
+ {
37
+ name: ROBOTS_TASK,
38
+ schedule: "@weekly",
39
+ feature: "robots.radarSync",
40
+ handler: async (ctx: PluginContext) => {
41
+ try {
42
+ await syncRadar(ctx);
43
+ } catch {
44
+ // Already logged and recorded for the admin page.
45
+ }
46
+ },
47
+ },
48
+ ],
49
+ adminPages: [{ path: "/robots", label: "Robots.txt", icon: "robot" }],
50
+ storage: { [OVERLAY_COLLECTION]: { indexes: [] }, [HISTORY_COLLECTION]: { indexes: [] }, [BOT_OVERRIDES_COLLECTION]: { indexes: [] } },
51
+ capabilities: ["network:request"],
52
+ allowedHosts: [RADAR_HOST],
53
+ };
54
+ }
55
+
56
+ /** Serves /robots.txt while the feature is on (site database binding "DB"). */
57
+ /** robots.txt barely changes: kept at the edge for a day (crawlers ask for it constantly). */
58
+ export const ROBOTS_EDGE_MAX_AGE = 86400;
59
+
60
+ export const robotsMiddleware: PackMiddleware = {
61
+ module: "robots",
62
+ feature: "robots",
63
+ ownsCache: true,
64
+ handle: async (context, env) => {
65
+ const response = await serveRobots(context.url, context.request.method, env);
66
+ if (response) keepAtEdge(context, ROBOTS_EDGE_MAX_AGE, [ROBOTS_TAG]);
67
+ return response;
68
+ },
69
+ };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Weekly crawler-directory refresh from the Cloudflare Radar API.
3
+ *
4
+ * GET https://api.cloudflare.com/client/v4/radar/bots?limit=1000&offset=N
5
+ * Authorization: Bearer <token> (token permission: Account → Radar → Read)
6
+ *
7
+ * One request covers the whole directory (~710 bots in October 2026; the
8
+ * API caps `limit` at 1000, so we page by offset just in case). The list
9
+ * gives slug, name, operator, category, description, kind and
10
+ * userAgentPatterns — not robots.txt tokens, which is why new bots arrive
11
+ * unverified (see ./bots.ts).
12
+ */
13
+ import type { PluginContext } from "emdash";
14
+
15
+ import { secret, workerEnv } from "../shared.js";
16
+ import { type BotOverlay, type RadarBot, mergeRadar } from "./bots.js";
17
+ import { baselineBots } from "./directory.js";
18
+
19
+ export const RADAR_HOST = "api.cloudflare.com";
20
+ const RADAR_URL = `https://${RADAR_HOST}/client/v4/radar/bots`;
21
+ const PAGE = 1000;
22
+ const MAX_PAGES = 3;
23
+
24
+ export const RADAR_TOKEN_SETTING = "robotsRadarToken";
25
+ export const RADAR_TOKEN_SECRET = "RADAR_API_TOKEN";
26
+ export const OVERLAY_COLLECTION = "robots_bots";
27
+ export const SYNC_STATE_KEY = "state:robots.radarSync";
28
+
29
+ export interface SyncState {
30
+ at: string;
31
+ ok: boolean;
32
+ total?: number;
33
+ added?: number;
34
+ updated?: number;
35
+ delisted?: number;
36
+ error?: string;
37
+ }
38
+
39
+ /** Where the Radar token comes from: the encrypted plugin setting, the Worker secret, or nowhere. */
40
+ export async function radarTokenSource(ctx: Pick<PluginContext, "settings">): Promise<"settings" | "env" | null> {
41
+ const fromSettings = await ctx.settings.get<string>(RADAR_TOKEN_SETTING).catch(() => null);
42
+ if (fromSettings?.trim()) return "settings";
43
+ try {
44
+ return secret(await workerEnv(), RADAR_TOKEN_SECRET)?.trim() ? "env" : null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ export async function radarToken(ctx: Pick<PluginContext, "settings">): Promise<string | undefined> {
51
+ const fromSettings = await ctx.settings.get<string>(RADAR_TOKEN_SETTING);
52
+ if (fromSettings?.trim()) return fromSettings.trim();
53
+ try {
54
+ return secret(await workerEnv(), RADAR_TOKEN_SECRET)?.trim() || undefined;
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ /** Every overlay document (plugin storage pages are capped at 100). */
61
+ export async function readOverlays(ctx: PluginContext): Promise<BotOverlay[]> {
62
+ const collection = ctx.storage[OVERLAY_COLLECTION];
63
+ if (!collection) return [];
64
+ const out: BotOverlay[] = [];
65
+ let cursor: string | undefined;
66
+ for (let i = 0; i < 50; i++) {
67
+ const page = await collection.query({ limit: 100, ...(cursor ? { cursor } : {}) });
68
+ for (const item of page.items) out.push(item.data as BotOverlay);
69
+ if (!page.hasMore || !page.cursor) break;
70
+ cursor = page.cursor;
71
+ }
72
+ return out;
73
+ }
74
+
75
+ async function fetchRadar(ctx: PluginContext, token: string): Promise<RadarBot[]> {
76
+ const doFetch = ctx.http ? ctx.http.fetch.bind(ctx.http) : fetch;
77
+ const bots: RadarBot[] = [];
78
+ for (let page = 0; page < MAX_PAGES; page++) {
79
+ const url = `${RADAR_URL}?limit=${PAGE}&offset=${page * PAGE}&format=JSON`;
80
+ const response = await doFetch(url, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } });
81
+ const body = (await response.json().catch(() => null)) as {
82
+ success?: boolean;
83
+ result?: { bots?: RadarBot[] };
84
+ errors?: Array<{ code?: number; message?: string }>;
85
+ } | null;
86
+ if (!response.ok || !body?.success) {
87
+ const detail = body?.errors?.map((e) => e.message).filter(Boolean).join("; ");
88
+ if (response.status === 401 || response.status === 403) {
89
+ throw new Error(`Cloudflare rejected the Radar API token (${response.status}). It needs the Account → Radar → Read permission.`);
90
+ }
91
+ throw new Error(`Radar API error ${response.status}${detail ? `: ${detail}` : ""}`);
92
+ }
93
+ const list = body.result?.bots ?? [];
94
+ bots.push(...list);
95
+ if (list.length < PAGE) break;
96
+ }
97
+ return bots;
98
+ }
99
+
100
+ /** Fetch Radar, merge into the stored overlay, and record the outcome. Throws on failure (after recording it). */
101
+ export async function syncRadar(ctx: PluginContext): Promise<SyncState> {
102
+ const at = new Date().toISOString();
103
+ const token = await radarToken(ctx);
104
+ if (!token) {
105
+ const state: SyncState = { at, ok: false, error: "No Radar API token. Add one under Coywolf Pack settings, or set the RADAR_API_TOKEN Worker secret." };
106
+ await ctx.kv.set(SYNC_STATE_KEY, state);
107
+ return state;
108
+ }
109
+ try {
110
+ const radar = await fetchRadar(ctx, token);
111
+ if (radar.length < 100) throw new Error(`Radar returned only ${radar.length} bots; keeping the current directory.`);
112
+ const result = mergeRadar(await baselineBots(), await readOverlays(ctx), radar, at.slice(0, 10));
113
+ const collection = ctx.storage[OVERLAY_COLLECTION];
114
+ if (result.writes.length && collection) {
115
+ for (let i = 0; i < result.writes.length; i += 100) {
116
+ await collection.putMany(result.writes.slice(i, i + 100).map((data) => ({ id: data.slug, data })));
117
+ }
118
+ }
119
+ const state: SyncState = { at, ok: true, total: result.total, added: result.added, updated: result.updated, delisted: result.delisted };
120
+ await ctx.kv.set(SYNC_STATE_KEY, state);
121
+ ctx.log.info("Robots: Radar bot directory synced", { total: result.total, added: result.added, updated: result.updated, delisted: result.delisted });
122
+ return state;
123
+ } catch (error) {
124
+ const state: SyncState = { at, ok: false, error: error instanceof Error ? error.message : String(error) };
125
+ await ctx.kv.set(SYNC_STATE_KEY, state);
126
+ ctx.log.error("Robots: Radar sync failed", { error: state.error });
127
+ throw error;
128
+ }
129
+ }