@escape-game-over/atlas 0.1.23 → 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 +29 -45
- 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 +25 -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 +14 -60
- 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 +46 -37
- package/src/astro/images.ts +27 -26
- package/src/astro/index.ts +2 -9
- package/src/astro/markup.ts +6 -6
- package/src/astro/site-routes.ts +10 -15
- package/src/config.ts +23 -36
- package/src/content/index.ts +2 -1
- package/src/content/marks.ts +13 -13
- package/src/content/rich.ts +58 -29
- package/src/hours.ts +48 -11
- package/src/i18n/define.ts +14 -74
- package/src/index.ts +41 -57
- package/src/jsonld/faq.ts +2 -1
- package/src/jsonld/node.ts +4 -14
- package/src/meta/index.ts +7 -13
- package/src/meta/share-image.ts +6 -19
- 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/filters.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* ```ts
|
|
5
5
|
* const list = filters({
|
|
6
6
|
* fields: {
|
|
7
|
-
* q: { kind: "text"
|
|
8
|
-
* category: { kind: "choice"
|
|
7
|
+
* q: { kind: "text" },
|
|
8
|
+
* category: { kind: "choice" },
|
|
9
9
|
* },
|
|
10
10
|
* items: entries.map((entry) => ({
|
|
11
11
|
* key: entry.id,
|
|
@@ -40,10 +40,11 @@
|
|
|
40
40
|
*
|
|
41
41
|
* `flag` is not a tri-state: on, off and *either* is a `choice` with two values.
|
|
42
42
|
*/
|
|
43
|
-
export
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
export interface Field {
|
|
44
|
+
readonly kind: "text" | "choice" | "flag";
|
|
45
|
+
/** The URL parameter. Defaults to the field's name; `false` keeps it out of the URL. */
|
|
46
|
+
readonly param?: string | false;
|
|
47
|
+
}
|
|
47
48
|
|
|
48
49
|
/** The fields of one list, named by the caller. */
|
|
49
50
|
export type FieldMap = Readonly<Record<string, Field>>;
|
|
@@ -66,14 +67,6 @@ export type ItemValue<F extends Field> = F["kind"] extends "flag"
|
|
|
66
67
|
? string | readonly string[]
|
|
67
68
|
: string;
|
|
68
69
|
|
|
69
|
-
/**
|
|
70
|
-
* What one field contributes.
|
|
71
|
-
*
|
|
72
|
-
* @deprecated Say which side you are on: `StateValue` or `ItemValue`. This
|
|
73
|
-
* named both while they were the same type, and is `StateValue` now.
|
|
74
|
-
*/
|
|
75
|
-
export type FieldValue<F extends Field> = StateValue<F>;
|
|
76
|
-
|
|
77
70
|
/** Every field's value for one item, derived from the field declaration. */
|
|
78
71
|
export type ItemValues<F extends FieldMap> = {
|
|
79
72
|
readonly [K in keyof F]: ItemValue<F[K]>;
|
|
@@ -144,6 +137,11 @@ export interface Filters<F extends FieldMap> {
|
|
|
144
137
|
): void;
|
|
145
138
|
/** Clears one field, or all of them. Always a push: clearing is deliberate. */
|
|
146
139
|
reset(field?: keyof F): void;
|
|
140
|
+
/**
|
|
141
|
+
* Also called on every change, after `onChange`. Returns the undo. For a
|
|
142
|
+
* helper like `searchBox` that keeps its own part of the page in step.
|
|
143
|
+
*/
|
|
144
|
+
subscribe(listener: (change: FilterChange<F>) => void): () => void;
|
|
147
145
|
/**
|
|
148
146
|
* Reads the URL, applies it, and starts listening — returns the undo.
|
|
149
147
|
*
|
|
@@ -189,16 +187,20 @@ export function filters<const F extends FieldMap>(
|
|
|
189
187
|
}
|
|
190
188
|
keys.add(item.key);
|
|
191
189
|
}
|
|
192
|
-
|
|
190
|
+
// Field name → URL parameter, for the fields that are in the URL.
|
|
191
|
+
const params = new Map<string, string>();
|
|
192
|
+
const taken = new Set<string>();
|
|
193
193
|
for (const name of names) {
|
|
194
|
-
const
|
|
195
|
-
if (
|
|
196
|
-
|
|
194
|
+
const given = fields[name]?.param;
|
|
195
|
+
if (given === false) continue;
|
|
196
|
+
const param = given ?? name;
|
|
197
|
+
if (taken.has(param)) {
|
|
197
198
|
throw new Error(
|
|
198
199
|
`Two filter fields share the URL parameter "${param}".`
|
|
199
200
|
);
|
|
200
201
|
}
|
|
201
|
-
|
|
202
|
+
taken.add(param);
|
|
203
|
+
params.set(name, param);
|
|
202
204
|
}
|
|
203
205
|
|
|
204
206
|
// Folded once at construction: doing it per item per keystroke is the
|
|
@@ -305,15 +307,16 @@ export function filters<const F extends FieldMap>(
|
|
|
305
307
|
const next: Record<string, string | boolean> = {};
|
|
306
308
|
for (const name of names) {
|
|
307
309
|
const field = fields[name];
|
|
310
|
+
const param = params.get(name);
|
|
308
311
|
if (field === undefined) continue;
|
|
309
|
-
if (
|
|
312
|
+
if (param === undefined) {
|
|
310
313
|
// A state-only field is not in the URL, so the URL has nothing
|
|
311
314
|
// to say about it. Clearing it here wiped a search mid-typing
|
|
312
315
|
// whenever another parameter on the page moved.
|
|
313
316
|
next[name] = state[name];
|
|
314
317
|
continue;
|
|
315
318
|
}
|
|
316
|
-
const raw = search.get(
|
|
319
|
+
const raw = search.get(param);
|
|
317
320
|
next[name] = field.kind === "flag" ? raw === FLAG_ON : (raw ?? "");
|
|
318
321
|
}
|
|
319
322
|
state = next as FilterState<F>;
|
|
@@ -328,33 +331,34 @@ export function filters<const F extends FieldMap>(
|
|
|
328
331
|
// attributed the first time someone typed in the search box. Its own
|
|
329
332
|
// are deleted first, then rewritten, so one state gives one URL.
|
|
330
333
|
const search = new URLSearchParams(window.location.search);
|
|
331
|
-
for (const
|
|
332
|
-
|
|
333
|
-
if (
|
|
334
|
-
|
|
335
|
-
for (const name of names) {
|
|
336
|
-
const field = fields[name];
|
|
337
|
-
if (field?.param === undefined) continue;
|
|
338
|
-
if (field.kind === "flag") {
|
|
339
|
-
if (state[name] === true) search.set(field.param, FLAG_ON);
|
|
334
|
+
for (const param of params.values()) search.delete(param);
|
|
335
|
+
for (const [name, param] of params) {
|
|
336
|
+
if (fields[name]?.kind === "flag") {
|
|
337
|
+
if (state[name] === true) search.set(param, FLAG_ON);
|
|
340
338
|
continue;
|
|
341
339
|
}
|
|
342
340
|
const value = (state[name] as string).trim();
|
|
343
|
-
if (value !== "") search.set(
|
|
341
|
+
if (value !== "") search.set(param, value);
|
|
344
342
|
}
|
|
345
343
|
|
|
346
344
|
const query = search.toString();
|
|
347
345
|
// The bare path when nothing is left, rather than a trailing `?`.
|
|
348
346
|
const url = query === "" ? window.location.pathname : `?${query}`;
|
|
349
|
-
|
|
350
|
-
|
|
347
|
+
// `null` on a pushed step, so Astro's `ClientRouter` leaves its popstate
|
|
348
|
+
// to this module; a replaced entry keeps whatever state it already had.
|
|
349
|
+
if (history === "push") window.history.pushState(null, "", url);
|
|
350
|
+
else window.history.replaceState(window.history.state, "", url);
|
|
351
351
|
}
|
|
352
352
|
|
|
353
353
|
/** One field's value as the matcher and the URL will read it. */
|
|
354
354
|
const normalized = (name: keyof F & string, value: unknown): unknown =>
|
|
355
355
|
fields[name]?.kind === "text" ? (value as string).trim() : value;
|
|
356
356
|
|
|
357
|
-
const
|
|
357
|
+
const listeners = new Set<(change: FilterChange<F>) => void>();
|
|
358
|
+
const announce = (): void => {
|
|
359
|
+
onChange({ state, matched });
|
|
360
|
+
for (const listener of listeners) listener({ state, matched });
|
|
361
|
+
};
|
|
358
362
|
|
|
359
363
|
recompute();
|
|
360
364
|
|
|
@@ -415,8 +419,13 @@ export function filters<const F extends FieldMap>(
|
|
|
415
419
|
announce();
|
|
416
420
|
},
|
|
417
421
|
|
|
422
|
+
subscribe(listener) {
|
|
423
|
+
listeners.add(listener);
|
|
424
|
+
return () => listeners.delete(listener);
|
|
425
|
+
},
|
|
426
|
+
|
|
418
427
|
attach() {
|
|
419
|
-
const
|
|
428
|
+
const events = new AbortController();
|
|
420
429
|
|
|
421
430
|
// A `popstate` listener that outlives its page is the leak with no
|
|
422
431
|
// visible symptom: it keeps rendering into markup that was replaced.
|
|
@@ -427,7 +436,7 @@ export function filters<const F extends FieldMap>(
|
|
|
427
436
|
recompute();
|
|
428
437
|
announce();
|
|
429
438
|
},
|
|
430
|
-
{ signal:
|
|
439
|
+
{ signal: events.signal }
|
|
431
440
|
);
|
|
432
441
|
|
|
433
442
|
attached = true;
|
|
@@ -439,7 +448,7 @@ export function filters<const F extends FieldMap>(
|
|
|
439
448
|
announce();
|
|
440
449
|
|
|
441
450
|
return () => {
|
|
442
|
-
|
|
451
|
+
events.abort();
|
|
443
452
|
attached = false;
|
|
444
453
|
};
|
|
445
454
|
},
|
package/src/astro/images.ts
CHANGED
|
@@ -158,16 +158,6 @@ export async function shareImage(
|
|
|
158
158
|
// Not warned about here. `metaFor` already warns about an undersized
|
|
159
159
|
// `og:image`, on every page and whether or not this helper made it, so
|
|
160
160
|
// saying it twice would only halve the chance either line is read.
|
|
161
|
-
//
|
|
162
|
-
// Measured as a scale factor, not by comparing dimensions, because the two
|
|
163
|
-
// fits reach the box differently. `cover` scales until *both* sides are
|
|
164
|
-
// covered, so a source short in either dimension would be enlarged.
|
|
165
|
-
// `contain` scales until the *first* side fits, so an 800x800 logo lands at
|
|
166
|
-
// 630x630 — a reduction, though it is narrower than 1200.
|
|
167
|
-
const scale =
|
|
168
|
-
fit === "cover"
|
|
169
|
-
? Math.max(width / source.width, height / source.height)
|
|
170
|
-
: Math.min(width / source.width, height / source.height);
|
|
171
161
|
const image = await getImage({
|
|
172
162
|
src: source,
|
|
173
163
|
width,
|
|
@@ -187,20 +177,32 @@ export async function shareImage(
|
|
|
187
177
|
})(),
|
|
188
178
|
});
|
|
189
179
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
180
|
+
return {
|
|
181
|
+
src: image.src,
|
|
182
|
+
...producedSize(source, width, height, fit),
|
|
183
|
+
format,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The size `getImage` actually returns, not the size asked for.
|
|
189
|
+
*
|
|
190
|
+
* Neither `attributes` nor `options` can be trusted: both echo the request.
|
|
191
|
+
* Astro refuses to enlarge, so a source too small for the box comes back
|
|
192
|
+
* untouched, at its own size. Measured as a scale factor because the fits reach
|
|
193
|
+
* the box differently: `cover` scales until *both* sides are covered, `contain`
|
|
194
|
+
* until the *first* side fits.
|
|
195
|
+
*/
|
|
196
|
+
function producedSize(
|
|
197
|
+
source: ImageMetadata,
|
|
198
|
+
width: number,
|
|
199
|
+
height: number,
|
|
200
|
+
fit: "cover" | "contain"
|
|
201
|
+
): { width: number; height: number } {
|
|
202
|
+
const pick = fit === "cover" ? Math.max : Math.min;
|
|
203
|
+
return pick(width / source.width, height / source.height) > 1
|
|
204
|
+
? { width: source.width, height: source.height }
|
|
205
|
+
: { width, height };
|
|
204
206
|
}
|
|
205
207
|
|
|
206
208
|
/**
|
|
@@ -307,8 +309,7 @@ export async function photoSet(
|
|
|
307
309
|
});
|
|
308
310
|
return {
|
|
309
311
|
src: image.src,
|
|
310
|
-
|
|
311
|
-
height: ratio.height,
|
|
312
|
+
...producedSize(source, ratio.width, ratio.height, "cover"),
|
|
312
313
|
format,
|
|
313
314
|
};
|
|
314
315
|
})
|
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.
|
|
@@ -280,6 +271,7 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
|
|
|
280
271
|
// optional, so `buld: {…}` would be assignable and silently do
|
|
281
272
|
// nothing. The annotation is what makes a typo an error.
|
|
282
273
|
const config: ConfigUpdate = {
|
|
274
|
+
site: site.url,
|
|
283
275
|
output: "static",
|
|
284
276
|
build: { format: "file" },
|
|
285
277
|
trailingSlash: "ignore",
|
|
@@ -348,6 +340,9 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
|
|
|
348
340
|
// every locale is prefixed, which is the first URL anyone
|
|
349
341
|
// opens. Serving them here makes dev answer as production
|
|
350
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.
|
|
351
346
|
// Matched with and without a trailing slash, because a host
|
|
352
347
|
// either normalises one away before its rules run or treats
|
|
353
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
|
|
@@ -29,6 +29,7 @@ export { assertNoMarks, type ParsedSpan, parseMarks } from "./marks.ts";
|
|
|
29
29
|
export {
|
|
30
30
|
createPlainText,
|
|
31
31
|
createRichText,
|
|
32
|
+
html,
|
|
32
33
|
type LinkResolver,
|
|
33
34
|
type LinkTarget,
|
|
34
35
|
type PlainTextFor,
|
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
|
}
|