@escape-game-over/atlas 0.1.110 → 0.1.112

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 |
@@ -131,7 +132,7 @@ dependencies at all. Vitest type-checks them itself when it runs them.
131
132
  | `money.test.ts` | a bare count widened to a band, the span of a table, the gaps and overlaps that throw, and an audience priced twice |
132
133
  | `url.test.ts` | joining an origin to a path exactly once, and normalising the origin an `@id` is built from |
133
134
  | `warn.test.ts` | the shared prefix, and reducing a URL to its path |
134
- | `filters.test.ts` | folding both sides of a query, how each kind narrows, the four URL decisions, and attach/detach |
135
+ | `filters.test.ts` | folding both sides of a query, how each kind narrows, the four URL decisions, values no item holds, and attach/detach |
135
136
  | `filters-view.test.ts` | the write-back into the input and that it is skipped when unchanged, clear taking focus, and the nesting order |
136
137
  | `build-cache.test.ts` | that an unset `CI_ASTRO_CACHE` yields nothing, so Astro's own `cacheDir` default survives |
137
138
 
@@ -241,6 +241,12 @@ instead of flashing the whole list back. Nothing writes to the URL before
241
241
  `attach`, because `attach` is what reads it — a `set` on an unattached instance
242
242
  would overwrite state nobody had loaded.
243
243
 
244
+ A choice no item holds, or a flag no item carries, is a stale or mistyped link:
245
+ reading it — on `attach` and on the back button — takes it as no choice, and
246
+ replaces the URL without it. Otherwise `?category=castle` showed an empty list
247
+ with nothing pressed, not even "All". A `text` query is left alone: it matches
248
+ words, not values, and finding nothing is a fair answer to it.
249
+
244
250
  A flag is on by being there, as an HTML boolean attribute is: written as the bare
245
251
  `?featured`, and **absent** when off. Reading asks only whether the name is
246
252
  present, so `?featured=1` from an older link still reads as on — and so does a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.110",
3
+ "version": "0.1.112",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -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
  /**
@@ -199,6 +199,18 @@ export function filters<const F extends FieldMap, T>(
199
199
  return { item, values, haystacks };
200
200
  });
201
201
 
202
+ // What the items hold, per choice and flag: a link asking for anything
203
+ // else is stale or mistyped, and is read as no choice rather than an empty
204
+ // list with nothing pressed.
205
+ const held = new Map<string, ReadonlySet<unknown>>();
206
+ for (const name of names) {
207
+ if (kindOf(name) === "text") continue;
208
+ held.set(
209
+ name,
210
+ new Set(entries.flatMap((entry) => entry.values[name] as unknown))
211
+ );
212
+ }
213
+
202
214
  const empty = (name: keyof F & string): StateValue<F[typeof name]> =>
203
215
  (kindOf(name) === "flag" ? false : "") as StateValue<F[typeof name]>;
204
216
 
@@ -287,9 +299,11 @@ export function filters<const F extends FieldMap, T>(
287
299
  matched = next;
288
300
  }
289
301
 
290
- function readUrl(): void {
302
+ /** Applies the URL; says whether it asked for anything no item holds. */
303
+ function readUrl(): boolean {
291
304
  const search = new URLSearchParams(window.location.search);
292
305
  const next: Record<string, string | boolean> = {};
306
+ let dropped = false;
293
307
  for (const name of names) {
294
308
  if (!inUrl.includes(name)) {
295
309
  // A state-only field is not in the URL, so the URL has nothing
@@ -300,12 +314,17 @@ export function filters<const F extends FieldMap, T>(
300
314
  }
301
315
  // A flag is on by being there, as an HTML boolean attribute is:
302
316
  // `?featured`, and `?featured=1` from an older link, alike.
303
- next[name] =
317
+ const value =
304
318
  kindOf(name) === "flag"
305
319
  ? search.has(name)
306
320
  : (search.get(name) ?? "");
321
+ const unheld =
322
+ value !== empty(name) && held.get(name)?.has(value) === false;
323
+ next[name] = unheld ? empty(name) : value;
324
+ if (unheld) dropped = true;
307
325
  }
308
326
  state = next as FilterState<F>;
327
+ return dropped;
309
328
  }
310
329
 
311
330
  function writeUrl(history: "push" | "replace"): void {
@@ -424,8 +443,9 @@ export function filters<const F extends FieldMap, T>(
424
443
  window.addEventListener(
425
444
  "popstate",
426
445
  () => {
427
- readUrl();
446
+ const dropped = readUrl();
428
447
  recompute();
448
+ if (dropped) writeUrl("replace");
429
449
  announce();
430
450
  },
431
451
  { signal: events.signal }
@@ -435,8 +455,11 @@ export function filters<const F extends FieldMap, T>(
435
455
  // Read before the first announcement: the URL may already carry a
436
456
  // state, and announcing the empty one first flashes the unfiltered
437
457
  // list over it.
438
- readUrl();
458
+ // A value no item holds leaves the address bar as well, replaced
459
+ // rather than pushed: it is a correction, not a step to go back to.
460
+ const dropped = readUrl();
439
461
  recompute();
462
+ if (dropped) writeUrl("replace");
440
463
  announce();
441
464
 
442
465
  return () => {
@@ -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). */
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
@@ -146,6 +146,12 @@ export {
146
146
  countriesFor,
147
147
  type NamedCountry,
148
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";
149
155
  export type { GeneratedFile } from "./file.ts";
150
156
  export type { PublicFilePath, PublicFileRegistry } from "./files.ts";
151
157
  export type { Font, Fonts } from "./fonts.ts";
@@ -250,19 +256,12 @@ export {
250
256
  type ShareImage,
251
257
  type SiteVerification,
252
258
  } from "./meta/index.ts";
253
- // `quantitiesOf`, `quantityRange` and `isContiguous` are deliberately not here.
254
- // They are how lib decides between "2–6" and "2, 4, 6", and a consumer holding
255
- // them writes that decision a second time — which is how `llms.txt` came to
256
- // advertise a range for a room that skips sizes. `formatQuantities` answers it
257
- // for prose and `quantitiesFor` for structured data; both read the same tiers.
258
259
  export {
259
260
  type AudiencePrice,
260
261
  type AudienceTier,
261
262
  amountAt,
262
263
  assertAudiences,
263
264
  assertPriceTiers,
264
- type CurrencyCode,
265
- formatPrice,
266
265
  formatQuantities,
267
266
  type Price,
268
267
  type PriceTier,
@@ -272,6 +271,11 @@ export {
272
271
  type TieredPrice,
273
272
  tierRange,
274
273
  } from "./money.ts";
274
+ export {
275
+ formatPrice,
276
+ type PriceDecimals,
277
+ type PriceFormat,
278
+ } from "./price-format.ts";
275
279
  export type {
276
280
  AddedCatalog,
277
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,