@escape-game-over/atlas 0.1.70 → 0.1.72

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.
package/README.md CHANGED
@@ -119,6 +119,7 @@ it returns and nothing else, resolves it, and writes `_redirects`:
119
119
  // projects/rome/redirects.ts
120
120
  export default redirects([
121
121
  { from: "/jobs", to: { route: "careers" }, kind: "permanent" }, // ✗ if Rome has no careers page
122
+ { from: "/en/*", to: { route: "home", locale: "en-US", splat: true }, kind: "permanent" }, // /en/faq → /en-US/faq
122
123
  ])
123
124
  ```
124
125
 
@@ -194,7 +195,7 @@ than printing the brackets. See [`docs/rich-text.md`](docs/rich-text.md).
194
195
  | `LocalBusiness` JSON-LD | `localBusiness()` | address, phone, hours |
195
196
  | `Organization` JSON-LD | `organization()` | name, URL, logo |
196
197
  | `WebSite` JSON-LD | `website()`, home page only | site name, optional alternate |
197
- | `Product` JSON-LD | `product()` | name, price table |
198
+ | `Product` JSON-LD | `product()` | name, price by quantity or audience |
198
199
  | `Article` JSON-LD | `article()` | headline, publication date |
199
200
  | `VideoObject` JSON-LD | `videoObject()` | a video, its stills and date |
200
201
  | `FAQPage` JSON-LD | `faqPage()` | the questions the page itself shows |
package/docs/checks.md CHANGED
@@ -116,7 +116,7 @@ dependencies at all. Vitest type-checks them itself when it runs them.
116
116
  | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
117
117
  | `meta.test.ts` | the `robots` tag a policy builds, the pinned `twitter:card`, share-image warnings, length warnings, `fileUrl` |
118
118
  | `not-found.test.ts` | that a 404 carries the site's icon and theme colour, still names no canonical, and is held to the same icon rules |
119
- | `redirects.test.ts` | rule resolution for all three target kinds, the five rejections, the Cloudflare 2000-rule cap |
119
+ | `redirects.test.ts` | rule resolution for every target kind, the rejections, Cloudflare's 2000 static and 100 wildcard caps, and which rule answers a request |
120
120
  | `sitemap.test.ts` | splitting into an index plus numbered parts, and that the entry keeps its name either way |
121
121
  | `merge.test.ts` | catalog and route overlay precedence — per locale, never per key |
122
122
  | `llms.test.ts` | section grouping, owners, heading and link precedence, and the undescribed fallback |
@@ -127,7 +127,7 @@ dependencies at all. Vitest type-checks them itself when it runs them.
127
127
  | `google-analytics.test.ts` | that consent is denied first and precedes every tag, one loader for many ids, dead `UA-` properties, and the preconnect — once, ahead of the block that writes the loader's URL, never `crossorigin` |
128
128
  | `contact.test.ts` | the E.164 a `tel:` needs — trunk zero dropped, spacing stripped — and the displayed form kept |
129
129
  | `hours.test.ts` | collapsing a week into runs, week start changing the answer, and every impossible week that throws |
130
- | `money.test.ts` | a bare count widened to a band, the span of a table, and the gaps and overlaps that throw |
130
+ | `money.test.ts` | a bare count widened to a band, the span of a table, the gaps and overlaps that throw, and an audience priced twice |
131
131
  | `url.test.ts` | joining an origin to a path exactly once, and normalising the origin an `@id` is built from |
132
132
  | `warn.test.ts` | the shared prefix, and reducing a URL to its path |
133
133
  | `filters.test.ts` | folding both sides of a query, how each kind narrows, the four URL decisions, and attach/detach |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.70",
3
+ "version": "0.1.72",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -5,6 +5,7 @@ import type { AstroIntegration } from "astro";
5
5
  import type { GeneratedFile } from "../file.ts";
6
6
  import {
7
7
  buildCloudflareRedirects,
8
+ matchRedirect,
8
9
  type ProjectRedirects,
9
10
  type RedirectRule,
10
11
  type ResolvedRedirect,
@@ -392,21 +393,15 @@ export function siteRoutes<Id extends string, L extends string>(
392
393
  // Not Astro's `redirects`: it serves every redirect to a
393
394
  // non-route (an external URL, a public file) as 301,
394
395
  // whatever status it was given.
395
- // Matched with and without a trailing slash, because a host
396
- // either normalises one away before its rules run or treats
397
- // the two as the same URL. Dev should not be the only place
398
- // where `/rooms/` misses a rule that `/rooms` hits.
399
- const canonical =
400
- path.length > 1 && path.endsWith("/")
401
- ? path.slice(0, -1)
402
- : path;
403
- const rule = redirects.find((it) => it.from === canonical);
404
- if (rule !== undefined) {
396
+ // Matched as the host matches them — wildcards included,
397
+ // top-most first — so `/en/faq` follows `/en/*` here too.
398
+ const match = matchRedirect(redirects, path);
399
+ if (match !== undefined) {
405
400
  logger.info(
406
- `redirect: ${path} → ${rule.to} (${rule.status})`
401
+ `redirect: ${path} → ${match.location} (${match.rule.status})`
407
402
  );
408
- response.statusCode = rule.status;
409
- response.setHeader("Location", rule.to);
403
+ response.statusCode = match.rule.status;
404
+ response.setHeader("Location", match.location);
410
405
  response.end();
411
406
  return;
412
407
  }
package/src/index.ts CHANGED
@@ -196,6 +196,9 @@ export {
196
196
  // advertise a range for a room that skips sizes. `formatQuantities` answers it
197
197
  // for prose and `quantitiesFor` for structured data; both read the same tiers.
198
198
  export {
199
+ type AudiencePrice,
200
+ type AudienceTier,
201
+ assertAudiences,
199
202
  assertPriceTiers,
200
203
  type CurrencyCode,
201
204
  formatQuantities,
@@ -1,9 +1,14 @@
1
1
  import {
2
+ type AudiencePrice,
3
+ type AudienceTier,
4
+ assertAmount,
5
+ assertAudiences,
2
6
  assertPriceTiers,
3
7
  type Price,
4
8
  type PriceTier,
5
9
  type TieredPrice,
6
10
  } from "../money.ts";
11
+ import type { NonEmpty } from "../types.ts";
7
12
  import type { HttpsUrl } from "../url.ts";
8
13
  import type { BusinessId, OrganizationId } from "./ids.ts";
9
14
  import { type JsonLdNode, schemaType } from "./node.ts";
@@ -17,7 +22,7 @@ export interface ProductInput {
17
22
  /** A picture of it, absolute. */
18
23
  readonly image?: HttpsUrl;
19
24
  /**
20
- * What it costs: one price, or a price per quantity.
25
+ * What it costs: one price, a price per quantity, or a price per audience.
21
26
  *
22
27
  * The reason to emit this node at all — an offer with a price and a
23
28
  * currency is what a search result can show a number for. A product with no
@@ -25,9 +30,10 @@ export interface ProductInput {
25
30
  *
26
31
  * A table becomes an `AggregateOffer` carrying the range, with each band as
27
32
  * an `Offer` inside it. Two bare prices on one product would otherwise read
28
- * as a contradiction rather than as a discount.
33
+ * as a contradiction rather than as a discount. An audience's `Offer` is
34
+ * named for it, so pass the words a reader sees, not the stored ids.
29
35
  */
30
- readonly price: Price | TieredPrice;
36
+ readonly price: Price | TieredPrice | AudiencePrice;
31
37
  /**
32
38
  * The `@id` of whoever sells it — a venue, or the company itself.
33
39
  *
@@ -94,14 +100,23 @@ export function product(item: ProductInput): JsonLdNode {
94
100
  function offerFor(
95
101
  item: ProductInput,
96
102
  amount: number,
97
- band?: PriceTier
103
+ detail: {
104
+ /** The quantity band this price applies at, from a `TieredPrice`. */
105
+ readonly band?: PriceTier;
106
+ /** Who pays it, from an `AudiencePrice`. */
107
+ readonly name?: string;
108
+ } = {}
98
109
  ): JsonLdNode {
99
- // `currency` is on both members of the union, so no narrowing is needed;
100
- // `unit` describes what a quantity counts and only a table has one.
110
+ const { band, name } = detail;
111
+ // `currency` is on every member of the union, so no narrowing is needed;
112
+ // `unit` describes what a quantity counts and only a quantity table has one.
101
113
  const unit = "tiers" in item.price ? item.price.unit : undefined;
102
114
 
103
115
  return {
104
116
  "@type": "Offer",
117
+ // An `Offer` is a `Thing`, so it can carry a `name`; it is what tells
118
+ // the adults' price from the children's.
119
+ ...(name === undefined ? {} : { name }),
105
120
  price: amount,
106
121
  priceCurrency: item.price.currency,
107
122
  availability: "https://schema.org/InStock",
@@ -131,21 +146,25 @@ function offerFor(
131
146
  /** One `Offer`, or an `AggregateOffer` wrapping the bands. */
132
147
  function offersFor(item: ProductInput): JsonLdNode {
133
148
  if ("amount" in item.price) {
149
+ assertAmount(item.price.amount, "product");
134
150
  return offerFor(item, item.price.amount);
135
151
  }
136
152
 
137
- assertPriceTiers(item.price, "product");
153
+ const offers =
154
+ "audiences" in item.price
155
+ ? audienceOffers(item, item.price)
156
+ : tierOffers(item, item.price);
138
157
 
139
158
  // A table of one is one price, whatever it was written as. Wrapping it
140
159
  // would emit an `AggregateOffer` whose low and high are the same number
141
160
  // and whose `offerCount` is 1 — a range across nothing, and a shape that
142
- // says "prices vary" of a product whose price does not. The band is still
143
- // passed through, so a single row covering 2 to 6 keeps its
144
- // `eligibleQuantity`: what collapses is the wrapper, not the quantity.
145
- const [only, ...rest] = item.price.tiers;
146
- if (rest.length === 0) return offerFor(item, only.amountPerUnit, only);
161
+ // says "prices vary" of a product whose price does not. The detail is
162
+ // kept, so a single row covering 2 to 6 keeps its `eligibleQuantity` and a
163
+ // single audience its name: what collapses is the wrapper, not the claim.
164
+ const [only, ...rest] = offers;
165
+ if (rest.length === 0) return only.offer;
147
166
 
148
- const amounts = item.price.tiers.map((tier) => tier.amountPerUnit);
167
+ const amounts = offers.map((entry) => entry.amount);
149
168
 
150
169
  // The wrapper carries the *aggregate* facts and nothing else. `seller`,
151
170
  // `url` and `availability` are per-offer and sit on the bands, so a reader
@@ -164,9 +183,41 @@ function offersFor(item: ProductInput): JsonLdNode {
164
183
  // behind it. Both, because either alone is a worse answer.
165
184
  lowPrice: Math.min(...amounts),
166
185
  highPrice: Math.max(...amounts),
167
- offerCount: item.price.tiers.length,
168
- offers: item.price.tiers.map((tier) =>
169
- offerFor(item, tier.amountPerUnit, tier)
170
- ),
186
+ offerCount: offers.length,
187
+ offers: offers.map((entry) => entry.offer),
171
188
  };
172
189
  }
190
+
191
+ /** One offer, and the amount it was built from, for the range. */
192
+ interface BuiltOffer {
193
+ readonly amount: number;
194
+ readonly offer: JsonLdNode;
195
+ }
196
+
197
+ /** A quantity table's bands, each saying the quantity it applies at. */
198
+ function tierOffers(
199
+ item: ProductInput,
200
+ price: TieredPrice
201
+ ): NonEmpty<BuiltOffer> {
202
+ assertPriceTiers(price, "product");
203
+ const build = (tier: PriceTier): BuiltOffer => ({
204
+ amount: tier.amountPerUnit,
205
+ offer: offerFor(item, tier.amountPerUnit, { band: tier }),
206
+ });
207
+ const [first, ...rest] = price.tiers;
208
+ return [build(first), ...rest.map(build)];
209
+ }
210
+
211
+ /** An audience table's prices, each named for who pays it. */
212
+ function audienceOffers(
213
+ item: ProductInput,
214
+ price: AudiencePrice
215
+ ): NonEmpty<BuiltOffer> {
216
+ assertAudiences(price, "product");
217
+ const build = (tier: AudienceTier): BuiltOffer => ({
218
+ amount: tier.amount,
219
+ offer: offerFor(item, tier.amount, { name: tier.audience }),
220
+ });
221
+ const [first, ...rest] = price.audiences;
222
+ return [build(first), ...rest.map(build)];
223
+ }
package/src/money.ts CHANGED
@@ -242,6 +242,82 @@ export interface TieredPrice {
242
242
  readonly unit?: string;
243
243
  }
244
244
 
245
+ /**
246
+ * What one kind of buyer pays.
247
+ *
248
+ * Generic over `audience` because the same price is written twice, by two
249
+ * different hands. Where prices are stored it is an id in the project's own
250
+ * vocabulary — `"adults"`, `"reduced"` — and where they are published it is the
251
+ * words a reader sees, since `product()` writes it as the offer's `name`. lib
252
+ * holds no copy, so the call site translates between the two: one shape at both
253
+ * ends, with the strings swapped.
254
+ */
255
+ export interface AudienceTier<A extends string = string> {
256
+ readonly audience: A;
257
+ /** What one of them pays. */
258
+ readonly amount: number;
259
+ }
260
+
261
+ /**
262
+ * A price that depends on who is buying: the full rate, and less for children
263
+ * or students.
264
+ *
265
+ * Not a `TieredPrice`, though both are tables. That one varies with how many
266
+ * are bought and publishes each band as a quantity; a student rate written as a
267
+ * band would tell a search engine it applies to some number of players, which
268
+ * is a different claim and a wrong one.
269
+ *
270
+ * One currency for the table, for the reason `TieredPrice` has one.
271
+ */
272
+ export interface AudiencePrice<A extends string = string> {
273
+ readonly currency: CurrencyCode;
274
+ /** The full rate first, by convention. Never empty. */
275
+ readonly audiences: NonEmpty<AudienceTier<A>>;
276
+ }
277
+
278
+ /**
279
+ * Throws on an audience table that prices one audience twice.
280
+ *
281
+ * The audience counterpart of `assertPriceTiers`, and a contradiction for the
282
+ * same reason an overlap is: whichever of the two a reader sees is the one they
283
+ * will expect to pay.
284
+ */
285
+ export function assertAudiences(price: AudiencePrice, at: string): void {
286
+ const seen = new Set<string>();
287
+ const twice = new Set<string>();
288
+ for (const tier of price.audiences) {
289
+ // Published as the offer's name, so a blank one is an offer for
290
+ // nobody in particular — usually a translation that came back empty.
291
+ if (tier.audience.trim() === "") {
292
+ throw new Error(
293
+ `${at}: an audience has no name, so its price would be published unlabelled.`
294
+ );
295
+ }
296
+ assertAmount(tier.amount, at);
297
+ if (seen.has(tier.audience)) twice.add(tier.audience);
298
+ seen.add(tier.audience);
299
+ }
300
+ if (twice.size > 0) {
301
+ throw new Error(
302
+ `${at}: ${[...twice].map((audience) => `"${audience}"`).join(", ")} priced twice, so one buyer has two prices.`
303
+ );
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Throws on an amount nobody can be charged: below zero, or not a number.
309
+ *
310
+ * Zero passes. Free entry for small children is a real price, and schema.org
311
+ * reads an offer of 0 as free rather than as missing.
312
+ */
313
+ export function assertAmount(amount: number, at: string): void {
314
+ if (!Number.isFinite(amount) || amount < 0) {
315
+ throw new Error(
316
+ `${at}: ${amount} is not a price; an amount is zero or more.`
317
+ );
318
+ }
319
+ }
320
+
245
321
  /** The band a tier covers, with a bare count widened to a range of one. */
246
322
  export function tierRange(tier: PriceTier): QuantityRange {
247
323
  return typeof tier.quantity === "number"
@@ -375,6 +451,12 @@ export function assertPriceTiers(price: TieredPrice, at: string): void {
375
451
  for (const tier of price.tiers) {
376
452
  const range = tierRange(tier);
377
453
 
454
+ if (!Number.isFinite(tier.amountPerUnit) || tier.amountPerUnit < 0) {
455
+ problems.push(
456
+ `${tier.amountPerUnit} is not a price; an amount is zero or more.`
457
+ );
458
+ }
459
+
378
460
  if (range.max < range.min) {
379
461
  problems.push(
380
462
  `a tier covers ${range.min} to ${range.max}, which is backwards.`
package/src/redirects.ts CHANGED
@@ -71,13 +71,19 @@ export type SitePath = UrlPath;
71
71
  */
72
72
  export type ExternalUrl = HttpsUrl;
73
73
 
74
+ /** A `splat` target whose source has no `*` to carry: there is no rest to keep. */
75
+ export interface SplatNeedsWildcard<From extends string> {
76
+ readonly __SPLAT_WITHOUT_WILDCARD__: `"${From}" has no "/*" to carry over: a rule with \`splat: true\` needs a source ending in "/*"`;
77
+ }
78
+
74
79
  /**
75
- * Rejects an external target that is not `https://`, naming it.
80
+ * Rejects an external target that is not `https://`, and a `splat` target
81
+ * whose source has no wildcard, naming each.
76
82
  *
77
- * Applied to the rules array in parameter position, so a bad URL reports on its
78
- * own line rather than as the whole array failing to match. The check itself is
79
- * `ValidHttpsUrl`, shared with the site origin — one idea of an acceptable URL,
80
- * stated once.
83
+ * Applied to the rules array in parameter position, so a bad rule reports on
84
+ * its own line rather than as the whole array failing to match. The URL check
85
+ * itself is `ValidHttpsUrl`, shared with the site origin — one idea of an
86
+ * acceptable URL, stated once.
81
87
  */
82
88
  export type ValidateRedirectTargets<Rules> = {
83
89
  readonly [K in keyof Rules]: Rules[K] extends {
@@ -90,7 +96,18 @@ export type ValidateRedirectTargets<Rules> = {
90
96
  ? ValidHttpsUrl<To>
91
97
  : Rules[K][P];
92
98
  }
93
- : Rules[K];
99
+ : Rules[K] extends {
100
+ readonly from: infer From extends string;
101
+ readonly to: { readonly splat: true };
102
+ }
103
+ ? From extends `${string}/*`
104
+ ? Rules[K]
105
+ : {
106
+ readonly [P in keyof Rules[K]]: P extends "from"
107
+ ? SplatNeedsWildcard<From>
108
+ : Rules[K][P];
109
+ }
110
+ : Rules[K];
94
111
  };
95
112
 
96
113
  /**
@@ -105,7 +122,28 @@ export type ValidateRedirectTargets<Rules> = {
105
122
  */
106
123
  export type RedirectTarget<Id extends string, L extends string> =
107
124
  | ExternalUrl
108
- | { readonly route: Id; readonly locale?: L; readonly page?: number }
125
+ | {
126
+ readonly route: Id;
127
+ readonly locale?: L;
128
+ readonly page?: number;
129
+ readonly splat?: never;
130
+ }
131
+ /**
132
+ * A page, with whatever the source's `*` matched kept after its path:
133
+ * `/en/*` to `{ route: "home", locale: "en-US", splat: true }` sends
134
+ * `/en/faq` to `/en-US/faq`.
135
+ *
136
+ * For an old prefix whose pages kept their slugs, so one rule answers every
137
+ * URL under it. The target stays a route, so the prefix it lands on is
138
+ * derived like every other link rather than spelled out by hand. Not with
139
+ * `page`: the rest of a path cannot follow a page number.
140
+ */
141
+ | {
142
+ readonly route: Id;
143
+ readonly locale?: L;
144
+ readonly splat: true;
145
+ readonly page?: never;
146
+ }
109
147
  /**
110
148
  * A file served verbatim from `public/` — a PDF, a spreadsheet.
111
149
  *
@@ -172,15 +210,70 @@ export interface CloudflareRedirectsOptions {
172
210
  }
173
211
 
174
212
  /**
175
- * Cloudflare Pages honours the first 2000 rules and drops the rest in silence.
213
+ * Cloudflare Pages honours 2000 static rules and 100 dynamic ones — a source
214
+ * with a `*` — and drops the rest in silence.
215
+ *
216
+ * Counted apart because they are capped apart: a file of 150 wildcards is far
217
+ * under 2100 and still loses fifty of them. Not options: they are facts about
218
+ * the host this renderer is named for, not preferences. Exposing them would
219
+ * only let a caller supply a number that is not the real one, and the failure
220
+ * they guard against — redirects that quietly stop working because they sit at
221
+ * the bottom of a long file — is exactly the kind nobody goes looking for.
222
+ */
223
+ const CLOUDFLARE_STATIC_LIMIT = 2000;
224
+ const CLOUDFLARE_DYNAMIC_LIMIT = 100;
225
+
226
+ /** Whether a source matches more than itself: a `*` takes the rest of a path. */
227
+ function isDynamic(rule: ResolvedRedirect): boolean {
228
+ return rule.from.includes("*");
229
+ }
230
+
231
+ /** Where a request is sent, and by which rule. */
232
+ export interface RedirectMatch {
233
+ readonly rule: ResolvedRedirect;
234
+ /** The rule's target, with `:splat` filled in from the request. */
235
+ readonly location: string;
236
+ }
237
+
238
+ /**
239
+ * The rule a static host answers `path` with, as Cloudflare Pages picks it:
240
+ * the top-most rule that matches.
176
241
  *
177
- * Not an option: it is a fact about the host this renderer is named for, not a
178
- * preference. Exposing it would only let a caller supply a number that is not
179
- * the real one, and the failure it guards against — redirects that quietly stop
180
- * working because they sit at the bottom of a long file — is exactly the kind
181
- * nobody goes looking for.
242
+ * For serving the rules where no host applies them — the dev server — so a
243
+ * wildcard answers there as it will in production rather than only when the
244
+ * path is literally `/en/*`. A source ending in `/*` matches every path under
245
+ * it, and what the `*` took goes where the target says `:splat`; any other
246
+ * source matches itself, with or without a trailing slash, since a host either
247
+ * strips one before matching or treats the two as one URL.
248
+ *
249
+ * `path` is the path alone; a query string is the caller's to drop.
182
250
  */
183
- const CLOUDFLARE_RULE_LIMIT = 2000;
251
+ export function matchRedirect(
252
+ rules: readonly ResolvedRedirect[],
253
+ path: string
254
+ ): RedirectMatch | undefined {
255
+ const canonical =
256
+ path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
257
+ for (const rule of rules) {
258
+ if (rule.from.endsWith("/*")) {
259
+ // "/en/" from "/en/*": the bare "/en" is not under it, and every
260
+ // path that answers an old language has its own exact rule.
261
+ const prefix = rule.from.slice(0, -1);
262
+ if (path.startsWith(prefix)) {
263
+ return {
264
+ rule,
265
+ location: rule.to.replace(
266
+ ":splat",
267
+ path.slice(prefix.length)
268
+ ),
269
+ };
270
+ }
271
+ } else if (rule.from === canonical) {
272
+ return { rule, location: rule.to };
273
+ }
274
+ }
275
+ return undefined;
276
+ }
184
277
 
185
278
  export interface RedirectsInput {
186
279
  readonly rules: readonly ResolvedRedirect[];
@@ -271,9 +364,16 @@ export function buildCloudflareRedirects(
271
364
  redirects: readonly ResolvedRedirect[],
272
365
  options: CloudflareRedirectsOptions
273
366
  ): GeneratedFile {
274
- if (redirects.length > CLOUDFLARE_RULE_LIMIT) {
367
+ const dynamic = redirects.filter(isDynamic).length;
368
+ const fixed = redirects.length - dynamic;
369
+ if (fixed > CLOUDFLARE_STATIC_LIMIT) {
370
+ throw new Error(
371
+ `${fixed} static redirects exceeds the ${CLOUDFLARE_STATIC_LIMIT} Cloudflare Pages honours. Everything past that would be dropped silently.`
372
+ );
373
+ }
374
+ if (dynamic > CLOUDFLARE_DYNAMIC_LIMIT) {
275
375
  throw new Error(
276
- `${redirects.length} redirects exceeds the ${CLOUDFLARE_RULE_LIMIT} Cloudflare Pages honours. Everything past that would be dropped silently.`
376
+ `${dynamic} wildcard redirects exceeds the ${CLOUDFLARE_DYNAMIC_LIMIT} Cloudflare Pages honours. Everything past that would be dropped silently.`
277
377
  );
278
378
  }
279
379
 
@@ -610,6 +610,20 @@ export function createSite<
610
610
  to: ((): string => {
611
611
  if (typeof rule.to === "string") return rule.to;
612
612
  if ("file" in rule.to) return rule.to.file;
613
+ if (rule.to.splat === true) {
614
+ // Checked here too, for a rule the types could not see:
615
+ // without a `*`, the host has nothing to put in `:splat`.
616
+ if (!rule.from.endsWith("/*")) {
617
+ throw new Error(
618
+ `Redirect "${rule.from}" keeps the rest of the path (\`splat: true\`) but has no "/*" to take it from.`
619
+ );
620
+ }
621
+ const path = pathFor(
622
+ rule.to.route,
623
+ rule.to.locale ?? defaultLocale
624
+ );
625
+ return path === "/" ? "/:splat" : `${path}/:splat`;
626
+ }
613
627
  return pathFor(rule.to.route, rule.to.locale ?? defaultLocale, {
614
628
  page: rule.to.page,
615
629
  });