@escape-game-over/atlas 0.1.24 → 0.1.26
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 +27 -44
- package/bin/use-project.mjs +18 -13
- package/docs/NOT-BUILT.md +1 -1
- package/docs/client-scripts.md +96 -193
- package/docs/rich-text.md +18 -20
- package/package.json +9 -15
- package/src/analytics/google.ts +6 -6
- package/src/analytics/index.ts +4 -3
- package/src/analytics/tags.ts +13 -58
- package/src/analytics/umami.ts +8 -8
- package/src/astro/AtlasElement.astro +26 -0
- package/src/astro/ConsentBanner.astro +25 -0
- package/src/astro/ConsentElement.astro +67 -0
- package/src/astro/Document.astro +44 -0
- package/src/astro/Image.astro +101 -0
- package/src/astro/MetaTags.astro +30 -40
- package/src/astro/RichText.astro +88 -0
- package/src/astro/Zoom.astro +61 -0
- package/src/astro/client.ts +18 -9
- package/src/astro/consent.ts +20 -0
- package/src/astro/dev-log.ts +8 -14
- package/src/astro/element.ts +76 -112
- package/src/astro/filters-view.ts +48 -64
- package/src/astro/filters.ts +42 -35
- package/src/astro/index.ts +2 -9
- package/src/astro/ref.ts +46 -0
- package/src/astro/site-routes.ts +9 -15
- package/src/config.ts +23 -36
- package/src/content/index.ts +1 -1
- package/src/content/marks.ts +13 -13
- package/src/content/rich.ts +26 -42
- package/src/hours.ts +48 -11
- package/src/i18n/define.ts +14 -74
- package/src/index.ts +40 -57
- package/src/meta/index.ts +7 -13
- package/src/meta/share-image.ts +2 -26
- package/src/meta/tag.ts +1 -45
- package/src/money.ts +161 -6
- package/src/project.ts +84 -73
- package/src/routes/define.ts +8 -44
- package/src/routes/resolve.ts +1 -1
- package/src/site/api.ts +7 -33
- package/src/site/create.ts +6 -10
- package/src/site/define.ts +120 -0
- package/src/site/index.ts +2 -5
- package/src/site/page.ts +4 -2
- package/src/sitemap.ts +2 -35
- package/src/warn.ts +16 -17
- package/src/astro/dom.ts +0 -35
- package/src/astro/markup.ts +0 -656
package/src/astro/ref.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding the elements a script works on, by a selector the template wrote as
|
|
3
|
+
* a plain attribute — `[data-faq-search]` — so what the script asks for is what
|
|
4
|
+
* devtools shows.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Names the root in an error, for a selector that matched nothing. */
|
|
8
|
+
const where = (root: ParentNode): string =>
|
|
9
|
+
"localName" in root && typeof root.localName === "string"
|
|
10
|
+
? `<${root.localName}>`
|
|
11
|
+
: "the document";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The first match under `root`, or a thrown error naming the selector. Inside
|
|
15
|
+
* `element`'s `connect`, that error lands in the dev panel.
|
|
16
|
+
*/
|
|
17
|
+
export function ref<T extends Element = HTMLElement>(
|
|
18
|
+
root: ParentNode,
|
|
19
|
+
selector: string
|
|
20
|
+
): T {
|
|
21
|
+
const found = root.querySelector<T>(selector);
|
|
22
|
+
if (found === null) {
|
|
23
|
+
throw new Error(`${where(root)} has nothing matching ${selector}`);
|
|
24
|
+
}
|
|
25
|
+
return found;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* An element's `data-<name>` value, or a thrown error naming the element and
|
|
30
|
+
* the attribute. For an optional one, read `dataset` directly.
|
|
31
|
+
*/
|
|
32
|
+
export function data(element: Element, name: string): string {
|
|
33
|
+
const value = element.getAttribute(`data-${name}`);
|
|
34
|
+
if (value === null) {
|
|
35
|
+
throw new Error(`<${element.localName}> has no data-${name}`);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Every match under `root`. None is an answer here, not an error. */
|
|
41
|
+
export function refs<T extends Element = HTMLElement>(
|
|
42
|
+
root: ParentNode,
|
|
43
|
+
selector: string
|
|
44
|
+
): T[] {
|
|
45
|
+
return [...root.querySelectorAll<T>(selector)];
|
|
46
|
+
}
|
package/src/astro/site-routes.ts
CHANGED
|
@@ -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.
|
|
71
|
-
*
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/content/index.ts
CHANGED
|
@@ -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(
|
|
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
|
package/src/content/marks.ts
CHANGED
|
@@ -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: "
|
|
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: "
|
|
62
|
-
| { readonly kind: "
|
|
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: "
|
|
84
|
+
v: "variant",
|
|
85
85
|
a: "link",
|
|
86
|
-
mail: "
|
|
87
|
-
tel: "
|
|
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 "
|
|
247
|
+
case "variant":
|
|
248
248
|
return {
|
|
249
|
-
kind: "
|
|
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 "
|
|
260
|
-
return { kind: "
|
|
261
|
-
case "
|
|
262
|
-
return { kind: "
|
|
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(
|
|
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
|
}
|
package/src/content/rich.ts
CHANGED
|
@@ -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: "
|
|
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: "
|
|
108
|
+
readonly kind: "mail";
|
|
109
109
|
readonly text: string;
|
|
110
110
|
readonly href: MailtoUrl;
|
|
111
111
|
}
|
|
112
112
|
| {
|
|
113
|
-
readonly kind: "
|
|
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 "
|
|
290
|
-
return { kind: "
|
|
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 "
|
|
300
|
-
return { kind: "
|
|
301
|
-
case "
|
|
302
|
-
return { kind: "
|
|
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 "
|
|
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 "
|
|
524
|
-
case "
|
|
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
|
|
554
|
-
*
|
|
555
|
-
*
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
176
|
-
|
|
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
|
-
?
|
|
216
|
-
: `${
|
|
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(
|
|
257
|
+
.map(
|
|
258
|
+
(range) =>
|
|
259
|
+
`${time(range.opens)}${between}${time(range.closes)}`
|
|
260
|
+
)
|
|
224
261
|
.join(and),
|
|
225
262
|
}));
|
|
226
263
|
}
|
package/src/i18n/define.ts
CHANGED
|
@@ -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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
19
|
+
* true satisfies CatalogCovers<
|
|
20
20
|
* typeof baseMessages,
|
|
21
21
|
* `route.${RouteId}.${"nav" | "title"}`
|
|
22
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
190
|
-
*
|
|
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>>>;
|