@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 +2 -1
- package/docs/checks.md +2 -2
- package/package.json +1 -1
- package/src/astro/site-routes.ts +8 -13
- package/src/index.ts +3 -0
- package/src/jsonld/product.ts +68 -17
- package/src/money.ts +82 -0
- package/src/redirects.ts +116 -16
- package/src/site/create.ts +14 -0
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
|
|
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
|
|
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,
|
|
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
package/src/astro/site-routes.ts
CHANGED
|
@@ -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
|
|
396
|
-
//
|
|
397
|
-
|
|
398
|
-
|
|
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} → ${
|
|
401
|
+
`redirect: ${path} → ${match.location} (${match.rule.status})`
|
|
407
402
|
);
|
|
408
|
-
response.statusCode = rule.status;
|
|
409
|
-
response.setHeader("Location",
|
|
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,
|
package/src/jsonld/product.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
// `
|
|
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
|
-
|
|
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
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
const [only, ...rest] =
|
|
146
|
-
if (rest.length === 0) return
|
|
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 =
|
|
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:
|
|
168
|
-
offers:
|
|
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://`,
|
|
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
|
|
78
|
-
* own line rather than as the whole array failing to match. The check
|
|
79
|
-
* `ValidHttpsUrl`, shared with the site origin — one idea of an
|
|
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
|
-
| {
|
|
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
|
|
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
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`${
|
|
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
|
|
package/src/site/create.ts
CHANGED
|
@@ -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
|
});
|