@thomasfosterau/effect-robots 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,566 @@
1
+ import * as Data from "effect/Data";
2
+ import * as Duration from "effect/Duration";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Option from "effect/Option";
5
+ import * as Schema from "effect/Schema";
6
+ import * as HttpClient from "effect/http/HttpClient";
7
+ //#region src/index.d.ts
8
+ /**
9
+ * A single rule in a robots.txt file: a path pattern and whether it allows.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import { Rule } from "@thomasfosterau/effect-robots"
14
+ *
15
+ * const rule = new Rule({ path: "/admin", allow: false })
16
+ * rule.path // "/admin"
17
+ * ```
18
+ *
19
+ * @since 0.1.0
20
+ * @category models
21
+ */
22
+ export declare class Rule extends Data.Class<{
23
+ readonly path: string;
24
+ readonly allow: boolean;
25
+ }> {}
26
+ /**
27
+ * A group of rules for one or more user agents.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * import { RobotGroup, Rule } from "@thomasfosterau/effect-robots"
32
+ *
33
+ * const group = new RobotGroup({ userAgents: ["*"], rules: [new Rule({ path: "/admin", allow: false })] })
34
+ * group.crawlDelay // undefined
35
+ * ```
36
+ *
37
+ * @since 0.1.0
38
+ * @category models
39
+ */
40
+ export declare class RobotGroup extends Data.Class<{
41
+ readonly userAgents: ReadonlyArray<string>;
42
+ readonly rules: ReadonlyArray<Rule>;
43
+ readonly crawlDelay?: number | undefined;
44
+ }> {}
45
+ /**
46
+ * A parsed robots.txt file: its groups and its `Sitemap:` URLs.
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * import { RobotsTxt } from "@thomasfosterau/effect-robots"
51
+ *
52
+ * const robots = new RobotsTxt({ groups: [], sitemaps: ["https://example.com/sitemap.xml"] })
53
+ * robots.sitemaps.length // 1
54
+ * ```
55
+ *
56
+ * @since 0.1.0
57
+ * @category models
58
+ */
59
+ export declare class RobotsTxt extends Data.Class<{
60
+ readonly groups: ReadonlyArray<RobotGroup>;
61
+ readonly sitemaps: ReadonlyArray<string>;
62
+ }> {}
63
+ /**
64
+ * Validates that a value is a {@link Rule} instance (`Schema.instanceOf`).
65
+ *
66
+ * @since 0.1.0
67
+ * @category schemas
68
+ */
69
+ export declare const RuleSchema: Schema.instanceOf<Rule, Rule>;
70
+ /**
71
+ * Validates that a value is a {@link RobotGroup} instance (`Schema.instanceOf`).
72
+ *
73
+ * @since 0.1.0
74
+ * @category schemas
75
+ */
76
+ export declare const RobotGroupSchema: Schema.instanceOf<RobotGroup, RobotGroup>;
77
+ /**
78
+ * Validates that a value is a {@link RobotsTxt} instance (`Schema.instanceOf`); use {@link RobotsTxtFromString} to parse text.
79
+ *
80
+ * @since 0.1.0
81
+ * @category schemas
82
+ */
83
+ export declare const RobotsTxtSchema: Schema.instanceOf<RobotsTxt, RobotsTxt>;
84
+ /**
85
+ * The schema between robots.txt text and {@link RobotsTxt}: decoding parses
86
+ * (RFC 9309 grouping, comments, unknown directives ignored), encoding
87
+ * serialises.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * import * as Schema from "effect/Schema"
92
+ * import { RobotsTxtFromString } from "@thomasfosterau/effect-robots"
93
+ *
94
+ * const robots = Schema.decodeUnknownSync(RobotsTxtFromString)("User-agent: *\nDisallow: /admin")
95
+ * robots.groups.length // 1
96
+ * Schema.encodeSync(RobotsTxtFromString)(robots) // "User-agent: *\nDisallow: /admin\n"
97
+ * ```
98
+ *
99
+ * @since 0.1.0
100
+ * @category schemas
101
+ */
102
+ export declare const RobotsTxtFromString: Schema.decodeTo<Schema.instanceOf<RobotsTxt, RobotsTxt>, Schema.String, never, never>;
103
+ /**
104
+ * The decoded side of {@link RobotsTxtFromString}: a {@link RobotsTxt}.
105
+ *
106
+ * @since 0.1.0
107
+ * @category schemas
108
+ */
109
+ export type RobotsTxtFromString = typeof RobotsTxtFromString.Type;
110
+ /**
111
+ * Parses robots.txt content into a {@link RobotsTxt} (synchronous).
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * import { parse } from "@thomasfosterau/effect-robots"
116
+ *
117
+ * const robots = parse("User-agent: *\nDisallow: /admin\nAllow: /admin/public\n\nSitemap: https://example.com/sitemap.xml")
118
+ * robots.groups[0]?.rules.length // 2
119
+ * robots.sitemaps // ["https://example.com/sitemap.xml"]
120
+ * ```
121
+ *
122
+ * @since 0.1.0
123
+ * @category parsing
124
+ */
125
+ export declare const parse: (content: string) => RobotsTxt;
126
+ /**
127
+ * Encodes a {@link RobotsTxt} back to robots.txt text (synchronous).
128
+ *
129
+ * @example
130
+ * ```ts
131
+ * import { encode, RobotGroup, RobotsTxt, Rule } from "@thomasfosterau/effect-robots"
132
+ *
133
+ * encode(new RobotsTxt({
134
+ * groups: [new RobotGroup({ userAgents: ["*"], rules: [new Rule({ path: "/admin", allow: false })] })],
135
+ * sitemaps: ["https://example.com/sitemap.xml"],
136
+ * }))
137
+ * // "User-agent: *\nDisallow: /admin\n\nSitemap: https://example.com/sitemap.xml"
138
+ * ```
139
+ *
140
+ * @since 0.1.0
141
+ * @category parsing
142
+ */
143
+ export declare const encode: (robotsTxt: RobotsTxt) => string;
144
+ /**
145
+ * Whether `userAgent` may access `urlPath` under the rules: the most
146
+ * specific matching group, then its longest matching rule, Allow winning
147
+ * ties; no matching group or rule means allowed. The query string and
148
+ * fragment are stripped before matching (the library's documented
149
+ * behaviour; {@link evaluateRobots} matches the query too, as RFC 9309
150
+ * §2.2.2 does).
151
+ *
152
+ * @example
153
+ * ```ts
154
+ * import { isAllowed, parse } from "@thomasfosterau/effect-robots"
155
+ *
156
+ * const robots = parse("User-agent: *\nDisallow: /admin\nAllow: /admin/public")
157
+ * isAllowed(robots, "MyBot", "/admin") // false
158
+ * isAllowed(robots, "MyBot", "/admin/public") // true
159
+ * isAllowed(robots, "MyBot", "/public") // true
160
+ * ```
161
+ *
162
+ * @since 0.1.0
163
+ * @category matching
164
+ */
165
+ export declare const isAllowed: (robotsTxt: RobotsTxt, userAgent: string, urlPath: string) => boolean;
166
+ /**
167
+ * The crawl delay (seconds) for `userAgent`, from its matching group.
168
+ *
169
+ * @example
170
+ * ```ts
171
+ * import { getCrawlDelay, parse } from "@thomasfosterau/effect-robots"
172
+ *
173
+ * const robots = parse("User-agent: Googlebot\nCrawl-delay: 5\nDisallow: /x\n\nUser-agent: *\nCrawl-delay: 10\nDisallow: /y")
174
+ * getCrawlDelay(robots, "Googlebot") // 5
175
+ * getCrawlDelay(robots, "OtherBot") // 10
176
+ * ```
177
+ *
178
+ * @since 0.1.0
179
+ * @category matching
180
+ */
181
+ export declare const getCrawlDelay: (robotsTxt: RobotsTxt, userAgent: string) => number | undefined;
182
+ /**
183
+ * Every `Sitemap:` URL in the file.
184
+ *
185
+ * @example
186
+ * ```ts
187
+ * import { getSitemaps, parse } from "@thomasfosterau/effect-robots"
188
+ *
189
+ * getSitemaps(parse("Sitemap: https://example.com/sitemap.xml")) // ["https://example.com/sitemap.xml"]
190
+ * ```
191
+ *
192
+ * @since 0.1.0
193
+ * @category matching
194
+ */
195
+ export declare const getSitemaps: (robotsTxt: RobotsTxt) => ReadonlyArray<string>;
196
+ /**
197
+ * Upper bound applied to `Crawl-delay` so a hostile value cannot stall a
198
+ * batch.
199
+ *
200
+ * @since 0.1.0
201
+ * @category constants
202
+ */
203
+ export declare const MAX_CRAWL_DELAY_SECONDS = 30;
204
+ /**
205
+ * The result of evaluating robots.txt rules for one URL.
206
+ *
207
+ * @since 0.1.0
208
+ * @category models
209
+ */
210
+ export interface RobotsEvaluation {
211
+ readonly allowed: boolean;
212
+ /** The matched rule's path pattern (diagnostics), if any rule matched. */
213
+ readonly matchedPattern: Option.Option<string>;
214
+ /** The selected group's crawl delay, capped; `None` when it declares none. */
215
+ readonly crawlDelaySeconds: Option.Option<number>;
216
+ }
217
+ /**
218
+ * Options for {@link evaluateRobots}.
219
+ *
220
+ * @since 0.1.0
221
+ * @category models
222
+ */
223
+ export interface EvaluateOptions {
224
+ /** The cap on a reported `Crawl-delay`. Defaults to {@link MAX_CRAWL_DELAY_SECONDS}. */
225
+ readonly maxCrawlDelaySeconds?: number | undefined;
226
+ }
227
+ /**
228
+ * Evaluates a parsed robots.txt for a user-agent token and a path — the
229
+ * crawlers' decision, with its diagnostics. Group selection: the longest
230
+ * matching product token wins, the `*` group is the fallback, none means
231
+ * allowed. Rule precedence: longest matching pattern wins, Allow wins ties.
232
+ * Unlike {@link isAllowed}, the path is matched as given, query included
233
+ * (RFC 9309 §2.2.2), and only a fragment is stripped. The crawl delay is
234
+ * capped and a non-positive one is dropped.
235
+ *
236
+ * @example
237
+ * ```ts
238
+ * import * as Option from "effect/Option"
239
+ * import { evaluateRobots, parse } from "@thomasfosterau/effect-robots"
240
+ *
241
+ * const robots = parse("User-agent: *\nCrawl-delay: 3600\nDisallow: /private/\nAllow: /private/public/")
242
+ * evaluateRobots(robots, "mybot", "/private/secret")
243
+ * // { allowed: false, matchedPattern: Option.some("/private/"), crawlDelaySeconds: Option.some(30) }
244
+ * evaluateRobots(robots, "mybot", "/private/public/page").allowed // true
245
+ * ```
246
+ *
247
+ * @since 0.1.0
248
+ * @category evaluation
249
+ */
250
+ export declare const evaluateRobots: (robotsTxt: RobotsTxt, userAgentToken: string, path: string, options?: EvaluateOptions) => RobotsEvaluation;
251
+ /**
252
+ * robots.txt responses are capped at this many bytes (RFC 9309 §2.5).
253
+ *
254
+ * @since 0.1.0
255
+ * @category constants
256
+ */
257
+ export declare const MAX_ROBOTS_BYTES: number;
258
+ /**
259
+ * Per-origin cache TTL for a fetched body or a 4xx (24 hours): a directive
260
+ * change is honoured within a day.
261
+ *
262
+ * @since 0.1.0
263
+ * @category constants
264
+ */
265
+ export declare const ROBOTS_CACHE_TTL_SECONDS: number;
266
+ /**
267
+ * Cache TTL for an unavailable robots.txt (one hour), so a misbehaving
268
+ * origin is not probed on every capture.
269
+ *
270
+ * @since 0.1.0
271
+ * @category constants
272
+ */
273
+ export declare const ROBOTS_UNAVAILABLE_TTL_SECONDS: number;
274
+ /**
275
+ * The cache value for a robots.txt that could not be read (5xx, transport
276
+ * failure, timeout). The leading character is a deliberate literal NUL
277
+ * byte — it can never collide with a real body. Written as the `\0` escape
278
+ * rather than an embedded raw NUL byte: same runtime string, but a raw NUL
279
+ * in the source makes tools that binary-sniff stop treating the file as
280
+ * text. Changing the value orphans entries already in a persistent cache.
281
+ *
282
+ * @since 0.1.0
283
+ * @category constants
284
+ */
285
+ export declare const ROBOTS_UNAVAILABLE_SENTINEL = "\0__robots_unavailable__";
286
+ /**
287
+ * The cache value for an origin with no restrictions (a 4xx, 404 included).
288
+ *
289
+ * @since 0.1.0
290
+ * @category constants
291
+ */
292
+ export declare const ROBOTS_NO_RESTRICTIONS = "";
293
+ /**
294
+ * The pluggable per-origin cache the checker reads through. Both methods
295
+ * are infallible by contract: a cache that can fail degrades to a miss / a
296
+ * dropped write inside its adapter. A persistent adapter, such as Workers
297
+ * KV, lives in the consuming application.
298
+ *
299
+ * @since 0.1.0
300
+ * @category models
301
+ */
302
+ export interface RobotsCache<R = never> {
303
+ /** The cached value for an origin (`https://host`), or `None`. */
304
+ readonly get: (origin: string) => Effect.Effect<Option.Option<string>, never, R>;
305
+ /** Stores the value with a TTL in seconds. */
306
+ readonly put: (origin: string, value: string, ttlSeconds: number) => Effect.Effect<void, never, R>;
307
+ }
308
+ /**
309
+ * A cache that stores nothing: every check fetches. The default cache of
310
+ * {@link makeRobotsChecker}.
311
+ *
312
+ * @example
313
+ * ```ts
314
+ * import * as Effect from "effect/Effect"
315
+ * import * as Option from "effect/Option"
316
+ * import { noRobotsCache } from "@thomasfosterau/effect-robots"
317
+ *
318
+ * await Effect.runPromise(noRobotsCache.put("https://example.com", "User-agent: *", 60))
319
+ * Option.isNone(await Effect.runPromise(noRobotsCache.get("https://example.com"))) // true
320
+ * ```
321
+ *
322
+ * @since 0.1.0
323
+ * @category caches
324
+ */
325
+ export declare const noRobotsCache: RobotsCache;
326
+ /**
327
+ * An in-memory cache (a `Map`), for tests and single-process crawlers. TTLs
328
+ * are recorded, not enforced.
329
+ *
330
+ * @example
331
+ * ```ts
332
+ * import * as Effect from "effect/Effect"
333
+ * import { memoryRobotsCache } from "@thomasfosterau/effect-robots"
334
+ *
335
+ * const cache = memoryRobotsCache()
336
+ * await Effect.runPromise(cache.put("https://example.com", "User-agent: *\nDisallow: /", 60))
337
+ * cache.entries.get("https://example.com")?.ttlSeconds // 60
338
+ * ```
339
+ *
340
+ * @since 0.1.0
341
+ * @category caches
342
+ */
343
+ export declare const memoryRobotsCache: () => RobotsCache & {
344
+ readonly entries: Map<string, {
345
+ readonly value: string;
346
+ readonly ttlSeconds: number;
347
+ }>;
348
+ };
349
+ /**
350
+ * Options for {@link fetchRobots} / {@link makeRobotsChecker}.
351
+ *
352
+ * @since 0.1.0
353
+ * @category models
354
+ */
355
+ export interface FetchRobotsOptions {
356
+ /** The full `User-Agent` header sent with the request. */
357
+ readonly userAgent: string;
358
+ /** Wall-clock bound on the request. Defaults to 10 seconds. */
359
+ readonly timeout?: Duration.Input | undefined;
360
+ /** The body cap. Defaults to {@link MAX_ROBOTS_BYTES}. */
361
+ readonly maxBytes?: number | undefined;
362
+ }
363
+ /**
364
+ * Fetches `<origin>/robots.txt` through the `HttpClient` in context and
365
+ * folds the outcome into the cache value with RFC 9309 §2.3.1's posture: a
366
+ * 2xx body (capped), {@link ROBOTS_NO_RESTRICTIONS} for a 4xx (404
367
+ * included), and {@link ROBOTS_UNAVAILABLE_SENTINEL} for a 5xx, a transport
368
+ * failure, a timeout or a body that cannot be read — the crawler must
369
+ * assume it is not permitted until it can read the rules. Never fails.
370
+ *
371
+ * Redirect policy is the client's: provide one whose redirects are
372
+ * revalidated against the SSRF block list (`@thomasfosterau/effect-web/net`).
373
+ *
374
+ * @example
375
+ * ```ts
376
+ * import * as Effect from "effect/Effect"
377
+ * import * as HttpClient from "effect/http/HttpClient"
378
+ * import * as HttpClientResponse from "effect/http/HttpClientResponse"
379
+ * import { fetchRobots, ROBOTS_NO_RESTRICTIONS } from "@thomasfosterau/effect-robots"
380
+ *
381
+ * const notFound = HttpClient.make((request) =>
382
+ * Effect.succeed(HttpClientResponse.fromWeb(request, new Response("nope", { status: 404 }))),
383
+ * )
384
+ * await Effect.runPromise(
385
+ * fetchRobots("https://example.com", { userAgent: "mybot/1.0" }).pipe(
386
+ * Effect.provideService(HttpClient.HttpClient, notFound),
387
+ * ),
388
+ * ) === ROBOTS_NO_RESTRICTIONS // true
389
+ * ```
390
+ *
391
+ * @since 0.1.0
392
+ * @category fetching
393
+ */
394
+ export declare const fetchRobots: (origin: string, options: FetchRobotsOptions) => Effect.Effect<string, never, HttpClient.HttpClient>;
395
+ /**
396
+ * The decision {@link makeRobotsChecker}'s `checkRobots` returns.
397
+ *
398
+ * @since 0.1.0
399
+ * @category models
400
+ */
401
+ export type RobotsDecision = {
402
+ readonly allowed: true;
403
+ readonly crawlDelaySeconds: Option.Option<number>;
404
+ } | {
405
+ readonly allowed: false;
406
+ readonly reason: string;
407
+ };
408
+ /**
409
+ * What one crawler's checker is built from.
410
+ *
411
+ * @since 0.1.0
412
+ * @category models
413
+ */
414
+ export interface RobotsCheckerOptions<R = never> extends FetchRobotsOptions, EvaluateOptions {
415
+ /** The product token matched against `User-agent:` lines. */
416
+ readonly userAgentToken: string;
417
+ /** The per-origin cache. Defaults to {@link noRobotsCache}. */
418
+ readonly cache?: RobotsCache<R> | undefined;
419
+ /** TTL for a body or a 4xx. Defaults to {@link ROBOTS_CACHE_TTL_SECONDS}. */
420
+ readonly cacheTtlSeconds?: number | undefined;
421
+ /** TTL for an unavailable outcome. Defaults to {@link ROBOTS_UNAVAILABLE_TTL_SECONDS}. */
422
+ readonly unavailableTtlSeconds?: number | undefined;
423
+ /**
424
+ * Whether the query string takes part in matching. Defaults to `true`
425
+ * (`pathname + search`, as RFC 9309 §2.2.2 matches); `false` matches the
426
+ * pathname alone.
427
+ */
428
+ readonly matchQuery?: boolean | undefined;
429
+ }
430
+ /**
431
+ * The reason a disallowed decision carries for an unavailable robots.txt.
432
+ *
433
+ * @since 0.1.0
434
+ * @category constants
435
+ */
436
+ export declare const ROBOTS_UNAVAILABLE_REASON = "robots.txt unavailable (server error) — treating as disallow";
437
+ /**
438
+ * Builds a crawler's `checkRobots`: whether it may fetch `url` under the
439
+ * origin's robots.txt, reading the origin's body through the cache and
440
+ * fetching on a miss ({@link fetchRobots}), then {@link evaluateRobots}
441
+ * for the crawler's token. Never fails — robots problems surface as
442
+ * `allowed: false` decisions, and cache problems are the adapter's to
443
+ * degrade. The fetch outcome is cached with the TTL its posture warrants
444
+ * (unavailability shorter), and a failed cache write is not a failed
445
+ * decision.
446
+ *
447
+ * @example
448
+ * ```ts
449
+ * import * as Effect from "effect/Effect"
450
+ * import * as HttpClient from "effect/http/HttpClient"
451
+ * import * as HttpClientResponse from "effect/http/HttpClientResponse"
452
+ * import { makeRobotsChecker, memoryRobotsCache } from "@thomasfosterau/effect-robots"
453
+ *
454
+ * const site = HttpClient.make((request) =>
455
+ * Effect.succeed(HttpClientResponse.fromWeb(request, new Response("User-agent: *\nDisallow: /secret/", { status: 200 }))),
456
+ * )
457
+ * const checkRobots = makeRobotsChecker({
458
+ * userAgent: "mybot/1.0 (+https://example.com)",
459
+ * userAgentToken: "mybot",
460
+ * cache: memoryRobotsCache(),
461
+ * })
462
+ * const decision = await Effect.runPromise(
463
+ * checkRobots("https://origin.example/secret/x").pipe(Effect.provideService(HttpClient.HttpClient, site)),
464
+ * )
465
+ * decision // { allowed: false, reason: "disallowed by robots.txt rule '/secret/'" }
466
+ * ```
467
+ *
468
+ * @since 0.1.0
469
+ * @category checking
470
+ */
471
+ export declare const makeRobotsChecker: <R = never>(options: RobotsCheckerOptions<R>) => ((url: string) => Effect.Effect<RobotsDecision, never, HttpClient.HttpClient | R>);
472
+ /**
473
+ * What a site's production `robots.txt` says.
474
+ *
475
+ * @since 0.1.0
476
+ * @category models
477
+ */
478
+ export interface RobotsPolicy {
479
+ /**
480
+ * Non-content URL spaces kept out of the crawl to preserve budget. A `*`
481
+ * wildcard matches across path segments (RFC 9309 §2.2.3), so `/*\/edit`
482
+ * covers `/posts/<uuid>/edit`.
483
+ */
484
+ readonly disallow: ReadonlyArray<string>;
485
+ /** The absolute, production-canonical sitemap URL. */
486
+ readonly sitemapUrl: string;
487
+ /** Comment lines emitted first, without the `# ` prefix. */
488
+ readonly comments?: ReadonlyArray<string> | undefined;
489
+ }
490
+ /**
491
+ * The non-production body: a full disallow, no sitemap (ROBOTS-005), so a
492
+ * `dev-<branch>` / `ci-<run>` / PR-preview deployment is never crawled.
493
+ *
494
+ * @since 0.1.0
495
+ * @category serving
496
+ */
497
+ export declare const NON_PRODUCTION_BODY: string;
498
+ /**
499
+ * The production body: open to all crawlers (`User-agent: *`, AI crawlers
500
+ * included), the hygiene disallows, then the absolute sitemap URL
501
+ * (ROBOTS-002 … -004).
502
+ *
503
+ * @example
504
+ * ```ts
505
+ * import { productionBody } from "@thomasfosterau/effect-robots"
506
+ *
507
+ * productionBody({ disallow: ["/login", "/*\/edit"], sitemapUrl: "https://example.com/sitemap.xml", comments: ["All crawlers welcome."] })
508
+ * // "# All crawlers welcome.\nUser-agent: *\nDisallow: /login\nDisallow: /*\/edit\n\nSitemap: https://example.com/sitemap.xml\n"
509
+ * ```
510
+ *
511
+ * @since 0.1.0
512
+ * @category serving
513
+ */
514
+ export declare const productionBody: (policy: RobotsPolicy) => string;
515
+ /**
516
+ * The `robots.txt` body for a stage: the production policy when
517
+ * `production`, else {@link NON_PRODUCTION_BODY} (ROBOTS-005). Pure, so the
518
+ * stage branch is unit-testable without a runtime; the caller decides the
519
+ * stage (`PUBLIC_STAGE === "production"`, say) and builds the sitemap URL
520
+ * from the canonical base, never the request origin.
521
+ *
522
+ * @example
523
+ * ```ts
524
+ * import { NON_PRODUCTION_BODY, robotsBody } from "@thomasfosterau/effect-robots"
525
+ *
526
+ * robotsBody(false, { disallow: ["/login"], sitemapUrl: "https://example.com/sitemap.xml" }) === NON_PRODUCTION_BODY // true
527
+ * robotsBody(true, { disallow: ["/login"], sitemapUrl: "https://example.com/sitemap.xml" }).includes("Sitemap: https://example.com/sitemap.xml") // true
528
+ * ```
529
+ *
530
+ * @since 0.1.0
531
+ * @category serving
532
+ */
533
+ export declare const robotsBody: (production: boolean, policy: RobotsPolicy) => string;
534
+ /**
535
+ * The `Cache-Control` a low-churn generated document (`robots.txt`,
536
+ * `sitemap.xml`, `feed.json`) is served with: the edge caches the whole
537
+ * document, bounding any request-time enumeration behind it to at most one
538
+ * per hour per edge (PERF-CACHE-002). A mutation that could change the
539
+ * document purges its `Cache-Tag` rather than relying on the TTL alone.
540
+ *
541
+ * @since 0.1.0
542
+ * @category serving
543
+ */
544
+ export declare const LOW_CHURN_CACHE_CONTROL = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400";
545
+ /**
546
+ * A guard for the strings the checker treats as sentinels: a real body is
547
+ * neither empty nor the unavailable marker.
548
+ *
549
+ * @example
550
+ * ```ts
551
+ * import {
552
+ * isRobotsBody,
553
+ * ROBOTS_NO_RESTRICTIONS,
554
+ * ROBOTS_UNAVAILABLE_SENTINEL,
555
+ * } from "@thomasfosterau/effect-robots"
556
+ *
557
+ * isRobotsBody("User-agent: *\nDisallow: /admin") // true
558
+ * isRobotsBody(ROBOTS_NO_RESTRICTIONS) // false
559
+ * isRobotsBody(ROBOTS_UNAVAILABLE_SENTINEL) // false
560
+ * ```
561
+ *
562
+ * @since 0.1.0
563
+ * @category predicates
564
+ */
565
+ export declare const isRobotsBody: (value: string) => boolean;
566
+ //#endregion