@escape-game-over/atlas 0.1.109 → 0.1.111

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
@@ -72,7 +72,10 @@ export const { project, redirects } = atlas.project(
72
72
 
73
73
  enabledLocales: ["el-GR", "en-US"],
74
74
  overrideRouting: { defaultLocale: "el-GR", prefixDefaultLocale: true },
75
- clock: { "el-GR": "h23", "en-US": "h12" }, // how each language prints times
75
+ formats: {
76
+ clock: { "el-GR": "h23", "en-US": "h12" }, // how each language prints times
77
+ prices: { decimals: "always" }, // "18,00 €", each page in its own locale
78
+ },
76
79
 
77
80
  overrideMessages: {
78
81
  // Greek only — the English copy still falls through to the default catalog.
@@ -244,8 +247,9 @@ regression-tested by an `@ts-expect-error` suite.
244
247
  | A `robots` directive that is not a real one | error — the vocabulary is a union |
245
248
  | A project `url` that is not `https://`, or ends in a slash | error |
246
249
  | `defaultLocale` not among the project's `enabledLocales` | error |
247
- | A `clock` leaving out a published locale, or naming an unpublished one | error |
250
+ | A `formats.clock` leaving out a published locale, or naming an unpublished one | error |
248
251
  | `site.clockFor` on a project that declares no clock | throws, naming the project |
252
+ | `site.priceFormatFor` on a project that declares no price format | throws, naming the project |
249
253
  | An unknown field in a project declaration | error — a typo cannot sit there unread |
250
254
  | Two routes resolving to the same URL | build throws, naming both |
251
255
  | A site icon that is not square, a multiple of 48px, and PNG | build throws |
package/docs/checks.md CHANGED
@@ -42,7 +42,8 @@ sees.
42
42
  | unknown message key, or an override dropping a `{placeholder}` | two routes resolving to the same URL |
43
43
  | a message the project declared mandatory and never wrote (`CatalogCovers`) | |
44
44
  | copy or a locale label written for a locale outside `enabledLocales` | |
45
- | a `clock` leaving out a published locale, or naming one outside `enabledLocales` | the same, for a project that was not type-checked first |
45
+ | a `formats.clock` leaving out a published locale, or naming one outside `enabledLocales` | the same, for a project that was not type-checked first |
46
+ | a `formats.prices.locale` the project does not publish | the same, for a project that was not type-checked first |
46
47
  | a slug whose translation sits at a different depth, in the registry or across the overlay | the same, when a slug is not a literal (computed, or from an env var) |
47
48
  | unknown route id, or a locale the site does not ship | a locale published twice |
48
49
  | a malformed language tag (`en_US`, `en-us`, `EN-US`, bare `en`) | an icon that is not square, a multiple of 48, and PNG |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.109",
3
+ "version": "0.1.111",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -22,6 +22,7 @@
22
22
  "./astro/consent-banner": "./src/astro/ConsentBanner.astro",
23
23
  "./astro/document": "./src/astro/Document.astro",
24
24
  "./astro/element": "./src/astro/AtlasElement.astro",
25
+ "./astro/escapeall": "./src/astro/EscapeAll.astro",
25
26
  "./astro/escape-navigator": "./src/astro/EscapeNavigator.astro",
26
27
  "./astro/four-escape": "./src/astro/FourEscape.astro",
27
28
  "./astro/google-map": "./src/astro/GoogleMap.astro",
@@ -53,6 +53,7 @@ export const STORAGE_VENDOR_NAMES = {
53
53
  clarity: "Microsoft Clarity",
54
54
  "4escape": "4escape",
55
55
  escapenavigator: "Escape Navigator",
56
+ escapeall: "EscapeAll",
56
57
  stripe: "Stripe",
57
58
  } as const satisfies Record<StorageVendor | BookingVendor | "stripe", string>;
58
59
 
@@ -314,6 +315,13 @@ const BOOKING = {
314
315
  stripe: true,
315
316
  loads: ["google-analytics", "google-ads", "meta"],
316
317
  },
318
+ // Read from its script 2026-11-12: nothing as it renders; the phone
319
+ // confirmation while a visitor books. Its queue cookie waits for its own
320
+ // consent cookie, which a site of ours never sets.
321
+ escapeall: {
322
+ kept: ["ea-cell-confirmation"],
323
+ tab: [],
324
+ },
317
325
  } as const satisfies Record<BookingVendor, BookingStorage>;
318
326
 
319
327
  /**
@@ -452,7 +460,7 @@ export function storedItems(
452
460
  let stripe = false;
453
461
  for (const widget of new Set(booking)) {
454
462
  const entry: BookingStorage = BOOKING[widget];
455
- widgetRows.push(
463
+ const own: StoredItem[] = [
456
464
  {
457
465
  id: widget,
458
466
  names: entry.kept,
@@ -466,8 +474,10 @@ export function storedItems(
466
474
  provider: widget,
467
475
  lasts: "tab",
468
476
  consent: "none",
469
- }
470
- );
477
+ },
478
+ ];
479
+ // A widget that keeps nothing for the tab has no row for it.
480
+ widgetRows.push(...own.filter((row) => row.names.length > 0));
471
481
  for (const vendor of entry.loads ?? []) vendors.add(vendor);
472
482
  if (entry.stripe) stripe = true;
473
483
  }
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import type { HTMLAttributes } from "astro/types";
16
16
  import type { BookingWidget, BookingWidgetVars } from "../booking/widget.ts";
17
+ import EscapeAll from "./EscapeAll.astro";
17
18
  import EscapeNavigator from "./EscapeNavigator.astro";
18
19
  import FourEscape from "./FourEscape.astro";
19
20
 
@@ -39,6 +40,18 @@ const { widget, locale, vars, colorScheme, ...attrs } = Astro.props;
39
40
  vars={vars}
40
41
  />
41
42
  )}
43
+ {widget.vendor === "escapeall" && (
44
+ <EscapeAll
45
+ {...attrs}
46
+ host={widget.host}
47
+ companyId={widget.companyId}
48
+ serviceId={widget.serviceId}
49
+ template={widget.template}
50
+ skin={widget.skin}
51
+ players={widget.players}
52
+ locale={locale}
53
+ />
54
+ )}
42
55
  {widget.vendor === "escapenavigator" && (
43
56
  <EscapeNavigator
44
57
  {...attrs}
@@ -0,0 +1,62 @@
1
+ ---
2
+ /**
3
+ * An EscapeAll booking calendar: one room's, or every room's. See
4
+ * `escapeall.ts` for what it loads and keeps.
5
+ *
6
+ * `locale` is the page's. Attributes go on the calendar's element.
7
+ *
8
+ * ```astro
9
+ * <EscapeAll host="nl.escapeall.com" companyId="…" serviceId="…" template={9} locale={locale} />
10
+ * ```
11
+ */
12
+ import type { HTMLAttributes } from "astro/types";
13
+ import {
14
+ checkEscapeAll,
15
+ type EscapeAllWidget,
16
+ escapeAllAssets,
17
+ escapeAllLanguage,
18
+ } from "../booking/escapeall.ts";
19
+
20
+ type Props = HTMLAttributes<"div"> &
21
+ EscapeAllWidget & {
22
+ readonly locale: string;
23
+ };
24
+
25
+ const {
26
+ host,
27
+ companyId,
28
+ serviceId,
29
+ template,
30
+ skin,
31
+ players,
32
+ locale,
33
+ id,
34
+ ...attrs
35
+ } = Astro.props;
36
+ const widget = { host, companyId, serviceId, template, skin, players };
37
+
38
+ checkEscapeAll(widget, Astro.url.pathname);
39
+
40
+ const { stylesheets, script } = escapeAllAssets(widget);
41
+ // EscapeAll's script builds its selectors from the one it is given, so the
42
+ // calendar is found by id: the page's own, or one from the room or account.
43
+ const elementId = id ?? `escapeall-${serviceId ?? companyId}`;
44
+ ---
45
+
46
+ {stylesheets.map((href) => (
47
+ <link
48
+ rel='stylesheet'
49
+ href={href}
50
+ />
51
+ ))}
52
+ <script src="./escapeall-calendar.ts"></script>
53
+ <div
54
+ {...attrs}
55
+ id={elementId}
56
+ data-escapeall-company={companyId}
57
+ data-escapeall-service={serviceId}
58
+ data-escapeall-template={template}
59
+ data-escapeall-players={players}
60
+ data-escapeall-language={escapeAllLanguage(locale)}
61
+ data-escapeall-script={script}
62
+ ></div>
@@ -1,4 +1,4 @@
1
- import type { CurrencyCode } from "../money.ts";
1
+ import type { CurrencyCode } from "../currency.ts";
2
2
  import { readConsent } from "./consent.ts";
3
3
 
4
4
  /**
@@ -0,0 +1,32 @@
1
+ import { appendScript } from "./append-script.ts";
2
+
3
+ /**
4
+ * Draws each EscapeAll calendar on the page, once EscapeAll's script has
5
+ * loaded: the call its store scripts make, with the settings the component
6
+ * wrote on the element.
7
+ */
8
+ // Its script builds selectors by appending to the one it is given, so the
9
+ // calendar is named by its id, not handed over as an element.
10
+ type Calendar = (selector: string, settings: Record<string, unknown>) => void;
11
+
12
+ const host = window as unknown as { GetBookingCalendra?: Calendar };
13
+
14
+ const elements = [...document.querySelectorAll("[data-escapeall-company]")];
15
+ const [first] = elements;
16
+ if (first instanceof HTMLElement && first.dataset.escapeallScript) {
17
+ appendScript(first.dataset.escapeallScript).addEventListener("load", () => {
18
+ for (const element of elements) {
19
+ if (!(element instanceof HTMLElement)) continue;
20
+ const { escapeallCompany, escapeallService, escapeallLanguage } =
21
+ element.dataset;
22
+ host.GetBookingCalendra?.(`#${CSS.escape(element.id)}`, {
23
+ CompanyId: [escapeallCompany],
24
+ ...(escapeallService ? { ServiceId: [escapeallService] } : {}),
25
+ lang: escapeallLanguage,
26
+ Template: Number(element.dataset.escapeallTemplate),
27
+ InitialQuantity: Number(element.dataset.escapeallPlayers ?? 2),
28
+ FirstWeekDay: "Monday",
29
+ });
30
+ }
31
+ });
32
+ }
@@ -2,7 +2,7 @@ import {
2
2
  GOOGLE_EVENT_GLOBAL,
3
3
  type GoogleEventSink,
4
4
  } from "../analytics/google.ts";
5
- import type { CurrencyCode } from "../money.ts";
5
+ import type { CurrencyCode } from "../currency.ts";
6
6
  import type { DeclaredEvent, EventDataArgs } from "./event-data.ts";
7
7
 
8
8
  /** GA4's recommended events that carry items and revenue. */
@@ -1,4 +1,4 @@
1
- import type { CurrencyCode } from "../money.ts";
1
+ import type { CurrencyCode } from "../currency.ts";
2
2
  import type { DeclaredEvent, EventDataArgs } from "./event-data.ts";
3
3
 
4
4
  /** Meta's standard events, which its ad optimisation understands by name. */
@@ -1,4 +1,4 @@
1
- import type { CurrencyCode } from "../money.ts";
1
+ import type { CurrencyCode } from "../currency.ts";
2
2
  import { readConsent } from "./consent.ts";
3
3
 
4
4
  /** Snap's standard events that a website sends. */
@@ -1,4 +1,4 @@
1
- import type { CurrencyCode } from "../money.ts";
1
+ import type { CurrencyCode } from "../currency.ts";
2
2
  import type { DeclaredEvent, EventDataArgs } from "./event-data.ts";
3
3
 
4
4
  /** TikTok's standard events, by their current names (May 2025 onwards). */
@@ -0,0 +1,86 @@
1
+ import { invalidId, UUID, type Uuid } from "../analytics/ids.ts";
2
+
3
+ /**
4
+ * EscapeAll's booking calendar: a store's rooms and their free slots, booked
5
+ * on the page. One room's calendar when `serviceId` names it (EscapeAll calls
6
+ * a room a service), or every room's.
7
+ *
8
+ * EscapeAll's embed is a store script of its own that waits for EscapeAll's
9
+ * jQuery, then calls `GetBookingCalendra` from the shared `all.js` with the
10
+ * store's settings. The component makes that call itself, with the page's
11
+ * language, so it needs no store script. Read 2026-11-12 from Aruba's.
12
+ *
13
+ * It stores nothing as it renders, and loads nothing from anyone else; while
14
+ * a visitor books it keeps a phone confirmation in local storage. Its queue
15
+ * cookie is written only once the page sets EscapeAll's own consent cookie,
16
+ * which ours never does. All of it is in `storedItems`.
17
+ */
18
+ export interface EscapeAllWidget {
19
+ /** EscapeAll's regional site the account is on: `nl.escapeall.com`. */
20
+ readonly host: string;
21
+ /** The account's id, from EscapeAll: the store script's `CompanyId`. */
22
+ readonly companyId: Uuid;
23
+ /** One room's id, for its calendar alone; every room's without it. */
24
+ readonly serviceId?: Uuid;
25
+ /** The calendar's layout, by EscapeAll's number: the store's `Template`. */
26
+ readonly template: number;
27
+ /** The store's stylesheet, as EscapeAll hosts it: `CustomCss/<skin>.css`. */
28
+ readonly skin?: string;
29
+ /** The group size the calendar opens on: the store's `InitialQuantity`. */
30
+ readonly players?: number;
31
+ }
32
+
33
+ /** EscapeAll's own CSS variables: none it documents, so none taken. */
34
+ export type EscapeAllVars = Readonly<Partial<Record<string, never>>>;
35
+
36
+ /** The languages the calendar is translated into, as its paths take them. */
37
+ export const ESCAPEALL_LANGUAGES = ["en", "nl", "de"] as const;
38
+
39
+ export type EscapeAllLanguage = (typeof ESCAPEALL_LANGUAGES)[number];
40
+
41
+ /** EscapeAll's regional sites: `nl.escapeall.com`. */
42
+ const ESCAPEALL_HOST = /^[a-z]{2}\.escapeall\.com$/;
43
+
44
+ /** Fails the build on a calendar that would render nothing. */
45
+ export function checkEscapeAll(widget: EscapeAllWidget, at: string): void {
46
+ if (!ESCAPEALL_HOST.test(widget.host)) {
47
+ invalidId(
48
+ at,
49
+ `EscapeAll host "${widget.host}" is not one of EscapeAll's regional sites, like "nl.escapeall.com".`
50
+ );
51
+ }
52
+ for (const [name, id] of [
53
+ ["company", widget.companyId],
54
+ ["service", widget.serviceId],
55
+ ] as const) {
56
+ if (id !== undefined && !UUID.test(id)) {
57
+ invalidId(
58
+ at,
59
+ `EscapeAll ${name} id "${id}" is not a UUID, so the calendar would render nothing.`
60
+ );
61
+ }
62
+ }
63
+ }
64
+
65
+ /** The stylesheets and script a calendar on `host` needs. */
66
+ export function escapeAllAssets(widget: EscapeAllWidget) {
67
+ const base = `https://${widget.host}`;
68
+ return {
69
+ stylesheets: [
70
+ `${base}/Content/API/styles.css`,
71
+ ...(widget.skin === undefined
72
+ ? []
73
+ : [`${base}/CustomCss/${widget.skin}.css`]),
74
+ ],
75
+ script: `${base}/Scripts/API/all.js`,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * The calendar's language for a page's locale: English for one it is not
81
+ * translated into, as EscapeAll's own embeds fall back.
82
+ */
83
+ export function escapeAllLanguage(locale: string): EscapeAllLanguage {
84
+ const { language } = new Intl.Locale(locale);
85
+ return ESCAPEALL_LANGUAGES.find((known) => known === language) ?? "en";
86
+ }
@@ -2,6 +2,7 @@ import type {
2
2
  EscapeNavigatorVars,
3
3
  EscapeNavigatorWidget,
4
4
  } from "./escape-navigator.ts";
5
+ import type { EscapeAllVars, EscapeAllWidget } from "./escapeall.ts";
5
6
  import type { FourEscapeVars, FourEscapeWidget } from "./four-escape.ts";
6
7
 
7
8
  /**
@@ -10,7 +11,8 @@ import type { FourEscapeVars, FourEscapeWidget } from "./four-escape.ts";
10
11
  */
11
12
  export type BookingWidget =
12
13
  | ({ readonly vendor: "4escape" } & FourEscapeWidget)
13
- | ({ readonly vendor: "escapenavigator" } & EscapeNavigatorWidget);
14
+ | ({ readonly vendor: "escapenavigator" } & EscapeNavigatorWidget)
15
+ | ({ readonly vendor: "escapeall" } & EscapeAllWidget);
14
16
 
15
17
  /** Who makes a booking widget: what `storedItems` takes for their rows. */
16
18
  export type BookingVendor = BookingWidget["vendor"];
@@ -19,6 +21,7 @@ export type BookingVendor = BookingWidget["vendor"];
19
21
  export interface BookingVendorVars {
20
22
  readonly "4escape": FourEscapeVars;
21
23
  readonly escapenavigator: EscapeNavigatorVars;
24
+ readonly escapeall: EscapeAllVars;
22
25
  }
23
26
 
24
27
  /** The CSS variables a widget takes: its own vendor's, and no other's. */
package/src/config.ts CHANGED
@@ -1,8 +1,9 @@
1
+ import type { CountryCode } from "./countries.ts";
1
2
  import type { Fonts } from "./fonts.ts";
2
3
  import type { LlmsConfig } from "./llms.ts";
3
4
  import type { RobotsConfig } from "./robots.ts";
4
5
  import type { SitemapConfig } from "./sitemap.ts";
5
- import type { NoExcessKeys, StringKeys } from "./types.ts";
6
+ import type { Letter, NoExcessKeys, StringKeys } from "./types.ts";
6
7
 
7
8
  export interface LocaleMeta {
8
9
  /** Name of the language, written in that language. */
@@ -22,29 +23,47 @@ export interface ResolvedLocaleMeta {
22
23
  readonly ogLocale: string;
23
24
  }
24
25
 
26
+ /** A language subtag: two or three lowercase letters, `en`, `ast`. */
27
+ type LanguageSubtag =
28
+ | `${Lowercase<Letter>}${Lowercase<Letter>}`
29
+ | `${Lowercase<Letter>}${Lowercase<Letter>}${Lowercase<Letter>}`;
25
30
  /**
26
- * A well-formed language tag, or `never`.
31
+ * A region subtag: a country, as `CountryCode` lists them, `US`. Not the
32
+ * three-digit areas (`419`, Latin America): a locale names the one country a
33
+ * site serves, and it is a URL segment.
34
+ */
35
+ type RegionSubtag = CountryCode;
36
+
37
+ /**
38
+ * A well-formed language tag, or `never`: a language of two or three
39
+ * lowercase letters and a region that is a country's code, `en-US`, `ast-ES`.
27
40
  *
28
41
  * The region is required: `en` is rejected, `en-US` accepted. A bare language
29
42
  * leaves the territory to the reader, and `en-US` and `en-GB` differ in spelling,
30
43
  * currency and date order — decisions worth stating rather than inferring.
31
44
  *
32
- * Checks shape and casing only, so `en_US`, `en-us` and `EN-US` are all
33
- * rejected. It deliberately does not check that the subtags are *real*: the
45
+ * So `en_US`, `en-us`, `EN-US`, `-US`, `en-U-S`, `es-419` and `en-XX` are all
46
+ * rejected, and so is a script subtag (`sr-Latn-RS`). The region is checked
47
+ * against the real countries; the language only for its shape: the
34
48
  * authoritative list is the IANA Language Subtag Registry, thousands of entries
35
49
  * that would go stale the moment they were copied into this folder.
50
+ *
51
+ * A check on one literal rather than a type of all tags: as a single type the
52
+ * shape spells out to more strings than TypeScript represents.
36
53
  */
37
54
  export type LanguageTag<T extends string> =
38
- T extends `${infer Lang}-${infer Region}`
39
- ? Lang extends Lowercase<Lang>
40
- ? Region extends Uppercase<Region>
55
+ T extends `${infer Language}-${infer Region}`
56
+ ? Language extends LanguageSubtag
57
+ ? Region extends RegionSubtag
41
58
  ? T
42
59
  : never
43
60
  : never
44
61
  : never;
45
62
 
63
+ /** The same rule at run time, for a project that was not type-checked first. */
64
+
46
65
  interface MalformedLanguageTag<T extends string> {
47
- readonly __MALFORMED_LANGUAGE_TAG__: `"${T}" is not a well-formed language tag: expected a lowercase language and an uppercase region, like "en-US"`;
66
+ readonly __MALFORMED_LANGUAGE_TAG__: `"${T}" is not a well-formed language tag: expected a language of two or three lowercase letters and a country's code as its region, like "en-US"`;
48
67
  }
49
68
 
50
69
  /**
@@ -0,0 +1,165 @@
1
+ /**
2
+ * An ISO 4217 currency code: `"EUR"`, `"RON"`, `"GBP"`.
3
+ *
4
+ * A code rather than a symbol — `€`, `lei` — because two currencies can share a
5
+ * symbol, and `priceCurrency` is read as a code.
6
+ *
7
+ * Circulating currencies from ISO 4217 List One (SIX, published 2026-09-17):
8
+ * fund, precious-metal, testing and accounting-unit codes left out.
9
+ */
10
+ export type CurrencyCode =
11
+ | "AED"
12
+ | "AFN"
13
+ | "ALL"
14
+ | "AMD"
15
+ | "AOA"
16
+ | "ARS"
17
+ | "AUD"
18
+ | "AWG"
19
+ | "AZN"
20
+ | "BAM"
21
+ | "BBD"
22
+ | "BDT"
23
+ | "BHD"
24
+ | "BIF"
25
+ | "BMD"
26
+ | "BND"
27
+ | "BOB"
28
+ | "BRL"
29
+ | "BSD"
30
+ | "BTN"
31
+ | "BWP"
32
+ | "BYN"
33
+ | "BZD"
34
+ | "CAD"
35
+ | "CDF"
36
+ | "CHF"
37
+ | "CLP"
38
+ | "CNY"
39
+ | "COP"
40
+ | "CRC"
41
+ | "CUP"
42
+ | "CVE"
43
+ | "CZK"
44
+ | "DJF"
45
+ | "DKK"
46
+ | "DOP"
47
+ | "DZD"
48
+ | "EGP"
49
+ | "ERN"
50
+ | "ETB"
51
+ | "EUR"
52
+ | "FJD"
53
+ | "FKP"
54
+ | "GBP"
55
+ | "GEL"
56
+ | "GHS"
57
+ | "GIP"
58
+ | "GMD"
59
+ | "GNF"
60
+ | "GTQ"
61
+ | "GYD"
62
+ | "HKD"
63
+ | "HNL"
64
+ | "HTG"
65
+ | "HUF"
66
+ | "IDR"
67
+ | "ILS"
68
+ | "INR"
69
+ | "IQD"
70
+ | "IRR"
71
+ | "ISK"
72
+ | "JMD"
73
+ | "JOD"
74
+ | "JPY"
75
+ | "KES"
76
+ | "KGS"
77
+ | "KHR"
78
+ | "KMF"
79
+ | "KPW"
80
+ | "KRW"
81
+ | "KWD"
82
+ | "KYD"
83
+ | "KZT"
84
+ | "LAK"
85
+ | "LBP"
86
+ | "LKR"
87
+ | "LRD"
88
+ | "LSL"
89
+ | "LYD"
90
+ | "MAD"
91
+ | "MDL"
92
+ | "MGA"
93
+ | "MKD"
94
+ | "MMK"
95
+ | "MNT"
96
+ | "MOP"
97
+ | "MRU"
98
+ | "MUR"
99
+ | "MVR"
100
+ | "MWK"
101
+ | "MXN"
102
+ | "MYR"
103
+ | "MZN"
104
+ | "NAD"
105
+ | "NGN"
106
+ | "NIO"
107
+ | "NOK"
108
+ | "NPR"
109
+ | "NZD"
110
+ | "OMR"
111
+ | "PAB"
112
+ | "PEN"
113
+ | "PGK"
114
+ | "PHP"
115
+ | "PKR"
116
+ | "PLN"
117
+ | "PYG"
118
+ | "QAR"
119
+ | "RON"
120
+ | "RSD"
121
+ | "RUB"
122
+ | "RWF"
123
+ | "SAR"
124
+ | "SBD"
125
+ | "SCR"
126
+ | "SDG"
127
+ | "SEK"
128
+ | "SGD"
129
+ | "SHP"
130
+ | "SLE"
131
+ | "SOS"
132
+ | "SRD"
133
+ | "SSP"
134
+ | "STN"
135
+ | "SVC"
136
+ | "SYP"
137
+ | "SZL"
138
+ | "THB"
139
+ | "TJS"
140
+ | "TMT"
141
+ | "TND"
142
+ | "TOP"
143
+ | "TRY"
144
+ | "TTD"
145
+ | "TWD"
146
+ | "TZS"
147
+ | "UAH"
148
+ | "UGX"
149
+ | "USD"
150
+ | "UYU"
151
+ | "UZS"
152
+ | "VED"
153
+ | "VES"
154
+ | "VND"
155
+ | "VUV"
156
+ | "WST"
157
+ | "XAF"
158
+ | "XCD"
159
+ | "XCG"
160
+ | "XOF"
161
+ | "XPF"
162
+ | "YER"
163
+ | "ZAR"
164
+ | "ZMW"
165
+ | "ZWG";
package/src/index.ts CHANGED
@@ -62,6 +62,15 @@ export {
62
62
  type EscapeNavigatorWidget,
63
63
  escapeNavigatorLanguage,
64
64
  } from "./booking/escape-navigator.ts";
65
+ export {
66
+ checkEscapeAll,
67
+ ESCAPEALL_LANGUAGES,
68
+ type EscapeAllLanguage,
69
+ type EscapeAllVars,
70
+ type EscapeAllWidget,
71
+ escapeAllAssets,
72
+ escapeAllLanguage,
73
+ } from "./booking/escapeall.ts";
65
74
  export {
66
75
  checkFourEscape,
67
76
  FOUR_ESCAPE_LANGUAGES,
@@ -137,6 +146,12 @@ export {
137
146
  countriesFor,
138
147
  type NamedCountry,
139
148
  } from "./countries.ts";
149
+ // `quantitiesOf`, `quantityRange` and `isContiguous` are deliberately not here.
150
+ // They are how lib decides between "2–6" and "2, 4, 6", and a consumer holding
151
+ // them writes that decision a second time — which is how `llms.txt` came to
152
+ // advertise a range for a room that skips sizes. `formatQuantities` answers it
153
+ // for prose and `quantitiesFor` for structured data; both read the same tiers.
154
+ export type { CurrencyCode } from "./currency.ts";
140
155
  export type { GeneratedFile } from "./file.ts";
141
156
  export type { PublicFilePath, PublicFileRegistry } from "./files.ts";
142
157
  export type { Font, Fonts } from "./fonts.ts";
@@ -241,19 +256,12 @@ export {
241
256
  type ShareImage,
242
257
  type SiteVerification,
243
258
  } from "./meta/index.ts";
244
- // `quantitiesOf`, `quantityRange` and `isContiguous` are deliberately not here.
245
- // They are how lib decides between "2–6" and "2, 4, 6", and a consumer holding
246
- // them writes that decision a second time — which is how `llms.txt` came to
247
- // advertise a range for a room that skips sizes. `formatQuantities` answers it
248
- // for prose and `quantitiesFor` for structured data; both read the same tiers.
249
259
  export {
250
260
  type AudiencePrice,
251
261
  type AudienceTier,
252
262
  amountAt,
253
263
  assertAudiences,
254
264
  assertPriceTiers,
255
- type CurrencyCode,
256
- formatPrice,
257
265
  formatQuantities,
258
266
  type Price,
259
267
  type PriceTier,
@@ -263,6 +271,11 @@ export {
263
271
  type TieredPrice,
264
272
  tierRange,
265
273
  } from "./money.ts";
274
+ export {
275
+ formatPrice,
276
+ type PriceDecimals,
277
+ type PriceFormat,
278
+ } from "./price-format.ts";
266
279
  export type {
267
280
  AddedCatalog,
268
281
  DefinedProject,
package/src/money.ts CHANGED
@@ -1,171 +1,6 @@
1
+ import type { CurrencyCode } from "./currency.ts";
1
2
  import type { NonEmpty } from "./types.ts";
2
3
 
3
- /**
4
- * An ISO 4217 currency code: `"EUR"`, `"RON"`, `"GBP"`.
5
- *
6
- * A code rather than a symbol — `€`, `lei` — because two currencies can share a
7
- * symbol, and `priceCurrency` is read as a code.
8
- *
9
- * Circulating currencies from ISO 4217 List One (SIX, published 2026-09-17):
10
- * fund, precious-metal, testing and accounting-unit codes left out.
11
- */
12
- export type CurrencyCode =
13
- | "AED"
14
- | "AFN"
15
- | "ALL"
16
- | "AMD"
17
- | "AOA"
18
- | "ARS"
19
- | "AUD"
20
- | "AWG"
21
- | "AZN"
22
- | "BAM"
23
- | "BBD"
24
- | "BDT"
25
- | "BHD"
26
- | "BIF"
27
- | "BMD"
28
- | "BND"
29
- | "BOB"
30
- | "BRL"
31
- | "BSD"
32
- | "BTN"
33
- | "BWP"
34
- | "BYN"
35
- | "BZD"
36
- | "CAD"
37
- | "CDF"
38
- | "CHF"
39
- | "CLP"
40
- | "CNY"
41
- | "COP"
42
- | "CRC"
43
- | "CUP"
44
- | "CVE"
45
- | "CZK"
46
- | "DJF"
47
- | "DKK"
48
- | "DOP"
49
- | "DZD"
50
- | "EGP"
51
- | "ERN"
52
- | "ETB"
53
- | "EUR"
54
- | "FJD"
55
- | "FKP"
56
- | "GBP"
57
- | "GEL"
58
- | "GHS"
59
- | "GIP"
60
- | "GMD"
61
- | "GNF"
62
- | "GTQ"
63
- | "GYD"
64
- | "HKD"
65
- | "HNL"
66
- | "HTG"
67
- | "HUF"
68
- | "IDR"
69
- | "ILS"
70
- | "INR"
71
- | "IQD"
72
- | "IRR"
73
- | "ISK"
74
- | "JMD"
75
- | "JOD"
76
- | "JPY"
77
- | "KES"
78
- | "KGS"
79
- | "KHR"
80
- | "KMF"
81
- | "KPW"
82
- | "KRW"
83
- | "KWD"
84
- | "KYD"
85
- | "KZT"
86
- | "LAK"
87
- | "LBP"
88
- | "LKR"
89
- | "LRD"
90
- | "LSL"
91
- | "LYD"
92
- | "MAD"
93
- | "MDL"
94
- | "MGA"
95
- | "MKD"
96
- | "MMK"
97
- | "MNT"
98
- | "MOP"
99
- | "MRU"
100
- | "MUR"
101
- | "MVR"
102
- | "MWK"
103
- | "MXN"
104
- | "MYR"
105
- | "MZN"
106
- | "NAD"
107
- | "NGN"
108
- | "NIO"
109
- | "NOK"
110
- | "NPR"
111
- | "NZD"
112
- | "OMR"
113
- | "PAB"
114
- | "PEN"
115
- | "PGK"
116
- | "PHP"
117
- | "PKR"
118
- | "PLN"
119
- | "PYG"
120
- | "QAR"
121
- | "RON"
122
- | "RSD"
123
- | "RUB"
124
- | "RWF"
125
- | "SAR"
126
- | "SBD"
127
- | "SCR"
128
- | "SDG"
129
- | "SEK"
130
- | "SGD"
131
- | "SHP"
132
- | "SLE"
133
- | "SOS"
134
- | "SRD"
135
- | "SSP"
136
- | "STN"
137
- | "SVC"
138
- | "SYP"
139
- | "SZL"
140
- | "THB"
141
- | "TJS"
142
- | "TMT"
143
- | "TND"
144
- | "TOP"
145
- | "TRY"
146
- | "TTD"
147
- | "TWD"
148
- | "TZS"
149
- | "UAH"
150
- | "UGX"
151
- | "USD"
152
- | "UYU"
153
- | "UZS"
154
- | "VED"
155
- | "VES"
156
- | "VND"
157
- | "VUV"
158
- | "WST"
159
- | "XAF"
160
- | "XCD"
161
- | "XCG"
162
- | "XOF"
163
- | "XPF"
164
- | "YER"
165
- | "ZAR"
166
- | "ZMW"
167
- | "ZWG";
168
-
169
4
  /**
170
5
  * An amount and what it is denominated in, kept together.
171
6
  *
@@ -501,22 +336,3 @@ export function amountAt(
501
336
  }
502
337
  return undefined;
503
338
  }
504
-
505
- /**
506
- * A price as a reader in `locale` writes it: the currency's symbol and that
507
- * language's separators, and no decimals on a whole amount — "£25", not
508
- * "£25.00", while "€12.50" keeps its cents.
509
- *
510
- * Whole amounts drop the decimals because that is how a price list reads; a
511
- * table of "25.00, 22.00, 20.00" says nothing "25, 22, 20" does not. An amount
512
- * with cents shows two, so a price is never rounded on the way to the page.
513
- */
514
- export function formatPrice(price: Price, locale: string): string {
515
- const whole = Number.isInteger(price.amount);
516
- return new Intl.NumberFormat(locale, {
517
- style: "currency",
518
- currency: price.currency,
519
- minimumFractionDigits: whole ? 0 : 2,
520
- maximumFractionDigits: whole ? 0 : 2,
521
- }).format(price.amount);
522
- }
@@ -0,0 +1,49 @@
1
+ import type { Price } from "./money.ts";
2
+
3
+ /**
4
+ * Whether a whole amount shows its decimals: `"always"` writes "18,00 €" and
5
+ * "$18.00"; `"whenNeeded"` writes "£18" and "18 €". Either way an amount with
6
+ * cents shows the currency's digits, so a price is never rounded on the way to
7
+ * the page.
8
+ */
9
+ export type PriceDecimals = "always" | "whenNeeded";
10
+
11
+ /**
12
+ * How a site writes its prices: a project's `formats.prices`. The decimals are
13
+ * the store's, from its price list and currency, so one rule serves every
14
+ * language. The locale is optional: without it each page writes prices in its
15
+ * own language's way ("€18.00" in English, "18,00 €" in Portuguese); with it,
16
+ * every page writes them the way of that one of the site's languages.
17
+ */
18
+ export interface PriceFormat<L extends string = string> {
19
+ readonly decimals: PriceDecimals;
20
+ /**
21
+ * The locale every page writes prices in: one the project publishes, so
22
+ * an English page can write them the store's own way, `"pt-PT"`. Each
23
+ * page's own without.
24
+ */
25
+ readonly locale?: L;
26
+ }
27
+
28
+ /**
29
+ * A price as `locale` writes it: the currency's symbol and that locale's
30
+ * separators, and whole amounts with or without their decimals as `decimals`
31
+ * says. Pages take both from `site.priceFormatFor(locale)`.
32
+ */
33
+ export function formatPrice(
34
+ price: Price,
35
+ locale: string,
36
+ decimals: PriceDecimals
37
+ ): string {
38
+ // The currency's own digits otherwise, as Intl knows them: two for the
39
+ // euro, three for the Kuwaiti dinar, none for the yen.
40
+ const whole =
41
+ decimals === "whenNeeded" && Number.isInteger(price.amount)
42
+ ? { minimumFractionDigits: 0, maximumFractionDigits: 0 }
43
+ : {};
44
+ return new Intl.NumberFormat(locale, {
45
+ style: "currency",
46
+ currency: price.currency,
47
+ ...whole,
48
+ }).format(price.amount);
49
+ }
package/src/project.ts CHANGED
@@ -16,6 +16,7 @@ import type {
16
16
  } from "./i18n/define.ts";
17
17
  import type { ImageAsset } from "./image.ts";
18
18
  import type { SiteVerification, ThemeColor } from "./meta/index.ts";
19
+ import type { PriceFormat } from "./price-format.ts";
19
20
  import type {
20
21
  ProjectRedirects,
21
22
  RedirectRule,
@@ -39,6 +40,28 @@ import type { HttpsUrl, ValidSiteUrl } from "./url.ts";
39
40
  * here. A project exports those separately, typed by the template's own
40
41
  * contract, so lib never carries data it cannot reason about.
41
42
  */
43
+ /** How a project writes what it formats: a project's `formats`. */
44
+ export interface ProjectFormats<L extends string> {
45
+ /**
46
+ * The clock each published language prints times on: `"h23"` for 18:00,
47
+ * `"h12"` for 6:00 PM. Read back with `site.clockFor(locale)`.
48
+ *
49
+ * One entry for every locale in `enabledLocales`, and none for any other,
50
+ * so what each page prints is written down beside the languages that print
51
+ * it: a bilingual Canadian site reads `{ "en-CA": "h12", "fr-CA": "h23" }`.
52
+ * Not taken from the locale's own default (`Intl`'s) on purpose: a site may
53
+ * print a 24-hour clock where its language's default is 12-hour, and that
54
+ * choice is the site's to state.
55
+ */
56
+ readonly clock?: Readonly<Partial<Record<L, HourCycle>>>;
57
+ /**
58
+ * How the site writes prices, one rule for every page: whether whole
59
+ * amounts show their decimals, and optionally the one locale they are all
60
+ * written in. Read back with `site.priceFormatFor(locale)`.
61
+ */
62
+ readonly prices?: PriceFormat<L>;
63
+ }
64
+
42
65
  export interface ProjectInput<L extends string, Routes> {
43
66
  /**
44
67
  * Absolute origin, e.g. `https://example.com`.
@@ -107,20 +130,11 @@ export interface ProjectInput<L extends string, Routes> {
107
130
  */
108
131
  readonly enabledLocales: readonly L[];
109
132
  /**
110
- * The clock each published language prints times on: `"h23"` for 18:00,
111
- * `"h12"` for 6:00 PM. Read back with `site.clockFor(locale)`.
112
- *
113
- * One entry for every locale in `enabledLocales`, and none for any other,
114
- * so what each page prints is written down beside the languages that print
115
- * it: a bilingual Canadian site reads `{ "en-CA": "h12", "fr-CA": "h23" }`.
116
- * Not taken from the locale's own default (`Intl`'s) on purpose: a site may
117
- * print a 24-hour clock where its language's default is 12-hour, and that
118
- * choice is the site's to state.
119
- *
120
- * Optional: a project that prints no times declares none, and
121
- * `clockFor` refuses to answer for it.
133
+ * How this site writes what it formats: times and prices. Each format is
134
+ * optional, and its reader refuses to answer for a project that declares
135
+ * none, so one that prints times or prices has to say how.
122
136
  */
123
- readonly clock?: Readonly<Partial<Record<L, HourCycle>>>;
137
+ readonly formats?: ProjectFormats<L>;
124
138
  /**
125
139
  * Replaces parts of the site's `defaultRouting` for this project.
126
140
  *
@@ -302,19 +316,41 @@ export type ProjectChecks<
302
316
  ? NoExcessKeys<T["overrideLocaleMeta"][K], keyof LocaleMetaOverride>
303
317
  : LocaleNotEnabled<K>;
304
318
  };
305
- // The locales written must be ones this project publishes, as for
319
+ // The clock's locales must be ones this project publishes, as for
306
320
  // `overrideLocaleMeta` above. That every one it publishes is there is
307
321
  // checked in `ProjectOverrideChecks`: a conditional on `T` here would make
308
322
  // the constraint circular.
309
- readonly clock?: {
310
- readonly [K in StringKeys<
311
- T["clock"]
312
- >]: K extends T["enabledLocales"][number]
313
- ? HourCycle
314
- : LocaleNotEnabled<K>;
323
+ readonly formats?: {
324
+ readonly clock?: {
325
+ readonly [K in StringKeys<
326
+ NonNullable<T["formats"]>["clock"]
327
+ >]: K extends T["enabledLocales"][number]
328
+ ? HourCycle
329
+ : LocaleNotEnabled<K>;
330
+ };
331
+ // Its locale too must be one this project publishes.
332
+ readonly prices?: PriceFormat & {
333
+ readonly locale?: PriceLocaleCheck<T>;
334
+ };
315
335
  };
316
336
  };
317
337
 
338
+ /** A project's price locale as written, or `never` when it names none. */
339
+ type PriceLocaleOf<T> = T extends {
340
+ readonly formats?: { readonly prices?: { readonly locale?: infer Locale } };
341
+ }
342
+ ? Exclude<Locale, undefined> & string
343
+ : never;
344
+
345
+ /** The price locale, if the project publishes it; else an error naming it. */
346
+ type PriceLocaleCheck<T> = T extends {
347
+ readonly enabledLocales: readonly (infer Published)[];
348
+ }
349
+ ? PriceLocaleOf<T> extends Published
350
+ ? PriceLocaleOf<T>
351
+ : LocaleNotEnabled<PriceLocaleOf<T>>
352
+ : never;
353
+
318
354
  /** The same checks that map the whole override objects, in parameter position. */
319
355
  export type ProjectOverrideChecks<
320
356
  L extends string,
@@ -331,7 +367,14 @@ export type ProjectOverrideChecks<
331
367
  Routes,
332
368
  T["overrideRoutes"]
333
369
  >;
334
- } & (T extends { readonly addMessages: infer A }
370
+ } & AdditionsCheck<Catalog, T> &
371
+ ClockCheck<T>;
372
+
373
+ /** A project's own messages, checked like the catalog's: when it adds any. */
374
+ type AdditionsCheck<
375
+ Catalog,
376
+ T extends ProjectInput<string, unknown>,
377
+ > = T extends { readonly addMessages: infer A }
335
378
  ? {
336
379
  readonly addMessages: ValidateAdditions<
337
380
  A,
@@ -339,16 +382,23 @@ export type ProjectOverrideChecks<
339
382
  T["enabledLocales"][number]
340
383
  >;
341
384
  }
342
- : unknown) &
343
- (T extends { readonly clock: object }
344
- ? {
345
- // A clock, once declared, is declared for every page: a locale
346
- // left out would have no clock to print its times on.
385
+ : unknown;
386
+
387
+ /**
388
+ * A clock, once declared, is declared for every page: a locale left out would
389
+ * have no clock to print its times on.
390
+ */
391
+ type ClockCheck<T extends ProjectInput<string, unknown>> = T extends {
392
+ readonly formats: { readonly clock: object };
393
+ }
394
+ ? {
395
+ readonly formats: {
347
396
  readonly clock: {
348
397
  readonly [K in T["enabledLocales"][number]]: HourCycle;
349
398
  };
350
- }
351
- : unknown);
399
+ };
400
+ }
401
+ : unknown;
352
402
 
353
403
  /** A project's own messages, as a catalog: none when it adds none. */
354
404
  export type AddedCatalog<P> = P extends { readonly addMessages: infer A }
@@ -406,17 +456,16 @@ export function defineProject<
406
456
  }
407
457
  }
408
458
  // The clock's rule, for the same reason.
409
- if (project.clock !== undefined) {
459
+ const clock = project.formats?.clock;
460
+ if (clock !== undefined) {
410
461
  const published: readonly string[] = project.enabledLocales;
411
- const missing = published.filter(
412
- (locale) => !(locale in (project.clock ?? {}))
413
- );
462
+ const missing = published.filter((locale) => !(locale in clock));
414
463
  if (missing.length > 0) {
415
464
  throw new Error(
416
465
  `Project "${project.siteName}": clock has no ${missing.join(", ")}. A project that declares a clock gives one for every locale it publishes.`
417
466
  );
418
467
  }
419
- const extra = Object.keys(project.clock).filter(
468
+ const extra = Object.keys(clock).filter(
420
469
  (locale) => !published.includes(locale)
421
470
  );
422
471
  if (extra.length > 0) {
@@ -425,5 +474,15 @@ export function defineProject<
425
474
  );
426
475
  }
427
476
  }
477
+ // The price locale's rule, for the same reason.
478
+ const priceLocale: string | undefined = project.formats?.prices?.locale;
479
+ if (
480
+ priceLocale !== undefined &&
481
+ !(project.enabledLocales as readonly string[]).includes(priceLocale)
482
+ ) {
483
+ throw new Error(
484
+ `Project "${project.siteName}": formats.prices.locale names ${priceLocale}, which this project does not publish.`
485
+ );
486
+ }
428
487
  return project;
429
488
  }
package/src/site/api.ts CHANGED
@@ -11,6 +11,7 @@ import type { HourCycle } from "../hours.ts";
11
11
  import type { TranslateFor } from "../i18n/translate.ts";
12
12
  import type { LlmsSection } from "../llms.ts";
13
13
  import type { ThemeColor } from "../meta/index.ts";
14
+ import type { PriceDecimals } from "../price-format.ts";
14
15
  import type {
15
16
  RedirectRule,
16
17
  ResolvedRedirect,
@@ -229,6 +230,19 @@ export interface Site<
229
230
  */
230
231
  clockFor(locale: L): HourCycle;
231
232
 
233
+ /**
234
+ * How a locale's pages write prices, as the project's `formats.prices`
235
+ * states it: hand both to `formatPrice`. The locale is the page's own,
236
+ * unless the project names one for every page.
237
+ *
238
+ * Throws for a project that declares no price format: one that prints
239
+ * prices has to say how.
240
+ */
241
+ priceFormatFor(locale: L): {
242
+ readonly decimals: PriceDecimals;
243
+ readonly locale: L;
244
+ };
245
+
232
246
  /** Absolute path of a route in one locale, e.g. `/el/sxetika-me-emas`. */
233
247
  pathFor(id: RouteId, locale: L, options?: LinkOptions): UrlPath;
234
248
  /** The same, prefixed with the site origin — for canonicals and sitemaps. */
@@ -24,6 +24,7 @@ import {
24
24
  buildNotFoundMeta,
25
25
  resolveShareImage,
26
26
  } from "../meta/index.ts";
27
+ import type { PriceDecimals } from "../price-format.ts";
27
28
  import type { AddedCatalog, ProjectInput, ProjectRouteId } from "../project.ts";
28
29
  import {
29
30
  buildRedirects,
@@ -194,17 +195,35 @@ export function createSite<
194
195
  const resolved = mergeRoutes<L, Routes>(routes, project.overrideRoutes);
195
196
 
196
197
  function clockFor(locale: L): HourCycle {
197
- const cycle = project.clock?.[locale];
198
+ const clock = project.formats?.clock;
199
+ const cycle = clock?.[locale];
198
200
  if (cycle === undefined) {
199
201
  throw new Error(
200
- project.clock === undefined
201
- ? `Project "${project.siteName}" declares no clock, so it cannot print a time. Add \`clock\`, with an hour cycle for each locale it publishes.`
202
- : `Project "${project.siteName}": clock has no ${locale}.`
202
+ clock === undefined
203
+ ? `Project "${project.siteName}" declares no clock, so it cannot print a time. Add \`formats.clock\`, with an hour cycle for each locale it publishes.`
204
+ : `Project "${project.siteName}": formats.clock has no ${locale}.`
203
205
  );
204
206
  }
205
207
  return cycle;
206
208
  }
207
209
 
210
+ function priceFormatFor(locale: L): {
211
+ readonly decimals: PriceDecimals;
212
+ readonly locale: L;
213
+ } {
214
+ const prices = project.formats?.prices;
215
+ if (prices === undefined) {
216
+ throw new Error(
217
+ `Project "${project.siteName}" declares no price format, so it cannot print a price. Add \`formats.prices\`, with its decimals.`
218
+ );
219
+ }
220
+ // Found among the published locales, which the project's checks hold
221
+ // it to, so it is typed as one of them; else the page's own.
222
+ const written =
223
+ locales.find((each) => each === prices.locale) ?? locale;
224
+ return { decimals: prices.decimals, locale: written };
225
+ }
226
+
208
227
  function pathFor(id: RouteId, locale: L, options?: LinkOptions): UrlPath {
209
228
  const route = resolved[id];
210
229
  if (route === undefined) {
@@ -881,6 +900,7 @@ export function createSite<
881
900
  }),
882
901
 
883
902
  clockFor,
903
+ priceFormatFor,
884
904
  pathFor,
885
905
  urlFor,
886
906
  fileUrl,