@coywolf/emdash 0.0.0-stage → 0.35.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -2
  3. package/package.json +70 -5
  4. package/src/admin/admin-css.ts +57 -0
  5. package/src/admin/ai.tsx +915 -0
  6. package/src/admin/backups.tsx +613 -0
  7. package/src/admin/breadcrumbs.tsx +542 -0
  8. package/src/admin/codeBlocks.tsx +204 -0
  9. package/src/admin/customBlocks.tsx +321 -0
  10. package/src/admin/dirty.ts +20 -0
  11. package/src/admin/discovery.tsx +463 -0
  12. package/src/admin/download.ts +28 -0
  13. package/src/admin/feature-switches.tsx +124 -0
  14. package/src/admin/features.tsx +142 -0
  15. package/src/admin/files.tsx +791 -0
  16. package/src/admin/formUploads.tsx +445 -0
  17. package/src/admin/guides.tsx +18 -0
  18. package/src/admin/headings.tsx +262 -0
  19. package/src/admin/images.tsx +825 -0
  20. package/src/admin/index.tsx +62 -0
  21. package/src/admin/links.tsx +980 -0
  22. package/src/admin/performance.tsx +330 -0
  23. package/src/admin/preview.tsx +63 -0
  24. package/src/admin/redirects-removed.tsx +266 -0
  25. package/src/admin/redirects.tsx +470 -0
  26. package/src/admin/reviews.tsx +232 -0
  27. package/src/admin/robots-bots.tsx +647 -0
  28. package/src/admin/robots-shared.tsx +109 -0
  29. package/src/admin/robots-wizard.tsx +748 -0
  30. package/src/admin/robots.tsx +911 -0
  31. package/src/admin/save-bar.tsx +134 -0
  32. package/src/admin/schema.tsx +1187 -0
  33. package/src/admin/search.tsx +355 -0
  34. package/src/admin/settings-ui.tsx +87 -0
  35. package/src/admin/trash-prompt-core.ts +135 -0
  36. package/src/admin/trash-prompt.tsx +156 -0
  37. package/src/admin/usage.tsx +301 -0
  38. package/src/admin/videos-display.tsx +248 -0
  39. package/src/admin/videos.tsx +1202 -0
  40. package/src/admin/wpImport.tsx +1516 -0
  41. package/src/ai/entities.ts +59 -0
  42. package/src/ai/export.ts +61 -0
  43. package/src/ai/logic.test.ts +187 -0
  44. package/src/ai/logic.ts +606 -0
  45. package/src/ai/pack.ts +121 -0
  46. package/src/ai/prompts.ts +87 -0
  47. package/src/ai/providers.ts +276 -0
  48. package/src/ai/queue.test.ts +97 -0
  49. package/src/ai/routes.ts +359 -0
  50. package/src/ai/store.ts +301 -0
  51. package/src/ai/wikidata.test.ts +184 -0
  52. package/src/ai/wikidata.ts +87 -0
  53. package/src/ai/worker.ts +530 -0
  54. package/src/astro/astro-modules.d.ts +6 -0
  55. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  56. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  57. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  58. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  59. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  60. package/src/astro/customBlocks/Details.astro +8 -0
  61. package/src/astro/customBlocks/Disclosure.astro +8 -0
  62. package/src/astro/customBlocks/Note.astro +8 -0
  63. package/src/astro/customBlocks/Podcast.astro +8 -0
  64. package/src/astro/customBlocks/Quote.astro +8 -0
  65. package/src/astro/customBlocks/Testimonial.astro +8 -0
  66. package/src/astro/files/FileCard.astro +103 -0
  67. package/src/astro/headings/HeadingBlock.astro +129 -0
  68. package/src/astro/headings/TableOfContents.astro +45 -0
  69. package/src/astro/headings/request.ts +19 -0
  70. package/src/astro/images/ResponsiveImage.astro +60 -0
  71. package/src/astro/index.ts +84 -0
  72. package/src/astro/reviews/CoywolfReview.astro +27 -0
  73. package/src/astro/reviews/Review.astro +49 -0
  74. package/src/astro/search/SearchBox.astro +61 -0
  75. package/src/astro/search/SearchBoxForm.astro +509 -0
  76. package/src/astro/tables/CaptionedTable.astro +72 -0
  77. package/src/astro/videos/CoywolfVideo.astro +448 -0
  78. package/src/backups/download.ts +112 -0
  79. package/src/backups/dump.test.mjs +287 -0
  80. package/src/backups/dump.ts +288 -0
  81. package/src/backups/module.ts +276 -0
  82. package/src/backups/pack.ts +33 -0
  83. package/src/backups/restore.test.mjs +103 -0
  84. package/src/backups/restore.ts +209 -0
  85. package/src/backups/store.ts +409 -0
  86. package/src/breadcrumbs/pack.ts +113 -0
  87. package/src/breadcrumbs/settings.ts +102 -0
  88. package/src/breadcrumbs/stamp.ts +40 -0
  89. package/src/breadcrumbs/trail.ts +123 -0
  90. package/src/codeBlocks/contrast.ts +179 -0
  91. package/src/codeBlocks/hljs.d.ts +5 -0
  92. package/src/codeBlocks/pack.ts +110 -0
  93. package/src/codeBlocks/render.test.ts +152 -0
  94. package/src/codeBlocks/render.ts +304 -0
  95. package/src/codeBlocks/settings.ts +18 -0
  96. package/src/codeBlocks/themes.generated.ts +304 -0
  97. package/src/codeBlocks/themes.ts +114 -0
  98. package/src/core/compose.ts +244 -0
  99. package/src/core/content-url.ts +888 -0
  100. package/src/core/csv.ts +119 -0
  101. package/src/core/d1-batch.ts +65 -0
  102. package/src/core/entry-docs.ts +175 -0
  103. package/src/core/features-module.ts +48 -0
  104. package/src/core/features.ts +392 -0
  105. package/src/core/generation.ts +88 -0
  106. package/src/core/guide-content.ts +166 -0
  107. package/src/core/module.ts +104 -0
  108. package/src/core/read-failures.ts +57 -0
  109. package/src/core/request-memo.ts +71 -0
  110. package/src/core/secrets.ts +83 -0
  111. package/src/core/settings-enhance.ts +74 -0
  112. package/src/core/site.ts +36 -0
  113. package/src/core/storage.ts +27 -0
  114. package/src/customBlocks/pack.ts +260 -0
  115. package/src/customBlocks/render.ts +424 -0
  116. package/src/customBlocks/rich.ts +313 -0
  117. package/src/customBlocks/settings.ts +46 -0
  118. package/src/discovery/build.ts +252 -0
  119. package/src/discovery/feed-build.ts +217 -0
  120. package/src/discovery/feed.ts +172 -0
  121. package/src/discovery/indexnow.ts +114 -0
  122. package/src/discovery/llms.ts +79 -0
  123. package/src/discovery/markdown.ts +294 -0
  124. package/src/discovery/memory-cache.ts +97 -0
  125. package/src/discovery/middleware.ts +186 -0
  126. package/src/discovery/module.ts +353 -0
  127. package/src/discovery/news.ts +99 -0
  128. package/src/discovery/pack.ts +58 -0
  129. package/src/discovery/settings.ts +91 -0
  130. package/src/discovery/urls.ts +35 -0
  131. package/src/env.d.ts +10 -0
  132. package/src/files/card.ts +231 -0
  133. package/src/files/counts.test.ts +114 -0
  134. package/src/files/counts.ts +60 -0
  135. package/src/files/export.ts +44 -0
  136. package/src/files/format.test.ts +93 -0
  137. package/src/files/format.ts +163 -0
  138. package/src/files/module.ts +596 -0
  139. package/src/files/pack.ts +92 -0
  140. package/src/files/r2-rules.test.ts +30 -0
  141. package/src/files/r2-rules.ts +41 -0
  142. package/src/files/range.ts +50 -0
  143. package/src/files/s3.ts +114 -0
  144. package/src/files/serve.ts +141 -0
  145. package/src/files/sigv4.test.ts +64 -0
  146. package/src/files/sigv4.ts +160 -0
  147. package/src/files/site.ts +137 -0
  148. package/src/files/walker.ts +58 -0
  149. package/src/formUploads/lib.ts +266 -0
  150. package/src/formUploads/pack.ts +55 -0
  151. package/src/formUploads/wrap.ts +592 -0
  152. package/src/forms.ts +16 -0
  153. package/src/headings/headings.test.ts +159 -0
  154. package/src/headings/pack.ts +154 -0
  155. package/src/headings/render.ts +139 -0
  156. package/src/headings/settings.ts +117 -0
  157. package/src/headings/slug.ts +68 -0
  158. package/src/headings/stamp.ts +142 -0
  159. package/src/headings/test-hooks.mjs +14 -0
  160. package/src/headings/toc.ts +48 -0
  161. package/src/images/cloudflare.ts +329 -0
  162. package/src/images/lib.ts +221 -0
  163. package/src/images/module.ts +372 -0
  164. package/src/images/pack.ts +804 -0
  165. package/src/images/pending.ts +37 -0
  166. package/src/images/settings.ts +48 -0
  167. package/src/images/setup.ts +192 -0
  168. package/src/images/variants-estimate.ts +162 -0
  169. package/src/images/variants-store.ts +475 -0
  170. package/src/images/variants.ts +311 -0
  171. package/src/index.ts +143 -0
  172. package/src/links/check.ts +101 -0
  173. package/src/links/classify.ts +270 -0
  174. package/src/links/export.ts +59 -0
  175. package/src/links/module.ts +652 -0
  176. package/src/links/pack.ts +43 -0
  177. package/src/links/pt.ts +325 -0
  178. package/src/links/store.ts +704 -0
  179. package/src/middleware.ts +287 -0
  180. package/src/modules.ts +53 -0
  181. package/src/options.ts +66 -0
  182. package/src/pageCache/lib.test.mjs +224 -0
  183. package/src/pageCache/lib.ts +274 -0
  184. package/src/pageCache/pack.ts +240 -0
  185. package/src/pageCache/usage-core.ts +152 -0
  186. package/src/pageCache/usage.ts +262 -0
  187. package/src/pageCache/warm.test.mjs +670 -0
  188. package/src/pageCache/warm.ts +722 -0
  189. package/src/redirects/middleware.ts +169 -0
  190. package/src/redirects/module.ts +124 -0
  191. package/src/redirects/pack.ts +89 -0
  192. package/src/redirects/removed-core.ts +151 -0
  193. package/src/redirects/removed.ts +293 -0
  194. package/src/redirects/rules.ts +355 -0
  195. package/src/redirects/slug-changes.ts +207 -0
  196. package/src/redirects/slug-core.test.ts +50 -0
  197. package/src/redirects/slug-core.ts +49 -0
  198. package/src/redirects/transfer.ts +92 -0
  199. package/src/reviews/lib.ts +624 -0
  200. package/src/reviews/pack.ts +135 -0
  201. package/src/reviews/schema.ts +128 -0
  202. package/src/reviews/settings.ts +26 -0
  203. package/src/robots/bots.ts +321 -0
  204. package/src/robots/data/bots.json +696 -0
  205. package/src/robots/data/verified.json +582 -0
  206. package/src/robots/directory.ts +26 -0
  207. package/src/robots/explain.ts +191 -0
  208. package/src/robots/guided.ts +300 -0
  209. package/src/robots/importer.ts +263 -0
  210. package/src/robots/middleware.ts +151 -0
  211. package/src/robots/module.ts +446 -0
  212. package/src/robots/pack.ts +69 -0
  213. package/src/robots/radar.ts +129 -0
  214. package/src/robots/rep.test.ts +335 -0
  215. package/src/robots/rep.ts +362 -0
  216. package/src/robots/rules.test.ts +251 -0
  217. package/src/robots/rules.ts +883 -0
  218. package/src/robots/transfer.ts +45 -0
  219. package/src/robots/validate.ts +524 -0
  220. package/src/schema/catalog.ts +146 -0
  221. package/src/schema/graph.test.mjs +522 -0
  222. package/src/schema/graph.ts +791 -0
  223. package/src/schema/module.ts +1138 -0
  224. package/src/schema/og-image.ts +100 -0
  225. package/src/schema/pack.ts +58 -0
  226. package/src/search/engine.ts +578 -0
  227. package/src/search/fallback.ts +80 -0
  228. package/src/search/live-cache.ts +116 -0
  229. package/src/search/live-client.ts +770 -0
  230. package/src/search/live-serve.ts +282 -0
  231. package/src/search/module.ts +261 -0
  232. package/src/search/pack.ts +57 -0
  233. package/src/search/query.ts +132 -0
  234. package/src/search/ratelimit-core.ts +118 -0
  235. package/src/search/ratelimit.ts +79 -0
  236. package/src/search/snippet.ts +135 -0
  237. package/src/search/title-index.ts +139 -0
  238. package/src/search/title-match.ts +192 -0
  239. package/src/shared.ts +71 -0
  240. package/src/version.ts +2 -0
  241. package/src/videos/date.ts +40 -0
  242. package/src/videos/engagement.ts +75 -0
  243. package/src/videos/lib.ts +679 -0
  244. package/src/videos/module.ts +1160 -0
  245. package/src/videos/pack.ts +187 -0
  246. package/src/videos/pause.ts +42 -0
  247. package/src/videos/poster.ts +324 -0
  248. package/src/videos/render.ts +336 -0
  249. package/src/videos/store.ts +551 -0
  250. package/src/videos/stream.ts +134 -0
  251. package/src/wpImport/convert.ts +702 -0
  252. package/src/wpImport/guests.ts +329 -0
  253. package/src/wpImport/gutenberg.ts +157 -0
  254. package/src/wpImport/markers.ts +66 -0
  255. package/src/wpImport/pack.ts +451 -0
  256. package/src/wpImport/parents.ts +202 -0
  257. package/src/wpImport/prepare.ts +592 -0
  258. package/src/wpImport/redirects.ts +358 -0
  259. package/src/wpImport/sources.ts +67 -0
  260. package/src/wpImport/stream.ts +160 -0
  261. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Plain-English descriptions of rules and of the whole robots.txt, for
3
+ * people who don't read robots.txt syntax.
4
+ */
5
+ import { evaluateParsed, parse } from "./rep.js";
6
+ import {
7
+ CRAWLER_GROUPS,
8
+ type DirectoryBot,
9
+ PROBE_AGENT,
10
+ type RobotsConfig,
11
+ type RobotsRule,
12
+ generate,
13
+ groupTokens,
14
+ matchGroup,
15
+ resolveAgents,
16
+ ruleExts,
17
+ ruleParams,
18
+ } from "./rules.js";
19
+
20
+ const FILE_TYPE_NAMES: Record<string, string> = {
21
+ pdf: "PDF",
22
+ doc: "Word",
23
+ docx: "Word",
24
+ xls: "Excel",
25
+ xlsx: "Excel",
26
+ csv: "CSV",
27
+ ppt: "PowerPoint",
28
+ pptx: "PowerPoint",
29
+ zip: "ZIP",
30
+ jpg: "JPEG",
31
+ jpeg: "JPEG",
32
+ png: "PNG",
33
+ gif: "GIF",
34
+ webp: "WebP",
35
+ avif: "AVIF",
36
+ svg: "SVG",
37
+ mp4: "MP4",
38
+ mov: "QuickTime",
39
+ webm: "WebM",
40
+ mp3: "MP3",
41
+ };
42
+
43
+ export function listPhrase(items: string[], max = 3): string {
44
+ if (items.length <= 1) return items[0] ?? "";
45
+ if (items.length <= max) return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
46
+ return `${items.slice(0, max).join(", ")} and ${items.length - max} more`;
47
+ }
48
+
49
+ /** "all crawlers", "AI training crawlers", "GPTBot and ClaudeBot"… */
50
+ export function describeAgents(rule: Pick<RobotsRule, "agents" | "group">, nameOf?: (token: string) => string): string {
51
+ if (rule.agents.includes("*")) {
52
+ const others = rule.agents.filter((a) => a !== "*");
53
+ return others.length ? `all crawlers (including ${listPhrase(others)})` : "all crawlers";
54
+ }
55
+ const group = (rule.group && CRAWLER_GROUPS.find((g) => g.id === rule.group)) || matchGroup(rule.agents);
56
+ if (group) {
57
+ const inGroup = new Set(group.tokens.map((t) => t.toLowerCase()));
58
+ const extra = rule.agents.filter((a) => !inGroup.has(a.toLowerCase()));
59
+ const count = rule.agents.length;
60
+ if (!extra.length || rule.group === group.id) return `${group.phrase} (${count})`;
61
+ return `${group.phrase} and ${listPhrase(extra.map((a) => nameOf?.(a) ?? a), 2)}`;
62
+ }
63
+ return listPhrase(rule.agents.map((a) => nameOf?.(a) ?? a));
64
+ }
65
+
66
+ function extsPhrase(exts: string[]): string {
67
+ const names = [...new Set(exts.map((e) => FILE_TYPE_NAMES[e] ?? `.${e}`))];
68
+ return `${listPhrase(names, 4)} files`;
69
+ }
70
+
71
+ /** What part of the site a rule covers, e.g. "the /private/ section and everything in it". */
72
+ export function describeTarget(rule: RobotsRule): string {
73
+ const path = rule.path ?? "";
74
+ switch (rule.kind) {
75
+ case "entire_site":
76
+ return "the whole site";
77
+ case "folder": {
78
+ const p = path.endsWith("/") ? path : `${path}/`;
79
+ const folder = p.startsWith("/") ? p : `/${p}`;
80
+ return rule.anyDepth
81
+ ? `every ${folder.replace(/^\/|\/$/g, "") || "/"}/ folder, at any level, and everything in them`
82
+ : `the ${folder} section and everything in it`;
83
+ }
84
+ case "prefix":
85
+ return `every address starting with ${path.startsWith("/") ? path : `/${path}`}`;
86
+ case "single_page":
87
+ return rule.strict ? `only the exact address ${path}` : `the page ${path} (and addresses that continue it)`;
88
+ case "exact_url":
89
+ return `only the exact address ${path.startsWith("/") ? path : `/${path}`}`;
90
+ case "filetype":
91
+ return `${extsPhrase(ruleExts(rule))} anywhere on the site`;
92
+ case "filetype_in_folder":
93
+ return `${extsPhrase(ruleExts(rule))} in ${path.endsWith("/") ? path : `${path}/`}`;
94
+ case "contains":
95
+ return `addresses containing “${path}”`;
96
+ case "any_depth":
97
+ return `any ${path.replace(/^\/+|\/+$/g, "")}/ folder below the top level`;
98
+ case "query_any":
99
+ return "addresses with a query string (anything after a ?)";
100
+ case "query_param": {
101
+ const params = ruleParams(rule).map((p) => (p.endsWith("*") ? `${p.slice(0, -1)}…` : p));
102
+ return `addresses with the ${listPhrase(params, 4)} parameter${params.length > 1 ? "s" : ""}`;
103
+ }
104
+ case "wildcard_prefix":
105
+ return `addresses matching ${path.endsWith("*") ? path : `${path}*`}`;
106
+ case "allow_exception":
107
+ return `the ${path} section, except ${rule.allow ?? ""}`;
108
+ default:
109
+ return path ? `addresses matching ${path}` : "nothing (an empty value)";
110
+ }
111
+ }
112
+
113
+ /** One sentence: "Block AI training crawlers (11) from the whole site". */
114
+ export function describeRule(rule: RobotsRule, nameOf?: (token: string) => string): string {
115
+ const who = describeAgents(rule, nameOf);
116
+ const what = describeTarget(rule);
117
+ let sentence =
118
+ rule.kind === "allow_exception"
119
+ ? `Block ${who} from ${what}`
120
+ : rule.directive === "allow"
121
+ ? `Let ${who} crawl ${what}`
122
+ : `Block ${who} from ${what}`;
123
+ if (rule.directive === "disallow" && rule.except?.length) sentence += `, except ${listPhrase(rule.except, 3)}`;
124
+ return sentence;
125
+ }
126
+
127
+ export interface SummaryLine {
128
+ who: string;
129
+ text: string;
130
+ tone: "open" | "partial" | "closed";
131
+ }
132
+
133
+ /**
134
+ * A plain-English summary of what the whole file does, by kind of crawler:
135
+ * "Search engines can crawl everything except EmDash's admin."
136
+ */
137
+ export function summarize(config: RobotsConfig, siteUrl: string, bots: DirectoryBot[] = []): SummaryLine[] {
138
+ const text = generate(config, { siteUrl });
139
+ const parsed = parse(text).directives;
140
+ const resolved = resolveAgents(config);
141
+ const ruleById = new Map(config.rules.map((r) => [r.id, r]));
142
+ const out: SummaryLine[] = [];
143
+ const describeFor = (tokens: string[], who: string) => {
144
+ const states = tokens.map((t) => {
145
+ const home = evaluateParsed(parsed, [t], "/").allowed;
146
+ const page = evaluateParsed(parsed, [t], "/some-page/").allowed;
147
+ const lines = resolved.get(t.toLowerCase())?.lines ?? resolved.get("*")?.lines ?? [];
148
+ const blocks = lines.filter((l) => !l.system && l.directive === "Disallow");
149
+ const allows = lines.filter((l) => !l.system && l.directive === "Allow");
150
+ const areas = (list: typeof lines) =>
151
+ [...new Set(list.flatMap((l) => l.ruleIds.map((id) => ruleById.get(id))).filter(Boolean).map((r) => describeTarget(r as RobotsRule)))];
152
+ if (!home && !page) {
153
+ const open = areas(allows);
154
+ return open.length ? { tone: "partial" as const, text: `are blocked from the whole site except ${listPhrase(open, 3)}` } : { tone: "closed" as const, text: "are blocked from the whole site" };
155
+ }
156
+ const closed = areas(blocks);
157
+ if (closed.length) return { tone: "partial" as const, text: `can crawl everything except ${listPhrase(closed, 3)}` };
158
+ return { tone: "open" as const, text: "can crawl everything" };
159
+ });
160
+ if (!states.length) return;
161
+ const buckets = new Map<string, { tone: SummaryLine["tone"]; tokens: string[] }>();
162
+ states.forEach((st, i) => {
163
+ const b = buckets.get(st.text) ?? { tone: st.tone, tokens: [] };
164
+ b.tokens.push(tokens[i]);
165
+ buckets.set(st.text, b);
166
+ });
167
+ if (buckets.size === 1) {
168
+ const [[text, b]] = [...buckets];
169
+ out.push({ who, text: tokens.length === 1 && who !== "All other crawlers" ? text.replace(/^are /, "is ") : text, tone: b.tone });
170
+ return;
171
+ }
172
+ const sorted = [...buckets].sort((a, b) => a[1].tokens.length - b[1].tokens.length);
173
+ const parts = sorted.map(([text, b], i) => (i === sorted.length - 1 && b.tokens.length > 3 ? `the others ${text}` : `${listPhrase(b.tokens, 3)} ${b.tokens.length === 1 ? text.replace(/^are /, "is ") : text}`));
174
+ out.push({ who: `${who}:`, text: parts.join("; "), tone: "partial" });
175
+ };
176
+ const named = new Set<string>();
177
+ for (const group of CRAWLER_GROUPS) {
178
+ const tokens = groupTokens(group, bots);
179
+ for (const t of tokens) named.add(t.toLowerCase());
180
+ describeFor(tokens, group.phrase.charAt(0).toUpperCase() + group.phrase.slice(1));
181
+ }
182
+ const others = [...resolved.values()].map((e) => e.token).filter((t) => t !== "*" && !named.has(t.toLowerCase()));
183
+ if (others.length) describeFor(others, listPhrase(others, 3));
184
+ describeFor([PROBE_AGENT], "All other crawlers");
185
+ return out;
186
+ }
187
+
188
+ /** "Search engines can crawl everything." as one string per line. */
189
+ export function summaryText(lines: SummaryLine[]): string[] {
190
+ return lines.map((l) => `${l.who} ${l.text}.`);
191
+ }
@@ -0,0 +1,300 @@
1
+ /**
2
+ * The guided "Add rule" flow's model: a small set of plain choices that
3
+ * map onto stored rules (and back, for editing), smart reading of pasted
4
+ * addresses, and live "will match / won't match" examples from the real
5
+ * matcher.
6
+ *
7
+ * Choices → stored kinds:
8
+ * Everything → entire_site
9
+ * A section (+ "at any depth") → folder (+ anyDepth)
10
+ * One page (+ "only this address") → single_page / exact_url
11
+ * A kind of file (+ "only in…") → filetype / filetype_in_folder
12
+ * Links with parameters → query_param / query_any
13
+ * Advanced pattern → custom (covers prefix, contains, wildcard…)
14
+ * Block … "Except…" → except[] (more specific Allow lines)
15
+ */
16
+ import { matchRaw } from "./rep.js";
17
+ import {
18
+ CRAWLER_GROUPS,
19
+ type DirectoryBot,
20
+ type RobotsRule,
21
+ directives,
22
+ groupTokens,
23
+ matchGroup,
24
+ normalizePathInput,
25
+ ruleExts,
26
+ ruleParams,
27
+ samplePaths,
28
+ } from "./rules.js";
29
+
30
+ export type Area = "everything" | "section" | "page" | "files" | "params" | "advanced";
31
+ export type Who = "everyone" | "search_engines" | "ai_training" | "ai_search" | "seo_tools" | "specific";
32
+
33
+ export interface Draft {
34
+ id: string;
35
+ enabled: boolean;
36
+ action: "block" | "allow";
37
+ area: Area;
38
+ section: string;
39
+ anyDepth: boolean;
40
+ page: string;
41
+ exact: boolean;
42
+ fileTypes: string[];
43
+ otherExt: string;
44
+ filesIn: string;
45
+ params: string[];
46
+ anyQuery: boolean;
47
+ otherParam: string;
48
+ pattern: string;
49
+ except: string[];
50
+ who: Who;
51
+ agents: string[];
52
+ name: string;
53
+ description: string;
54
+ nameTouched: boolean;
55
+ descriptionTouched: boolean;
56
+ }
57
+
58
+ export const FILE_TYPES: Array<{ id: string; label: string; exts: string[] }> = [
59
+ { id: "pdf", label: "PDF", exts: ["pdf"] },
60
+ { id: "word", label: "Word", exts: ["doc", "docx"] },
61
+ { id: "sheets", label: "Spreadsheets", exts: ["xls", "xlsx", "csv"] },
62
+ { id: "slides", label: "Presentations", exts: ["ppt", "pptx"] },
63
+ { id: "images", label: "Images", exts: ["jpg", "jpeg", "png", "gif", "webp", "avif", "svg"] },
64
+ { id: "zip", label: "ZIP archives", exts: ["zip"] },
65
+ { id: "video", label: "Video", exts: ["mp4", "mov", "webm"] },
66
+ { id: "audio", label: "Audio", exts: ["mp3", "m4a", "wav"] },
67
+ ];
68
+
69
+ export const PARAMS: Array<{ id: string; label: string; names: string[]; hint: string }> = [
70
+ { id: "utm", label: "utm_… (campaign tracking)", names: ["utm_*"], hint: "?utm_source=newsletter" },
71
+ { id: "ref", label: "ref", names: ["ref"], hint: "?ref=twitter" },
72
+ { id: "fbclid", label: "fbclid (Facebook)", names: ["fbclid"], hint: "?fbclid=…" },
73
+ { id: "gclid", label: "gclid (Google Ads)", names: ["gclid"], hint: "?gclid=…" },
74
+ { id: "session", label: "Session IDs", names: ["sessionid", "sid"], hint: "?sessionid=…" },
75
+ { id: "sort", label: "Sorting and filters", names: ["sort", "order", "filter"], hint: "?sort=price" },
76
+ { id: "search", label: "Site search", names: ["q", "s", "search"], hint: "?q=oats" },
77
+ ];
78
+
79
+ export function blankDraft(id: string): Draft {
80
+ return {
81
+ id,
82
+ enabled: true,
83
+ action: "block",
84
+ area: "section",
85
+ section: "",
86
+ anyDepth: false,
87
+ page: "",
88
+ exact: false,
89
+ fileTypes: [],
90
+ otherExt: "",
91
+ filesIn: "",
92
+ params: [],
93
+ anyQuery: false,
94
+ otherParam: "",
95
+ pattern: "",
96
+ except: [],
97
+ who: "everyone",
98
+ agents: [],
99
+ name: "",
100
+ description: "",
101
+ nameTouched: false,
102
+ descriptionTouched: false,
103
+ };
104
+ }
105
+
106
+ const splitList = (s: string) =>
107
+ s
108
+ .split(/[\s,]+/)
109
+ .map((x) => x.trim())
110
+ .filter(Boolean);
111
+
112
+ export function draftExts(d: Draft): string[] {
113
+ return [...new Set([...FILE_TYPES.filter((f) => d.fileTypes.includes(f.id)).flatMap((f) => f.exts), ...splitList(d.otherExt).map((e) => e.replace(/^\.+/, "").toLowerCase())])];
114
+ }
115
+
116
+ export function draftParams(d: Draft): string[] {
117
+ return [...new Set([...PARAMS.filter((p) => d.params.includes(p.id)).flatMap((p) => p.names), ...splitList(d.otherParam).map((p) => p.replace(/^[?&]+/, "").replace(/=.*$/, ""))])];
118
+ }
119
+
120
+ export function draftAgents(d: Draft, bots: DirectoryBot[]): string[] {
121
+ if (d.who === "everyone") return ["*"];
122
+ if (d.who === "specific") return d.agents;
123
+ const group = CRAWLER_GROUPS.find((g) => g.id === d.who);
124
+ return group ? groupTokens(group, bots) : [];
125
+ }
126
+
127
+ const asFolder = (v: string) => (v === "" ? "" : v.endsWith("/") ? v : `${v}/`);
128
+
129
+ /** The stored rule a draft produces (name/description filled by the caller). */
130
+ export function draftToRule(d: Draft, bots: DirectoryBot[], siteUrl = ""): RobotsRule {
131
+ const norm = (v: string) => normalizePathInput(v, siteUrl).value;
132
+ const base = {
133
+ id: d.id,
134
+ name: d.name,
135
+ description: d.description || undefined,
136
+ enabled: d.enabled,
137
+ agents: draftAgents(d, bots),
138
+ directive: d.action === "allow" ? ("allow" as const) : ("disallow" as const),
139
+ ...(d.who !== "everyone" && d.who !== "specific" ? { group: d.who } : {}),
140
+ source: "user" as const,
141
+ };
142
+ const except = d.action === "block" && (d.area === "section" || d.area === "everything" || d.area === "advanced") ? d.except.map((e) => e.trim()).filter(Boolean).map(norm) : [];
143
+ const withExcept = except.length ? { except } : {};
144
+ switch (d.area) {
145
+ case "everything":
146
+ return { ...base, kind: "entire_site", ...withExcept };
147
+ case "section":
148
+ return { ...base, kind: "folder", path: asFolder(norm(d.section)), ...(d.anyDepth ? { anyDepth: true } : {}), ...withExcept };
149
+ case "page":
150
+ return { ...base, kind: d.exact ? "exact_url" : "single_page", path: norm(d.page).replace(/\$$/, "") };
151
+ case "files": {
152
+ const exts = draftExts(d);
153
+ const folder = d.filesIn.trim() ? asFolder(norm(d.filesIn)) : "";
154
+ return folder ? { ...base, kind: "filetype_in_folder", path: folder, exts } : { ...base, kind: "filetype", exts };
155
+ }
156
+ case "params":
157
+ return d.anyQuery ? { ...base, kind: "query_any" } : { ...base, kind: "query_param", params: draftParams(d) };
158
+ default:
159
+ return { ...base, kind: "custom", path: d.pattern.trim() ? norm(d.pattern) : "", ...withExcept };
160
+ }
161
+ }
162
+
163
+ /** A draft that edits an existing rule (anything the plain choices can't show opens as Advanced). */
164
+ export function ruleToDraft(rule: RobotsRule): Draft {
165
+ const d = blankDraft(rule.id);
166
+ d.enabled = rule.enabled;
167
+ d.action = rule.directive === "allow" && rule.kind !== "allow_exception" ? "allow" : "block";
168
+ d.name = rule.name;
169
+ d.description = rule.description ?? "";
170
+ d.nameTouched = true;
171
+ d.descriptionTouched = Boolean(rule.description);
172
+ d.except = [...(rule.except ?? [])];
173
+ const path = rule.path ?? "";
174
+ switch (rule.kind) {
175
+ case "entire_site":
176
+ d.area = "everything";
177
+ break;
178
+ case "folder":
179
+ d.area = "section";
180
+ d.section = path;
181
+ d.anyDepth = Boolean(rule.anyDepth);
182
+ break;
183
+ case "allow_exception":
184
+ d.area = "section";
185
+ d.section = path;
186
+ if (rule.allow) d.except = [rule.allow, ...d.except];
187
+ break;
188
+ case "single_page":
189
+ d.area = "page";
190
+ d.page = path;
191
+ d.exact = Boolean(rule.strict);
192
+ break;
193
+ case "exact_url":
194
+ d.area = "page";
195
+ d.page = path;
196
+ d.exact = true;
197
+ break;
198
+ case "filetype":
199
+ case "filetype_in_folder": {
200
+ d.area = "files";
201
+ const exts = ruleExts(rule);
202
+ const chips = FILE_TYPES.filter((f) => f.exts.every((e) => exts.includes(e)));
203
+ const covered = new Set(chips.flatMap((c) => c.exts));
204
+ d.fileTypes = chips.map((c) => c.id);
205
+ d.otherExt = exts.filter((e) => !covered.has(e)).join(", ");
206
+ d.filesIn = rule.kind === "filetype_in_folder" ? path : "";
207
+ break;
208
+ }
209
+ case "query_any":
210
+ d.area = "params";
211
+ d.anyQuery = true;
212
+ break;
213
+ case "query_param": {
214
+ d.area = "params";
215
+ const names = ruleParams(rule);
216
+ const chips = PARAMS.filter((p) => p.names.every((n) => names.includes(n)));
217
+ const covered = new Set(chips.flatMap((c) => c.names));
218
+ d.params = chips.map((c) => c.id);
219
+ d.otherParam = names.filter((n) => !covered.has(n)).join(", ");
220
+ break;
221
+ }
222
+ default: {
223
+ d.area = "advanced";
224
+ const lines = directives({ ...rule, except: [] });
225
+ d.pattern = lines[0]?.value ?? path;
226
+ }
227
+ }
228
+ if (rule.agents.length === 1 && rule.agents[0] === "*") d.who = "everyone";
229
+ else {
230
+ const group = (rule.group && CRAWLER_GROUPS.find((g) => g.id === rule.group)) || matchGroup(rule.agents);
231
+ if (group && rule.group === group.id) d.who = group.id as Who;
232
+ else {
233
+ d.who = "specific";
234
+ d.agents = [...rule.agents];
235
+ }
236
+ }
237
+ return d;
238
+ }
239
+
240
+ export interface Reading {
241
+ area: Area;
242
+ value: string;
243
+ /** "We read this as: the /recipes/ section" */
244
+ message: string;
245
+ notes: string[];
246
+ }
247
+
248
+ /** Read a pasted address or path and suggest the plain choice that fits it. */
249
+ export function inferInput(input: string, siteUrl = "", sections: string[] = []): Reading | null {
250
+ const raw = input.trim();
251
+ if (!raw) return null;
252
+ const { value, notes } = normalizePathInput(raw, siteUrl);
253
+ if (/[*$]/.test(value)) return { area: "advanced", value, message: `“${value}” is a pattern (* means anything, $ means the address ends there), so it's set up as an advanced pattern.`, notes };
254
+ if (value === "/") return { area: "everything", value, message: "That's your home page address, which covers the whole site.", notes };
255
+ const q = value.indexOf("?");
256
+ if (q !== -1) {
257
+ const name = value.slice(q + 1).split(/[=&]/)[0];
258
+ if (name) return { area: "params", value: name, message: `That address has a ?${name}= parameter. Do you mean links with that parameter?`, notes };
259
+ }
260
+ const ext = /\/[^/]+\.([A-Za-z0-9]{1,8})$/.exec(value)?.[1];
261
+ if (ext && !/^html?$/i.test(ext)) return { area: "page", value, message: `We read this as one ${ext.toUpperCase()} file. To cover every .${ext.toLowerCase()} file, pick “A kind of file”.`, notes };
262
+ const known = sections.find((s) => value === s || `${value}/` === s);
263
+ if (value.endsWith("/") || known) {
264
+ const folder = known ?? value;
265
+ return { area: "section", value: folder, message: `We read this as the ${folder} section and everything in it.`, notes };
266
+ }
267
+ return { area: "page", value, message: `We read this as the page ${value}.`, notes };
268
+ }
269
+
270
+ /** Example addresses a rule matches and nearby ones it doesn't, from the real matcher. */
271
+ export function matchExamples(rule: RobotsRule): { matches: string[]; misses: string[] } {
272
+ let lines: ReturnType<typeof directives>;
273
+ try {
274
+ lines = directives(rule).filter((l) => l.directive === (rule.directive === "allow" ? "Allow" : "Disallow"));
275
+ } catch {
276
+ return { matches: [], misses: [] };
277
+ }
278
+ const exceptions = rule.directive === "disallow" ? directives(rule).filter((l) => l.directive === "Allow") : [];
279
+ const hit = (p: string) => lines.some((l) => matchRaw(l.value, p)) && !exceptions.some((l) => matchRaw(l.value, p));
280
+ const matches = [...new Set(lines.flatMap((l) => samplePaths(l.value)))].filter(hit).slice(0, 4);
281
+ const near = new Set<string>();
282
+ for (const e of exceptions) for (const p of samplePaths(e.value).slice(0, 1)) near.add(p);
283
+ for (const l of lines) {
284
+ const literal = l.value.replace(/\$$/, "").replace(/\*/g, "");
285
+ const trimmed = literal.replace(/\/$/, "");
286
+ if (trimmed) {
287
+ near.add(trimmed);
288
+ near.add(`${trimmed}-index/`);
289
+ near.add(`/other${literal.startsWith("/") ? literal : `/${literal}`}`);
290
+ near.add(`${literal}x`.replace(/^([^/])/, "/$1"));
291
+ near.add(`${trimmed.toUpperCase()}/`);
292
+ }
293
+ near.add(`${literal || "/"}?page=2`.replace(/^([^/])/, "/$1"));
294
+ }
295
+ near.add("/");
296
+ near.add("/blog/a-post/");
297
+ near.add("/file.pdf?download=1");
298
+ const misses = [...near].filter((p) => p.startsWith("/") && !p.startsWith("//") && !hit(p) && !matches.includes(p)).slice(0, 3);
299
+ return { matches, misses };
300
+ }