@escape-game-over/atlas 0.1.115 → 0.1.116
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/docs/NOT-BUILT.md +2 -2
- package/package.json +1 -1
- package/src/analytics/storage.ts +34 -39
- package/src/astro/images.ts +12 -26
- package/src/astro/index.ts +2 -9
- package/src/astro/public-files.ts +11 -52
- package/src/astro/site-routes.ts +11 -55
- package/src/config.ts +8 -17
- package/src/content/rich.ts +15 -18
- package/src/i18n/catalog.ts +20 -1
- package/src/i18n/define.ts +0 -5
- package/src/i18n/translate.ts +9 -48
- package/src/index.ts +23 -160
- package/src/jsonld/article.ts +7 -17
- package/src/jsonld/business.ts +27 -28
- package/src/jsonld/creative-work.ts +8 -20
- package/src/jsonld/node.ts +37 -7
- package/src/jsonld/organization.ts +21 -30
- package/src/jsonld/place.ts +2 -6
- package/src/jsonld/product.ts +16 -22
- package/src/jsonld/service.ts +21 -37
- package/src/jsonld/video.ts +4 -8
- package/src/jsonld/website.ts +3 -5
- package/src/meta/content.ts +14 -66
- package/src/meta/index.ts +10 -32
- package/src/project.ts +4 -18
- package/src/routes/define.ts +0 -13
- package/src/site/api.ts +1 -21
- package/src/site/create.ts +17 -26
- package/src/site/page.ts +1 -10
package/docs/NOT-BUILT.md
CHANGED
|
@@ -209,10 +209,10 @@ something renders a currency picker, and not before.
|
|
|
209
209
|
|
|
210
210
|
`LocalBusinessInput.type` and `ProductInput.alsoA` take bare strings, so
|
|
211
211
|
`"EntertainmentBussiness"` compiles, gets emitted, and is ignored by every
|
|
212
|
-
reader — the silent failure that `RobotsDirective`
|
|
212
|
+
reader — the silent failure that `RobotsDirective` was
|
|
213
213
|
tightened to prevent.
|
|
214
214
|
|
|
215
|
-
They are not the same case.
|
|
215
|
+
They are not the same case. That vocabulary is closed; schema.org has
|
|
216
216
|
around 800 types and is still growing. A partial enumeration of an open
|
|
217
217
|
vocabulary rejects types that are perfectly valid, and the response to that is
|
|
218
218
|
to cast past it — at which point the check is theatre. A literal union that
|
package/package.json
CHANGED
package/src/analytics/storage.ts
CHANGED
|
@@ -97,7 +97,7 @@ export interface StoredItem {
|
|
|
97
97
|
readonly consent: ConsentCategory | "none";
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
export interface StoredItemsOptions {
|
|
100
|
+
export interface StoredItemsOptions<Purpose> {
|
|
101
101
|
/** As passed to `Document`, and with its default: `true`. */
|
|
102
102
|
readonly rememberLocale?: boolean;
|
|
103
103
|
/** As passed to `Document`, and with its default: `false`. */
|
|
@@ -109,12 +109,17 @@ export interface StoredItemsOptions {
|
|
|
109
109
|
*/
|
|
110
110
|
readonly booking?: readonly BookingVendor[];
|
|
111
111
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
112
|
+
* What each row is for, in the project's words — a message key, usually,
|
|
113
|
+
* since the words are the project's and in every locale.
|
|
114
|
+
*
|
|
115
|
+
* Required, and every row this deployment has must be named: a notice
|
|
116
|
+
* that lists a cookie without a purpose is the one thing it must not
|
|
117
|
+
* publish, so a vendor switched on, or a row a new Atlas adds, fails the
|
|
118
|
+
* build until the site says what it is for. Rows this deployment does not
|
|
119
|
+
* have may be named too; a site keeps its wording when a vendor is
|
|
120
|
+
* switched off.
|
|
116
121
|
*/
|
|
117
|
-
readonly
|
|
122
|
+
readonly purposes: Readonly<Partial<Record<StoredId, Purpose>>>;
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
interface Stored {
|
|
@@ -264,7 +269,7 @@ type VendorWithOwn = {
|
|
|
264
269
|
: never;
|
|
265
270
|
}[keyof typeof CATALOG];
|
|
266
271
|
|
|
267
|
-
/** Catalog order, which is
|
|
272
|
+
/** Catalog order, which is how rows that sort alike stay ordered. */
|
|
268
273
|
const VENDOR_ORDER: readonly StorageVendor[] = [
|
|
269
274
|
"google-analytics",
|
|
270
275
|
"google-ads",
|
|
@@ -372,8 +377,12 @@ const CONSENT_ORDER: readonly StoredItem["consent"][] = [
|
|
|
372
377
|
"marketing",
|
|
373
378
|
];
|
|
374
379
|
|
|
375
|
-
/**
|
|
376
|
-
|
|
380
|
+
/**
|
|
381
|
+
* Rows the site sets first, then by what has to be granted — nothing,
|
|
382
|
+
* analytics, marketing. Ties keep catalog order: Atlas's own, then each
|
|
383
|
+
* vendor's site row before its vendor-domain one.
|
|
384
|
+
*/
|
|
385
|
+
function compareStoredItems(a: StoredItem, b: StoredItem): number {
|
|
377
386
|
return (
|
|
378
387
|
Number(b.provider === "site") - Number(a.provider === "site") ||
|
|
379
388
|
CONSENT_ORDER.indexOf(a.consent) - CONSENT_ORDER.indexOf(b.consent)
|
|
@@ -381,45 +390,22 @@ export function compareStoredItems(a: StoredItem, b: StoredItem): number {
|
|
|
381
390
|
}
|
|
382
391
|
|
|
383
392
|
/**
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
* Throws, naming every row without one, so a vendor switched on, or a row a
|
|
388
|
-
* new Atlas adds, fails the build until the site has said what it is for: a
|
|
389
|
-
* notice that lists a cookie without a purpose is the one thing it must not
|
|
390
|
-
* publish. `purposes` may name rows this deployment does not have; a site
|
|
391
|
-
* keeps its wording when a vendor is switched off.
|
|
392
|
-
*/
|
|
393
|
-
export function withPurposes<K>(
|
|
394
|
-
rows: readonly StoredItem[],
|
|
395
|
-
purposes: Partial<Record<StoredId, K>>
|
|
396
|
-
): readonly (StoredItem & { readonly purpose: K })[] {
|
|
397
|
-
const missing = rows.filter((row) => purposes[row.id] === undefined);
|
|
398
|
-
if (missing.length > 0) {
|
|
399
|
-
throw new Error(
|
|
400
|
-
`The storage table has no purpose for ${missing.map((row) => `"${row.id}"`).join(", ")}. Add one for each to the purposes given to withPurposes.`
|
|
401
|
-
);
|
|
402
|
-
}
|
|
403
|
-
return rows.map((row) => ({ ...row, purpose: purposes[row.id] as K }));
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* Everything this deployment keeps on a device: Atlas's own keys, then each
|
|
408
|
-
* loaded vendor's.
|
|
393
|
+
* Everything this deployment keeps on a device, each row with its purpose:
|
|
394
|
+
* the site's own rows first, then by what has to be granted.
|
|
409
395
|
*
|
|
410
396
|
* Atlas's rows follow what the page renders, which `Document` decides from
|
|
411
397
|
* its props, so pass the same `rememberLocale` and `rememberCampaign` here.
|
|
412
398
|
* The consent record is listed whenever there is a banner to answer.
|
|
413
399
|
*/
|
|
414
|
-
export function storedItems(
|
|
400
|
+
export function storedItems<const Purpose>(
|
|
415
401
|
analytics: AnalyticsSettings | undefined,
|
|
416
|
-
options: StoredItemsOptions
|
|
417
|
-
): readonly StoredItem[] {
|
|
402
|
+
options: StoredItemsOptions<Purpose>
|
|
403
|
+
): readonly (StoredItem & { readonly purpose: Purpose })[] {
|
|
418
404
|
const {
|
|
419
405
|
rememberLocale = true,
|
|
420
406
|
rememberCampaign = false,
|
|
421
407
|
booking = [],
|
|
422
|
-
|
|
408
|
+
purposes,
|
|
423
409
|
} = options;
|
|
424
410
|
const asked = consentVendors(analytics).length > 0;
|
|
425
411
|
|
|
@@ -506,5 +492,14 @@ export function storedItems(
|
|
|
506
492
|
|
|
507
493
|
rows.push(...widgetRows);
|
|
508
494
|
|
|
509
|
-
|
|
495
|
+
const missing = rows.filter((row) => purposes[row.id] === undefined);
|
|
496
|
+
if (missing.length > 0) {
|
|
497
|
+
throw new Error(
|
|
498
|
+
`The storage table has no purpose for ${missing.map((row) => `"${row.id}"`).join(", ")}. Add one for each to storedItems' purposes.`
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
return rows.toSorted(compareStoredItems).flatMap((row) => {
|
|
502
|
+
const purpose = purposes[row.id];
|
|
503
|
+
return purpose === undefined ? [] : [{ ...row, purpose }];
|
|
504
|
+
});
|
|
510
505
|
}
|
package/src/astro/images.ts
CHANGED
|
@@ -331,36 +331,22 @@ export async function photoSet(
|
|
|
331
331
|
export type ImagePathOptions = Omit<Parameters<typeof getImage>[0], "src">;
|
|
332
332
|
|
|
333
333
|
/**
|
|
334
|
-
* The built URL of an optimised image, for
|
|
335
|
-
*
|
|
334
|
+
* The built URL of an optimised image, for places that take a path rather than
|
|
335
|
+
* an `<img>`: a CSS `url()` in an inline style or custom property, a
|
|
336
|
+
* `<link rel="preload">`, a manifest icon.
|
|
336
337
|
*
|
|
337
|
-
* ```
|
|
338
|
-
*
|
|
339
|
-
* import { imagePath } from "@escape-game-over/atlas/astro/images";
|
|
340
|
-
*
|
|
341
|
-
* const company = organization({
|
|
342
|
-
* logo: await imagePath(logo, { width: 512, height: 512, format: "png" }),
|
|
343
|
-
* // …
|
|
344
|
-
* });
|
|
338
|
+
* ```astro
|
|
339
|
+
* <section style={`--hero: url(${await imagePath(hero, { width: 1600, format: "webp" })})`}>
|
|
345
340
|
* ```
|
|
346
341
|
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
* assumption in as many places as there are consumers.
|
|
351
|
-
*
|
|
352
|
-
* **Not for entity data.** What comes back is `/_astro/logo.<hash>.png`, and the
|
|
353
|
-
* hash moves every time the source file is touched. That is right for a page
|
|
354
|
-
* resource, which is re-fetched from a document that was just re-fetched, and
|
|
355
|
-
* wrong for anything a search engine stores against an identity — an
|
|
356
|
-
* `Organization.logo` above all, which is held long after the build that
|
|
357
|
-
* produced it. Those want a stable path out of `public/`, written as `/logo.png`
|
|
358
|
-
* and joined to the site's own origin; both examples in this repository do that
|
|
359
|
-
* deliberately and say so at the call site.
|
|
342
|
+
* A function rather than `getImage().src` at each call site because that is
|
|
343
|
+
* typed `string`, and a path is wanted as `UrlPath`: narrowed once here, with
|
|
344
|
+
* no cast at every caller.
|
|
360
345
|
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
* `
|
|
346
|
+
* **Not for structured data.** What comes back is `/_astro/hero.<hash>.webp`,
|
|
347
|
+
* and the hash moves whenever the source changes. Right for a resource fetched
|
|
348
|
+
* with the page; wrong for an `Organization.logo` or anything else a search
|
|
349
|
+
* engine stores against an identity, which wants a stable path in `public/`.
|
|
364
350
|
*/
|
|
365
351
|
export async function imagePath(
|
|
366
352
|
src: ImageMetadata,
|
package/src/astro/index.ts
CHANGED
|
@@ -27,12 +27,5 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
export { siteFonts } from "./fonts.ts";
|
|
30
|
-
export {
|
|
31
|
-
|
|
32
|
-
publicFiles,
|
|
33
|
-
} from "./public-files.ts";
|
|
34
|
-
export {
|
|
35
|
-
type SiteFiles,
|
|
36
|
-
type SiteRoutesOptions,
|
|
37
|
-
siteRoutes,
|
|
38
|
-
} from "./site-routes.ts";
|
|
30
|
+
export { publicFiles } from "./public-files.ts";
|
|
31
|
+
export { siteRoutes } from "./site-routes.ts";
|
|
@@ -2,13 +2,8 @@ import type { Dirent } from "node:fs";
|
|
|
2
2
|
import { readdir } from "node:fs/promises";
|
|
3
3
|
import type { AstroIntegration } from "astro";
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
-
*
|
|
8
|
-
* Types only, so nothing has to exist at runtime: a consumer reaches it with
|
|
9
|
-
* `import type`, which is erased before anything tries to resolve it.
|
|
10
|
-
*/
|
|
11
|
-
const PUBLIC_MODULE = "atlas:public";
|
|
5
|
+
/** The package the generated declaration merges into, as consumers import it. */
|
|
6
|
+
const LIB_MODULE = "@escape-game-over/atlas";
|
|
12
7
|
|
|
13
8
|
/** Files in `public/` that are not content: editor litter and placeholders. */
|
|
14
9
|
const IGNORED = new Set([".DS_Store", "Thumbs.db", ".gitkeep"]);
|
|
@@ -38,13 +33,9 @@ async function walk(dir: URL, prefix = ""): Promise<string[]> {
|
|
|
38
33
|
}
|
|
39
34
|
|
|
40
35
|
/**
|
|
41
|
-
* Types every file in `public/`, so linking to one is checked.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* import type { PublicFile } from "atlas:public";
|
|
45
|
-
*
|
|
46
|
-
* const statement: PublicFile = "/reports/annual-2026.pdf";
|
|
47
|
-
* ```
|
|
36
|
+
* Types every file in `public/`, so linking to one is checked: `site.fileUrl()`
|
|
37
|
+
* and a redirect's `{ file }` target take `PublicFilePath`, which this narrows
|
|
38
|
+
* from any path to the files that are there.
|
|
48
39
|
*
|
|
49
40
|
* `public/` is the one part of a site the compiler cannot see: files are copied
|
|
50
41
|
* to the output verbatim, so a link to one is a string nothing verifies, and a
|
|
@@ -56,45 +47,19 @@ async function walk(dir: URL, prefix = ""): Promise<string[]> {
|
|
|
56
47
|
* commit or add to a tsconfig. A file added while the dev server runs is picked
|
|
57
48
|
* up when it restarts.
|
|
58
49
|
*/
|
|
59
|
-
export
|
|
60
|
-
/**
|
|
61
|
-
* How the consuming project imports this package.
|
|
62
|
-
*
|
|
63
|
-
* Defaults to the published name, which is what a project installing it
|
|
64
|
-
* from the registry writes. State it only where that is not the specifier
|
|
65
|
-
* in the import statements — a path alias, or a fork under another name.
|
|
66
|
-
*
|
|
67
|
-
* Needed because the generated file *augments* that module: the union has to
|
|
68
|
-
* reach `fileUrl` and the redirect target, and a declaration can only be
|
|
69
|
-
* merged into a module named the way the consumer names it.
|
|
70
|
-
*/
|
|
71
|
-
readonly libModule?: string;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function publicFiles(
|
|
75
|
-
options: PublicFilesOptions = {}
|
|
76
|
-
): AstroIntegration {
|
|
77
|
-
const libModule = options.libModule ?? "@escape-game-over/atlas";
|
|
78
|
-
|
|
50
|
+
export function publicFiles(): AstroIntegration {
|
|
79
51
|
return {
|
|
80
52
|
name: "public-files",
|
|
81
53
|
hooks: {
|
|
82
54
|
"astro:config:done": async ({ config, injectTypes, logger }) => {
|
|
83
55
|
const files = (await walk(config.publicDir)).sort();
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
*
|
|
87
|
-
* A count rather than the whitespace itself: the two call sites
|
|
88
|
-
* sit at different depths in the emitted file, and passing the
|
|
89
|
-
* indent as a literal means counting spaces by eye in a string
|
|
90
|
-
* that looks identical whether it holds eight of them or nine.
|
|
91
|
-
*/
|
|
92
|
-
const union = (spaces: number) =>
|
|
56
|
+
// One file per line, at the depth the declaration sits.
|
|
57
|
+
const union =
|
|
93
58
|
files.length === 0
|
|
94
59
|
? "never"
|
|
95
60
|
: files
|
|
96
61
|
.map((file) => `"${file}"`)
|
|
97
|
-
.join(
|
|
62
|
+
.join("\n | ");
|
|
98
63
|
|
|
99
64
|
injectTypes({
|
|
100
65
|
filename: "public-files.d.ts",
|
|
@@ -105,17 +70,11 @@ export function publicFiles(
|
|
|
105
70
|
// export lib has.
|
|
106
71
|
content: `export {};
|
|
107
72
|
|
|
108
|
-
declare module "${
|
|
109
|
-
/** Every file in \`public/\`, as it is served. */
|
|
110
|
-
export type PublicFile =
|
|
111
|
-
| ${union(8)};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
declare module "${libModule}" {
|
|
73
|
+
declare module "${LIB_MODULE}" {
|
|
115
74
|
/** Merged so \`fileUrl\` and redirect targets accept only these. */
|
|
116
75
|
interface PublicFileRegistry {
|
|
117
76
|
path:
|
|
118
|
-
| ${union
|
|
77
|
+
| ${union};
|
|
119
78
|
}
|
|
120
79
|
}
|
|
121
80
|
`,
|
package/src/astro/site-routes.ts
CHANGED
|
@@ -119,16 +119,11 @@ export function siteRoutes<Id extends string, L extends string>(
|
|
|
119
119
|
const describe = (file: GeneratedFile): string =>
|
|
120
120
|
`${file.name} (${size(file.body)})`;
|
|
121
121
|
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
// config was evaluated, and nothing re-evaluates that until the server
|
|
128
|
-
// restarts — so rebuilding per request derives the same answer from the same
|
|
129
|
-
// frozen inputs. Measured: editing a message refreshes the page that renders
|
|
130
|
-
// it and leaves the same string in `llms.txt` untouched. A build is always
|
|
131
|
-
// right, since it evaluates the config in a fresh process.
|
|
122
|
+
// Built when the dev server starts and when a build ends. The `site` and
|
|
123
|
+
// `llms` this closes over are fixed when the Astro config is evaluated, so
|
|
124
|
+
// in dev these files change only on a restart: editing a message refreshes
|
|
125
|
+
// the page that renders it, not `llms.txt`. A build is always right, since
|
|
126
|
+
// it evaluates the config in a fresh process.
|
|
132
127
|
const generate = (): GeneratedFile[] => {
|
|
133
128
|
const files: GeneratedFile[] = [...site.sitemap().files, site.robots()];
|
|
134
129
|
// Every page's head links to it, so one is written unless the config
|
|
@@ -352,35 +347,11 @@ export function siteRoutes<Id extends string, L extends string>(
|
|
|
352
347
|
* them out of the page graph.
|
|
353
348
|
*/
|
|
354
349
|
"astro:server:setup": ({ server, logger }) => {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
* middleware sees every request the dev server takes — each
|
|
361
|
-
* page, each asset, each Vite ping — and regenerating for all
|
|
362
|
-
* of them rebuilds the sitemap three times over (once directly,
|
|
363
|
-
* once inside `robots()`, once inside `llms()`) to answer a
|
|
364
|
-
* request for `/favicon.ico`.
|
|
365
|
-
*
|
|
366
|
-
* A name that appears only later is the one thing this can
|
|
367
|
-
* miss: a sitemap crossing `entryLimit` mid-session gains
|
|
368
|
-
* `sitemap-0.xml` and friends, which are not in the set until
|
|
369
|
-
* something regenerates. Acceptable because the set is
|
|
370
|
-
* refreshed on every hit and the dev server restarts on a
|
|
371
|
-
* config change — and because the alternative is paying for a
|
|
372
|
-
* full generate on every asset request to catch it.
|
|
373
|
-
*/
|
|
374
|
-
const served = new Set<string>();
|
|
375
|
-
const remember = (files: readonly GeneratedFile[]) => {
|
|
376
|
-
served.clear();
|
|
377
|
-
for (const file of files) served.add(`/${file.name}`);
|
|
378
|
-
return files;
|
|
379
|
-
};
|
|
380
|
-
|
|
381
|
-
remember(generate()).map((file) =>
|
|
382
|
-
logger.info(`generated ${describe(file)}`)
|
|
383
|
-
);
|
|
350
|
+
const files = new Map<string, GeneratedFile>();
|
|
351
|
+
for (const file of generate()) {
|
|
352
|
+
logger.info(`generated ${describe(file)}`);
|
|
353
|
+
files.set(`/${file.name}`, file);
|
|
354
|
+
}
|
|
384
355
|
server.middlewares.use((request, response, next) => {
|
|
385
356
|
const path = (request.url ?? "").split("?")[0] ?? "";
|
|
386
357
|
|
|
@@ -406,23 +377,8 @@ export function siteRoutes<Id extends string, L extends string>(
|
|
|
406
377
|
return;
|
|
407
378
|
}
|
|
408
379
|
|
|
409
|
-
|
|
410
|
-
// building anything. This is the check that keeps the cost
|
|
411
|
-
// below off every page and asset request in dev.
|
|
412
|
-
if (!served.has(path)) {
|
|
413
|
-
next();
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// Regenerated per request, so an edit to a route or a
|
|
418
|
-
// message shows up on reload rather than at restart.
|
|
419
|
-
const file = remember(generate()).find(
|
|
420
|
-
(candidate) => `/${candidate.name}` === path
|
|
421
|
-
);
|
|
380
|
+
const file = files.get(path);
|
|
422
381
|
if (file === undefined) {
|
|
423
|
-
// The set said this was ours and the fresh build
|
|
424
|
-
// disagrees — a file that stopped being generated, so
|
|
425
|
-
// it is now a 404 like any other missing path.
|
|
426
382
|
next();
|
|
427
383
|
return;
|
|
428
384
|
}
|
package/src/config.ts
CHANGED
|
@@ -173,15 +173,6 @@ export function defineSiteConfig<
|
|
|
173
173
|
return config;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
/**
|
|
177
|
-
* What a project may restate about a locale.
|
|
178
|
-
*
|
|
179
|
-
* Only the label: writing direction is a fact about the script a language is
|
|
180
|
-
* written in, not a choice a deployment makes, so it is derived from the tag.
|
|
181
|
-
* The label is the one part that is a matter of taste.
|
|
182
|
-
*/
|
|
183
|
-
export type LocaleMetaOverride = Pick<LocaleMeta, "label">;
|
|
184
|
-
|
|
185
176
|
/**
|
|
186
177
|
* Editor-facing error for a locale this site does not declare at all.
|
|
187
178
|
*
|
|
@@ -199,27 +190,27 @@ export interface LocaleNotEnabled<L extends string> {
|
|
|
199
190
|
}
|
|
200
191
|
|
|
201
192
|
/**
|
|
202
|
-
*
|
|
193
|
+
* Each published locale's label, the project's where it gives one, with the
|
|
194
|
+
* tag fields derived from its key.
|
|
203
195
|
*
|
|
204
196
|
* The locale key is the language tag, and `ogLocale` is that tag with `-`
|
|
205
197
|
* swapped for `_` — Open Graph writes `en_US` where BCP 47 writes `en-US`.
|
|
206
198
|
* `dir` comes from the tag's script, so `ar-EG` and `az-Arab-IR` are
|
|
207
199
|
* right-to-left and `az-AZ` is not.
|
|
208
200
|
*/
|
|
209
|
-
export function
|
|
201
|
+
export function resolveLocaleMeta<L extends string>(
|
|
210
202
|
base: Readonly<Record<L, LocaleMeta>>,
|
|
211
|
-
|
|
212
|
-
|
|
203
|
+
locales: readonly L[],
|
|
204
|
+
labels: Readonly<Partial<Record<L, string>>>
|
|
213
205
|
): Readonly<Record<L, ResolvedLocaleMeta>> {
|
|
214
206
|
const merged: Record<string, ResolvedLocaleMeta> = {};
|
|
215
207
|
for (const locale of locales) {
|
|
216
|
-
const
|
|
217
|
-
if (
|
|
208
|
+
const meta = base[locale];
|
|
209
|
+
if (meta === undefined) {
|
|
218
210
|
throw new Error(`No locale metadata for "${locale}".`);
|
|
219
211
|
}
|
|
220
|
-
const meta = { ...defaults, ...overrides?.[locale] };
|
|
221
212
|
merged[locale] = {
|
|
222
|
-
label: meta.label,
|
|
213
|
+
label: labels[locale] ?? meta.label,
|
|
223
214
|
dir: writingDirection(locale),
|
|
224
215
|
htmlLang: locale,
|
|
225
216
|
ogLocale: locale.replace(/-/g, "_"),
|
package/src/content/rich.ts
CHANGED
|
@@ -61,8 +61,8 @@ export type Span =
|
|
|
61
61
|
/**
|
|
62
62
|
* A link, in the three shapes a destination actually comes in.
|
|
63
63
|
*
|
|
64
|
-
* **A union rather than an optional `url` and an `external` flag
|
|
65
|
-
*
|
|
64
|
+
* **A union rather than an optional `url` and an `external` flag**: those
|
|
65
|
+
* two fields could disagree, and `external:
|
|
66
66
|
* true` with no absolute URL is a state that means nothing and would still
|
|
67
67
|
* type-check. Here the discriminant *is* the answer — a consumer that needs
|
|
68
68
|
* an absolute URL narrows on `to` and gets one, or is told plainly that this
|
|
@@ -297,16 +297,15 @@ export function createRichText<
|
|
|
297
297
|
): RichTextFor<Catalog, RouteId, Links, Values> {
|
|
298
298
|
const { catalog, locale, link, origin, links, values } = options;
|
|
299
299
|
|
|
300
|
-
return
|
|
301
|
-
|
|
300
|
+
// Untyped inside: the return type is what callers see, keys and params
|
|
301
|
+
// checked. Placeholder values and link targets are both strings once here —
|
|
302
|
+
// the distinction they are worth making is entirely a compile-time one.
|
|
303
|
+
return (
|
|
304
|
+
key: string,
|
|
305
|
+
params?: Readonly<Record<string, string>>
|
|
302
306
|
): RichText => {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
// Placeholder values and link targets are both strings once here — the
|
|
306
|
-
// distinction they are worth making is entirely a compile-time one.
|
|
307
|
-
const [key, params] = args as [K, Readonly<Record<string, string>>?];
|
|
308
|
-
const at = `Message "${String(key)}" (${locale})`;
|
|
309
|
-
const template = lookupTemplate(catalog, locale, String(key));
|
|
307
|
+
const at = `Message "${key}" (${locale})`;
|
|
308
|
+
const template = lookupTemplate(catalog, locale, key);
|
|
310
309
|
|
|
311
310
|
return parseMarks(template, at).map((span) =>
|
|
312
311
|
resolve(span, at, params, { links, values }, link, origin)
|
|
@@ -379,7 +378,7 @@ function fill(
|
|
|
379
378
|
* The destination a slot was filled with.
|
|
380
379
|
*
|
|
381
380
|
* `Object.hasOwn` rather than reading the index straight, for the reason
|
|
382
|
-
* `
|
|
381
|
+
* `createTranslate` uses it: `__proto__` is a legal slot name by `SLOT`, so
|
|
383
382
|
* a plain object would answer with `Object.prototype` — not `undefined`, and so
|
|
384
383
|
* not caught below.
|
|
385
384
|
*
|
|
@@ -620,12 +619,10 @@ export function createPlainText<
|
|
|
620
619
|
): PlainTextFor<Catalog, Values> {
|
|
621
620
|
const { catalog, locale, values } = options;
|
|
622
621
|
|
|
623
|
-
return
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
const
|
|
627
|
-
const at = `Message "${String(key)}" (${locale})`;
|
|
628
|
-
const template = lookupTemplate(catalog, locale, String(key));
|
|
622
|
+
// Untyped inside, as `rich()`: the return type is what callers see.
|
|
623
|
+
return (key: string, params?: Readonly<Record<string, string>>): string => {
|
|
624
|
+
const at = `Message "${key}" (${locale})`;
|
|
625
|
+
const template = lookupTemplate(catalog, locale, key);
|
|
629
626
|
|
|
630
627
|
return collapse(
|
|
631
628
|
parseMarks(template, at).map((span) =>
|
package/src/i18n/catalog.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface ProjectCatalog<Base, Published extends string, Override, Add> {
|
|
|
29
29
|
readonly override: Override;
|
|
30
30
|
/** Messages only this project has. */
|
|
31
31
|
readonly add: Add;
|
|
32
|
+
/** The project's names for its languages, where the site's do not fit. */
|
|
33
|
+
readonly labels: Readonly<Partial<Record<Published, string>>>;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
/**
|
|
@@ -99,7 +101,8 @@ export type OverrideChecks<Base, Published extends string, O> = NoExcessKeys<
|
|
|
99
101
|
* - `override` keys exist in the base catalog, keep its `{placeholders}` and
|
|
100
102
|
* `[marks]`, and are written only for published locales;
|
|
101
103
|
* - `add` keys are new to the base catalog, written for exactly the published
|
|
102
|
-
* locales, with the same `{placeholders}` and `[marks]` in each
|
|
104
|
+
* locales, with the same `{placeholders}` and `[marks]` in each;
|
|
105
|
+
* - `labels` are for published locales.
|
|
103
106
|
*/
|
|
104
107
|
export function defineCatalog<
|
|
105
108
|
const C extends SiteConfigShape,
|
|
@@ -119,6 +122,13 @@ export function defineCatalog<
|
|
|
119
122
|
readonly override: Override &
|
|
120
123
|
ValidateOverrideCatalog<Override, Base, LocalesOf<C>>;
|
|
121
124
|
readonly add?: Add & ValidateAdditions<Add, Base, Published>;
|
|
125
|
+
/**
|
|
126
|
+
* A language's name in the language switcher, replacing the site's
|
|
127
|
+
* label for that locale on this project only. `NoInfer`, so a label
|
|
128
|
+
* cannot widen the published locales: a locale written here that
|
|
129
|
+
* `locales` lacks is an error on its own line.
|
|
130
|
+
*/
|
|
131
|
+
readonly labels?: Readonly<Partial<Record<NoInfer<Published>, string>>>;
|
|
122
132
|
}
|
|
123
133
|
): ProjectCatalog<Base, Published, Override, Add> {
|
|
124
134
|
// Checked at runtime as well as in the type: an Astro config is evaluated
|
|
@@ -144,10 +154,19 @@ export function defineCatalog<
|
|
|
144
154
|
);
|
|
145
155
|
}
|
|
146
156
|
}
|
|
157
|
+
const labels = own.labels ?? {};
|
|
158
|
+
for (const locale of Object.keys(labels)) {
|
|
159
|
+
if (!own.locales.some((published) => published === locale)) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`labels: "${locale}" is not among this catalog's locales, so its label would never be shown.`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
147
165
|
return {
|
|
148
166
|
base,
|
|
149
167
|
locales: own.locales,
|
|
150
168
|
override: own.override,
|
|
151
169
|
add: add as Add,
|
|
170
|
+
labels,
|
|
152
171
|
};
|
|
153
172
|
}
|
package/src/i18n/define.ts
CHANGED
|
@@ -54,11 +54,6 @@ export type OverrideCatalog<L extends string> = Readonly<
|
|
|
54
54
|
Record<string, OverrideEntry<L>>
|
|
55
55
|
>;
|
|
56
56
|
|
|
57
|
-
/** The locale union a catalog covers, read back off its own entries. */
|
|
58
|
-
export type LocalesOfCatalog<Catalog> = StringKeys<
|
|
59
|
-
Catalog[StringKeys<Catalog>]
|
|
60
|
-
>;
|
|
61
|
-
|
|
62
57
|
type SelfConsistent<E, K> = [MismatchedLocales<E, EntryTokens<E>>] extends [
|
|
63
58
|
never,
|
|
64
59
|
]
|