@escape-game-over/atlas 0.1.111 → 0.1.113
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 +51 -32
- package/docs/checks.md +15 -15
- package/docs/client-scripts.md +6 -0
- package/package.json +1 -1
- package/src/astro/filters.ts +27 -4
- package/src/config.ts +1 -1
- package/src/i18n/catalog.ts +149 -0
- package/src/i18n/define.ts +4 -4
- package/src/i18n/placeholders.ts +1 -1
- package/src/index.ts +9 -3
- package/src/project.ts +83 -167
- package/src/site/create.ts +47 -32
- package/src/site/define.ts +62 -17
package/README.md
CHANGED
|
@@ -58,40 +58,58 @@ Linking to a page the active deployment switched off is a compile error, not a
|
|
|
58
58
|
|
|
59
59
|
**2. A deployment is an overlay, not a fork.**
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
A few files per deployment, saying only what differs. Its copy first, the
|
|
62
|
+
languages it publishes and what it rewords or adds:
|
|
62
63
|
|
|
63
64
|
```ts
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
{
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
clock: { "el-GR": "h23", "en-US": "h12" }, // how each language prints times
|
|
77
|
-
prices: { decimals: "always" }, // "18,00 €", each page in its own locale
|
|
65
|
+
// projects/rome/messages.ts
|
|
66
|
+
export const messages = atlas.catalog(defaultMessages, {
|
|
67
|
+
locales: ["el-GR", "en-US"],
|
|
68
|
+
override: {
|
|
69
|
+
// Greek only — the English copy still falls through to the default catalog.
|
|
70
|
+
"home.hero.body": { "el-GR": "Η {company} κρατά το απόθεμα…" },
|
|
71
|
+
},
|
|
72
|
+
add: {
|
|
73
|
+
// Copy only this deployment has, in every language it publishes.
|
|
74
|
+
"venue.parking": {
|
|
75
|
+
"el-GR": "Πάρκινγκ στη γωνία",
|
|
76
|
+
"en-US": "Parking round the corner",
|
|
78
77
|
},
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
```
|
|
79
81
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
82
|
+
Then the project, beside it:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// projects/rome/project.ts
|
|
86
|
+
export const { project, redirects } = atlas.project(messages, defaultRoutes, {
|
|
87
|
+
url: "https://acme.example",
|
|
88
|
+
siteName: "Acme Rome",
|
|
89
|
+
icon,
|
|
90
|
+
themeColor: "#1d51e0",
|
|
91
|
+
|
|
92
|
+
overrideRouting: { defaultLocale: "el-GR", prefixDefaultLocale: true },
|
|
93
|
+
formats: {
|
|
94
|
+
clock: { "el-GR": "h23", "en-US": "h12" }, // how each language prints times
|
|
95
|
+
prices: { decimals: "always" }, // "18,00 €", each page in its own locale
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
overrideRoutes: {
|
|
99
|
+
careers: { enabled: true }, // opt into a page
|
|
100
|
+
contact: { slugByLocale: { "el-GR": "epikoinoniste-mazi-mas" } }, // retranslate a URL
|
|
101
|
+
},
|
|
102
|
+
});
|
|
90
103
|
```
|
|
91
104
|
|
|
92
105
|
Every field is named for what it _does_ to the defaults, so a project file can
|
|
93
|
-
never be mistaken for the whole truth: `
|
|
94
|
-
declares, and every `override*` is a patch over it.
|
|
106
|
+
never be mistaken for the whole truth: `locales` picks from what the site
|
|
107
|
+
declares, and `override` and every `override*` is a patch over it.
|
|
108
|
+
|
|
109
|
+
The copy comes first so a deployment's other files can name its messages by
|
|
110
|
+
key. Anything declared before the project, its venues or its settings, types a
|
|
111
|
+
message as `CatalogKey<typeof messages>`: the default catalog's keys and the
|
|
112
|
+
deployment's own, exactly the ones `t()` takes. A typo fails on its line.
|
|
95
113
|
|
|
96
114
|
## The four steps
|
|
97
115
|
|
|
@@ -106,11 +124,12 @@ export const atlas = defineSite({ locales: {...}, defaultRouting: {...} })
|
|
|
106
124
|
export const baseMessages = atlas.messages({...})
|
|
107
125
|
export const baseRoutes = atlas.routes({...})
|
|
108
126
|
|
|
109
|
-
// 3. one deployment, and its redirects
|
|
110
|
-
export const
|
|
127
|
+
// 3. one deployment's copy, then the deployment and its redirects
|
|
128
|
+
export const messages = atlas.catalog(baseMessages, { locales: [...], override: {...} })
|
|
129
|
+
export const { project, redirects } = atlas.project(messages, baseRoutes, {...})
|
|
111
130
|
|
|
112
|
-
// 4. the API the pages use
|
|
113
|
-
export const site = atlas.site(
|
|
131
|
+
// 4. the API the pages use: the deployment's catalog, the routes, the deployment
|
|
132
|
+
export const site = atlas.site(messages, baseRoutes, project)
|
|
114
133
|
```
|
|
115
134
|
|
|
116
135
|
A site that publishes one language under several regions writes the words once:
|
|
@@ -246,7 +265,7 @@ regression-tested by an `@ts-expect-error` suite.
|
|
|
246
265
|
| A page with no share image, alt text, description or `robots` directives | error — none of them have a default |
|
|
247
266
|
| A `robots` directive that is not a real one | error — the vocabulary is a union |
|
|
248
267
|
| A project `url` that is not `https://`, or ends in a slash | error |
|
|
249
|
-
| `defaultLocale` not among the project's
|
|
268
|
+
| `defaultLocale` not among the locales the project's catalog publishes | error |
|
|
250
269
|
| A `formats.clock` leaving out a published locale, or naming an unpublished one | error |
|
|
251
270
|
| `site.clockFor` on a project that declares no clock | throws, naming the project |
|
|
252
271
|
| `site.priceFormatFor` on a project that declares no price format | throws, naming the project |
|
package/docs/checks.md
CHANGED
|
@@ -37,20 +37,20 @@ conditional's result, but goes structural — `Property '__X__' is missing` —
|
|
|
37
37
|
it is an annotation, and there the property name is the only part the reader
|
|
38
38
|
sees.
|
|
39
39
|
|
|
40
|
-
| Compile error
|
|
41
|
-
|
|
|
42
|
-
| unknown message key, or an override dropping a `{placeholder}`
|
|
43
|
-
| a message the project declared mandatory and never wrote (`CatalogCovers`)
|
|
44
|
-
| copy or a locale label written for a locale
|
|
45
|
-
| a `formats.clock` leaving out a published locale, or naming one
|
|
46
|
-
| a `formats.prices.locale` the project does not publish
|
|
47
|
-
| a slug whose translation sits at a different depth, in the registry or across the overlay
|
|
48
|
-
| unknown route id, or a locale the site does not ship
|
|
49
|
-
| a malformed language tag (`en_US`, `en-us`, `EN-US`, bare `en`)
|
|
50
|
-
| `defaultLocale` outside `locales`; unknown top-level field
|
|
51
|
-
| a `url` literal that is not `https://` or ends in `/`
|
|
52
|
-
| a `lastmod` that is not a `YYYY-MM-DD` calendar date
|
|
53
|
-
| config for a route this project does not build, or missing for one it does (`PerRoute`)
|
|
40
|
+
| Compile error | Build-time throw |
|
|
41
|
+
| -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
|
42
|
+
| unknown message key, or an override dropping a `{placeholder}` | two routes resolving to the same URL |
|
|
43
|
+
| a message the project declared mandatory and never wrote (`CatalogCovers`) | |
|
|
44
|
+
| copy or a locale label written for a locale its catalog does not publish | |
|
|
45
|
+
| a `formats.clock` leaving out a published locale, or naming one its catalog does not publish | the same, for a project that was not type-checked first |
|
|
46
|
+
| a `formats.prices.locale` the project does not publish | the same, for a project that was not type-checked first |
|
|
47
|
+
| a slug whose translation sits at a different depth, in the registry or across the overlay | the same, when a slug is not a literal (computed, or from an env var) |
|
|
48
|
+
| unknown route id, or a locale the site does not ship | a locale published twice |
|
|
49
|
+
| a malformed language tag (`en_US`, `en-us`, `EN-US`, bare `en`) | an icon that is not square, a multiple of 48, and PNG |
|
|
50
|
+
| `defaultLocale` outside `locales`; unknown top-level field | a `url` that is not `https://` or ends in `/`, when it is not a literal |
|
|
51
|
+
| a `url` literal that is not `https://` or ends in `/` | |
|
|
52
|
+
| a `lastmod` that is not a `YYYY-MM-DD` calendar date | |
|
|
53
|
+
| config for a route this project does not build, or missing for one it does (`PerRoute`) | |
|
|
54
54
|
|
|
55
55
|
The runtime checks are the ones types cannot see: facts about a _file_ (an icon's
|
|
56
56
|
real dimensions), facts only known after merging (URL collisions), or a value
|
|
@@ -132,7 +132,7 @@ dependencies at all. Vitest type-checks them itself when it runs them.
|
|
|
132
132
|
| `money.test.ts` | a bare count widened to a band, the span of a table, the gaps and overlaps that throw, and an audience priced twice |
|
|
133
133
|
| `url.test.ts` | joining an origin to a path exactly once, and normalising the origin an `@id` is built from |
|
|
134
134
|
| `warn.test.ts` | the shared prefix, and reducing a URL to its path |
|
|
135
|
-
| `filters.test.ts` | folding both sides of a query, how each kind narrows, the four URL decisions, and attach/detach
|
|
135
|
+
| `filters.test.ts` | folding both sides of a query, how each kind narrows, the four URL decisions, values no item holds, and attach/detach |
|
|
136
136
|
| `filters-view.test.ts` | the write-back into the input and that it is skipped when unchanged, clear taking focus, and the nesting order |
|
|
137
137
|
| `build-cache.test.ts` | that an unset `CI_ASTRO_CACHE` yields nothing, so Astro's own `cacheDir` default survives |
|
|
138
138
|
|
package/docs/client-scripts.md
CHANGED
|
@@ -241,6 +241,12 @@ instead of flashing the whole list back. Nothing writes to the URL before
|
|
|
241
241
|
`attach`, because `attach` is what reads it — a `set` on an unattached instance
|
|
242
242
|
would overwrite state nobody had loaded.
|
|
243
243
|
|
|
244
|
+
A choice no item holds, or a flag no item carries, is a stale or mistyped link:
|
|
245
|
+
reading it — on `attach` and on the back button — takes it as no choice, and
|
|
246
|
+
replaces the URL without it. Otherwise `?category=castle` showed an empty list
|
|
247
|
+
with nothing pressed, not even "All". A `text` query is left alone: it matches
|
|
248
|
+
words, not values, and finding nothing is a fair answer to it.
|
|
249
|
+
|
|
244
250
|
A flag is on by being there, as an HTML boolean attribute is: written as the bare
|
|
245
251
|
`?featured`, and **absent** when off. Reading asks only whether the name is
|
|
246
252
|
present, so `?featured=1` from an older link still reads as on — and so does a
|
package/package.json
CHANGED
package/src/astro/filters.ts
CHANGED
|
@@ -199,6 +199,18 @@ export function filters<const F extends FieldMap, T>(
|
|
|
199
199
|
return { item, values, haystacks };
|
|
200
200
|
});
|
|
201
201
|
|
|
202
|
+
// What the items hold, per choice and flag: a link asking for anything
|
|
203
|
+
// else is stale or mistyped, and is read as no choice rather than an empty
|
|
204
|
+
// list with nothing pressed.
|
|
205
|
+
const held = new Map<string, ReadonlySet<unknown>>();
|
|
206
|
+
for (const name of names) {
|
|
207
|
+
if (kindOf(name) === "text") continue;
|
|
208
|
+
held.set(
|
|
209
|
+
name,
|
|
210
|
+
new Set(entries.flatMap((entry) => entry.values[name] as unknown))
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
202
214
|
const empty = (name: keyof F & string): StateValue<F[typeof name]> =>
|
|
203
215
|
(kindOf(name) === "flag" ? false : "") as StateValue<F[typeof name]>;
|
|
204
216
|
|
|
@@ -287,9 +299,11 @@ export function filters<const F extends FieldMap, T>(
|
|
|
287
299
|
matched = next;
|
|
288
300
|
}
|
|
289
301
|
|
|
290
|
-
|
|
302
|
+
/** Applies the URL; says whether it asked for anything no item holds. */
|
|
303
|
+
function readUrl(): boolean {
|
|
291
304
|
const search = new URLSearchParams(window.location.search);
|
|
292
305
|
const next: Record<string, string | boolean> = {};
|
|
306
|
+
let dropped = false;
|
|
293
307
|
for (const name of names) {
|
|
294
308
|
if (!inUrl.includes(name)) {
|
|
295
309
|
// A state-only field is not in the URL, so the URL has nothing
|
|
@@ -300,12 +314,17 @@ export function filters<const F extends FieldMap, T>(
|
|
|
300
314
|
}
|
|
301
315
|
// A flag is on by being there, as an HTML boolean attribute is:
|
|
302
316
|
// `?featured`, and `?featured=1` from an older link, alike.
|
|
303
|
-
|
|
317
|
+
const value =
|
|
304
318
|
kindOf(name) === "flag"
|
|
305
319
|
? search.has(name)
|
|
306
320
|
: (search.get(name) ?? "");
|
|
321
|
+
const unheld =
|
|
322
|
+
value !== empty(name) && held.get(name)?.has(value) === false;
|
|
323
|
+
next[name] = unheld ? empty(name) : value;
|
|
324
|
+
if (unheld) dropped = true;
|
|
307
325
|
}
|
|
308
326
|
state = next as FilterState<F>;
|
|
327
|
+
return dropped;
|
|
309
328
|
}
|
|
310
329
|
|
|
311
330
|
function writeUrl(history: "push" | "replace"): void {
|
|
@@ -424,8 +443,9 @@ export function filters<const F extends FieldMap, T>(
|
|
|
424
443
|
window.addEventListener(
|
|
425
444
|
"popstate",
|
|
426
445
|
() => {
|
|
427
|
-
readUrl();
|
|
446
|
+
const dropped = readUrl();
|
|
428
447
|
recompute();
|
|
448
|
+
if (dropped) writeUrl("replace");
|
|
429
449
|
announce();
|
|
430
450
|
},
|
|
431
451
|
{ signal: events.signal }
|
|
@@ -435,8 +455,11 @@ export function filters<const F extends FieldMap, T>(
|
|
|
435
455
|
// Read before the first announcement: the URL may already carry a
|
|
436
456
|
// state, and announcing the empty one first flashes the unfiltered
|
|
437
457
|
// list over it.
|
|
438
|
-
|
|
458
|
+
// A value no item holds leaves the address bar as well, replaced
|
|
459
|
+
// rather than pushed: it is a correction, not a step to go back to.
|
|
460
|
+
const dropped = readUrl();
|
|
439
461
|
recompute();
|
|
462
|
+
if (dropped) writeUrl("replace");
|
|
440
463
|
announce();
|
|
441
464
|
|
|
442
465
|
return () => {
|
package/src/config.ts
CHANGED
|
@@ -195,7 +195,7 @@ export interface LocaleNotDeclared<L extends string> {
|
|
|
195
195
|
|
|
196
196
|
/** Editor-facing error for anything written for a locale a project does not ship. */
|
|
197
197
|
export interface LocaleNotEnabled<L extends string> {
|
|
198
|
-
readonly __LOCALE_NOT_ENABLED__: `locale "${L}" is not among this project's
|
|
198
|
+
readonly __LOCALE_NOT_ENABLED__: `locale "${L}" is not among the locales this project's catalog publishes, so anything written for it would never be built`;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
/**
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LocaleNotEnabled,
|
|
3
|
+
LocalesOf,
|
|
4
|
+
SiteConfigShape,
|
|
5
|
+
} from "../config.ts";
|
|
6
|
+
import type { NoExcessKeys, StringKeys } from "../types.ts";
|
|
7
|
+
import type {
|
|
8
|
+
BaseCatalog,
|
|
9
|
+
OverrideCatalog,
|
|
10
|
+
ValidateAdditions,
|
|
11
|
+
ValidateOverrideCatalog,
|
|
12
|
+
} from "./define.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One project's copy: the site's catalog, the locales the project publishes,
|
|
16
|
+
* and what it rewords or adds.
|
|
17
|
+
*
|
|
18
|
+
* Built before the project, and from nothing but copy, so the project's other
|
|
19
|
+
* files (its settings, its venues' data) can name a message by its exact key
|
|
20
|
+
* with `CatalogKey<typeof catalog>`. Were the copy declared inside the project,
|
|
21
|
+
* as it once was, a file the project is built from could only name keys as a
|
|
22
|
+
* string pattern: reaching the project's keys from there is a cycle.
|
|
23
|
+
*/
|
|
24
|
+
export interface ProjectCatalog<Base, Published extends string, Override, Add> {
|
|
25
|
+
readonly base: Base;
|
|
26
|
+
/** The locales the project publishes, in the order it lists them. */
|
|
27
|
+
readonly locales: readonly Published[];
|
|
28
|
+
/** Rewordings of the base catalog's messages. */
|
|
29
|
+
readonly override: Override;
|
|
30
|
+
/** Messages only this project has. */
|
|
31
|
+
readonly add: Add;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every message key a project's copy has: the base catalog's and its own.
|
|
36
|
+
*
|
|
37
|
+
* What a site built from the project translates, so a key typed with it is
|
|
38
|
+
* one `t()` takes as it is.
|
|
39
|
+
*/
|
|
40
|
+
export type CatalogKey<C> =
|
|
41
|
+
C extends ProjectCatalog<infer Base, string, unknown, infer Add>
|
|
42
|
+
? StringKeys<Base> | StringKeys<Add>
|
|
43
|
+
: never;
|
|
44
|
+
|
|
45
|
+
/** The messages a site built from the catalog reads: the base's and its own. */
|
|
46
|
+
export type MessagesOf<C> =
|
|
47
|
+
C extends ProjectCatalog<infer Base, string, unknown, infer Add>
|
|
48
|
+
? Base & Add
|
|
49
|
+
: never;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What `override` may hold, as the editor offers it: the base catalog's keys,
|
|
53
|
+
* each written for the locales the project publishes. A key or a locale beyond
|
|
54
|
+
* these is named by `OverrideChecks`.
|
|
55
|
+
*/
|
|
56
|
+
export type OverrideEntries<Base, Published extends string> = {
|
|
57
|
+
readonly [K in StringKeys<Base>]?: Readonly<
|
|
58
|
+
Partial<Record<Published, string>>
|
|
59
|
+
>;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** What `add` may hold, as the editor offers it: new keys, published locales. */
|
|
63
|
+
export type AddedEntries<Published extends string> = Readonly<
|
|
64
|
+
Record<string, Readonly<Partial<Record<Published, string>>>>
|
|
65
|
+
>;
|
|
66
|
+
|
|
67
|
+
/** The locales a catalog publishes. */
|
|
68
|
+
export type PublishedOf<C> =
|
|
69
|
+
C extends ProjectCatalog<unknown, infer Published, unknown, unknown>
|
|
70
|
+
? Published
|
|
71
|
+
: never;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What `override` must be: keys the base catalog declares, each written only
|
|
75
|
+
* for locales the project publishes. Copy for a locale it does not publish is
|
|
76
|
+
* never built, so it is always a mistake: a locale removed from the list, or a
|
|
77
|
+
* project copied from another.
|
|
78
|
+
*/
|
|
79
|
+
export type OverrideChecks<Base, Published extends string, O> = NoExcessKeys<
|
|
80
|
+
O,
|
|
81
|
+
StringKeys<Base>
|
|
82
|
+
> & {
|
|
83
|
+
readonly [K in StringKeys<O>]: {
|
|
84
|
+
readonly [Locale in StringKeys<O[K]>]: Locale extends Published
|
|
85
|
+
? O[K][Locale]
|
|
86
|
+
: LocaleNotEnabled<Locale>;
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Declares one project's copy, validated against the base catalog.
|
|
92
|
+
*
|
|
93
|
+
* Enforced at compile time, each on the line that is wrong:
|
|
94
|
+
* - `locales` are ones the site ships;
|
|
95
|
+
* - `override` keys exist in the base catalog, keep its `{placeholders}` and
|
|
96
|
+
* `[marks]`, and are written only for published locales;
|
|
97
|
+
* - `add` keys are new to the base catalog, written for exactly the published
|
|
98
|
+
* locales, with the same `{placeholders}` and `[marks]` in each.
|
|
99
|
+
*/
|
|
100
|
+
export function defineCatalog<
|
|
101
|
+
const C extends SiteConfigShape,
|
|
102
|
+
const Base extends BaseCatalog<LocalesOf<C>>,
|
|
103
|
+
const Published extends LocalesOf<C>,
|
|
104
|
+
const Override extends OverrideEntries<Base, Published> &
|
|
105
|
+
OverrideChecks<Base, Published, Override>,
|
|
106
|
+
const Add extends AddedEntries<Published> = Record<never, never>,
|
|
107
|
+
>(
|
|
108
|
+
// Read for its type only: it is how the site's locales are inferred.
|
|
109
|
+
_config: C,
|
|
110
|
+
base: Base,
|
|
111
|
+
// The whole-object validators sit here rather than in the constraints, so
|
|
112
|
+
// a mistake is reported on its own line instead of at this call.
|
|
113
|
+
own: {
|
|
114
|
+
readonly locales: readonly Published[];
|
|
115
|
+
readonly override: Override &
|
|
116
|
+
ValidateOverrideCatalog<Override, Base, LocalesOf<C>>;
|
|
117
|
+
readonly add?: Add & ValidateAdditions<Add, Base, Published>;
|
|
118
|
+
}
|
|
119
|
+
): ProjectCatalog<Base, Published, Override, Add> {
|
|
120
|
+
// Checked at runtime as well as in the type: an Astro config is evaluated
|
|
121
|
+
// before the type check runs, so the error has to be readable from here.
|
|
122
|
+
if (own.override === undefined) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
"A catalog declares no override. It is required even when nothing is reworded — write `override: {}`, which tells a reader the shared copy is used as it is."
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const add: OverrideCatalog<Published> = own.add ?? {};
|
|
128
|
+
for (const [key, entry] of Object.entries(add)) {
|
|
129
|
+
if (key in base) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`add: "${key}" is already in the base catalog. Reword it in override instead.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const missing = own.locales.filter(
|
|
135
|
+
(locale) => typeof entry[locale] !== "string"
|
|
136
|
+
);
|
|
137
|
+
if (missing.length > 0) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`add: "${key}" has no ${missing.join(", ")}. A message a project adds is written for every locale it publishes.`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
base,
|
|
145
|
+
locales: own.locales,
|
|
146
|
+
override: own.override,
|
|
147
|
+
add: add as Add,
|
|
148
|
+
};
|
|
149
|
+
}
|
package/src/i18n/define.ts
CHANGED
|
@@ -103,7 +103,7 @@ export function defineMessages<
|
|
|
103
103
|
return catalog;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
/** What `
|
|
106
|
+
/** What `defineCatalog` checks an `override` against: placeholders and locales. */
|
|
107
107
|
export type ValidateOverrideCatalog<
|
|
108
108
|
T,
|
|
109
109
|
Base,
|
|
@@ -139,12 +139,12 @@ export type MessageOverrides<Catalog, L extends string> = {
|
|
|
139
139
|
};
|
|
140
140
|
|
|
141
141
|
/**
|
|
142
|
-
* What `
|
|
142
|
+
* What `defineCatalog` checks an `add` against: each key new to the
|
|
143
143
|
* base catalog, written for exactly the locales the project publishes, with
|
|
144
144
|
* the same `{placeholders}` and `[marks]` in each.
|
|
145
145
|
*
|
|
146
146
|
* New only, because a key the base already has is copy every project shares,
|
|
147
|
-
* and rewording it is what `
|
|
147
|
+
* and rewording it is what `override` is for — with its own check that
|
|
148
148
|
* the placeholders survive. An addition shadowing a base key would get round
|
|
149
149
|
* that check, and two fields could then say different things about one key.
|
|
150
150
|
*/
|
|
@@ -182,7 +182,7 @@ export function mergeCatalog<L extends string>(
|
|
|
182
182
|
// Astro config is evaluated before the type check, so say it plainly.
|
|
183
183
|
if (key in merged) {
|
|
184
184
|
throw new Error(
|
|
185
|
-
`
|
|
185
|
+
`add: "${key}" is already in the default catalog. Reword it in override instead.`
|
|
186
186
|
);
|
|
187
187
|
}
|
|
188
188
|
merged[key] = { ...entry } as Record<string, string>;
|
package/src/i18n/placeholders.ts
CHANGED
|
@@ -114,7 +114,7 @@ export interface PlaceholderMismatch<Key extends string, L extends string> {
|
|
|
114
114
|
|
|
115
115
|
/** Editor-facing error when a project adds a message the base already has. */
|
|
116
116
|
export interface AddedMessageExists<Key extends string> {
|
|
117
|
-
readonly __ADDED_MESSAGE_EXISTS__: `Message "${Key}" is already in the default catalog: reword it in
|
|
117
|
+
readonly __ADDED_MESSAGE_EXISTS__: `Message "${Key}" is already in the default catalog: reword it in override instead of adding it`;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
/** Editor-facing error when an override drops or invents one. */
|
package/src/index.ts
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* export const atlas = defineSite({ locales: {...}, defaultRouting: {...} }) // site.config.ts
|
|
8
8
|
* export const defaultMessages = atlas.messages({...}) // messages.ts
|
|
9
9
|
* export const defaultRoutes = atlas.routes({...}) // routes.ts
|
|
10
|
-
* export const
|
|
11
|
-
* export const
|
|
10
|
+
* export const messages = atlas.catalog(defaultMessages, {...}) // projects/<name>/messages.ts
|
|
11
|
+
* export const { project, redirects } = atlas.project(messages, defaultRoutes, {...}) // projects/<name>/project.ts
|
|
12
|
+
* export const site = atlas.site(messages, defaultRoutes, project) // site.ts
|
|
12
13
|
* ```
|
|
13
14
|
*/
|
|
14
15
|
|
|
@@ -176,6 +177,12 @@ export {
|
|
|
176
177
|
type Weekday,
|
|
177
178
|
type WeeklyHours,
|
|
178
179
|
} from "./hours.ts";
|
|
180
|
+
export type {
|
|
181
|
+
CatalogKey,
|
|
182
|
+
MessagesOf,
|
|
183
|
+
ProjectCatalog,
|
|
184
|
+
PublishedOf,
|
|
185
|
+
} from "./i18n/catalog.ts";
|
|
179
186
|
export type {
|
|
180
187
|
BaseCatalog,
|
|
181
188
|
CatalogCovers,
|
|
@@ -277,7 +284,6 @@ export {
|
|
|
277
284
|
type PriceFormat,
|
|
278
285
|
} from "./price-format.ts";
|
|
279
286
|
export type {
|
|
280
|
-
AddedCatalog,
|
|
281
287
|
DefinedProject,
|
|
282
288
|
ProjectInput,
|
|
283
289
|
ProjectRouteId,
|
package/src/project.ts
CHANGED
|
@@ -8,12 +8,8 @@ import type {
|
|
|
8
8
|
} from "./config.ts";
|
|
9
9
|
import type { Fonts } from "./fonts.ts";
|
|
10
10
|
import type { HourCycle } from "./hours.ts";
|
|
11
|
-
import type {
|
|
12
|
-
|
|
13
|
-
OverrideCatalog,
|
|
14
|
-
ValidateAdditions,
|
|
15
|
-
ValidateOverrideCatalog,
|
|
16
|
-
} from "./i18n/define.ts";
|
|
11
|
+
import type { ProjectCatalog, PublishedOf } from "./i18n/catalog.ts";
|
|
12
|
+
import type { BaseCatalog, OverrideCatalog } from "./i18n/define.ts";
|
|
17
13
|
import type { ImageAsset } from "./image.ts";
|
|
18
14
|
import type { SiteVerification, ThemeColor } from "./meta/index.ts";
|
|
19
15
|
import type { PriceFormat } from "./price-format.ts";
|
|
@@ -46,7 +42,7 @@ export interface ProjectFormats<L extends string> {
|
|
|
46
42
|
* The clock each published language prints times on: `"h23"` for 18:00,
|
|
47
43
|
* `"h12"` for 6:00 PM. Read back with `site.clockFor(locale)`.
|
|
48
44
|
*
|
|
49
|
-
* One entry for every locale
|
|
45
|
+
* One entry for every locale its catalog publishes, and none for any other,
|
|
50
46
|
* so what each page prints is written down beside the languages that print
|
|
51
47
|
* it: a bilingual Canadian site reads `{ "en-CA": "h12", "fr-CA": "h23" }`.
|
|
52
48
|
* Not taken from the locale's own default (`Intl`'s) on purpose: a site may
|
|
@@ -121,14 +117,6 @@ export interface ProjectInput<L extends string, Routes> {
|
|
|
121
117
|
readonly themeColor: ThemeColor;
|
|
122
118
|
/** Which colour schemes the pages support, e.g. `"light"`, `"dark light"`. */
|
|
123
119
|
readonly colorScheme?: string;
|
|
124
|
-
/**
|
|
125
|
-
* Which of the site's locales this project publishes.
|
|
126
|
-
*
|
|
127
|
-
* A selection, not an override: the site config declares which languages
|
|
128
|
-
* exist at all, and a project picks from them. Naming one it does not
|
|
129
|
-
* declare is a compile error.
|
|
130
|
-
*/
|
|
131
|
-
readonly enabledLocales: readonly L[];
|
|
132
120
|
/**
|
|
133
121
|
* How this site writes what it formats: times and prices. Each format is
|
|
134
122
|
* optional, and its reader refuses to answer for a project that declares
|
|
@@ -150,41 +138,13 @@ export interface ProjectInput<L extends string, Routes> {
|
|
|
150
138
|
/** Adds to the site's `defaultFonts`, or replaces one by key. */
|
|
151
139
|
readonly overrideFonts?: Fonts;
|
|
152
140
|
/**
|
|
153
|
-
*
|
|
141
|
+
* Slug retranslations and page on/off switches.
|
|
154
142
|
*
|
|
155
143
|
* **Required, and `{}` is the answer for a deployment that overrides
|
|
156
|
-
* nothing.**
|
|
157
|
-
*
|
|
158
|
-
* The reason is not that an absent overlay and an empty one read
|
|
159
|
-
* differently — they do, and that argument would make all four of these
|
|
160
|
-
* required. It is that these two are what the type machinery *computes
|
|
161
|
-
* from*: the route ids a project builds, the message keys it may override,
|
|
162
|
-
* and the locales each may be written for are all read off these fields,
|
|
163
|
-
* at seven sites in this file. Optional means `undefined` flowing into
|
|
164
|
-
* every one of them, `NonNullable<>` at each — see `overrideRouting` above,
|
|
165
|
-
* the one optional field that is read, for what that looks like — and an
|
|
144
|
+
* nothing.** The route ids a project builds are computed from it, and an
|
|
145
|
+
* optional field would mean `undefined` flowing into each of them and an
|
|
166
146
|
* inference that widens where it should narrow.
|
|
167
|
-
*
|
|
168
|
-
* `{}` costing two characters is the price of the ids downstream being
|
|
169
|
-
* exact. It also happens to read as a statement, which is a bonus rather
|
|
170
|
-
* than the argument.
|
|
171
147
|
*/
|
|
172
|
-
readonly overrideMessages: OverrideCatalog<L>;
|
|
173
|
-
/**
|
|
174
|
-
* Copy only this project has: keys the default catalog does not declare,
|
|
175
|
-
* each written for every locale the project publishes and no others.
|
|
176
|
-
*
|
|
177
|
-
* For what one deployment says and no other does — a venue's own FAQ, a
|
|
178
|
-
* house rule — so it goes through `translate` and `rich` like any message,
|
|
179
|
-
* marks and placeholders included, without the shared catalog carrying it
|
|
180
|
-
* in languages the venue never publishes. A key the default catalog
|
|
181
|
-
* already has is refused: reword it in `overrideMessages`.
|
|
182
|
-
*
|
|
183
|
-
* Optional, unlike `overrideMessages`: nothing downstream is computed from
|
|
184
|
-
* it except the catalog's keys, which an absent field leaves as they are.
|
|
185
|
-
*/
|
|
186
|
-
readonly addMessages?: OverrideCatalog<L>;
|
|
187
|
-
/** Slug retranslations and page on/off switches. See `overrideMessages`. */
|
|
188
148
|
readonly overrideRoutes: RouteOverrides<Routes, L>;
|
|
189
149
|
}
|
|
190
150
|
|
|
@@ -217,60 +177,64 @@ export type ProjectRouteId<
|
|
|
217
177
|
export interface DefinedProject<
|
|
218
178
|
Routes extends EnabledFlagSource,
|
|
219
179
|
T extends ProjectInput<string, Routes>,
|
|
180
|
+
Published extends string,
|
|
220
181
|
> {
|
|
221
182
|
readonly project: T;
|
|
222
183
|
/** Rules for this project's old URLs, checked against what it builds. */
|
|
223
184
|
readonly redirects: <
|
|
224
185
|
const Rules extends readonly RedirectRule<
|
|
225
186
|
ProjectRouteId<Routes, T>,
|
|
226
|
-
|
|
187
|
+
Published
|
|
227
188
|
>[],
|
|
228
189
|
>(
|
|
229
190
|
rules: Rules & ValidateRedirectTargets<Rules>
|
|
230
|
-
) => ProjectRedirects<
|
|
231
|
-
ProjectRouteId<Routes, T>,
|
|
232
|
-
T["enabledLocales"][number]
|
|
233
|
-
>;
|
|
191
|
+
) => ProjectRedirects<ProjectRouteId<Routes, T>, Published>;
|
|
234
192
|
}
|
|
235
193
|
|
|
236
194
|
/**
|
|
237
195
|
* A project's `redirects`. The one place a rule list is branded as checked.
|
|
238
196
|
*
|
|
239
|
-
* Takes the routes
|
|
240
|
-
* as they went in.
|
|
197
|
+
* Takes the routes, the project and its catalog only for their types; the
|
|
198
|
+
* rules come back as they went in.
|
|
241
199
|
*/
|
|
242
200
|
export function bindRedirects<
|
|
243
201
|
Routes extends EnabledFlagSource,
|
|
244
202
|
T extends ProjectInput<string, Routes>,
|
|
245
|
-
|
|
203
|
+
Published extends string,
|
|
204
|
+
>(
|
|
205
|
+
_routes: Routes,
|
|
206
|
+
_project: T,
|
|
207
|
+
_catalog: ProjectCatalog<unknown, Published, unknown, unknown>
|
|
208
|
+
): DefinedProject<Routes, T, Published>["redirects"] {
|
|
246
209
|
return (rules) => rules as never;
|
|
247
210
|
}
|
|
248
211
|
|
|
249
212
|
/**
|
|
250
|
-
* Declares one deployment, validated against the site config
|
|
213
|
+
* Declares one deployment, validated against the site config, its catalog
|
|
214
|
+
* and the routes.
|
|
251
215
|
*
|
|
252
216
|
* Everything is inferred from the values passed in, so a project file writes no
|
|
253
217
|
* type arguments at all.
|
|
254
218
|
*
|
|
255
219
|
* Enforced at compile time:
|
|
256
220
|
* - `url` must not end with a slash;
|
|
257
|
-
* - `overrideMessages` keys must exist in the default catalog, and must keep
|
|
258
|
-
* the default message's `{placeholders}`;
|
|
259
221
|
* - `overrideRoutes` keys must be real route ids;
|
|
260
222
|
* - unknown top-level fields are rejected, so a typo cannot sit there unread;
|
|
261
|
-
* - locales named anywhere must be ones
|
|
223
|
+
* - locales named anywhere must be ones its catalog publishes.
|
|
224
|
+
*
|
|
225
|
+
* Its copy is checked by `atlas.catalog()`, before the project exists.
|
|
262
226
|
*/
|
|
263
227
|
/** Everything a project file must satisfy; see `defineSite`. */
|
|
264
228
|
export type ProjectChecks<
|
|
265
229
|
L extends string,
|
|
266
|
-
|
|
230
|
+
Published extends string,
|
|
267
231
|
Routes,
|
|
268
232
|
T extends ProjectInput<L, Routes>,
|
|
269
233
|
> = NoExcessKeys<T, keyof ProjectInput<L, Routes>> & {
|
|
270
234
|
readonly url: ValidSiteUrl<T["url"]>;
|
|
271
235
|
readonly overrideRouting?: {
|
|
272
|
-
readonly defaultLocale?:
|
|
273
|
-
// Against
|
|
236
|
+
readonly defaultLocale?: Published;
|
|
237
|
+
// Against the published locales rather than the site's list: a
|
|
274
238
|
// segment spelled for a language this deployment does not
|
|
275
239
|
// publish is a word that appears in no URL it builds. Mapped
|
|
276
240
|
// over the keys written, for the reason `overrideLocaleMeta`
|
|
@@ -278,26 +242,7 @@ export type ProjectChecks<
|
|
|
278
242
|
readonly pageSegmentByLocale?: {
|
|
279
243
|
readonly [K in StringKeys<
|
|
280
244
|
NonNullable<T["overrideRouting"]>["pageSegmentByLocale"]
|
|
281
|
-
>]: K extends
|
|
282
|
-
? string
|
|
283
|
-
: LocaleNotEnabled<K>;
|
|
284
|
-
};
|
|
285
|
-
};
|
|
286
|
-
// Two separate checks on the same field: the keys must be messages
|
|
287
|
-
// the default catalog declares, and the locales inside each must be
|
|
288
|
-
// ones this project publishes. Copy written for a locale left out of
|
|
289
|
-
// `enabledLocales` is never built, so it is always a mistake — a
|
|
290
|
-
// locale removed from the list, or a project copied from another.
|
|
291
|
-
readonly overrideMessages: NoExcessKeys<
|
|
292
|
-
T["overrideMessages"],
|
|
293
|
-
StringKeys<Catalog>
|
|
294
|
-
> & {
|
|
295
|
-
readonly [K in StringKeys<T["overrideMessages"]>]: {
|
|
296
|
-
readonly [L in StringKeys<
|
|
297
|
-
T["overrideMessages"][K]
|
|
298
|
-
>]: L extends T["enabledLocales"][number]
|
|
299
|
-
? T["overrideMessages"][K][L]
|
|
300
|
-
: LocaleNotEnabled<L>;
|
|
245
|
+
>]: K extends Published ? string : LocaleNotEnabled<K>;
|
|
301
246
|
};
|
|
302
247
|
};
|
|
303
248
|
readonly overrideRoutes: NoExcessKeys<
|
|
@@ -310,9 +255,7 @@ export type ProjectChecks<
|
|
|
310
255
|
// part of `T` and there is no fresh object for excess-property
|
|
311
256
|
// checking to fire on.
|
|
312
257
|
readonly overrideLocaleMeta?: {
|
|
313
|
-
readonly [K in StringKeys<
|
|
314
|
-
T["overrideLocaleMeta"]
|
|
315
|
-
>]: K extends T["enabledLocales"][number]
|
|
258
|
+
readonly [K in StringKeys<T["overrideLocaleMeta"]>]: K extends Published
|
|
316
259
|
? NoExcessKeys<T["overrideLocaleMeta"][K], keyof LocaleMetaOverride>
|
|
317
260
|
: LocaleNotEnabled<K>;
|
|
318
261
|
};
|
|
@@ -324,13 +267,11 @@ export type ProjectChecks<
|
|
|
324
267
|
readonly clock?: {
|
|
325
268
|
readonly [K in StringKeys<
|
|
326
269
|
NonNullable<T["formats"]>["clock"]
|
|
327
|
-
>]: K extends
|
|
328
|
-
? HourCycle
|
|
329
|
-
: LocaleNotEnabled<K>;
|
|
270
|
+
>]: K extends Published ? HourCycle : LocaleNotEnabled<K>;
|
|
330
271
|
};
|
|
331
272
|
// Its locale too must be one this project publishes.
|
|
332
273
|
readonly prices?: PriceFormat & {
|
|
333
|
-
readonly locale?: PriceLocaleCheck<T>;
|
|
274
|
+
readonly locale?: PriceLocaleCheck<T, Published>;
|
|
334
275
|
};
|
|
335
276
|
};
|
|
336
277
|
};
|
|
@@ -343,122 +284,99 @@ type PriceLocaleOf<T> = T extends {
|
|
|
343
284
|
: never;
|
|
344
285
|
|
|
345
286
|
/** The price locale, if the project publishes it; else an error naming it. */
|
|
346
|
-
type PriceLocaleCheck<T
|
|
347
|
-
|
|
348
|
-
}
|
|
349
|
-
? PriceLocaleOf<T> extends Published
|
|
287
|
+
type PriceLocaleCheck<T, Published extends string> =
|
|
288
|
+
PriceLocaleOf<T> extends Published
|
|
350
289
|
? PriceLocaleOf<T>
|
|
351
|
-
: LocaleNotEnabled<PriceLocaleOf<T
|
|
352
|
-
: never;
|
|
290
|
+
: LocaleNotEnabled<PriceLocaleOf<T>>;
|
|
353
291
|
|
|
354
292
|
/** The same checks that map the whole override objects, in parameter position. */
|
|
355
293
|
export type ProjectOverrideChecks<
|
|
356
|
-
|
|
357
|
-
Catalog,
|
|
294
|
+
Published extends string,
|
|
358
295
|
Routes,
|
|
359
|
-
T extends ProjectInput<
|
|
296
|
+
T extends ProjectInput<string, Routes>,
|
|
360
297
|
> = {
|
|
361
|
-
readonly overrideMessages: ValidateOverrideCatalog<
|
|
362
|
-
T["overrideMessages"],
|
|
363
|
-
Catalog,
|
|
364
|
-
L
|
|
365
|
-
>;
|
|
366
298
|
readonly overrideRoutes: ValidateOverrideSlugDepth<
|
|
367
299
|
Routes,
|
|
368
300
|
T["overrideRoutes"]
|
|
369
301
|
>;
|
|
370
|
-
} &
|
|
371
|
-
ClockCheck<T>;
|
|
372
|
-
|
|
373
|
-
/** A project's own messages, checked like the catalog's: when it adds any. */
|
|
374
|
-
type AdditionsCheck<
|
|
375
|
-
Catalog,
|
|
376
|
-
T extends ProjectInput<string, unknown>,
|
|
377
|
-
> = T extends { readonly addMessages: infer A }
|
|
378
|
-
? {
|
|
379
|
-
readonly addMessages: ValidateAdditions<
|
|
380
|
-
A,
|
|
381
|
-
Catalog,
|
|
382
|
-
T["enabledLocales"][number]
|
|
383
|
-
>;
|
|
384
|
-
}
|
|
385
|
-
: unknown;
|
|
302
|
+
} & ClockCheck<T, Published>;
|
|
386
303
|
|
|
387
304
|
/**
|
|
388
305
|
* A clock, once declared, is declared for every page: a locale left out would
|
|
389
306
|
* have no clock to print its times on.
|
|
390
307
|
*/
|
|
391
|
-
type ClockCheck<T extends
|
|
308
|
+
type ClockCheck<T, Published extends string> = T extends {
|
|
392
309
|
readonly formats: { readonly clock: object };
|
|
393
310
|
}
|
|
394
311
|
? {
|
|
395
312
|
readonly formats: {
|
|
396
313
|
readonly clock: {
|
|
397
|
-
readonly [K in
|
|
314
|
+
readonly [K in Published]: HourCycle;
|
|
398
315
|
};
|
|
399
316
|
};
|
|
400
317
|
}
|
|
401
318
|
: unknown;
|
|
402
319
|
|
|
403
|
-
/**
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
320
|
+
/**
|
|
321
|
+
* The catalog each project was built with, so the site can check it is given
|
|
322
|
+
* the same one. Weak, so a project nothing holds any more is not kept alive.
|
|
323
|
+
*/
|
|
324
|
+
const builtWith = new WeakMap<object, object>();
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Throws if a site is given a catalog other than the one its project was built
|
|
328
|
+
* with: the project's locale checks were made against that one. A project
|
|
329
|
+
* assembled by hand, which `defineProject` never saw, has nothing to compare.
|
|
330
|
+
*/
|
|
331
|
+
export function checkProjectCatalog(
|
|
332
|
+
project: { readonly siteName: string },
|
|
333
|
+
catalog: object
|
|
334
|
+
): void {
|
|
335
|
+
const own = builtWith.get(project);
|
|
336
|
+
if (own !== undefined && own !== catalog) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`Project "${project.siteName}" was built with a different catalog than the one passed to atlas.site(): pass the catalog its project.ts uses.`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
407
342
|
|
|
408
343
|
export function defineProject<
|
|
409
344
|
const C extends SiteConfigShape,
|
|
410
|
-
const
|
|
345
|
+
const Cat extends ProjectCatalog<
|
|
346
|
+
BaseCatalog<LocalesOf<C>>,
|
|
347
|
+
LocalesOf<C>,
|
|
348
|
+
OverrideCatalog<LocalesOf<C>>,
|
|
349
|
+
OverrideCatalog<LocalesOf<C>>
|
|
350
|
+
>,
|
|
411
351
|
const Routes extends RouteRegistry<LocalesOf<C>>,
|
|
412
352
|
const T extends ProjectInput<LocalesOf<C>, Routes> &
|
|
413
|
-
ProjectChecks<LocalesOf<C>,
|
|
353
|
+
ProjectChecks<LocalesOf<C>, PublishedOf<Cat>, Routes, T>,
|
|
414
354
|
>(
|
|
415
|
-
//
|
|
416
|
-
//
|
|
355
|
+
// Read for its type: it is how the locale union is inferred without type
|
|
356
|
+
// arguments.
|
|
417
357
|
_config: C,
|
|
418
|
-
|
|
358
|
+
catalog: Cat,
|
|
359
|
+
// Read for its type: the route ids.
|
|
419
360
|
_routes: Routes,
|
|
420
|
-
//
|
|
421
|
-
// above
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
project: T & ProjectOverrideChecks<LocalesOf<C>, Catalog, Routes, T>
|
|
361
|
+
// The whole-object validators sit in parameter position rather than in the
|
|
362
|
+
// constraint above: in a constraint, one unrelated mistake fails the
|
|
363
|
+
// argument outright, and TypeScript reports at this call instead of on the
|
|
364
|
+
// line that is wrong.
|
|
365
|
+
project: T & ProjectOverrideChecks<PublishedOf<Cat>, Routes, T>
|
|
426
366
|
): T {
|
|
427
367
|
// Checked at runtime as well as in the type, because the type is not always
|
|
428
368
|
// what fails first. An Astro config is *evaluated* before `astro check`
|
|
429
|
-
// runs, so a project file missing
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
for (const field of ["overrideMessages", "overrideRoutes"] as const) {
|
|
435
|
-
if (project[field] === undefined) {
|
|
436
|
-
throw new Error(
|
|
437
|
-
`Project "${project.siteName}" declares no ${field}. It is required even when nothing is overridden — write \`${field}: {}\`, which is what the type asks for and what tells a reader the shared ${field === "overrideMessages" ? "copy" : "route table"} is used as it is.`
|
|
438
|
-
);
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
// The same two rules the type states, for the same reason: the config is
|
|
442
|
-
// evaluated before the type check runs.
|
|
443
|
-
for (const [key, entry] of Object.entries(project.addMessages ?? {})) {
|
|
444
|
-
if (key in _messages) {
|
|
445
|
-
throw new Error(
|
|
446
|
-
`Project "${project.siteName}": addMessages "${key}" is already in the default catalog. Reword it in overrideMessages instead.`
|
|
447
|
-
);
|
|
448
|
-
}
|
|
449
|
-
const missing = project.enabledLocales.filter(
|
|
450
|
-
(locale) => typeof entry[locale] !== "string"
|
|
369
|
+
// runs, so a project file missing this reaches the route table and throws
|
|
370
|
+
// there, far from the field the compiler would have named precisely.
|
|
371
|
+
if (project.overrideRoutes === undefined) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`Project "${project.siteName}" declares no overrideRoutes. It is required even when nothing is overridden — write \`overrideRoutes: {}\`, which is what the type asks for and what tells a reader the shared route table is used as it is.`
|
|
451
374
|
);
|
|
452
|
-
if (missing.length > 0) {
|
|
453
|
-
throw new Error(
|
|
454
|
-
`Project "${project.siteName}": addMessages "${key}" has no ${missing.join(", ")}. A message a project adds is written for every locale it publishes.`
|
|
455
|
-
);
|
|
456
|
-
}
|
|
457
375
|
}
|
|
376
|
+
const published: readonly string[] = catalog.locales;
|
|
458
377
|
// The clock's rule, for the same reason.
|
|
459
378
|
const clock = project.formats?.clock;
|
|
460
379
|
if (clock !== undefined) {
|
|
461
|
-
const published: readonly string[] = project.enabledLocales;
|
|
462
380
|
const missing = published.filter((locale) => !(locale in clock));
|
|
463
381
|
if (missing.length > 0) {
|
|
464
382
|
throw new Error(
|
|
@@ -476,13 +394,11 @@ export function defineProject<
|
|
|
476
394
|
}
|
|
477
395
|
// The price locale's rule, for the same reason.
|
|
478
396
|
const priceLocale: string | undefined = project.formats?.prices?.locale;
|
|
479
|
-
if (
|
|
480
|
-
priceLocale !== undefined &&
|
|
481
|
-
!(project.enabledLocales as readonly string[]).includes(priceLocale)
|
|
482
|
-
) {
|
|
397
|
+
if (priceLocale !== undefined && !published.includes(priceLocale)) {
|
|
483
398
|
throw new Error(
|
|
484
399
|
`Project "${project.siteName}": formats.prices.locale names ${priceLocale}, which this project does not publish.`
|
|
485
400
|
);
|
|
486
401
|
}
|
|
402
|
+
builtWith.set(project, catalog);
|
|
487
403
|
return project;
|
|
488
404
|
}
|
package/src/site/create.ts
CHANGED
|
@@ -15,7 +15,12 @@ import type { GeneratedFile } from "../file.ts";
|
|
|
15
15
|
import type { PublicFilePath } from "../files.ts";
|
|
16
16
|
import { mergeFonts } from "../fonts.ts";
|
|
17
17
|
import type { HourCycle } from "../hours.ts";
|
|
18
|
-
import {
|
|
18
|
+
import type { MessagesOf, ProjectCatalog } from "../i18n/catalog.ts";
|
|
19
|
+
import {
|
|
20
|
+
type BaseCatalog,
|
|
21
|
+
mergeCatalog,
|
|
22
|
+
type OverrideCatalog,
|
|
23
|
+
} from "../i18n/define.ts";
|
|
19
24
|
import { createTranslateFactory } from "../i18n/translate.ts";
|
|
20
25
|
import type { ImageAsset } from "../image.ts";
|
|
21
26
|
import { buildLlms, type LlmsItem, type LlmsSection } from "../llms.ts";
|
|
@@ -25,7 +30,11 @@ import {
|
|
|
25
30
|
resolveShareImage,
|
|
26
31
|
} from "../meta/index.ts";
|
|
27
32
|
import type { PriceDecimals } from "../price-format.ts";
|
|
28
|
-
import
|
|
33
|
+
import {
|
|
34
|
+
checkProjectCatalog,
|
|
35
|
+
type ProjectInput,
|
|
36
|
+
type ProjectRouteId,
|
|
37
|
+
} from "../project.ts";
|
|
29
38
|
import {
|
|
30
39
|
buildRedirects,
|
|
31
40
|
type RedirectRule,
|
|
@@ -78,47 +87,52 @@ function decorate(path: UrlPath, options: LinkOptions | undefined): UrlPath {
|
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
/**
|
|
81
|
-
* Turns a site config,
|
|
82
|
-
* pages use.
|
|
90
|
+
* Turns a site config, one project's catalog, the routes and the project into
|
|
91
|
+
* the API the pages use.
|
|
83
92
|
*
|
|
84
93
|
* This is the only wiring a consuming project writes: the merging, slug
|
|
85
94
|
* fallbacks, URL building, translation and sitemap all come back ready to use.
|
|
86
95
|
*/
|
|
87
96
|
export function createSite<
|
|
88
97
|
const C extends SiteConfigShape,
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
98
|
+
// The catalog carries the locales the project publishes and its copy.
|
|
99
|
+
const Cat extends ProjectCatalog<
|
|
100
|
+
BaseCatalog<LocalesOf<C>>,
|
|
101
|
+
LocalesOf<C>,
|
|
102
|
+
OverrideCatalog<LocalesOf<C>>,
|
|
103
|
+
OverrideCatalog<LocalesOf<C>>
|
|
104
|
+
>,
|
|
105
|
+
// Inferred from `catalog.locales` as its own parameter rather than read
|
|
106
|
+
// back off `Cat`: a type read back is not known to be one of the site's
|
|
107
|
+
// locales, where a parameter constrained to them is, and an indexed access
|
|
108
|
+
// through the constraint would collapse to the whole locale universe and
|
|
109
|
+
// silently un-type every locale the API accepts.
|
|
95
110
|
const Published extends LocalesOf<C>,
|
|
111
|
+
const Routes extends RouteRegistry<LocalesOf<C>>,
|
|
96
112
|
const P extends ProjectInput<LocalesOf<C>, Routes>,
|
|
97
113
|
>(
|
|
98
114
|
config_: C,
|
|
99
|
-
|
|
115
|
+
// `Published` is named in the *parameter*: a type parameter mentioned
|
|
116
|
+
// nowhere an argument reaches has nothing to infer from and silently falls
|
|
117
|
+
// back to its constraint.
|
|
118
|
+
catalog: Cat & { readonly locales: readonly Published[] },
|
|
100
119
|
routes: Routes,
|
|
101
|
-
|
|
102
|
-
// a type parameter mentioned nowhere an argument reaches has nothing to
|
|
103
|
-
// infer from and silently falls back to its constraint — here the whole
|
|
104
|
-
// locale universe, which is the bug this was meant to fix.
|
|
105
|
-
project: P & { readonly enabledLocales: readonly Published[] }
|
|
120
|
+
project: P
|
|
106
121
|
): Site<
|
|
107
122
|
Published,
|
|
108
|
-
|
|
123
|
+
MessagesOf<Cat>,
|
|
109
124
|
ProjectRouteId<Routes, P>,
|
|
110
125
|
SectionPrefixOf<Routes, P["overrideRoutes"]>,
|
|
111
126
|
OrphanSectionPrefixOf<Routes, P["overrideRoutes"]>
|
|
112
127
|
> {
|
|
113
|
-
// The locales this *project* publishes,
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
// returned a URL that was never generated.
|
|
128
|
+
// The locales this *project* publishes, from its catalog, not the
|
|
129
|
+
// universe the site config declares: `pathFor("home", "el-GR")` must not
|
|
130
|
+
// type-check on a build with no Greek pages, since it would return a URL
|
|
131
|
+
// that was never generated.
|
|
118
132
|
type L = Published;
|
|
119
133
|
type RouteId = ProjectRouteId<Routes, P>;
|
|
120
134
|
|
|
121
|
-
const locales =
|
|
135
|
+
const locales = catalog.locales as readonly L[];
|
|
122
136
|
const defaultLocale = (project.overrideRouting?.defaultLocale ??
|
|
123
137
|
config_.defaultRouting.defaultLocale) as L;
|
|
124
138
|
const prefixDefaultLocale =
|
|
@@ -126,6 +140,7 @@ export function createSite<
|
|
|
126
140
|
config_.defaultRouting.prefixDefaultLocale;
|
|
127
141
|
|
|
128
142
|
// Checks that types cannot express, run once at build time.
|
|
143
|
+
checkProjectCatalog(project, catalog);
|
|
129
144
|
if (locales.length === 0) {
|
|
130
145
|
throw new Error("A project must publish at least one locale.");
|
|
131
146
|
}
|
|
@@ -182,13 +197,13 @@ export function createSite<
|
|
|
182
197
|
const llmsUrl: HttpsUrl | undefined =
|
|
183
198
|
config_.llms === false ? undefined : joinUrl(siteUrl, `/${llmsName}`);
|
|
184
199
|
|
|
185
|
-
// The project's own messages join the
|
|
200
|
+
// The project's own messages join the base here, typed and resolved like
|
|
186
201
|
// any other: `Full` is what every reader below knows the keys by.
|
|
187
|
-
type Full =
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
202
|
+
type Full = MessagesOf<Cat>;
|
|
203
|
+
const messages = mergeCatalog<L>(
|
|
204
|
+
catalog.base,
|
|
205
|
+
catalog.override,
|
|
206
|
+
catalog.add
|
|
192
207
|
);
|
|
193
208
|
const translateFactory = createTranslateFactory<Full, L>();
|
|
194
209
|
|
|
@@ -872,7 +887,7 @@ export function createSite<
|
|
|
872
887
|
translate: <const Values extends string = never>(
|
|
873
888
|
locale: L,
|
|
874
889
|
bind?: { readonly values?: Readonly<Record<Values, string>> }
|
|
875
|
-
) => translateFactory<Values>(
|
|
890
|
+
) => translateFactory<Values>(messages, locale, bind?.values),
|
|
876
891
|
rich: <
|
|
877
892
|
const Links extends string = never,
|
|
878
893
|
const Values extends string = never,
|
|
@@ -881,7 +896,7 @@ export function createSite<
|
|
|
881
896
|
bind?: MessageBindings<RouteId, Links, Values>
|
|
882
897
|
) =>
|
|
883
898
|
createRichText<Full, L, RouteId, Links, Values>({
|
|
884
|
-
catalog,
|
|
899
|
+
catalog: messages,
|
|
885
900
|
locale,
|
|
886
901
|
link: richLink(locale),
|
|
887
902
|
origin: siteUrl,
|
|
@@ -892,7 +907,7 @@ export function createSite<
|
|
|
892
907
|
bind?: { readonly values?: Readonly<Record<Values, string>> }
|
|
893
908
|
) =>
|
|
894
909
|
createPlainText<Full, L, Values>({
|
|
895
|
-
catalog,
|
|
910
|
+
catalog: messages,
|
|
896
911
|
locale,
|
|
897
912
|
link: richLink(locale),
|
|
898
913
|
origin: siteUrl,
|
package/src/site/define.ts
CHANGED
|
@@ -4,11 +4,22 @@ import {
|
|
|
4
4
|
type SiteConfigChecks,
|
|
5
5
|
type SiteConfigShape,
|
|
6
6
|
} from "../config.ts";
|
|
7
|
+
import {
|
|
8
|
+
type AddedEntries,
|
|
9
|
+
defineCatalog,
|
|
10
|
+
type OverrideChecks,
|
|
11
|
+
type OverrideEntries,
|
|
12
|
+
type ProjectCatalog,
|
|
13
|
+
type PublishedOf,
|
|
14
|
+
} from "../i18n/catalog.ts";
|
|
7
15
|
import {
|
|
8
16
|
type BaseCatalog,
|
|
9
17
|
defineMessages,
|
|
10
18
|
type NoExtraLocales,
|
|
19
|
+
type OverrideCatalog,
|
|
20
|
+
type ValidateAdditions,
|
|
11
21
|
type ValidateBase,
|
|
22
|
+
type ValidateOverrideCatalog,
|
|
12
23
|
} from "../i18n/define.ts";
|
|
13
24
|
import {
|
|
14
25
|
expandLanguages,
|
|
@@ -47,8 +58,9 @@ import { createSite } from "./create.ts";
|
|
|
47
58
|
* const rooms = atlas.family("rooms", ROOMS, { enabled: true });
|
|
48
59
|
* export const defaultRoutes = atlas.routes({ ...rooms, home: {…} });
|
|
49
60
|
* export const defaultMessages = atlas.messages({…});
|
|
50
|
-
* export const
|
|
51
|
-
* export const
|
|
61
|
+
* export const messages = atlas.catalog(defaultMessages, { locales: […], override: {…} });
|
|
62
|
+
* export const { project, redirects } = atlas.project(messages, defaultRoutes, {…});
|
|
63
|
+
* export const site = atlas.site(messages, defaultRoutes, project);
|
|
52
64
|
* ```
|
|
53
65
|
*
|
|
54
66
|
* Every check each step makes is the same one it makes as a function: a typo'd
|
|
@@ -98,41 +110,74 @@ export function defineSite<
|
|
|
98
110
|
return expandLanguages(Object.keys(config.locales) as L[], texts);
|
|
99
111
|
},
|
|
100
112
|
|
|
101
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* One deployment's copy: the locales it publishes, and what it rewords
|
|
115
|
+
* or adds. Built before the project, so the project's other files can
|
|
116
|
+
* name its messages by key: `CatalogKey<typeof catalog>`.
|
|
117
|
+
*/
|
|
118
|
+
catalog<
|
|
119
|
+
const Base extends BaseCatalog<L>,
|
|
120
|
+
const Published extends L,
|
|
121
|
+
const Override extends OverrideEntries<Base, Published> &
|
|
122
|
+
OverrideChecks<Base, Published, Override>,
|
|
123
|
+
const Add extends AddedEntries<Published> = Record<never, never>,
|
|
124
|
+
>(
|
|
125
|
+
base: Base,
|
|
126
|
+
own: {
|
|
127
|
+
readonly locales: readonly Published[];
|
|
128
|
+
readonly override: Override &
|
|
129
|
+
ValidateOverrideCatalog<Override, Base, L>;
|
|
130
|
+
readonly add?: Add & ValidateAdditions<Add, Base, Published>;
|
|
131
|
+
}
|
|
132
|
+
): ProjectCatalog<Base, Published, Override, Add> {
|
|
133
|
+
return defineCatalog(config, base, own as never) as never;
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
/** One deployment: what differs from the defaults, beside its catalog. */
|
|
102
137
|
project<
|
|
103
|
-
const
|
|
138
|
+
const Cat extends ProjectCatalog<
|
|
139
|
+
BaseCatalog<L>,
|
|
140
|
+
L,
|
|
141
|
+
OverrideCatalog<L>,
|
|
142
|
+
OverrideCatalog<L>
|
|
143
|
+
>,
|
|
104
144
|
const Routes extends RouteRegistry<L>,
|
|
105
145
|
const T extends ProjectInput<L, Routes> &
|
|
106
|
-
ProjectChecks<L,
|
|
146
|
+
ProjectChecks<L, PublishedOf<Cat>, Routes, T>,
|
|
107
147
|
>(
|
|
108
|
-
|
|
148
|
+
catalog: Cat,
|
|
109
149
|
routes: Routes,
|
|
110
|
-
project: T & ProjectOverrideChecks<
|
|
111
|
-
): DefinedProject<Routes, T
|
|
150
|
+
project: T & ProjectOverrideChecks<PublishedOf<Cat>, Routes, T>
|
|
151
|
+
): DefinedProject<Routes, T, PublishedOf<Cat>> {
|
|
112
152
|
const defined = defineProject(
|
|
113
153
|
config,
|
|
114
|
-
|
|
154
|
+
catalog,
|
|
115
155
|
routes,
|
|
116
156
|
project as never
|
|
117
157
|
) as T;
|
|
118
158
|
return {
|
|
119
159
|
project: defined,
|
|
120
|
-
redirects: bindRedirects(routes, defined),
|
|
160
|
+
redirects: bindRedirects(routes, defined, catalog),
|
|
121
161
|
};
|
|
122
162
|
},
|
|
123
163
|
|
|
124
|
-
/** The API every page reads, wired for one project. */
|
|
164
|
+
/** The API every page reads, wired for one project and its catalog. */
|
|
125
165
|
site<
|
|
126
|
-
const
|
|
127
|
-
|
|
166
|
+
const Cat extends ProjectCatalog<
|
|
167
|
+
BaseCatalog<L>,
|
|
168
|
+
L,
|
|
169
|
+
OverrideCatalog<L>,
|
|
170
|
+
OverrideCatalog<L>
|
|
171
|
+
>,
|
|
128
172
|
const Published extends L,
|
|
173
|
+
const Routes extends RouteRegistry<L>,
|
|
129
174
|
const P extends ProjectInput<L, Routes>,
|
|
130
175
|
>(
|
|
131
|
-
|
|
176
|
+
catalog: Cat & { readonly locales: readonly Published[] },
|
|
132
177
|
routes: Routes,
|
|
133
|
-
project: P
|
|
134
|
-
): ReturnType<typeof createSite<C,
|
|
135
|
-
return createSite(config,
|
|
178
|
+
project: P
|
|
179
|
+
): ReturnType<typeof createSite<C, Cat, Published, Routes, P>> {
|
|
180
|
+
return createSite(config, catalog, routes, project);
|
|
136
181
|
},
|
|
137
182
|
};
|
|
138
183
|
}
|