@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.
- package/README.md +27 -44
- package/bin/use-project.mjs +18 -13
- package/docs/NOT-BUILT.md +1 -1
- package/docs/client-scripts.md +73 -141
- package/docs/rich-text.md +13 -20
- package/package.json +5 -12
- 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/ConsentBanner.astro +25 -0
- package/src/astro/ConsentElement.astro +61 -0
- package/src/astro/Document.astro +44 -0
- package/src/astro/Image.astro +102 -0
- package/src/astro/MetaTags.astro +3 -26
- package/src/astro/RichText.astro +71 -0
- package/src/astro/Zoom.astro +61 -0
- package/src/astro/client.ts +19 -9
- package/src/astro/consent.ts +20 -0
- package/src/astro/dev-log.ts +8 -14
- package/src/astro/element.ts +111 -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/markup.ts +6 -6
- 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/index.ts
CHANGED
|
@@ -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
|
|
26
|
-
*
|
|
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 {
|
package/src/astro/markup.ts
CHANGED
|
@@ -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 `
|
|
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
|
-
* `
|
|
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
|
-
* //
|
|
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:
|
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>>>;
|