@escape-game-over/atlas 0.1.24 → 0.1.25

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 (48) hide show
  1. package/README.md +27 -44
  2. package/bin/use-project.mjs +18 -13
  3. package/docs/NOT-BUILT.md +1 -1
  4. package/docs/client-scripts.md +73 -141
  5. package/docs/rich-text.md +13 -20
  6. package/package.json +5 -12
  7. package/src/analytics/google.ts +6 -6
  8. package/src/analytics/index.ts +4 -3
  9. package/src/analytics/tags.ts +13 -58
  10. package/src/analytics/umami.ts +8 -8
  11. package/src/astro/ConsentBanner.astro +25 -0
  12. package/src/astro/ConsentElement.astro +61 -0
  13. package/src/astro/Document.astro +44 -0
  14. package/src/astro/Image.astro +102 -0
  15. package/src/astro/MetaTags.astro +3 -26
  16. package/src/astro/RichText.astro +71 -0
  17. package/src/astro/Zoom.astro +61 -0
  18. package/src/astro/client.ts +19 -9
  19. package/src/astro/consent.ts +20 -0
  20. package/src/astro/dev-log.ts +8 -14
  21. package/src/astro/element.ts +111 -112
  22. package/src/astro/filters-view.ts +48 -64
  23. package/src/astro/filters.ts +42 -35
  24. package/src/astro/index.ts +2 -9
  25. package/src/astro/markup.ts +6 -6
  26. package/src/astro/site-routes.ts +9 -15
  27. package/src/config.ts +23 -36
  28. package/src/content/index.ts +1 -1
  29. package/src/content/marks.ts +13 -13
  30. package/src/content/rich.ts +26 -42
  31. package/src/hours.ts +48 -11
  32. package/src/i18n/define.ts +14 -74
  33. package/src/index.ts +40 -57
  34. package/src/meta/index.ts +7 -13
  35. package/src/meta/share-image.ts +2 -26
  36. package/src/meta/tag.ts +1 -45
  37. package/src/money.ts +161 -6
  38. package/src/project.ts +84 -73
  39. package/src/routes/define.ts +8 -44
  40. package/src/routes/resolve.ts +1 -1
  41. package/src/site/api.ts +7 -33
  42. package/src/site/create.ts +6 -10
  43. package/src/site/define.ts +120 -0
  44. package/src/site/index.ts +2 -5
  45. package/src/site/page.ts +4 -2
  46. package/src/sitemap.ts +2 -35
  47. package/src/warn.ts +16 -17
  48. package/src/astro/dom.ts +0 -35
@@ -22,15 +22,8 @@
22
22
  * - `public-files.ts` types the contents of `public/`, the one part of a site
23
23
  * the compiler cannot otherwise see.
24
24
  *
25
- * The rest of this folder is imported directly rather than from here, because
26
- * each is reached from a different place in a project: `…/astro/images` from
27
- * a view or a config, `…/astro/consent` from a client-side script, and
28
- * `…/astro/meta-tags` from a layout.
29
- *
30
- * That last one is a `.astro` component, and the only one lib carries. It is
31
- * here rather than in a project's `src/` because it holds no design and no
32
- * copy — it renders the `MetaTag` union and nothing else, which makes it the
33
- * mirror of `asMetaTag` in `lib/meta/tag.ts` rather than a piece of a theme.
25
+ * The rest is imported from its own path: `…/astro/images` from a view,
26
+ * `…/astro/document` from a layout, `…/client` from a script.
34
27
  */
35
28
 
36
29
  export {
@@ -99,7 +99,7 @@ type CheckedMarkupName<N extends string> = string extends N
99
99
  ? unknown
100
100
  : MalformedMarkupName<N>;
101
101
 
102
- type CheckedTagName<N extends string> = string extends N
102
+ export type CheckedTagName<N extends string> = string extends N
103
103
  ? unknown
104
104
  : IsHyphenated<N> extends true
105
105
  ? N extends `${string}-${string}`
@@ -108,7 +108,7 @@ type CheckedTagName<N extends string> = string extends N
108
108
  : MalformedTagName<N>;
109
109
 
110
110
  /** The same, for every key of a fields or roles object. */
111
- type CheckedKeys<T> = {
111
+ export type CheckedKeys<T> = {
112
112
  [K in keyof T]: K extends string
113
113
  ? IsCamelCase<K> extends true
114
114
  ? unknown
@@ -515,20 +515,20 @@ export function markup<
515
515
  export type ComponentRoles = Readonly<Record<string, MarkupFields>>;
516
516
 
517
517
  /** The keys the component object uses itself, so no role may take them. */
518
- type Reserved = "tag" | "root";
518
+ export type Reserved = "tag" | "root";
519
519
 
520
520
  /**
521
521
  * What marks a component's root element.
522
522
  *
523
523
  * One name, so a project gives every root a display in a single stylesheet rule
524
- * rather than a class per component, and so `defineElement` can say when a
524
+ * rather than a class per component, and so `element` can say when a
525
525
  * template forgot to spread it.
526
526
  */
527
527
  export const ROOT_ATTRIBUTE = "data-atlas-root";
528
528
 
529
529
  export type Component<Tag extends string, R extends ComponentRoles> = {
530
530
  /** The custom element's name: what the template renders, and what
531
- * `defineElement` registers the behaviour under. */
531
+ * `element` registers the behaviour under. */
532
532
  readonly tag: Tag;
533
533
 
534
534
  /**
@@ -549,7 +549,7 @@ export type Component<Tag extends string, R extends ComponentRoles> = {
549
549
  * });
550
550
  *
551
551
  * // template: <faq.tag {...faq.root}> … <details {...faq.row.attrs({ key, text })}>
552
- * // script: defineElement(faq, (host, signal, { row }) => row.all().forEach(…));
552
+ * // behaviour: see `element` in element.ts, which builds on this.
553
553
  * ```
554
554
  *
555
555
  * Two things `markup` alone leaves to the project:
@@ -67,16 +67,10 @@ export interface SiteRoutesOptions {
67
67
  *
68
68
  * The finished file rather than a flag, because this is the one output whose
69
69
  * words lib does not have: page names and summaries live in a catalog under
70
- * a key convention it cannot guess. Omitting it does not turn the file off —
71
- * `site.llms()` still writes one, listing every page by route id with no
72
- * summaries. That is a worse file and a real one; switching it off is a
73
- * decision, made with `llms: false` in the site config.
70
+ * a key convention it cannot guess. Omitted, `site.llms()` writes one listing
71
+ * every page by route id with no summaries.
74
72
  */
75
73
  readonly llms?: GeneratedFile;
76
- /** Write the sitemap. Defaults to true. */
77
- readonly sitemap?: boolean;
78
- /** Write `robots.txt`. Defaults to true. */
79
- readonly robots?: boolean;
80
74
  }
81
75
 
82
76
  /**
@@ -98,7 +92,7 @@ export interface SiteRoutesOptions {
98
92
  * again for the reason above.
99
93
  */
100
94
  export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
101
- const { site, redirects, llms, sitemap = true, robots = true } = options;
95
+ const { site, redirects, llms } = options;
102
96
 
103
97
  /** `llms.txt (4.2 kB)` — the name and what it actually weighs. */
104
98
  const describe = (file: GeneratedFile): string =>
@@ -115,12 +109,9 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
115
109
  // it and leaves the same string in `llms.txt` untouched. A build is always
116
110
  // right, since it evaluates the config in a fresh process.
117
111
  const generate = (): GeneratedFile[] => {
118
- const files: GeneratedFile[] = [];
119
- if (sitemap) files.push(...site.sitemap().files);
120
- if (robots) files.push(site.robots());
121
- // `llmsUrl` is what every page's head links to, so a file must exist at
122
- // it: the described one when given, a bare listing otherwise. Undefined
123
- // means the config turned both off.
112
+ const files: GeneratedFile[] = [...site.sitemap().files, site.robots()];
113
+ // Every page's head links to it, so one is written unless the config
114
+ // turned it off.
124
115
  if (site.llmsUrl !== undefined) files.push(llms ?? site.llms());
125
116
  // Length rather than presence: a site with no rules gets no file, not an
126
117
  // empty one.
@@ -349,6 +340,9 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
349
340
  // every locale is prefixed, which is the first URL anyone
350
341
  // opens. Serving them here makes dev answer as production
351
342
  // will, rather than as a place where the rules do not exist.
343
+ // Not Astro's `redirects`: it serves every redirect to a
344
+ // non-route (an external URL, a public file) as 301,
345
+ // whatever status it was given.
352
346
  // Matched with and without a trailing slash, because a host
353
347
  // either normalises one away before its rules run or treats
354
348
  // the two as the same URL. Dev should not be the only place
package/src/config.ts CHANGED
@@ -99,18 +99,7 @@ export interface SiteConfigShape {
99
99
  readonly defaultRouting: RoutingConfig<string>;
100
100
  readonly sitemap?: SitemapConfig;
101
101
  readonly robots?: RobotsConfig;
102
- /**
103
- * `false` to publish no `llms.txt`. Omitted, one is published under the
104
- * default name.
105
- *
106
- * Opt-out like the two above, even though lib cannot write this one
107
- * unaided — it needs a name and a summary per page, which only the consumer
108
- * has. Forgetting to supply them is a build error rather than a reason to
109
- * make the file opt-in: this key is also what puts `<link rel="describedby">`
110
- * in every head, and a page cannot tell whether `site.llms()` was called
111
- * somewhere, so the declaration stands in for it and `siteRoutes` checks
112
- * that it was kept.
113
- */
102
+ /** `false` publishes no `llms.txt`, and no page links to one. */
114
103
  readonly llms?: LlmsConfig | false;
115
104
  // No share image here on purpose: an image belongs to the page it
116
105
  // represents, so the page passes its own to `metaFor`.
@@ -135,25 +124,28 @@ export type LocalesOf<C> = C extends { readonly locales: infer M }
135
124
  * `string`, and the config reports a cascade of malformed-tag errors on lines
136
125
  * that were fine.
137
126
  */
138
- export function defineSiteConfig<
139
- const T extends SiteConfigShape & {
140
- readonly defaultRouting: {
141
- readonly defaultLocale: StringKeys<T["locales"]>;
142
- // Mapped over the keys actually written, not declared as a
143
- // `Partial<Record<…>>`: a constraint is checked by assignability,
144
- // and an extra key is assignable to a type whose properties are all
145
- // optional. Demanding an impossible value for a bad key is what
146
- // makes the error land on that line.
147
- readonly pageSegmentByLocale?: {
148
- readonly [K in StringKeys<
149
- T["defaultRouting"]["pageSegmentByLocale"]
150
- >]: K extends StringKeys<T["locales"]>
151
- ? string
152
- : LocaleNotDeclared<K>;
153
- };
127
+ /** Everything a site config must satisfy; see `defineSite`. */
128
+ export type SiteConfigChecks<T extends SiteConfigShape> = SiteConfigShape & {
129
+ readonly defaultRouting: {
130
+ readonly defaultLocale: StringKeys<T["locales"]>;
131
+ // Mapped over the keys actually written, not declared as a
132
+ // `Partial<Record<…>>`: a constraint is checked by assignability,
133
+ // and an extra key is assignable to a type whose properties are all
134
+ // optional. Demanding an impossible value for a bad key is what
135
+ // makes the error land on that line.
136
+ readonly pageSegmentByLocale?: {
137
+ readonly [K in StringKeys<
138
+ T["defaultRouting"]["pageSegmentByLocale"]
139
+ >]: K extends StringKeys<T["locales"]>
140
+ ? string
141
+ : LocaleNotDeclared<K>;
154
142
  };
155
- readonly locales: ValidateLocales<T["locales"]>;
156
- },
143
+ };
144
+ readonly locales: ValidateLocales<T["locales"]>;
145
+ };
146
+
147
+ export function defineSiteConfig<
148
+ const T extends SiteConfigShape & SiteConfigChecks<T>,
157
149
  >(config: T): T {
158
150
  return config;
159
151
  }
@@ -179,12 +171,7 @@ export interface LocaleNotDeclared<L extends string> {
179
171
  readonly __LOCALE_NOT_DECLARED__: `locale "${L}" is not among this site's locales, so anything keyed by it would never be read`;
180
172
  }
181
173
 
182
- /**
183
- * Editor-facing error for anything written for a locale a project does not ship.
184
- *
185
- * Lives here rather than beside one of its users because two of them need it:
186
- * `defineProject`, for locale metadata, and `defineMessageOverrides`, for copy.
187
- */
174
+ /** Editor-facing error for anything written for a locale a project does not ship. */
188
175
  export interface LocaleNotEnabled<L extends string> {
189
176
  readonly __LOCALE_NOT_ENABLED__: `locale "${L}" is not among this project's enabledLocales, so anything written for it would never be built`;
190
177
  }
@@ -17,7 +17,7 @@
17
17
  * <RichText spans={rich("about.intro")} />
18
18
  *
19
19
  * // the same sentence, where a string is what fits
20
- * description: plain(rich("about.intro"))
20
+ * description: site.plain(locale)("about.intro")
21
21
  * ```
22
22
  *
23
23
  * There is no authored `ContentItem[]`. Where a project needs to compose runs
@@ -48,7 +48,7 @@ export type ParsedSpan =
48
48
  | { readonly kind: "text"; readonly text: string }
49
49
  | { readonly kind: "bold"; readonly text: string }
50
50
  | {
51
- readonly kind: "styled";
51
+ readonly kind: "variant";
52
52
  readonly text: string;
53
53
  readonly variant: string;
54
54
  }
@@ -58,8 +58,8 @@ export type ParsedSpan =
58
58
  /** The slot to fill, e.g. `venue` in `[a:venue]`. Never a URL. */
59
59
  readonly name: string;
60
60
  }
61
- | { readonly kind: "email"; readonly text: string }
62
- | { readonly kind: "phone"; readonly text: string }
61
+ | { readonly kind: "mail"; readonly text: string }
62
+ | { readonly kind: "tel"; readonly text: string }
63
63
  | { readonly kind: "break" };
64
64
 
65
65
  /**
@@ -81,10 +81,10 @@ export type ParsedSpan =
81
81
  */
82
82
  const MARKS = {
83
83
  b: "bold",
84
- v: "styled",
84
+ v: "variant",
85
85
  a: "link",
86
- mail: "email",
87
- tel: "phone",
86
+ mail: "mail",
87
+ tel: "tel",
88
88
  } as const;
89
89
 
90
90
  type MarkName = keyof typeof MARKS;
@@ -244,9 +244,9 @@ export function parseMarks(
244
244
  switch (MARKS[mark.name]) {
245
245
  case "bold":
246
246
  return { kind: "bold", text: content };
247
- case "styled":
247
+ case "variant":
248
248
  return {
249
- kind: "styled",
249
+ kind: "variant",
250
250
  text: content,
251
251
  variant: mark.argument,
252
252
  };
@@ -256,10 +256,10 @@ export function parseMarks(
256
256
  text: content,
257
257
  name: mark.argument,
258
258
  };
259
- case "email":
260
- return { kind: "email", text: content };
261
- case "phone":
262
- return { kind: "phone", text: content };
259
+ case "mail":
260
+ return { kind: "mail", text: content };
261
+ case "tel":
262
+ return { kind: "tel", text: content };
263
263
  }
264
264
  }
265
265
 
@@ -407,7 +407,7 @@ export function assertNoMarks(template: string, at: string): void {
407
407
  );
408
408
  if (marked) {
409
409
  throw new Error(
410
- `${at} carries marks, and t() can only print them. Read it with rich(), or with plain(rich(…)) for the words alone.`
410
+ `${at} carries marks, and t() can only print them. Read it with rich(), or with site.plain() for the words alone.`
411
411
  );
412
412
  }
413
413
  }
@@ -53,7 +53,7 @@ export type Span =
53
53
  * edit rather than a sweep through fifty-four deployments' copy.
54
54
  */
55
55
  | {
56
- readonly kind: "styled";
56
+ readonly kind: "variant";
57
57
  readonly text: string;
58
58
  readonly variant: string;
59
59
  }
@@ -105,12 +105,12 @@ export type Span =
105
105
  readonly href: Hash;
106
106
  }
107
107
  | {
108
- readonly kind: "email";
108
+ readonly kind: "mail";
109
109
  readonly text: string;
110
110
  readonly href: MailtoUrl;
111
111
  }
112
112
  | {
113
- readonly kind: "phone";
113
+ readonly kind: "tel";
114
114
  readonly text: string;
115
115
  readonly href: TelUrl;
116
116
  }
@@ -230,7 +230,7 @@ export type RichTextFor<Catalog, RouteId extends string> = <
230
230
  ...args: RichArgsOf<Catalog, K, RouteId>
231
231
  ) => RichText;
232
232
 
233
- /** `plain()`, keyed and parameterised exactly as `t()` is. */
233
+ /** `site.plain()`, keyed and parameterised exactly as `t()` is. */
234
234
  export type PlainTextFor<Catalog> = <K extends StringKeys<Catalog>>(
235
235
  ...args: TranslateArgsOf<Catalog, K>
236
236
  ) => string;
@@ -286,8 +286,8 @@ function resolve(
286
286
  case "text":
287
287
  case "bold":
288
288
  return { kind: span.kind, text };
289
- case "styled":
290
- return { kind: "styled", text, variant: span.variant };
289
+ case "variant":
290
+ return { kind: "variant", text, variant: span.variant };
291
291
  case "link":
292
292
  return resolveLink(
293
293
  text,
@@ -296,10 +296,10 @@ function resolve(
296
296
  link,
297
297
  origin
298
298
  );
299
- case "email":
300
- return { kind: "email", text, href: mailto(text, at) };
301
- case "phone":
302
- return { kind: "phone", text, href: tel(text, at) };
299
+ case "mail":
300
+ return { kind: "mail", text, href: mailto(text, at) };
301
+ case "tel":
302
+ return { kind: "tel", text, href: tel(text, at) };
303
303
  }
304
304
  }
305
305
 
@@ -473,27 +473,6 @@ function tel(text: string, at: string): TelUrl {
473
473
  return `tel:${dialled}`;
474
474
  }
475
475
 
476
- /**
477
- * The words alone, for everywhere that takes a string rather than markup.
478
- *
479
- * The reason this whole module sits in lib rather than in each project. A meta
480
- * description, an `llms.txt` summary and a structured-data `description` all
481
- * want the same sentence the page renders, and every one of them takes a plain
482
- * string — so without this each project flattens the runs by hand, and they
483
- * drift. The implementation this replaces did exactly that: a chain of `if`s
484
- * per kind, `return ""` for the ones it could not render, and a `throw` on
485
- * anything it had not been taught.
486
- *
487
- * A `[br]` becomes a space, because that is what it is once the markup is gone,
488
- * and runs of whitespace collapse — copy split across a line break otherwise
489
- * arrives with a double space in the middle of a `<meta>` tag.
490
- */
491
- export function plain(rich: RichText): string {
492
- return collapse(
493
- rich.map((span) => (span.kind === "break" ? " " : span.text))
494
- );
495
- }
496
-
497
476
  /**
498
477
  * The runs as inline HTML, for a field that takes markup rather than a page.
499
478
  *
@@ -512,7 +491,7 @@ export function html(rich: RichText): string {
512
491
  .map((span) => {
513
492
  switch (span.kind) {
514
493
  case "text":
515
- case "styled":
494
+ case "variant":
516
495
  return escapeXml(span.text);
517
496
  case "bold":
518
497
  return `<strong>${escapeXml(span.text)}</strong>`;
@@ -520,8 +499,8 @@ export function html(rich: RichText): string {
520
499
  return span.to === "anchor"
521
500
  ? escapeXml(span.text)
522
501
  : anchor(span.url, span.text);
523
- case "email":
524
- case "phone":
502
+ case "mail":
503
+ case "tel":
525
504
  return anchor(span.href, span.text);
526
505
  case "break":
527
506
  return "<br>";
@@ -538,6 +517,16 @@ function anchor(href: string, text: string): string {
538
517
  return `<a href="${escapeXml(href)}">${escapeXml(text)}</a>`;
539
518
  }
540
519
 
520
+ /**
521
+ * The words of runs already built — for a message whose values `rich()` was
522
+ * given in code. A `[br]` becomes a space and whitespace collapses.
523
+ */
524
+ export function plain(rich: RichText): string {
525
+ return collapse(
526
+ rich.map((span) => (span.kind === "break" ? " " : span.text))
527
+ );
528
+ }
529
+
541
530
  /**
542
531
  * Runs of whitespace become one, and the ends are trimmed.
543
532
  *
@@ -550,14 +539,9 @@ function collapse(parts: readonly string[]): string {
550
539
  }
551
540
 
552
541
  /**
553
- * The same, straight from a message key — `plain(rich(…))` without the nesting.
554
- *
555
- * Which sounds like sugar and is mostly about where it gets used. The callers
556
- * that want words rather than runs are the ones furthest from a renderer:
557
- * `llms()`'s `describe`, a `<meta name="description">`, a structured-data
558
- * `description`. Those are already assembling several strings at once, and
559
- * `plain(rich("about.intro", { company }))` reads as two operations there when
560
- * it is one question — what does this message say.
542
+ * The words of a message alone, for everywhere that takes a string rather than
543
+ * markup: a meta description, an `llms.txt` summary, a structured-data
544
+ * `description`. A `[br]` becomes a space and whitespace collapses.
561
545
  *
562
546
  * It also answers that question for a message with no marks at all, which `t()`
563
547
  * would too. That overlap is deliberate: a description built this way keeps
package/src/hours.ts CHANGED
@@ -164,18 +164,22 @@ export function groupHours(
164
164
  }
165
165
 
166
166
  /**
167
- * The words a printed week needs, which lib does not have.
168
- *
169
- * Day names and "closed" are copy: they are translated, and lib holds no copy.
170
- * Everything else about the line — which days collapse into a span, what order
171
- * they come in, where the dashes go — is mechanical and is done here, because
172
- * otherwise every project rewrites the same joins in a component.
167
+ * How to print a week. Day names come from `Intl` in `locale`; "closed" is
168
+ * copy, so the caller translates it.
173
169
  */
174
170
  export interface HoursFormat {
175
- /** What to call a day. Usually a lookup in the project's catalog. */
176
- day(day: Weekday): string;
171
+ /** The language the days are named in, e.g. `"el-GR"`. */
172
+ locale: string;
173
+ /** `Mon` or `Monday`. Defaults to `"short"`. */
174
+ weekday?: "short" | "long";
177
175
  /** The word for a day the venue is shut. */
178
176
  closed: string;
177
+ /**
178
+ * Times print as written (`14:00`) unless this is `"h12"` (`2:00 PM`).
179
+ * Not the locale's default, which gives Greek a 12-hour clock nobody there
180
+ * uses on a door.
181
+ */
182
+ hourCycle?: "h12";
179
183
  /** Between the ends of a span: `Mon–Thu`, `14:00–23:30`. */
180
184
  between?: string;
181
185
  /** Between spans: `Mon–Thu, Sun`. */
@@ -207,20 +211,53 @@ export function formatHours(
207
211
  ): readonly FormattedHours[] {
208
212
  const between = format.between ?? "–";
209
213
  const and = format.and ?? ", ";
214
+ // 2024-01-01 was a Monday, so `WEEK[i]` falls on the 1st plus `i`.
215
+ const names = new Intl.DateTimeFormat(format.locale, {
216
+ weekday: format.weekday ?? "short",
217
+ timeZone: "UTC",
218
+ });
219
+ const dayName = (day: Weekday): string =>
220
+ names.format(new Date(Date.UTC(2024, 0, 1 + WEEK.indexOf(day))));
221
+ const clock =
222
+ format.hourCycle === "h12"
223
+ ? new Intl.DateTimeFormat(format.locale, {
224
+ hour: "numeric",
225
+ minute: "2-digit",
226
+ hourCycle: "h12",
227
+ timeZone: "UTC",
228
+ })
229
+ : undefined;
230
+ const time = (at: TimeOfDay): string =>
231
+ clock === undefined
232
+ ? at
233
+ : clock.format(
234
+ new Date(
235
+ Date.UTC(
236
+ 2024,
237
+ 0,
238
+ 1,
239
+ Number(at.slice(0, 2)),
240
+ Number(at.slice(3, 5))
241
+ )
242
+ )
243
+ );
210
244
 
211
245
  return groupHours(hours, format.weekStart).map((group) => ({
212
246
  days: group.runs
213
247
  .map(([first, last]) =>
214
248
  first === last
215
- ? format.day(first)
216
- : `${format.day(first)}${between}${format.day(last)}`
249
+ ? dayName(first)
250
+ : `${dayName(first)}${between}${dayName(last)}`
217
251
  )
218
252
  .join(and),
219
253
  times:
220
254
  group.hours === "closed"
221
255
  ? format.closed
222
256
  : group.hours
223
- .map((range) => `${range.opens}${between}${range.closes}`)
257
+ .map(
258
+ (range) =>
259
+ `${time(range.opens)}${between}${time(range.closes)}`
260
+ )
224
261
  .join(and),
225
262
  }));
226
263
  }
@@ -11,15 +11,15 @@ import type {
11
11
  * `true` when `Catalog` declares every key in `Required`; otherwise an object
12
12
  * type naming the ones it does not.
13
13
  *
14
- * Annotate a `const … = true` with it. A missing message then fails at that
15
- * declaration, and the error text lists exactly which keys are absent, rather
16
- * than surfacing later as a page with no `<title>`:
14
+ * Check it with `satisfies`. A missing message then fails on that line, and the
15
+ * error text lists exactly which keys are absent, rather than surfacing later
16
+ * as a page with no `<title>`:
17
17
  *
18
18
  * ```ts
19
- * export const routeMessagesAreComplete: CatalogCovers<
19
+ * true satisfies CatalogCovers<
20
20
  * typeof baseMessages,
21
21
  * `route.${RouteId}.${"nav" | "title"}`
22
- * > = true;
22
+ * >;
23
23
  * ```
24
24
  *
25
25
  * The pattern earns its keep over a plain `Required extends StringKeys<Catalog>`
@@ -67,7 +67,7 @@ type SelfConsistent<E, K> = [MismatchedLocales<E, EntryTokens<E>>] extends [
67
67
  MismatchedLocales<E, EntryTokens<E>> & string
68
68
  >;
69
69
 
70
- type ValidateBase<T> = { [K in keyof T]: SelfConsistent<T[K], K> };
70
+ export type ValidateBase<T> = { [K in keyof T]: SelfConsistent<T[K], K> };
71
71
 
72
72
  /**
73
73
  * Rejects locale keys the site does not ship.
@@ -76,7 +76,7 @@ type ValidateBase<T> = { [K in keyof T]: SelfConsistent<T[K], K> };
76
76
  * from the literal rather than checked against a fixed target, so an extra key
77
77
  * structurally satisfies the constraint and would sit in the catalog unread.
78
78
  */
79
- type NoExtraLocales<T, L extends string> = {
79
+ export type NoExtraLocales<T, L extends string> = {
80
80
  [K in keyof T]: NoExcessKeys<T[K], L>;
81
81
  };
82
82
 
@@ -102,21 +102,7 @@ export function defineMessages<
102
102
  return catalog;
103
103
  }
104
104
 
105
- /**
106
- * Everything a copy overlay must satisfy — and the only place it is stated.
107
- *
108
- * The mirror of `RouteOverlayKeys` / `RouteOverlayShape`, for the same reason:
109
- * `defineMessageOverrides` and `defineProject` both accept an overlay, and a
110
- * check added to one and not the other makes that one silently the safer place
111
- * to write copy. Both name these, so a new rule reaches both.
112
- *
113
- * Excess keys go in the constraint, where a typo'd message id reports on that
114
- * key; the placeholder and locale rules go in the parameter, because they map
115
- * the whole object.
116
- */
117
- export type MessageOverlayKeys<Base, T> = NoExcessKeys<T, StringKeys<Base>>;
118
-
119
- /** @see {@link MessageOverlayKeys} — the parameter-position half. */
105
+ /** What `defineProject` checks an `overrideMessages` against: placeholders and locales. */
120
106
  export type ValidateOverrideCatalog<
121
107
  T,
122
108
  Base,
@@ -135,63 +121,17 @@ type ValidateOverrides<T, Base> = {
135
121
  };
136
122
 
137
123
  /**
138
- * Declares a project's copy overlay: any subset of keys, any subset of locales.
139
- *
140
- * Both the message ids and the locales are inferred from the base catalog you
141
- * pass in, so overriding one locale of one string needs no type arguments.
142
- *
143
- * Enforced at compile time:
144
- * - the key must exist in the base catalog, so a typo is rejected rather than
145
- * silently becoming a string nothing reads;
146
- * - the replacement must use exactly the `{placeholders}` and `[marks]` of the
147
- * base message, so every existing `t()` and `rich()` call site stays correct.
148
- */
149
- export function defineMessageOverrides<
150
- const C extends SiteConfigShape,
151
- const Base extends BaseCatalog<LocalesOf<C>>,
152
- const T extends OverrideCatalog<LocalesOf<C>> & MessageOverlayKeys<Base, T>,
153
- >(
154
- // Read for its type only, and taken first like every other definer here.
155
- // It supplies the locale universe directly rather than having it inferred
156
- // from the catalog's entries: `LocalesOfCatalog` reads the locales actually
157
- // *used*, which is the same union for a well-formed catalog and a narrower
158
- // one for a partial or empty draft.
159
- //
160
- // Note what this does not do: `config` declares which locales the site has,
161
- // not which ones a project publishes. Rejecting copy for a language a given
162
- // deployment has switched off is `MessageOverrides`' job, because only the
163
- // project knows its own `enabledLocales`.
164
- _config: C,
165
- _base: Base,
166
- overrides: T & ValidateOverrides<T, Base> & NoExtraLocales<T, LocalesOf<C>>
167
- ): T {
168
- return overrides;
169
- }
170
-
171
- /**
172
- * The shape of one project's copy overrides: known keys, published locales.
173
- *
174
- * For annotating the object itself, which is what puts the error on the line
175
- * that is wrong:
124
+ * One project's copy overrides — known keys, published locales — for an overlay
125
+ * kept in its own file:
176
126
  *
177
127
  * ```ts
178
- * const overrides = {
128
+ * export const romeMessages = {
179
129
  * "site.cta": { "en-US": "Book a room" },
180
- * } satisfies MessageOverrides<typeof defaultMessages, EnabledLocale>;
181
- *
182
- * export const overrideMessages = defineMessageOverrides(
183
- * config,
184
- * defaultMessages,
185
- * overrides
186
- * );
130
+ * } as const satisfies MessageOverrides<typeof defaultMessages, EnabledLocale>;
187
131
  * ```
188
132
  *
189
- * The two halves do different jobs and both are needed. This type knows the
190
- * project's locale set, so it rejects copy for a language that is never built —
191
- * but it cannot compare an override's *text* with the default's, so it cannot
192
- * see a dropped `{placeholder}`. `defineMessageOverrides` is the reverse: it
193
- * reads both texts, and knows nothing about which locales this deployment
194
- * publishes. Annotate with one, wrap in the other.
133
+ * A dropped `{placeholder}` is not visible to a type like this; `defineProject`
134
+ * reports it, naming the key and locale.
195
135
  */
196
136
  export type MessageOverrides<Catalog, L extends string> = {
197
137
  readonly [K in StringKeys<Catalog>]?: Readonly<Partial<Record<L, string>>>;