@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,362 @@
1
+ /**
2
+ * Robots Exclusion Protocol (RFC 9309) evaluator: a TypeScript port of
3
+ * Google's open-source robots.txt parser and matcher
4
+ * (https://github.com/google/robotstxt), by way of Coywolf SEO's PHP port.
5
+ *
6
+ * - `matchRaw` is RobotsMatchStrategy::Matches(): `*` matches any run of
7
+ * bytes, a final `$` anchors the end, everything else is literal. Linear,
8
+ * no backtracking, so hostile patterns can't cause ReDoS.
9
+ * - `parse` is RobotsTxtParser: BOM skipping, \n / \r / \r\n lines, `#`
10
+ * comments, missing-colon recovery, Google's key typos, the 16,664-byte
11
+ * line cap, and percent-normalization of Allow/Disallow values.
12
+ * - `evaluate` is RobotsMatcher: groups per user-agent (every group naming
13
+ * the agent is merged, as RFC 9309 §2.2.1 requires), the specific group
14
+ * shadows `*`, longest match wins, and Allow wins ties.
15
+ *
16
+ * Google works on bytes, so inputs are converted to a "byte string" (one
17
+ * char per UTF-8 byte) first; that keeps lengths and the non-ASCII escaping
18
+ * identical to the reference implementation.
19
+ *
20
+ * This file has no imports so the tests can run it under plain Node.
21
+ */
22
+
23
+ const MAX_LINE_LEN = 2083 * 8;
24
+
25
+ /** One char per UTF-8 byte (chars ≤ 0xFF pass through unchanged when already bytes). */
26
+ export function toBytes(input: string): string {
27
+ // Already a byte string (e.g. the output of this function)? Leave it.
28
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: byte range check.
29
+ if (!/[^\u0000-\u007f]/.test(input)) return input;
30
+ const bytes = new TextEncoder().encode(input);
31
+ let out = "";
32
+ for (const b of bytes) out += String.fromCharCode(b);
33
+ return out;
34
+ }
35
+
36
+ const isHex = (c: string | undefined) => c !== undefined && /^[0-9a-fA-F]$/.test(c);
37
+
38
+ /**
39
+ * MaybeEscapePattern(): uppercase existing %xx escapes and percent-encode
40
+ * bytes with the high bit set. Expects a byte string.
41
+ */
42
+ export function escapePattern(s: string): string {
43
+ let need = false;
44
+ for (let i = 0; i < s.length; i++) {
45
+ const c = s[i];
46
+ if (c === "%" && i + 2 < s.length && isHex(s[i + 1]) && isHex(s[i + 2])) {
47
+ if (/[a-f]/.test(s[i + 1]) || /[a-f]/.test(s[i + 2])) need = true;
48
+ i += 2;
49
+ } else if (s.charCodeAt(i) & 0x80) need = true;
50
+ }
51
+ if (!need) return s;
52
+ let out = "";
53
+ for (let i = 0; i < s.length; i++) {
54
+ const c = s[i];
55
+ if (c === "%" && i + 2 < s.length && isHex(s[i + 1]) && isHex(s[i + 2])) {
56
+ out += `%${s[i + 1].toUpperCase()}${s[i + 2].toUpperCase()}`;
57
+ i += 2;
58
+ } else if (s.charCodeAt(i) & 0x80) {
59
+ out += `%${s.charCodeAt(i).toString(16).toUpperCase().padStart(2, "0")}`;
60
+ } else out += c;
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /** The raw REP wildcard match (no normalization), anchored at the start of the path. */
66
+ export function matchRaw(pattern: string, path: string): boolean {
67
+ const pathlen = path.length;
68
+ const pos: number[] = [0];
69
+ let numpos = 1;
70
+ for (let pi = 0; pi < pattern.length; pi++) {
71
+ const pc = pattern[pi];
72
+ if (pc === "$" && pi + 1 === pattern.length) return pos[numpos - 1] === pathlen;
73
+ if (pc === "*") {
74
+ numpos = pathlen - pos[0] + 1;
75
+ for (let i = 1; i < numpos; i++) pos[i] = pos[i - 1] + 1;
76
+ } else {
77
+ let next = 0;
78
+ for (let i = 0; i < numpos; i++) {
79
+ if (pos[i] < pathlen && path[pos[i]] === pc) pos[next++] = pos[i] + 1;
80
+ }
81
+ numpos = next;
82
+ if (numpos === 0) return false;
83
+ }
84
+ }
85
+ return true;
86
+ }
87
+
88
+ /** Pattern vs path with both sides normalized (for author-entered values, e.g. the rule tester). */
89
+ export function matches(pattern: string, path: string): boolean {
90
+ return matchRaw(escapePattern(toBytes(pattern)), escapePattern(toBytes(path)));
91
+ }
92
+
93
+ /** GetPathParamsQuery(): path + params + query of a URL (no scheme, host or fragment); always starts with "/". */
94
+ export function pathParamsQuery(input: string): string {
95
+ const url = toBytes(input);
96
+ const n = url.length;
97
+ const NPOS = Number.MAX_SAFE_INTEGER;
98
+ const findFirstOf = (chars: string, from: number) => {
99
+ for (let i = from; i < n; i++) if (chars.includes(url[i])) return i;
100
+ return NPOS;
101
+ };
102
+ const searchStart = n >= 2 && url[0] === "/" && url[1] === "/" ? 2 : 0;
103
+ const earlyPath = findFirstOf("/?;", searchStart);
104
+ const proto = url.indexOf("://", searchStart);
105
+ let protocolEnd = proto === -1 ? NPOS : proto;
106
+ if (earlyPath < protocolEnd) protocolEnd = NPOS;
107
+ protocolEnd = protocolEnd === NPOS ? searchStart : protocolEnd + 3;
108
+
109
+ const pathStart = findFirstOf("/?;", protocolEnd);
110
+ if (pathStart === NPOS) return "/";
111
+ const hash = url.indexOf("#", searchStart);
112
+ const hashPos = hash === -1 ? NPOS : hash;
113
+ if (hashPos < pathStart) return "/";
114
+ const pathEnd = hashPos === NPOS ? n : hashPos;
115
+ const out = url.slice(pathStart, pathEnd);
116
+ return url[pathStart] === "/" ? out : `/${out}`;
117
+ }
118
+
119
+ /** ExtractUserAgent(): the leading run of [a-zA-Z_-]. */
120
+ export function extractUserAgent(userAgent: string): string {
121
+ const m = /^[a-zA-Z_-]*/.exec(userAgent);
122
+ return m ? m[0] : "";
123
+ }
124
+
125
+ /** IsValidUserAgentToObey(): non-empty and only [a-zA-Z_-]. */
126
+ export function isValidUserAgentToObey(userAgent: string): boolean {
127
+ return userAgent.length > 0 && extractUserAgent(userAgent) === userAgent;
128
+ }
129
+
130
+ export type DirectiveType = "user-agent" | "allow" | "disallow" | "sitemap" | "unknown";
131
+
132
+ export interface Directive {
133
+ line: number;
134
+ type: DirectiveType;
135
+ key: string;
136
+ value: string;
137
+ }
138
+
139
+ function keyType(key: string): DirectiveType {
140
+ const k = key.toLowerCase();
141
+ const starts = (p: string) => k.startsWith(p);
142
+ if (starts("user-agent") || starts("useragent") || starts("user agent")) return "user-agent";
143
+ if (starts("allow")) return "allow";
144
+ if (["disallow", "dissallow", "dissalow", "disalow", "diasllow", "disallaw"].some(starts)) return "disallow";
145
+ if (starts("sitemap") || starts("site-map")) return "sitemap";
146
+ return "unknown";
147
+ }
148
+
149
+ const WS = " \t\n\v\f\r";
150
+ const trimWs = (s: string) => {
151
+ let a = 0;
152
+ let b = s.length;
153
+ while (a < b && WS.includes(s[a])) a++;
154
+ while (b > a && WS.includes(s[b - 1])) b--;
155
+ return s.slice(a, b);
156
+ };
157
+
158
+ function parseLine(raw: string, lineNum: number): Directive | null {
159
+ let line = raw;
160
+ const hash = line.indexOf("#");
161
+ if (hash !== -1) line = line.slice(0, hash);
162
+ line = trimWs(line);
163
+ if (!line) return null;
164
+
165
+ let sep = line.indexOf(":");
166
+ if (sep === -1) {
167
+ // Missing-colon recovery: exactly two whitespace-separated tokens.
168
+ const ws = line.search(/[ \t]/);
169
+ if (ws === -1) return null;
170
+ let valStart = ws;
171
+ while (valStart < line.length && (line[valStart] === " " || line[valStart] === "\t")) valStart++;
172
+ if (/[ \t]/.test(line.slice(valStart))) return null;
173
+ sep = ws;
174
+ }
175
+ const key = trimWs(line.slice(0, sep));
176
+ if (!key) return null;
177
+ let value = trimWs(line.slice(sep + 1));
178
+ const type = keyType(key);
179
+ if (type === "allow" || type === "disallow") value = escapePattern(value);
180
+ return { line: lineNum, type, key, value };
181
+ }
182
+
183
+ /** Parse a robots.txt body into directives (Google's tolerances included). */
184
+ export function parse(input: string): { lines: number; directives: Directive[] } {
185
+ const body = toBytes(input);
186
+ const BOM = "\xEF\xBB\xBF";
187
+ const directives: Directive[] = [];
188
+ let line = "";
189
+ let lineNum = 0;
190
+ let bomPos = 0;
191
+ let lastCr = false;
192
+ const emit = (raw: string) => {
193
+ const d = parseLine(raw, ++lineNum);
194
+ if (d) directives.push(d);
195
+ };
196
+ for (let i = 0; i < body.length; i++) {
197
+ const ch = body[i];
198
+ if (bomPos < 3) {
199
+ const expected = BOM[bomPos++];
200
+ if (ch === expected) continue;
201
+ bomPos = 3;
202
+ }
203
+ if (ch !== "\n" && ch !== "\r") {
204
+ if (line.length < MAX_LINE_LEN - 1) line += ch;
205
+ } else {
206
+ const crlfContinuation = line === "" && lastCr && ch === "\n";
207
+ if (!crlfContinuation) emit(line);
208
+ line = "";
209
+ lastCr = ch === "\r";
210
+ }
211
+ }
212
+ emit(line);
213
+ return { lines: lineNum, directives };
214
+ }
215
+
216
+ type Match = { priority: number; line: number; value: string };
217
+ const NO_MATCH: Match = { priority: -1, line: 0, value: "" };
218
+
219
+ export interface Verdict {
220
+ allowed: boolean;
221
+ /** The path that was evaluated (path + query). */
222
+ path: string;
223
+ /** Which directive decided it ("none" when no rule matched). */
224
+ matchedDirective: "allow" | "disallow" | "none";
225
+ matchedValue: string;
226
+ /** 1-based line number in the robots.txt, 0 when none. */
227
+ matchedLine: number;
228
+ /** Whether the agent's own group(s) or the `*` group applied. */
229
+ scope: "specific" | "global";
230
+ }
231
+
232
+ /** Evaluate a URL against a whole robots.txt for one or more user-agent tokens. */
233
+ /**
234
+ * The product token at the start of a `User-agent:` value, for matching
235
+ * directory tokens: Google's ExtractUserAgent() stops at the first byte
236
+ * outside [a-zA-Z_-] ("MJ12bot" → "MJ"), which suits callers that pass
237
+ * pre-extracted tokens; this accepts digits and dots too so tokens such as
238
+ * MJ12bot or archive.org_bot match their own groups.
239
+ */
240
+ export function extractProductToken(userAgent: string): string {
241
+ const m = /^[A-Za-z0-9._-]*/.exec(userAgent);
242
+ return m ? m[0] : "";
243
+ }
244
+
245
+ /**
246
+ * Percent-encode a request path the way patterns are (non-ASCII bytes, spaces
247
+ * and controls; %xx hex uppercased). Expects a byte string (see toBytes).
248
+ */
249
+ export function normalizePath(bytes: string): string {
250
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: encoding control bytes.
251
+ return escapePattern(bytes).replace(/[\u0000-\u0020\u007f]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
252
+ }
253
+
254
+ export interface EvaluateOptions {
255
+ /**
256
+ * Percent-encode the request path like a crawler sends it (default false,
257
+ * which is Google's contract: the caller passes an already-encoded URL).
258
+ * The admin tester turns this on so a typed "/café" means "/caf%C3%A9".
259
+ */
260
+ encodePath?: boolean;
261
+ }
262
+
263
+ export function evaluate(body: string, userAgents: string[], url: string, options: EvaluateOptions = {}): Verdict {
264
+ return evaluateParsed(parse(body).directives, userAgents, url, options);
265
+ }
266
+
267
+ /** evaluate() over directives already returned by parse(), for callers that test many URLs against one file. */
268
+ export function evaluateParsed(directives: Directive[], userAgents: string[], url: string, options: EvaluateOptions = {}): Verdict {
269
+ const rawPath = pathParamsQuery(url);
270
+ const path = options.encodePath ? normalizePath(rawPath) : rawPath;
271
+
272
+ let allowGlobal = NO_MATCH;
273
+ let allowSpecific = NO_MATCH;
274
+ let disGlobal = NO_MATCH;
275
+ let disSpecific = NO_MATCH;
276
+ let seenGlobal = false;
277
+ let seenSpecific = false;
278
+ let everSpecific = false;
279
+ let seenSeparator = false;
280
+
281
+ const record = (kind: "allow" | "disallow", value: string, line: number): boolean => {
282
+ const priority = matchRaw(value, path) ? value.length : -1;
283
+ if (priority < 0) return false;
284
+ const m = { priority, line, value };
285
+ if (kind === "allow") {
286
+ if (seenSpecific) {
287
+ if (allowSpecific.priority < priority) allowSpecific = m;
288
+ } else if (allowGlobal.priority < priority) allowGlobal = m;
289
+ } else if (seenSpecific) {
290
+ if (disSpecific.priority < priority) disSpecific = m;
291
+ } else if (disGlobal.priority < priority) disGlobal = m;
292
+ return true;
293
+ };
294
+
295
+ const handleAllow = (value: string, line: number) => {
296
+ if (record("allow", value, line)) return;
297
+ // Google treats an Allow of ".../index.htm(l)" as also allowing the directory itself.
298
+ const slash = value.lastIndexOf("/");
299
+ if (slash !== -1 && value.slice(slash).startsWith("/index.htm")) handleAllow(`${value.slice(0, slash + 1)}$`, line);
300
+ };
301
+
302
+ for (const d of directives) {
303
+ if (d.type === "user-agent") {
304
+ if (seenSeparator) {
305
+ seenSpecific = false;
306
+ seenGlobal = false;
307
+ seenSeparator = false;
308
+ }
309
+ const v = d.value;
310
+ if (v.length >= 1 && v[0] === "*" && (v.length === 1 || WS.includes(v[1]))) {
311
+ seenGlobal = true;
312
+ } else {
313
+ const token = extractProductToken(v);
314
+ if (token && userAgents.some((qa) => qa.toLowerCase() === token.toLowerCase())) {
315
+ everSpecific = true;
316
+ seenSpecific = true;
317
+ }
318
+ }
319
+ continue;
320
+ }
321
+ if (d.type !== "allow" && d.type !== "disallow") continue;
322
+ if (!(seenGlobal || seenSpecific)) continue;
323
+ seenSeparator = true;
324
+ if (d.type === "allow") handleAllow(d.value, d.line);
325
+ else record("disallow", d.value, d.line);
326
+ }
327
+
328
+ let scope: Verdict["scope"] = "global";
329
+ let disPick = disGlobal;
330
+ let allowPick = allowGlobal;
331
+ let disallow = false;
332
+ if (allowSpecific.priority > 0 || disSpecific.priority > 0) {
333
+ scope = "specific";
334
+ disPick = disSpecific;
335
+ allowPick = allowSpecific;
336
+ disallow = disSpecific.priority > allowSpecific.priority;
337
+ } else if (everSpecific) {
338
+ scope = "specific";
339
+ disPick = NO_MATCH;
340
+ allowPick = NO_MATCH;
341
+ } else if (disGlobal.priority > 0 || allowGlobal.priority > 0) {
342
+ disallow = disGlobal.priority > allowGlobal.priority;
343
+ }
344
+
345
+ let matchedDirective: Verdict["matchedDirective"] = "none";
346
+ let matched = NO_MATCH;
347
+ if (disPick.priority > 0 || allowPick.priority > 0) {
348
+ if (disPick.priority > allowPick.priority) {
349
+ matchedDirective = "disallow";
350
+ matched = disPick;
351
+ } else {
352
+ matchedDirective = "allow";
353
+ matched = allowPick;
354
+ }
355
+ }
356
+ return { allowed: !disallow, path, matchedDirective, matchedValue: matched.value, matchedLine: matched.line, scope };
357
+ }
358
+
359
+ /** OneAgentAllowedByRobots(). */
360
+ export function oneAgentAllowed(body: string, userAgent: string, url: string): boolean {
361
+ return evaluate(body, [userAgent], url).allowed;
362
+ }