@escape-game-over/atlas 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +364 -0
  2. package/bin/use-project.mjs +131 -0
  3. package/docs/NOT-BUILT.md +329 -0
  4. package/docs/checks.md +139 -0
  5. package/docs/share-images.md +52 -0
  6. package/docs/toolchain.md +83 -0
  7. package/package.json +51 -0
  8. package/src/analytics/google.ts +351 -0
  9. package/src/analytics/index.ts +102 -0
  10. package/src/analytics/tags.ts +57 -0
  11. package/src/analytics/umami.ts +285 -0
  12. package/src/astro/MetaTags.astro +87 -0
  13. package/src/astro/consent.ts +165 -0
  14. package/src/astro/images.ts +315 -0
  15. package/src/astro/index.ts +44 -0
  16. package/src/astro/public-files.ts +129 -0
  17. package/src/astro/site-routes.ts +307 -0
  18. package/src/config.ts +218 -0
  19. package/src/contact.ts +233 -0
  20. package/src/file.ts +16 -0
  21. package/src/files.ts +39 -0
  22. package/src/hours.ts +312 -0
  23. package/src/i18n/define.ts +217 -0
  24. package/src/i18n/placeholders.ts +94 -0
  25. package/src/i18n/translate.ts +190 -0
  26. package/src/image.ts +29 -0
  27. package/src/index.ts +222 -0
  28. package/src/jsonld/article.ts +165 -0
  29. package/src/jsonld/breadcrumb.ts +34 -0
  30. package/src/jsonld/business.ts +196 -0
  31. package/src/jsonld/ids.ts +106 -0
  32. package/src/jsonld/index.ts +59 -0
  33. package/src/jsonld/node.ts +78 -0
  34. package/src/jsonld/organization.ts +154 -0
  35. package/src/jsonld/place.ts +96 -0
  36. package/src/jsonld/product.ts +172 -0
  37. package/src/jsonld/quantity.ts +55 -0
  38. package/src/jsonld/service.ts +237 -0
  39. package/src/jsonld/video.ts +239 -0
  40. package/src/jsonld/website.ts +58 -0
  41. package/src/llms.ts +160 -0
  42. package/src/meta/content.ts +190 -0
  43. package/src/meta/index.ts +432 -0
  44. package/src/meta/robots.ts +212 -0
  45. package/src/meta/share-image.ts +232 -0
  46. package/src/meta/tag.ts +133 -0
  47. package/src/meta/verification.ts +53 -0
  48. package/src/money.ts +237 -0
  49. package/src/project.ts +249 -0
  50. package/src/redirects.ts +266 -0
  51. package/src/robots.ts +80 -0
  52. package/src/routes/define.ts +412 -0
  53. package/src/routes/family.ts +251 -0
  54. package/src/routes/resolve.ts +266 -0
  55. package/src/site/api.ts +354 -0
  56. package/src/site/create.ts +660 -0
  57. package/src/site/index.ts +32 -0
  58. package/src/site/page.ts +148 -0
  59. package/src/sitemap.ts +257 -0
  60. package/src/types.ts +160 -0
  61. package/src/url.ts +144 -0
  62. package/src/warn.ts +88 -0
  63. package/src/xml.ts +103 -0
@@ -0,0 +1,232 @@
1
+ import type { ImageAsset } from "../image.ts";
2
+ import { absoluteUrl, type HttpsUrl } from "../url.ts";
3
+ import { warn } from "../warn.ts";
4
+ import { link, type MetaTag } from "./tag.ts";
5
+
6
+ export type ImageFormat = "png" | "jpg" | "webp";
7
+
8
+ const IMAGE_MIME: Readonly<Record<ImageFormat, string>> = {
9
+ png: "image/png",
10
+ jpg: "image/jpeg",
11
+ webp: "image/webp",
12
+ };
13
+
14
+ /**
15
+ * The MIME type of an asset's format, where lib knows one.
16
+ *
17
+ * `undefined` rather than a guess for anything else. Both the tags this feeds —
18
+ * `og:image:type` and the icon's `type` — are hints that let a client skip a
19
+ * fetch, so a missing one costs nothing and a wrong one sends it to the wrong
20
+ * decoder. An asset's `format` is a string off an import, so this is where the
21
+ * question of whether it is one lib recognises gets answered, once.
22
+ */
23
+ function imageMime(format: string | undefined): string | undefined {
24
+ const key = format?.toLowerCase();
25
+ // `Object.hasOwn`, not `in`, for the reason `familyGuard` uses it: `in`
26
+ // walks the prototype, so `"constructor"` and `"toString"` answer true and
27
+ // the cast below then hands back a function as a MIME type. Absurd input,
28
+ // but the check costs the same either way and the guard should not depend
29
+ // on nobody producing it.
30
+ return key !== undefined && Object.hasOwn(IMAGE_MIME, key)
31
+ ? IMAGE_MIME[key as ImageFormat]
32
+ : undefined;
33
+ }
34
+
35
+ /**
36
+ * A page's share image and the text describing it.
37
+ *
38
+ * One unit on purpose: alt text describes *this* image, so the two are declared
39
+ * together and cannot drift apart or be supplied from different places.
40
+ */
41
+ export interface ShareImage {
42
+ /**
43
+ * The image itself, at **1200×630** — near enough to 1.91:1, the box every
44
+ * large link preview uses: Facebook, LinkedIn, iMessage, Slack, Discord,
45
+ * Telegram.
46
+ *
47
+ * Wide even though some surfaces want a square — WhatsApp's chat thumbnail,
48
+ * Twitter's `summary` card — because there is only one `og:image` and the
49
+ * two crops are not symmetrical. A square surface takes the centre of a wide
50
+ * image, which for a logo padded to the box is the logo itself; a wide
51
+ * surface takes a band out of the middle of a square one, and cuts the top
52
+ * and bottom off. Wide degrades, square destroys.
53
+ *
54
+ * Anything much smaller is upscaled: the common mistake is reusing a
55
+ * favicon, which produces a card with a logo adrift in the middle of it.
56
+ * Below roughly 600×315 some platforms drop the large card entirely and
57
+ * fall back to a thumbnail — which is a reason to meet the size rather than
58
+ * an option to choose. lib always asks for the wide card and offers no way
59
+ * to request the square one; a logo belongs in the box via
60
+ * `shareImage(src, { fit: "contain", background })`, not in a smaller card.
61
+ *
62
+ * Nothing here enforces the size, because an imported image's real
63
+ * dimensions are a fact about the file rather than about its type. Passing
64
+ * `shareImage(source)` from the package's `astro/images` derives a correct one from
65
+ * any large-enough source and refuses to upscale.
66
+ *
67
+ * Format: PNG or JPEG. Both are read by every scraper; WebP is not.
68
+ */
69
+ readonly asset: ImageAsset;
70
+ /**
71
+ * Describes what is *in* the image, for `og:image:alt` and
72
+ * `twitter:image:alt`. Never the page title unless the image renders it.
73
+ */
74
+ readonly alt: string;
75
+ }
76
+
77
+ /**
78
+ * The site's icon: one square asset, used for every icon link.
79
+ *
80
+ * Site-wide rather than per page, because search engines use one favicon per
81
+ * hostname, read from the home page — a per-page icon changes the browser tab
82
+ * and nothing else.
83
+ *
84
+ * One asset rather than a set, because one square PNG already covers every
85
+ * consumer: browsers accept PNG for `rel="icon"`, and Apple scales it for
86
+ * `apple-touch-icon`. A `/favicon.ico` in `public/` is still requested by
87
+ * browsers on its own if you want the legacy fallback.
88
+ *
89
+ * Unlike `og:image`, the href stays as written — root-relative is correct for an
90
+ * icon, and absolutising it would only hard-code the origin.
91
+ */
92
+ export type SiteIcon = ImageAsset;
93
+
94
+ /** One page's resolved share image. */
95
+ export interface OgImage {
96
+ readonly url: HttpsUrl;
97
+ readonly width: number;
98
+ readonly height: number;
99
+ /** Only when the format is known; `og:image:type` is the least important. */
100
+ readonly mime?: string;
101
+ readonly alt: string;
102
+ }
103
+
104
+ /**
105
+ * The box every large link preview crops to, and the only shape worth serving.
106
+ *
107
+ * Wide is not a preference between two options. A square surface — WhatsApp's
108
+ * thumbnail — takes the *centre* of a wide image, which is where the subject
109
+ * is; a wide surface takes a band out of the middle of a square one and cuts the
110
+ * top and bottom off. Wide degrades, square destroys, and there is one
111
+ * `og:image` to satisfy both.
112
+ */
113
+ const SHARE_BOX = { width: 1200, height: 630 } as const;
114
+
115
+ /** Below this, a preview carries no image at all — not a small one, none. */
116
+ const NO_PREVIEW_BELOW = 200;
117
+
118
+ /**
119
+ * Where each surface starts cutting text off.
120
+ *
121
+ * One string feeds `description`, `og:description` and `twitter:description`, so
122
+ * the tightest is the one that counts. Facebook's mobile preview is that: a line
123
+ * under it survives everywhere, while a line written to Google's 160 is already
124
+ * clipped in a share. A title is cut far sooner, by the surface people search on.
125
+ */
126
+ const LENGTH_LIMITS = {
127
+ title: {
128
+ limit: 60,
129
+ why: "a search result cuts a title around there, and the end of it is usually the part that identifies the page",
130
+ },
131
+ description: {
132
+ limit: 125,
133
+ why: "search truncates nearer 160 and X caps at 200, but a share preview on mobile is the tightest of the three",
134
+ },
135
+ } as const;
136
+
137
+ /**
138
+ * Warns rather than throws: a long description is not broken, it is edited by
139
+ * someone else's ellipsis. The page still renders, the preview still appears,
140
+ * and the last clause is silently gone — which is precisely the kind of thing
141
+ * nobody notices without being told.
142
+ */
143
+ export function warnIfClipped(
144
+ field: keyof typeof LENGTH_LIMITS,
145
+ value: string,
146
+ at: string
147
+ ): void {
148
+ const { limit, why } = LENGTH_LIMITS[field];
149
+ if (value.length <= limit) return;
150
+
151
+ warn(at, `${field} is ${value.length} characters, past ~${limit}: ${why}.`);
152
+ }
153
+
154
+ /**
155
+ * Warns about a share image rather than rejecting it.
156
+ *
157
+ * A wrong-sized image still produces a page, still produces a preview, and is
158
+ * still worth shipping while someone finds a better one — so this is a warning
159
+ * and not a throw. It fires wherever `metaFor` runs, which is every page in a
160
+ * build and every page in dev.
161
+ *
162
+ * Checked here rather than in `shareImage`, because this is the path every page
163
+ * takes: that helper is optional, and an imported asset can be passed straight
164
+ * through. The size of one is a fact about a file, so no type can see it.
165
+ */
166
+ export function warnAboutShareImage(image: OgImage, at: string): void {
167
+ const { width, height } = image;
168
+ // The page is named by `at`, and it has one share image — repeating the
169
+ // asset URL only pastes a `/_image?href=…` endpoint into every dev warning.
170
+ const say = (problem: string) =>
171
+ warn(
172
+ at,
173
+ `share image is ${width}x${height}: ${problem}. ${SHARE_BOX.width}x${SHARE_BOX.height} is what every surface crops to.`
174
+ );
175
+
176
+ if (width < NO_PREVIEW_BELOW || height < NO_PREVIEW_BELOW) {
177
+ say(
178
+ `below ${NO_PREVIEW_BELOW}x${NO_PREVIEW_BELOW}, so the preview will carry no image at all`
179
+ );
180
+ return;
181
+ }
182
+ if (width < SHARE_BOX.width || height < SHARE_BOX.height) {
183
+ say(
184
+ "smaller than the box, so platforms will scale it up or fall back to a small preview"
185
+ );
186
+ return;
187
+ }
188
+ // 1.5 rather than the box's own 1.91: a 16:9 photograph crops cleanly, and
189
+ // warning about it would be noise. A squarer image loses its top and bottom.
190
+ if (width / height < 1.5) {
191
+ say("not wide, so wide surfaces will crop the top and bottom off");
192
+ }
193
+ }
194
+
195
+ /** Resolves the page's share image against the site origin. */
196
+ export function resolveShareImage(
197
+ siteUrl: HttpsUrl,
198
+ image: ShareImage
199
+ ): OgImage {
200
+ return {
201
+ url: absoluteUrl(siteUrl, image.asset.src, "share image"),
202
+ width: image.asset.width,
203
+ height: image.asset.height,
204
+ alt: image.alt,
205
+ mime: imageMime(image.asset.format),
206
+ };
207
+ }
208
+
209
+ /**
210
+ * The icon links: one asset, two `rel`s.
211
+ *
212
+ * Browsers read `icon`, Apple reads `apple-touch-icon` and scales it, and both
213
+ * point at the same file — so they are built together rather than left to a
214
+ * layout to remember the second one. `sizes` is stated so a browser need not
215
+ * fetch the file to find out what it is choosing between.
216
+ *
217
+ * The href stays as written. Unlike `og:image` these are read by a browser that
218
+ * already has the page, so root-relative is correct and making it absolute would
219
+ * only hard-code the origin.
220
+ */
221
+ export function iconLinks(icon: SiteIcon): MetaTag[] {
222
+ const mime = imageMime(icon.format);
223
+ const attrs = {
224
+ href: icon.src,
225
+ sizes: `${icon.width}x${icon.height}`,
226
+ ...(mime !== undefined ? { type: mime } : {}),
227
+ };
228
+ return [
229
+ link({ rel: "icon", ...attrs }),
230
+ link({ rel: "apple-touch-icon", ...attrs }),
231
+ ];
232
+ }
@@ -0,0 +1,133 @@
1
+ import type { AnalyticsTag } from "../analytics/index.ts";
2
+
3
+ /**
4
+ * A head tag as data, not markup.
5
+ *
6
+ * lib does not render HTML — that would tie it to one framework and to one
7
+ * templating syntax. It describes the tags; the consumer spreads them.
8
+ */
9
+ export type MetaTag =
10
+ | { readonly kind: "title"; readonly text: string }
11
+ | {
12
+ readonly kind: "meta";
13
+ readonly attrs: Readonly<Record<string, string>>;
14
+ }
15
+ | {
16
+ readonly kind: "link";
17
+ readonly attrs: Readonly<Record<string, string>>;
18
+ }
19
+ /**
20
+ * A block of text in a `<script>` — structured data, and nothing else.
21
+ *
22
+ * `content` is already escaped for the element it goes in, so a renderer
23
+ * must write it *raw*: escaping it again as HTML would turn every quote in
24
+ * the JSON into an entity and the block into gibberish.
25
+ */
26
+ | {
27
+ readonly kind: "script";
28
+ /** Omitted for JavaScript, which needs none. */
29
+ readonly type?: string;
30
+ readonly content: string;
31
+ }
32
+ /**
33
+ * A script fetched from somewhere else — analytics, and so far only that.
34
+ *
35
+ * Deliberately *not* a slot a consumer can put anything in. Nothing takes
36
+ * one of these as input: the only way to get one is to declare typed
37
+ * settings that lib knows how to build a script from, which is the
38
+ * difference between machinery and the passthrough rule 1 forbids.
39
+ *
40
+ * Always deferred. A third-party script that blocks the parser is a
41
+ * measurement that costs the thing it measures.
42
+ */
43
+ | {
44
+ readonly kind: "externalScript";
45
+ readonly src: string;
46
+ readonly attrs: Readonly<Record<string, string>>;
47
+ }
48
+ /**
49
+ * A hidden iframe inside `<noscript>`, for the body rather than the head.
50
+ *
51
+ * Tag Manager's documented fallback, and the only thing lib emits that does
52
+ * not belong in `<head>` — which is why `buildMeta` returns two lists
53
+ * rather than one. Structured rather than a string of markup, for the
54
+ * reason `externalScript` is.
55
+ */
56
+ | {
57
+ readonly kind: "noscriptFrame";
58
+ readonly src: string;
59
+ };
60
+
61
+ export const meta = (attrs: Readonly<Record<string, string>>): MetaTag => ({
62
+ kind: "meta",
63
+ attrs,
64
+ });
65
+
66
+ export const link = (attrs: Readonly<Record<string, string>>): MetaTag => ({
67
+ kind: "link",
68
+ attrs,
69
+ });
70
+
71
+ /**
72
+ * An analytics tag as a head tag. The two vocabularies overlap but are not the
73
+ * same type: `analytics/` knows nothing of `MetaTag`, which is what keeps this
74
+ * folder free to import it without a cycle.
75
+ *
76
+ * Every kind is named rather than left to a trailing `else`, so that adding one
77
+ * to `AnalyticsTag` and forgetting it here is a *compile* error and not a tag
78
+ * that quietly disappears: `script` narrows to `never` only while the list is
79
+ * complete, and assigning anything else to `never` does not type-check.
80
+ */
81
+ export function asMetaTag(script: AnalyticsTag): MetaTag {
82
+ if (script.kind === "inline") {
83
+ return { kind: "script", content: script.content };
84
+ }
85
+ if (script.kind === "external") {
86
+ return {
87
+ kind: "externalScript",
88
+ src: script.src,
89
+ attrs: script.attributes,
90
+ };
91
+ }
92
+ if (script.kind === "noscriptFrame") {
93
+ return { kind: "noscriptFrame", src: script.src };
94
+ }
95
+ const unhandled: never = script;
96
+ throw new Error(`Unhandled analytics tag: ${JSON.stringify(unhandled)}`);
97
+ }
98
+
99
+ const DEFAULT_VIEWPORT = "width=device-width, initial-scale=1";
100
+
101
+ interface Preamble {
102
+ readonly title: string;
103
+ /**
104
+ * The real URL of what this document represents.
105
+ *
106
+ * Optional for the one document that represents nothing: a 404 stands for
107
+ * whatever was requested and is not there, so naming a canonical would tell
108
+ * a crawler the missing page and that URL are the same thing.
109
+ */
110
+ readonly canonical?: string;
111
+ readonly viewport?: string;
112
+ }
113
+
114
+ /**
115
+ * What every document in the site opens with: what encoding it is in, how to
116
+ * scale it, what it is called, and — for anything that stands for a URL — which
117
+ * URL that is.
118
+ *
119
+ * A function returning the *head* of the list rather than something appended
120
+ * later: the spec requires `charset` within the first 1024 bytes, so its
121
+ * position is part of its meaning.
122
+ */
123
+ export function preamble(input: Preamble): MetaTag[] {
124
+ const tags: MetaTag[] = [
125
+ meta({ charset: "utf-8" }),
126
+ meta({ name: "viewport", content: input.viewport ?? DEFAULT_VIEWPORT }),
127
+ { kind: "title", text: input.title },
128
+ ];
129
+ if (input.canonical !== undefined) {
130
+ tags.push(link({ rel: "canonical", href: input.canonical }));
131
+ }
132
+ return tags;
133
+ }
@@ -0,0 +1,53 @@
1
+ import { type MetaTag, meta } from "./tag.ts";
2
+
3
+ /**
4
+ * Ownership tokens from webmaster tools, as `<meta name="…" content="token">`.
5
+ *
6
+ * An object with one key rather than a bare token, so the day a site needs Bing,
7
+ * Yandex or Meta the field grows a sibling instead of changing shape. Each of
8
+ * those is a fixed, unmemorable tag name — Bing's is `msvalidate.01` — and a
9
+ * misspelled one fails silently, so they get named keys if they are ever needed
10
+ * rather than a free `name`/`content` pair now.
11
+ */
12
+ export interface SiteVerification {
13
+ /**
14
+ * Google Search Console.
15
+ *
16
+ * One token or several. Several is normal: a token identifies an *owner*, so
17
+ * a site verified by two people, or claimed as both a domain and a URL
18
+ * prefix, legitimately carries more than one, and each is checked
19
+ * independently.
20
+ */
21
+ readonly google?: string | readonly string[];
22
+ }
23
+
24
+ /**
25
+ * The tag name each key is written as.
26
+ *
27
+ * Adding a service is this table plus its key on `SiteVerification`, and the
28
+ * `satisfies` is what keeps that a pair: a key with no tag name here, or a name
29
+ * here for a key that does not exist, fails to compile. Bing is
30
+ * `msvalidate.01`, Yandex `yandex-verification`, Meta
31
+ * `facebook-domain-verification`, Pinterest `p:domain_verify` — none of them
32
+ * guessable, and all of them silently inert when wrong.
33
+ */
34
+ const VERIFICATION_TAG_NAME = {
35
+ google: "google-site-verification",
36
+ } as const satisfies Readonly<Record<keyof SiteVerification, string>>;
37
+
38
+ export function verificationTags(
39
+ verification: SiteVerification | undefined
40
+ ): readonly MetaTag[] {
41
+ if (verification === undefined) return [];
42
+
43
+ const tags: MetaTag[] = [];
44
+ for (const [key, name] of Object.entries(VERIFICATION_TAG_NAME)) {
45
+ const tokens = verification[key as keyof SiteVerification];
46
+ if (tokens === undefined) continue;
47
+
48
+ for (const token of typeof tokens === "string" ? [tokens] : tokens) {
49
+ tags.push(meta({ name, content: token }));
50
+ }
51
+ }
52
+ return tags;
53
+ }
package/src/money.ts ADDED
@@ -0,0 +1,237 @@
1
+ import type { Letter } from "./types.ts";
2
+
3
+ /**
4
+ * An ISO 4217 currency code: `"EUR"`, `"RON"`, `"GBP"`.
5
+ *
6
+ * Three uppercase letters, which is what `priceCurrency` is read as. A symbol —
7
+ * `€`, `lei` — is how a price is *printed* in one place for one audience, and
8
+ * two currencies can share one symbol, so it identifies nothing.
9
+ */
10
+ export type CurrencyCode = `${Letter}${Letter}${Letter}`;
11
+
12
+ /**
13
+ * An amount and what it is denominated in, kept together.
14
+ *
15
+ * A bare number is the mistake this exists to stop. Two deployments of one
16
+ * template charge 28 and 120 for the same thing because one is in euros and the
17
+ * other is not, and nothing about `28` says which — least of all to a search
18
+ * engine reading it as a price.
19
+ *
20
+ * `amount` is a number rather than a string: it is arithmetic to whoever sets
21
+ * it, and formatting it for a reader is a separate job needing a locale this
22
+ * type has no business holding.
23
+ */
24
+ export interface Price {
25
+ readonly amount: number;
26
+ readonly currency: CurrencyCode;
27
+ }
28
+
29
+ /**
30
+ * What one quantity band costs.
31
+ *
32
+ * `quantity` takes a count or a range, because both occur and the range alone
33
+ * would make the ordinary case noisy: `{ min: 2, max: 2 }` five times over says
34
+ * nothing `2, 3, 4, 5, 6` does not. Same shape as `ThemeColor` — a scalar for
35
+ * the common case, an object when it genuinely varies.
36
+ */
37
+ export interface PriceTier {
38
+ readonly quantity: number | { readonly min: number; readonly max: number };
39
+ /**
40
+ * What one unit costs at this quantity — not the total for the band.
41
+ *
42
+ * Named for what it is, because the two readings differ by a factor of the
43
+ * group size and both are plausible numbers. A room at 22 for four people
44
+ * is 88 or it is 22, and a field called `amount` does not say which.
45
+ */
46
+ readonly amountPerUnit: number;
47
+ }
48
+
49
+ /**
50
+ * A price that depends on how many are bought.
51
+ *
52
+ * One currency for the whole table rather than one per tier: a product priced
53
+ * in two currencies at once is not a discount, it is a mistake, and stating it
54
+ * once removes the chance of writing it.
55
+ */
56
+ export interface TieredPrice {
57
+ readonly currency: CurrencyCode;
58
+ /**
59
+ * The bands, cheapest quantity first. Never empty — a product with no price
60
+ * at all is an offer of nothing.
61
+ */
62
+ readonly tiers: readonly [PriceTier, ...PriceTier[]];
63
+ /**
64
+ * What the quantity counts: `"players"`, `"seats"`, `"nights"`.
65
+ *
66
+ * lib has no idea what is being sold, and a bare number in the output reads
67
+ * as "how many items" — which for a room booked by the group is wrong.
68
+ */
69
+ readonly unit?: string;
70
+ }
71
+
72
+ /** The band a tier covers, with a bare count widened to a range of one. */
73
+ export function tierRange(tier: PriceTier): {
74
+ readonly min: number;
75
+ readonly max: number;
76
+ } {
77
+ return typeof tier.quantity === "number"
78
+ ? { min: tier.quantity, max: tier.quantity }
79
+ : tier.quantity;
80
+ }
81
+
82
+ /**
83
+ * The whole span a price table covers, floor of the first band to ceiling of
84
+ * the last.
85
+ *
86
+ * Worth having at all because the alternative is storing the span beside the
87
+ * table, where the two can disagree and only one of them can be right.
88
+ *
89
+ * Internal, and not exported from the package: on its own it is only half an
90
+ * answer, true of a run and an overstatement of a table with holes in it. What
91
+ * a caller wants is `formatQuantities` or `quantitiesFor`, which pick between
92
+ * this and `quantitiesOf` by reading the tiers.
93
+ */
94
+ export function quantityRange(tiers: readonly [PriceTier, ...PriceTier[]]): {
95
+ readonly min: number;
96
+ readonly max: number;
97
+ } {
98
+ const first = tierRange(tiers[0]);
99
+ const last = tiers.at(-1);
100
+ return {
101
+ min: first.min,
102
+ // Non-empty by type, so `at(-1)` cannot really miss; answering with the
103
+ // first band's ceiling beats inventing a number for a case that is
104
+ // unreachable.
105
+ max: last === undefined ? first.max : tierRange(last).max,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Every quantity a table actually sells, ascending.
111
+ *
112
+ * The honest form of `quantityRange` for a table with holes in it. A room sold
113
+ * to twos, fours and sixes spans 2 to 6 and is not sellable at 3 — so the span
114
+ * is a claim about two sizes that cannot be booked, while this is a list of the
115
+ * ones that can.
116
+ *
117
+ * Both exist because they answer different questions and structured data wants
118
+ * different things: a contiguous table is one `QuantitativeValue` with a min and
119
+ * a max, and a table with holes has to enumerate.
120
+ */
121
+ export function quantitiesOf(
122
+ tiers: readonly [PriceTier, ...PriceTier[]]
123
+ ): readonly number[] {
124
+ const sizes = new Set<number>();
125
+ for (const tier of tiers) {
126
+ const band = tierRange(tier);
127
+ for (let size = band.min; size <= band.max; size++) {
128
+ sizes.add(size);
129
+ }
130
+ }
131
+ return [...sizes].sort((a, b) => a - b);
132
+ }
133
+
134
+ /**
135
+ * Whether a table's quantities run without a break.
136
+ *
137
+ * What decides how the quantities are published: a run gets a min and a max, a
138
+ * broken set has to be listed. Derived rather than declared, so it cannot
139
+ * disagree with the tiers it describes.
140
+ */
141
+ export function isContiguous(
142
+ tiers: readonly [PriceTier, ...PriceTier[]]
143
+ ): boolean {
144
+ const sizes = quantitiesOf(tiers);
145
+ const first = sizes[0];
146
+ const last = sizes.at(-1);
147
+
148
+ // Both ends exist — `tiers` is non-empty and every band contributes at
149
+ // least one size — but the type cannot see it, so the unreachable case
150
+ // answers "yes" rather than being asserted away.
151
+ if (first === undefined || last === undefined) return true;
152
+
153
+ // A run of n sizes spans exactly n - 1 steps. Counting beats comparing
154
+ // neighbours: the sizes are already deduplicated and sorted.
155
+ return sizes.length === last - first + 1;
156
+ }
157
+
158
+ /**
159
+ * The quantities a table sells, written out for a reader.
160
+ *
161
+ * The prose half of `quantitiesFor`, and it exists for the same reason: a run
162
+ * reads as `2–6`, and a table that skips sizes has to say `2, 4, 6`, because
163
+ * a range would tell someone they can book a three when they cannot.
164
+ *
165
+ * Which form to use is decided here rather than by the caller — it follows from
166
+ * the tiers, and there is one right answer per table. What the caller supplies
167
+ * is punctuation, exactly as `formatHours` takes its separators: lib has no
168
+ * language and no opinion about dashes.
169
+ */
170
+ export function formatQuantities(
171
+ tiers: readonly [PriceTier, ...PriceTier[]],
172
+ format: {
173
+ /** Between the ends of a run: `2–6`. */
174
+ readonly between?: string;
175
+ /** Between sizes that are listed one by one: `2, 4, 6`. */
176
+ readonly and?: string;
177
+ } = {}
178
+ ): string {
179
+ const sizes = quantitiesOf(tiers);
180
+ const first = sizes[0];
181
+ const last = sizes.at(-1);
182
+ if (first === undefined || last === undefined) return "";
183
+
184
+ if (!isContiguous(tiers)) return sizes.join(format.and ?? ", ");
185
+ return first === last
186
+ ? `${first}`
187
+ : `${first}${format.between ?? "–"}${last}`;
188
+ }
189
+
190
+ /**
191
+ * Throws on a price table that cannot be read as one.
192
+ *
193
+ * Two failures, and both are contradictions rather than omissions. An overlap
194
+ * gives one quantity two prices, and whichever a reader sees is the one they
195
+ * will expect to pay. A backwards band names no quantity at all.
196
+ *
197
+ * Throws rather than warns, for the reason a wrong opening time does: this is a
198
+ * number a customer acts on.
199
+ *
200
+ * **A gap is not an error.** The tiers *are* the list of what is sold, so a
201
+ * table running 2, 4, 6 describes a thing sold to twos, fours and sixes — not
202
+ * one missing its odd sizes. Rejecting that would need the table to carry a
203
+ * second field saying which reading applies, and a field that can contradict
204
+ * the data beside it is the thing worth avoiding: it goes stale the moment a
205
+ * tier is added. What a gap changes is how the quantities are *published*,
206
+ * which `quantitiesFor` derives rather than being told.
207
+ */
208
+ export function assertPriceTiers(price: TieredPrice, at: string): void {
209
+ const problems: string[] = [];
210
+ let previous: { min: number; max: number } | undefined;
211
+
212
+ for (const tier of price.tiers) {
213
+ const range = tierRange(tier);
214
+
215
+ if (range.max < range.min) {
216
+ problems.push(
217
+ `a tier covers ${range.min} to ${range.max}, which is backwards.`
218
+ );
219
+ } else if (previous !== undefined) {
220
+ if (range.min <= previous.max) {
221
+ problems.push(
222
+ `${range.min} to ${range.max} overlaps ${previous.min} to ${previous.max}, so one quantity has two prices.`
223
+ );
224
+ }
225
+ }
226
+
227
+ previous = range;
228
+ }
229
+
230
+ if (problems.length > 0) {
231
+ throw new Error(
232
+ `${at}: price tiers do not describe a single table.\n${problems
233
+ .map((problem) => ` - ${problem}`)
234
+ .join("\n")}`
235
+ );
236
+ }
237
+ }