@escape-game-over/atlas 0.1.88 → 0.1.91
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/package.json +1 -1
- package/src/i18n/define.ts +35 -2
- package/src/i18n/placeholders.ts +5 -0
- package/src/index.ts +3 -0
- package/src/project.ts +46 -1
- package/src/routes/define.ts +6 -1
- package/src/routes/family.ts +116 -16
- package/src/site/create.ts +13 -6
package/package.json
CHANGED
package/src/i18n/define.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { LocalesOf, SiteConfigShape } from "../config.ts";
|
|
2
2
|
import type { IsNever, NoExcessKeys, StringKeys } from "../types.ts";
|
|
3
3
|
import type {
|
|
4
|
+
AddedMessageExists,
|
|
4
5
|
EntryTokens,
|
|
5
6
|
MismatchedLocales,
|
|
6
7
|
OverridePlaceholderMismatch,
|
|
@@ -137,10 +138,32 @@ export type MessageOverrides<Catalog, L extends string> = {
|
|
|
137
138
|
readonly [K in StringKeys<Catalog>]?: Readonly<Partial<Record<L, string>>>;
|
|
138
139
|
};
|
|
139
140
|
|
|
140
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* What `defineProject` checks an `addMessages` against: each key new to the
|
|
143
|
+
* base catalog, written for exactly the locales the project publishes, with
|
|
144
|
+
* the same `{placeholders}` and `[marks]` in each.
|
|
145
|
+
*
|
|
146
|
+
* New only, because a key the base already has is copy every project shares,
|
|
147
|
+
* and rewording it is what `overrideMessages` is for — with its own check that
|
|
148
|
+
* the placeholders survive. An addition shadowing a base key would get round
|
|
149
|
+
* that check, and two fields could then say different things about one key.
|
|
150
|
+
*/
|
|
151
|
+
export type ValidateAdditions<T, Base, Published extends string> = {
|
|
152
|
+
[K in keyof T]: K extends keyof Base
|
|
153
|
+
? AddedMessageExists<K & string>
|
|
154
|
+
: NoExcessKeys<T[K], Published> &
|
|
155
|
+
Readonly<Record<Published, string>> &
|
|
156
|
+
SelfConsistent<T[K], K>;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Merges a project's override catalog over the base catalog, locale by locale,
|
|
161
|
+
* then adds the project's own messages.
|
|
162
|
+
*/
|
|
141
163
|
export function mergeCatalog<L extends string>(
|
|
142
164
|
base: BaseCatalog<L>,
|
|
143
|
-
overrides: OverrideCatalog<L
|
|
165
|
+
overrides: OverrideCatalog<L>,
|
|
166
|
+
additions: OverrideCatalog<L> = {}
|
|
144
167
|
): Readonly<Record<string, Readonly<Record<L, string>>>> {
|
|
145
168
|
const merged: Record<string, Record<string, string>> = {};
|
|
146
169
|
for (const [key, entry] of Object.entries(base)) {
|
|
@@ -154,5 +177,15 @@ export function mergeCatalog<L extends string>(
|
|
|
154
177
|
if (typeof text === "string") target[locale] = text;
|
|
155
178
|
}
|
|
156
179
|
}
|
|
180
|
+
for (const [key, entry] of Object.entries(additions)) {
|
|
181
|
+
// Unreachable via the typed API, which refuses a base key here; an
|
|
182
|
+
// Astro config is evaluated before the type check, so say it plainly.
|
|
183
|
+
if (key in merged) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`addMessages: "${key}" is already in the default catalog. Reword it in overrideMessages instead.`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
merged[key] = { ...entry } as Record<string, string>;
|
|
189
|
+
}
|
|
157
190
|
return merged as Readonly<Record<string, Readonly<Record<L, string>>>>;
|
|
158
191
|
}
|
package/src/i18n/placeholders.ts
CHANGED
|
@@ -112,6 +112,11 @@ export interface PlaceholderMismatch<Key extends string, L extends string> {
|
|
|
112
112
|
readonly __PLACEHOLDER_MISMATCH__: `Message "${Key}" uses different {placeholders} or [marks] in locale "${L}" than in its other locales`;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/** Editor-facing error when a project adds a message the base already has. */
|
|
116
|
+
export interface AddedMessageExists<Key extends string> {
|
|
117
|
+
readonly __ADDED_MESSAGE_EXISTS__: `Message "${Key}" is already in the default catalog: reword it in overrideMessages instead of adding it`;
|
|
118
|
+
}
|
|
119
|
+
|
|
115
120
|
/** Editor-facing error when an override drops or invents one. */
|
|
116
121
|
export interface OverridePlaceholderMismatch<
|
|
117
122
|
Key extends string,
|
package/src/index.ts
CHANGED
|
@@ -230,6 +230,7 @@ export {
|
|
|
230
230
|
tierRange,
|
|
231
231
|
} from "./money.ts";
|
|
232
232
|
export type {
|
|
233
|
+
AddedCatalog,
|
|
233
234
|
DefinedProject,
|
|
234
235
|
ProjectInput,
|
|
235
236
|
ProjectRouteId,
|
|
@@ -262,8 +263,10 @@ export {
|
|
|
262
263
|
enableRoutes,
|
|
263
264
|
type FamilyMember,
|
|
264
265
|
type FamilyMemberInput,
|
|
266
|
+
type FamilyMemberSlugs,
|
|
265
267
|
type FamilyOptions,
|
|
266
268
|
familyGuard,
|
|
269
|
+
familyMemberSlugs,
|
|
267
270
|
type RouteIdsOf,
|
|
268
271
|
} from "./routes/family.ts";
|
|
269
272
|
export type { RouteEntry } from "./routes/resolve.ts";
|
package/src/project.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { Fonts } from "./fonts.ts";
|
|
|
10
10
|
import type {
|
|
11
11
|
BaseCatalog,
|
|
12
12
|
OverrideCatalog,
|
|
13
|
+
ValidateAdditions,
|
|
13
14
|
ValidateOverrideCatalog,
|
|
14
15
|
} from "./i18n/define.ts";
|
|
15
16
|
import type { ImageAsset } from "./image.ts";
|
|
@@ -139,6 +140,20 @@ export interface ProjectInput<L extends string, Routes> {
|
|
|
139
140
|
* than the argument.
|
|
140
141
|
*/
|
|
141
142
|
readonly overrideMessages: OverrideCatalog<L>;
|
|
143
|
+
/**
|
|
144
|
+
* Copy only this project has: keys the default catalog does not declare,
|
|
145
|
+
* each written for every locale the project publishes and no others.
|
|
146
|
+
*
|
|
147
|
+
* For what one deployment says and no other does — a venue's own FAQ, a
|
|
148
|
+
* house rule — so it goes through `translate` and `rich` like any message,
|
|
149
|
+
* marks and placeholders included, without the shared catalog carrying it
|
|
150
|
+
* in languages the venue never publishes. A key the default catalog
|
|
151
|
+
* already has is refused: reword it in `overrideMessages`.
|
|
152
|
+
*
|
|
153
|
+
* Optional, unlike `overrideMessages`: nothing downstream is computed from
|
|
154
|
+
* it except the catalog's keys, which an absent field leaves as they are.
|
|
155
|
+
*/
|
|
156
|
+
readonly addMessages?: OverrideCatalog<L>;
|
|
142
157
|
/** Slug retranslations and page on/off switches. See `overrideMessages`. */
|
|
143
158
|
readonly overrideRoutes: RouteOverrides<Routes, L>;
|
|
144
159
|
}
|
|
@@ -289,7 +304,20 @@ export type ProjectOverrideChecks<
|
|
|
289
304
|
Routes,
|
|
290
305
|
T["overrideRoutes"]
|
|
291
306
|
>;
|
|
292
|
-
}
|
|
307
|
+
} & (T extends { readonly addMessages: infer A }
|
|
308
|
+
? {
|
|
309
|
+
readonly addMessages: ValidateAdditions<
|
|
310
|
+
A,
|
|
311
|
+
Catalog,
|
|
312
|
+
T["enabledLocales"][number]
|
|
313
|
+
>;
|
|
314
|
+
}
|
|
315
|
+
: unknown);
|
|
316
|
+
|
|
317
|
+
/** A project's own messages, as a catalog: none when it adds none. */
|
|
318
|
+
export type AddedCatalog<P> = P extends { readonly addMessages: infer A }
|
|
319
|
+
? A
|
|
320
|
+
: Record<never, never>;
|
|
293
321
|
|
|
294
322
|
export function defineProject<
|
|
295
323
|
const C extends SiteConfigShape,
|
|
@@ -324,5 +352,22 @@ export function defineProject<
|
|
|
324
352
|
);
|
|
325
353
|
}
|
|
326
354
|
}
|
|
355
|
+
// The same two rules the type states, for the same reason: the config is
|
|
356
|
+
// evaluated before the type check runs.
|
|
357
|
+
for (const [key, entry] of Object.entries(project.addMessages ?? {})) {
|
|
358
|
+
if (key in _messages) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
`Project "${project.siteName}": addMessages "${key}" is already in the default catalog. Reword it in overrideMessages instead.`
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
const missing = project.enabledLocales.filter(
|
|
364
|
+
(locale) => typeof entry[locale] !== "string"
|
|
365
|
+
);
|
|
366
|
+
if (missing.length > 0) {
|
|
367
|
+
throw new Error(
|
|
368
|
+
`Project "${project.siteName}": addMessages "${key}" has no ${missing.join(", ")}. A message a project adds is written for every locale it publishes.`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
327
372
|
return project;
|
|
328
373
|
}
|
package/src/routes/define.ts
CHANGED
|
@@ -204,7 +204,12 @@ type InheritedDepthMismatch<
|
|
|
204
204
|
> = K extends keyof Base
|
|
205
205
|
? Base[K] extends { readonly slugByLocale?: infer M }
|
|
206
206
|
? {
|
|
207
|
-
|
|
207
|
+
// `-?`: the translations are a `Partial`, and a mapped type keeps
|
|
208
|
+
// its `?`, so every entry would read `never | undefined` and the
|
|
209
|
+
// lookup below `undefined` — not `never` — failing every `slug`
|
|
210
|
+
// override of a route whose translations are optional, family
|
|
211
|
+
// members among them.
|
|
212
|
+
[L in keyof M]-?: L extends OverriddenLocales<T, K>
|
|
208
213
|
? never
|
|
209
214
|
: M[L] extends infer V extends string
|
|
210
215
|
? SegmentCount<V> extends Depth
|
package/src/routes/family.ts
CHANGED
|
@@ -75,6 +75,113 @@ export interface FamilyOptions<L extends string> {
|
|
|
75
75
|
readonly sitemap?: RouteSitemap;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/** Per-locale translations, keyed by locale. */
|
|
79
|
+
type ByLocale = Readonly<Record<string, string>>;
|
|
80
|
+
|
|
81
|
+
/** One locale's path: its translated halves where it has them, else the defaults. */
|
|
82
|
+
type JoinedSlug<
|
|
83
|
+
Prefix extends string,
|
|
84
|
+
PrefixBy extends ByLocale,
|
|
85
|
+
Segment extends string,
|
|
86
|
+
SegmentBy extends ByLocale,
|
|
87
|
+
K,
|
|
88
|
+
> = `${K extends keyof PrefixBy ? PrefixBy[K] : Prefix}/${K extends keyof SegmentBy
|
|
89
|
+
? SegmentBy[K]
|
|
90
|
+
: Segment}`;
|
|
91
|
+
|
|
92
|
+
/** What {@link familyMemberSlugs} returns: literal paths, so depth checks still apply. */
|
|
93
|
+
export type FamilyMemberSlugs<
|
|
94
|
+
Prefix extends string,
|
|
95
|
+
Segment extends string,
|
|
96
|
+
PrefixBy extends ByLocale = Record<never, never>,
|
|
97
|
+
SegmentBy extends ByLocale = Record<never, never>,
|
|
98
|
+
> = { readonly slug: `${Prefix}/${Segment}` } & ([
|
|
99
|
+
keyof PrefixBy | keyof SegmentBy,
|
|
100
|
+
] extends [never]
|
|
101
|
+
? unknown
|
|
102
|
+
: {
|
|
103
|
+
readonly slugByLocale: {
|
|
104
|
+
readonly [K in keyof PrefixBy | keyof SegmentBy]: JoinedSlug<
|
|
105
|
+
Prefix,
|
|
106
|
+
PrefixBy,
|
|
107
|
+
Segment,
|
|
108
|
+
SegmentBy,
|
|
109
|
+
K
|
|
110
|
+
>;
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A family member's paths, in every locale, as {@link routeFamily} composes
|
|
116
|
+
* them — for a project that gives one member a segment of its own.
|
|
117
|
+
*
|
|
118
|
+
* ```ts
|
|
119
|
+
* overrideRoutes: {
|
|
120
|
+
* "rooms-illuminati": {
|
|
121
|
+
* enabled: true,
|
|
122
|
+
* ...familyMemberSlugs("rooms", "the-da-vinci-code", {
|
|
123
|
+
* prefixByLocale: ROOM_PREFIX_BY_LOCALE,
|
|
124
|
+
* }),
|
|
125
|
+
* },
|
|
126
|
+
* }
|
|
127
|
+
* ```
|
|
128
|
+
*
|
|
129
|
+
* Overriding only `slug` would move the default locale and leave every
|
|
130
|
+
* translated path at the family's id, at the same depth, so nothing would
|
|
131
|
+
* catch it. This restates them all from the same join the family used. Pass
|
|
132
|
+
* the family's `prefixByLocale`, so the member stays under its siblings in
|
|
133
|
+
* every locale.
|
|
134
|
+
*/
|
|
135
|
+
export function familyMemberSlugs<
|
|
136
|
+
const Prefix extends string,
|
|
137
|
+
const Segment extends string,
|
|
138
|
+
const PrefixBy extends ByLocale = Record<never, never>,
|
|
139
|
+
const SegmentBy extends ByLocale = Record<never, never>,
|
|
140
|
+
>(
|
|
141
|
+
prefix: Prefix,
|
|
142
|
+
segment: Segment,
|
|
143
|
+
translations: {
|
|
144
|
+
readonly prefixByLocale?: PrefixBy;
|
|
145
|
+
readonly segmentByLocale?: SegmentBy;
|
|
146
|
+
} = {}
|
|
147
|
+
): FamilyMemberSlugs<Prefix, Segment, PrefixBy, SegmentBy> {
|
|
148
|
+
const slugByLocale = joinByLocale(
|
|
149
|
+
prefix,
|
|
150
|
+
segment,
|
|
151
|
+
translations.prefixByLocale,
|
|
152
|
+
translations.segmentByLocale
|
|
153
|
+
);
|
|
154
|
+
return {
|
|
155
|
+
slug: `${prefix}/${segment}`,
|
|
156
|
+
...(slugByLocale === undefined ? {} : { slugByLocale }),
|
|
157
|
+
} as never;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The translated paths of one member, or `undefined` where neither half is
|
|
162
|
+
* translated. A locale gets one when either half is; the untranslated half
|
|
163
|
+
* falls back to the default, so every locale sits at the same depth by
|
|
164
|
+
* construction.
|
|
165
|
+
*/
|
|
166
|
+
function joinByLocale(
|
|
167
|
+
prefix: string,
|
|
168
|
+
segment: string,
|
|
169
|
+
prefixByLocale: Readonly<Partial<Record<string, string>>> | undefined,
|
|
170
|
+
segmentByLocale: Readonly<Partial<Record<string, string>>> | undefined
|
|
171
|
+
): Record<string, string> | undefined {
|
|
172
|
+
const locales = new Set([
|
|
173
|
+
...Object.keys(prefixByLocale ?? {}),
|
|
174
|
+
...Object.keys(segmentByLocale ?? {}),
|
|
175
|
+
]);
|
|
176
|
+
if (locales.size === 0) return undefined;
|
|
177
|
+
const slugByLocale: Record<string, string> = {};
|
|
178
|
+
for (const locale of locales) {
|
|
179
|
+
slugByLocale[locale] =
|
|
180
|
+
`${prefixByLocale?.[locale] ?? prefix}/${segmentByLocale?.[locale] ?? segment}`;
|
|
181
|
+
}
|
|
182
|
+
return slugByLocale;
|
|
183
|
+
}
|
|
184
|
+
|
|
78
185
|
/**
|
|
79
186
|
* Expands a family into ordinary route entries, ready to spread into
|
|
80
187
|
* `defineRoutes`.
|
|
@@ -127,26 +234,19 @@ export function routeFamily<
|
|
|
127
234
|
const segmentByLocale =
|
|
128
235
|
typeof member === "string" ? undefined : member.segmentByLocale;
|
|
129
236
|
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
for (const locale of locales) {
|
|
139
|
-
const localePrefix =
|
|
140
|
-
options?.prefixByLocale?.[locale as LocalesOf<C>] ?? prefix;
|
|
141
|
-
const localeSegment =
|
|
142
|
-
segmentByLocale?.[locale as LocalesOf<C>] ?? id;
|
|
143
|
-
slugByLocale[locale] = `${localePrefix}/${localeSegment}`;
|
|
144
|
-
}
|
|
237
|
+
// The same join `familyMemberSlugs` gives a project, so a member it
|
|
238
|
+
// moves sits exactly where the family would have put it.
|
|
239
|
+
const slugByLocale = joinByLocale(
|
|
240
|
+
prefix,
|
|
241
|
+
id,
|
|
242
|
+
options?.prefixByLocale,
|
|
243
|
+
segmentByLocale
|
|
244
|
+
);
|
|
145
245
|
|
|
146
246
|
routes[`${prefix.replaceAll("/", "-")}-${id}`] = {
|
|
147
247
|
slug: `${prefix}/${id}`,
|
|
148
248
|
enabled: options?.enabled ?? true,
|
|
149
|
-
...(
|
|
249
|
+
...(slugByLocale !== undefined
|
|
150
250
|
? {
|
|
151
251
|
slugByLocale: slugByLocale as Readonly<
|
|
152
252
|
Partial<Record<LocalesOf<C>, string>>
|
package/src/site/create.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
buildNotFoundMeta,
|
|
23
23
|
resolveShareImage,
|
|
24
24
|
} from "../meta/index.ts";
|
|
25
|
-
import type { ProjectInput, ProjectRouteId } from "../project.ts";
|
|
25
|
+
import type { AddedCatalog, ProjectInput, ProjectRouteId } from "../project.ts";
|
|
26
26
|
import {
|
|
27
27
|
buildRedirects,
|
|
28
28
|
type RedirectRule,
|
|
@@ -102,7 +102,7 @@ export function createSite<
|
|
|
102
102
|
project: P & { readonly enabledLocales: readonly Published[] }
|
|
103
103
|
): Site<
|
|
104
104
|
Published,
|
|
105
|
-
Catalog
|
|
105
|
+
Catalog & AddedCatalog<P>,
|
|
106
106
|
ProjectRouteId<Routes, P>,
|
|
107
107
|
SectionPrefixOf<Routes, P["overrideRoutes"]>,
|
|
108
108
|
OrphanSectionPrefixOf<Routes, P["overrideRoutes"]>
|
|
@@ -179,8 +179,15 @@ export function createSite<
|
|
|
179
179
|
const llmsUrl: HttpsUrl | undefined =
|
|
180
180
|
config_.llms === false ? undefined : joinUrl(siteUrl, `/${llmsName}`);
|
|
181
181
|
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
// The project's own messages join the catalog here, typed and resolved like
|
|
183
|
+
// any other: `Full` is what every reader below knows the keys by.
|
|
184
|
+
type Full = Catalog & AddedCatalog<P>;
|
|
185
|
+
const catalog = mergeCatalog<L>(
|
|
186
|
+
messages,
|
|
187
|
+
project.overrideMessages,
|
|
188
|
+
project.addMessages
|
|
189
|
+
);
|
|
190
|
+
const translateFactory = createTranslateFactory<Full, L>();
|
|
184
191
|
|
|
185
192
|
const resolved = mergeRoutes<L, Routes>(routes, project.overrideRoutes);
|
|
186
193
|
|
|
@@ -831,14 +838,14 @@ export function createSite<
|
|
|
831
838
|
|
|
832
839
|
translate: (locale) => translateFactory(catalog, locale),
|
|
833
840
|
rich: (locale) =>
|
|
834
|
-
createRichText<
|
|
841
|
+
createRichText<Full, L, RouteId>({
|
|
835
842
|
catalog,
|
|
836
843
|
locale,
|
|
837
844
|
link: richLink(locale),
|
|
838
845
|
origin: siteUrl,
|
|
839
846
|
}),
|
|
840
847
|
plain: (locale) =>
|
|
841
|
-
createPlainText<
|
|
848
|
+
createPlainText<Full, L>({
|
|
842
849
|
catalog,
|
|
843
850
|
locale,
|
|
844
851
|
link: richLink(locale),
|