@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,148 @@
1
+ import type { MetaContent, MetaTag } from "../meta/index.ts";
2
+ import type { HttpsUrl, UrlPath } from "../url.ts";
3
+
4
+ /**
5
+ * The vocabulary of one page: what a view is handed, and what it hands back.
6
+ *
7
+ * Separate from `api.ts`, which describes the site as a whole. Everything here
8
+ * is about a single document — where it sits, what it says, which slice of a
9
+ * list it is — and none of it mentions a route registry.
10
+ */
11
+
12
+ export interface LinkOptions {
13
+ /**
14
+ * Which page of a paginated list to link to, from 1.
15
+ *
16
+ * `1` and `undefined` both give the bare path, because page one *is* the
17
+ * bare path — so a loop over page numbers needs no special case for the
18
+ * first, and cannot produce a `/page/1` that was never built.
19
+ */
20
+ readonly page?: number;
21
+ /** Without the leading `#`. */
22
+ readonly hash?: string;
23
+ readonly query?: Readonly<Record<string, string>>;
24
+ }
25
+
26
+ /**
27
+ * Props for one generated page.
28
+ *
29
+ * A type alias, not an interface: SSG frameworks require props to be assignable
30
+ * to an index-signature type, which interfaces are not.
31
+ */
32
+ export type PageProps<L extends string, Id extends string> = {
33
+ readonly routeId: Id;
34
+ readonly locale: L;
35
+ /**
36
+ * Which page of this route's list to render, from 1.
37
+ *
38
+ * Always present, and `1` for a route that does not paginate — a view that
39
+ * ignores it renders the only page there is, and one that slices a list by
40
+ * it needs no guard.
41
+ */
42
+ readonly page: number;
43
+ };
44
+
45
+ /** One `{ params, props }` pair, shaped for a static-site router. */
46
+ export type StaticPath<L extends string, Id extends string> = {
47
+ readonly params: Readonly<Record<string, string | undefined>>;
48
+ readonly props: PageProps<L, Id>;
49
+ };
50
+
51
+ /** Everything one page's `<html>` and `<head>` need. */
52
+ export interface PageMeta {
53
+ /** For `<html lang dir>` — this page's locale, not the site default. */
54
+ readonly lang: string;
55
+ readonly dir: "ltr" | "rtl";
56
+ /** Everything for `<head>`, which is nearly everything. */
57
+ readonly tags: MetaTag[];
58
+ /**
59
+ * For immediately after `<body>`, and usually empty.
60
+ *
61
+ * Its own list because one thing lib emits genuinely belongs there — Tag
62
+ * Manager's `<noscript>` iframe, which the head ignores. A layout renders
63
+ * both, and cannot put either in the other's place.
64
+ */
65
+ readonly bodyTags: MetaTag[];
66
+ }
67
+
68
+ /**
69
+ * What a page passes to `metaFor`: its words, its picture, its trail, and which
70
+ * page of a list it is.
71
+ *
72
+ * `MetaContent` is about a *document* — the title, the description, the share
73
+ * image — and knows nothing of a site around it. The two fields here are what a
74
+ * document cannot answer alone: where it sits among other pages, and which
75
+ * slice of a list it shows.
76
+ *
77
+ * No longer generic over `RouteId`. It was, while a callback named each step of
78
+ * the trail and had to be handed ids this project actually builds. The trail
79
+ * now arrives finished, so nothing here mentions a route.
80
+ */
81
+ export type PageContent = MetaContent & PageExtras;
82
+
83
+ /**
84
+ * The two things a document cannot answer about itself.
85
+ *
86
+ * Split out because `MetaContent` is an intersection with a union — an
87
+ * interface cannot extend one — and the union is what stops a page being both
88
+ * an article and something else.
89
+ */
90
+ interface PageExtras {
91
+ /**
92
+ * Which page of a paginated list this is, from 1.
93
+ *
94
+ * Pass `Astro.props.page` straight through. It is what makes page 2
95
+ * canonical to itself rather than to the list — *"don't use the first page
96
+ * of a paginated sequence as the canonical page"* — and what points its
97
+ * `hreflang` alternates at page 2 of each other language instead of at
98
+ * their first.
99
+ *
100
+ * Omitted is page one, so an unpaginated view says nothing and is right.
101
+ */
102
+ readonly page?: number;
103
+ /**
104
+ * The trail, already built — by `breadcrumbFor`, which is what knows a
105
+ * route's ancestors.
106
+ *
107
+ * Handed over rather than derived here from callbacks, because the page
108
+ * renders this same trail and the two have to be one array. Callbacks meant
109
+ * two places building it from the same rules: two places to keep in step,
110
+ * and one of them wrong the moment a rule gained a case. The version this
111
+ * replaced pointed a paginated list's own crumb at page one, so the
112
+ * structured data on page three claimed to be page one.
113
+ *
114
+ * Omit it and no breadcrumb is emitted.
115
+ */
116
+ readonly breadcrumb?: readonly Crumb[];
117
+ }
118
+
119
+ /**
120
+ * One step of a breadcrumb trail: what it is called, and where it goes.
121
+ *
122
+ * Both forms of the destination, as `Alternate` carries both, because the two
123
+ * readers want different ones. A link in the page wants the path — an absolute
124
+ * one sends a developer from localhost to the production host mid-click — while
125
+ * structured data requires the absolute URL.
126
+ */
127
+ export interface Crumb {
128
+ readonly name: string;
129
+ /** Root-relative, for an `href`. */
130
+ readonly path: UrlPath;
131
+ /** Absolute, for structured data. */
132
+ readonly url: HttpsUrl;
133
+ }
134
+
135
+ export interface Alternate<L extends string> {
136
+ readonly locale: L;
137
+ readonly path: UrlPath;
138
+ readonly url: HttpsUrl;
139
+ }
140
+
141
+ /** One entry in a language switcher: where it goes, and how to label it. */
142
+ export interface LocaleLink<L extends string> extends Alternate<L> {
143
+ /** The language's name, written in that language. */
144
+ readonly label: string;
145
+ readonly htmlLang: string;
146
+ readonly dir: "ltr" | "rtl";
147
+ readonly isCurrent: boolean;
148
+ }
package/src/sitemap.ts ADDED
@@ -0,0 +1,257 @@
1
+ import type { ResolvedLocaleMeta } from "./config.ts";
2
+ import type { GeneratedFile } from "./file.ts";
3
+ import type { RouteEntry } from "./routes/resolve.ts";
4
+ import { type HttpsUrl, joinUrl } from "./url.ts";
5
+ import { el, renderXmlDocument, type XmlElement } from "./xml.ts";
6
+
7
+ const SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9";
8
+ const XHTML_NS = "http://www.w3.org/1999/xhtml";
9
+ const XML_CONTENT_TYPE = "application/xml; charset=utf-8";
10
+
11
+ /**
12
+ * What the sitemaps.org spec allows in one file. A file above it is not a
13
+ * large sitemap, it is a rejected one.
14
+ *
15
+ * A value rather than a sentence in two doc comments, because two things need
16
+ * it: the default below sits under it, and `checkEntryLimit` refuses anything
17
+ * over it. Written once, so the number in the error cannot drift from the
18
+ * number that produced it.
19
+ */
20
+ const SPEC_MAX_ENTRIES = 50_000;
21
+
22
+ /** Under the cap, with headroom — see `SPEC_MAX_ENTRIES`. */
23
+ const DEFAULT_ENTRY_LIMIT = 45_000;
24
+
25
+ /** How a site configures its sitemap. Part of `site.config.ts`. */
26
+ export interface SitemapConfig {
27
+ /** Filename the sitemap is served under. Defaults to `sitemap.xml`. */
28
+ readonly name?: string;
29
+ /**
30
+ * Maximum URLs per file before the sitemap splits into an index plus
31
+ * numbered parts. Defaults to 45000 (the spec caps a file at 50000).
32
+ *
33
+ * A whole number from 1 to 50000, the most the spec allows in one file.
34
+ * Anything else throws at build rather than being clamped — see
35
+ * `checkEntryLimit` for why this one is not forgiving, and for what a value
36
+ * on either side of the range would otherwise do.
37
+ */
38
+ readonly entryLimit?: number;
39
+ }
40
+
41
+ /** One `{ params, props }` pair for a sitemap file, shaped for a static router. */
42
+ export type SitemapStaticPath = {
43
+ readonly params: Readonly<Record<string, string>>;
44
+ readonly props: { readonly file: GeneratedFile };
45
+ };
46
+
47
+ export interface Sitemap {
48
+ /**
49
+ * The file to submit and to reference from `robots.txt`.
50
+ *
51
+ * Always keeps the configured name, so the public URL is stable whether or
52
+ * not the sitemap ended up split: it is the single sitemap when everything
53
+ * fits, and the index when it does not.
54
+ */
55
+ readonly entry: GeneratedFile;
56
+ /** Every file that must be served, `entry` included. */
57
+ readonly files: readonly GeneratedFile[];
58
+
59
+ /**
60
+ * Every file to generate, as `{ params, props }` for a dynamic route.
61
+ *
62
+ * `param` is the name of the parameter in the router's filename, e.g.
63
+ * `"sitemap"` for `[sitemap].ts`. One route therefore serves the single
64
+ * sitemap or the index plus its parts, without the caller knowing which
65
+ * case it is in.
66
+ *
67
+ * Returns a fresh mutable array, because SSG routers typically demand one.
68
+ */
69
+ staticPaths(param: string): SitemapStaticPath[];
70
+ }
71
+
72
+ function toSitemap(
73
+ entry: GeneratedFile,
74
+ files: readonly GeneratedFile[]
75
+ ): Sitemap {
76
+ return {
77
+ entry,
78
+ files,
79
+ staticPaths: (param) =>
80
+ files.map((file) => ({
81
+ params: { [param]: file.name },
82
+ props: { file },
83
+ })),
84
+ };
85
+ }
86
+
87
+ export interface SitemapInput<L extends string, Id extends string> {
88
+ readonly siteUrl: HttpsUrl;
89
+ readonly name: string;
90
+ readonly entryLimit?: number;
91
+ readonly entries: readonly RouteEntry<L, Id>[];
92
+ readonly localeMeta: Readonly<Record<L, ResolvedLocaleMeta>>;
93
+ /**
94
+ * Alternates for one route, so each URL can advertise its translations.
95
+ *
96
+ * Takes the page as well as the id, and must: a paginated route emits an
97
+ * entry per page, and asking without one would answer for page 1 every
98
+ * time — so `/news/page/2` would advertise `/news` as its own translation,
99
+ * never reference itself, and declare the same alternate cluster the list
100
+ * does. Google discards a cluster whose members disagree rather than
101
+ * picking a winner, so that is the whole route's `hreflang` lost, not one
102
+ * URL's.
103
+ *
104
+ * Structural rather than `LinkOptions`, which lives in `site/` and imports
105
+ * this module — the shape is the contract, and naming the type would be a
106
+ * cycle.
107
+ */
108
+ readonly alternatesFor: (
109
+ id: Id,
110
+ options?: { readonly page?: number }
111
+ ) => readonly { readonly locale: L; readonly url: HttpsUrl }[];
112
+ }
113
+
114
+ /** `sitemap.xml` -> `sitemap-0.xml`, keeping whatever extension was configured. */
115
+ function partName(name: string, index: number): string {
116
+ const dot = name.lastIndexOf(".");
117
+ return dot === -1
118
+ ? `${name}-${index}`
119
+ : `${name.slice(0, dot)}-${index}${name.slice(dot)}`;
120
+ }
121
+
122
+ /**
123
+ * The cap a sitemap splits at, checked before anything divides by it.
124
+ *
125
+ * Two ways to get it wrong, and both are caught here because both fail
126
+ * silently in their own way.
127
+ *
128
+ * *Too small* — zero or less, or a fraction. Thrown rather than clamped, which
129
+ * is the opposite of what `pages` on a route wants, and the difference is
130
+ * whether the wrong value means anything: a route computing `pages: 0` from an
131
+ * empty list plainly means "one page", so lib normalises it, while "at most
132
+ * zero URLs per file" describes no sitemap at all. And the consequence is not a
133
+ * bad file, it is no build — `chunk` advances by this value, so zero or less
134
+ * loops forever, allocating until the process is killed with nothing said about
135
+ * why.
136
+ *
137
+ * *Too large* — over `SPEC_MAX_ENTRIES`. This one builds perfectly and produces
138
+ * a file a search engine refuses to read, which is the worse failure of the
139
+ * two: splitting is the whole point of the field, and a limit above the cap
140
+ * quietly switches it off.
141
+ */
142
+ function checkEntryLimit(limit: number): void {
143
+ if (Number.isInteger(limit) && limit > 0 && limit <= SPEC_MAX_ENTRIES) {
144
+ return;
145
+ }
146
+
147
+ throw new Error(
148
+ `sitemap.entryLimit is ${limit}, which is not a number of URLs one file can hold: it wants a whole number from 1 to ${SPEC_MAX_ENTRIES}, the most the sitemaps.org spec allows in a file.`
149
+ );
150
+ }
151
+
152
+ function chunk<T>(
153
+ items: readonly T[],
154
+ size: number
155
+ ): readonly (readonly T[])[] {
156
+ if (items.length <= size) return [items];
157
+ const chunks: T[][] = [];
158
+ for (let i = 0; i < items.length; i += size) {
159
+ chunks.push(items.slice(i, i + size));
160
+ }
161
+ return chunks;
162
+ }
163
+
164
+ function file(
165
+ siteUrl: HttpsUrl,
166
+ name: string,
167
+ root: XmlElement
168
+ ): GeneratedFile {
169
+ return {
170
+ name,
171
+ url: joinUrl(siteUrl, `/${name}`),
172
+ body: renderXmlDocument(root),
173
+ contentType: XML_CONTENT_TYPE,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Renders the sitemap from the same route entries the pages are built from, so
179
+ * a retranslated slug or a switched-off page can never drift out of sync with it.
180
+ *
181
+ * Each URL carries `xhtml:link` alternates for its other locales, which is how
182
+ * search engines are told these are translations of one page rather than
183
+ * duplicates. Astro's own sitemap integration cannot express this: it pairs
184
+ * locales by matching the path after the locale prefix, which translated slugs
185
+ * never share.
186
+ *
187
+ * Above `entryLimit` URLs the result splits into an index plus numbered parts.
188
+ */
189
+ export function buildSitemap<L extends string, Id extends string>(
190
+ input: SitemapInput<L, Id>
191
+ ): Sitemap {
192
+ // Before anything divides by it. A project supplies this as a plain
193
+ // `number` and nothing else stands between the config and the loop.
194
+ const entryLimit = input.entryLimit ?? DEFAULT_ENTRY_LIMIT;
195
+ checkEntryLimit(entryLimit);
196
+
197
+ const included = input.entries.filter((entry) => !entry.sitemap?.exclude);
198
+
199
+ const urlFor = (entry: RouteEntry<L, Id>): XmlElement => {
200
+ return el("url", [
201
+ el("loc", joinUrl(input.siteUrl, entry.path)),
202
+
203
+ // This entry's own page, so each URL advertises the same-numbered
204
+ // page of every other locale. Safe for an unpaginated route:
205
+ // `pathFor` reads page 1 and the bare path as the same thing, and
206
+ // every locale of a route shares one `pages` count — so a page that
207
+ // produced an entry here exists in all of them.
208
+ ...input
209
+ .alternatesFor(entry.routeId, { page: entry.page })
210
+ .map((alternate) =>
211
+ el("xhtml:link", {
212
+ rel: "alternate",
213
+ hreflang: input.localeMeta[alternate.locale].htmlLang,
214
+ href: alternate.url,
215
+ })
216
+ ),
217
+
218
+ entry.sitemap?.lastmod !== undefined &&
219
+ el("lastmod", entry.sitemap.lastmod),
220
+ entry.sitemap?.changefreq !== undefined &&
221
+ el("changefreq", entry.sitemap.changefreq),
222
+ entry.sitemap?.priority !== undefined &&
223
+ el("priority", entry.sitemap.priority.toFixed(1)),
224
+ ]);
225
+ };
226
+
227
+ const urlset = (entries: readonly RouteEntry<L, Id>[]): XmlElement =>
228
+ el(
229
+ "urlset",
230
+ { xmlns: SITEMAP_NS, "xmlns:xhtml": XHTML_NS },
231
+ entries.map(urlFor)
232
+ );
233
+
234
+ const chunks = chunk(included, entryLimit);
235
+
236
+ if (chunks.length <= 1) {
237
+ const only = file(input.siteUrl, input.name, urlset(included));
238
+ return toSitemap(only, [only]);
239
+ }
240
+
241
+ const parts = chunks.map((entries, index) =>
242
+ file(input.siteUrl, partName(input.name, index), urlset(entries))
243
+ );
244
+
245
+ // The index keeps the configured name, so the submitted URL never changes.
246
+ const index = file(
247
+ input.siteUrl,
248
+ input.name,
249
+ el(
250
+ "sitemapindex",
251
+ { xmlns: SITEMAP_NS },
252
+ parts.map((part) => el("sitemap", [el("loc", part.url)]))
253
+ )
254
+ );
255
+
256
+ return toSitemap(index, [index, ...parts]);
257
+ }
package/src/types.ts ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Small type utilities shared across `lib/`.
3
+ *
4
+ * Each one names an idiom that is otherwise easy to misread — or, in the case of
5
+ * `IsNever` and `NoExcessKeys`, easy to get subtly wrong.
6
+ */
7
+
8
+ /**
9
+ * The string keys of `T`.
10
+ *
11
+ * Plain `keyof T` is `string | number | symbol`, which breaks template literal
12
+ * types like `` `route.${K}.title` ``. Every key we care about is a string.
13
+ */
14
+ export type StringKeys<T> = keyof T & string;
15
+
16
+ /**
17
+ * `true` when `T` is exactly `never`.
18
+ *
19
+ * The tuple wrappers are load-bearing: a bare `T extends never ? …` is a
20
+ * *distributive* conditional, and distributing over `never` produces `never` —
21
+ * so the check silently never runs. Wrapping both sides turns off distribution.
22
+ */
23
+ export type IsNever<T> = [T] extends [never] ? true : false;
24
+
25
+ /**
26
+ * Rejects any key of `T` that is not in `Allowed`, by demanding those keys hold
27
+ * `never` — a type no value has.
28
+ *
29
+ * Needed because these helpers *infer* `T` from the argument: the object literal
30
+ * becomes the type rather than being checked against a fixed target, so ordinary
31
+ * excess-property checking has nothing to compare against. Use it in a
32
+ * self-referential constraint:
33
+ *
34
+ * ```ts
35
+ * function f<const T extends Shape & NoExcessKeys<T, KnownIds>>(value: T): T
36
+ * ```
37
+ */
38
+ export type NoExcessKeys<T, Allowed extends PropertyKey> = Readonly<
39
+ Record<Exclude<keyof T, Allowed>, never>
40
+ >;
41
+
42
+ type Month =
43
+ | "01"
44
+ | "02"
45
+ | "03"
46
+ | "04"
47
+ | "05"
48
+ | "06"
49
+ | "07"
50
+ | "08"
51
+ | "09"
52
+ | "10"
53
+ | "11"
54
+ | "12";
55
+
56
+ type Day =
57
+ | "01"
58
+ | "02"
59
+ | "03"
60
+ | "04"
61
+ | "05"
62
+ | "06"
63
+ | "07"
64
+ | "08"
65
+ | "09"
66
+ | "10"
67
+ | "11"
68
+ | "12"
69
+ | "13"
70
+ | "14"
71
+ | "15"
72
+ | "16"
73
+ | "17"
74
+ | "18"
75
+ | "19"
76
+ | "20"
77
+ | "21"
78
+ | "22"
79
+ | "23"
80
+ | "24"
81
+ | "25"
82
+ | "26"
83
+ | "27"
84
+ | "28"
85
+ | "29"
86
+ | "30"
87
+ | "31";
88
+
89
+ export type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
90
+
91
+ /**
92
+ * One uppercase letter, for the code standards that are spelled in them.
93
+ *
94
+ * Beside `Digit` because it is the same kind of thing: a character class the
95
+ * template-literal types elsewhere are built from, rather than anything about
96
+ * this library's own subject.
97
+ */
98
+ export type Letter =
99
+ | "A"
100
+ | "B"
101
+ | "C"
102
+ | "D"
103
+ | "E"
104
+ | "F"
105
+ | "G"
106
+ | "H"
107
+ | "I"
108
+ | "J"
109
+ | "K"
110
+ | "L"
111
+ | "M"
112
+ | "N"
113
+ | "O"
114
+ | "P"
115
+ | "Q"
116
+ | "R"
117
+ | "S"
118
+ | "T"
119
+ | "U"
120
+ | "V"
121
+ | "W"
122
+ | "X"
123
+ | "Y"
124
+ | "Z";
125
+
126
+ /**
127
+ * A calendar date, `YYYY-MM-DD`.
128
+ *
129
+ * Checked by the type system rather than at build time, so `"2026-3-1"`,
130
+ * `"01/03/2026"`, `"2026-13-01"` and `"2026-03-01T00:00:00Z"` are all rejected
131
+ * on the line where they were written — as is a bare `string`, which is the case
132
+ * a runtime check would have had to exist for.
133
+ *
134
+ * Every part is spelled out digit by digit rather than left as a `${number}`
135
+ * pattern, which would also admit `"1e3-03-01"` and five-digit years. That costs
136
+ * 100 × 12 × 31 = 37 200 members, comfortably inside what TypeScript will hold;
137
+ * a full four-digit year would be 10 000 × 372 and far past it. The price is
138
+ * that this needs one more digit in 2100.
139
+ */
140
+ export type IsoDate = `20${Digit}${Digit}-${Month}-${Day}`;
141
+
142
+ /**
143
+ * A whole percentage, `0` to `100`.
144
+ *
145
+ * Built by counting rather than written out, which is the one place this file
146
+ * departs from spelling a union in full: a hundred and one literals is not
147
+ * readable, and the rule producing them is. `Acc["length"]` grows by one per
148
+ * step until it reaches the target, and every length along the way is a member.
149
+ *
150
+ * The ceiling is TypeScript's instantiation depth, and it is lower in practice
151
+ * than the thousand the limit suggests — measured in this project, `Upto<1000>`
152
+ * reports "excessively deep" and `Upto<512>` does not. Well above a percentage,
153
+ * and worth knowing before reaching for a wider range: pick the bound the
154
+ * domain justifies and check it compiles.
155
+ */
156
+ type Upto<N extends number, Acc extends number[] = []> = Acc["length"] extends N
157
+ ? Acc[number]
158
+ : Upto<N, [...Acc, Acc["length"]]>;
159
+
160
+ export type Percentage = Upto<101>;