@escape-game-over/atlas 0.1.71 → 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
@@ -195,7 +195,7 @@ than printing the brackets. See [`docs/rich-text.md`](docs/rich-text.md).
195
195
  | `LocalBusiness` JSON-LD | `localBusiness()` | address, phone, hours |
196
196
  | `Organization` JSON-LD | `organization()` | name, URL, logo |
197
197
  | `WebSite` JSON-LD | `website()`, home page only | site name, optional alternate |
198
- | `Product` JSON-LD | `product()` | name, price table |
198
+ | `Product` JSON-LD | `product()` | name, price by quantity or audience |
199
199
  | `Article` JSON-LD | `article()` | headline, publication date |
200
200
  | `VideoObject` JSON-LD | `videoObject()` | a video, its stills and date |
201
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.71",
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
@@ -210,15 +210,70 @@ export interface CloudflareRedirectsOptions {
210
210
  }
211
211
 
212
212
  /**
213
- * 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.
214
215
  *
215
- * Not an option: it is a fact about the host this renderer is named for, not a
216
- * preference. Exposing it would only let a caller supply a number that is not
217
- * the real one, and the failure it guards against — redirects that quietly stop
218
- * working because they sit at the bottom of a long file — is exactly the kind
219
- * nobody goes looking for.
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.
220
222
  */
221
- const CLOUDFLARE_RULE_LIMIT = 2000;
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.
241
+ *
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.
250
+ */
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
+ }
222
277
 
223
278
  export interface RedirectsInput {
224
279
  readonly rules: readonly ResolvedRedirect[];
@@ -309,9 +364,16 @@ export function buildCloudflareRedirects(
309
364
  redirects: readonly ResolvedRedirect[],
310
365
  options: CloudflareRedirectsOptions
311
366
  ): GeneratedFile {
312
- 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) {
313
375
  throw new Error(
314
- `${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.`
315
377
  );
316
378
  }
317
379