@escape-game-over/atlas 0.1.1
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 +364 -0
- package/bin/use-project.mjs +131 -0
- package/docs/NOT-BUILT.md +329 -0
- package/docs/checks.md +139 -0
- package/docs/share-images.md +52 -0
- package/docs/toolchain.md +83 -0
- package/package.json +51 -0
- package/src/analytics/google.ts +351 -0
- package/src/analytics/index.ts +102 -0
- package/src/analytics/tags.ts +57 -0
- package/src/analytics/umami.ts +285 -0
- package/src/astro/MetaTags.astro +87 -0
- package/src/astro/consent.ts +165 -0
- package/src/astro/images.ts +315 -0
- package/src/astro/index.ts +44 -0
- package/src/astro/public-files.ts +129 -0
- package/src/astro/site-routes.ts +307 -0
- package/src/config.ts +218 -0
- package/src/contact.ts +233 -0
- package/src/file.ts +16 -0
- package/src/files.ts +39 -0
- package/src/hours.ts +312 -0
- package/src/i18n/define.ts +217 -0
- package/src/i18n/placeholders.ts +94 -0
- package/src/i18n/translate.ts +190 -0
- package/src/image.ts +29 -0
- package/src/index.ts +222 -0
- package/src/jsonld/article.ts +165 -0
- package/src/jsonld/breadcrumb.ts +34 -0
- package/src/jsonld/business.ts +196 -0
- package/src/jsonld/ids.ts +106 -0
- package/src/jsonld/index.ts +59 -0
- package/src/jsonld/node.ts +78 -0
- package/src/jsonld/organization.ts +154 -0
- package/src/jsonld/place.ts +96 -0
- package/src/jsonld/product.ts +172 -0
- package/src/jsonld/quantity.ts +55 -0
- package/src/jsonld/service.ts +237 -0
- package/src/jsonld/video.ts +239 -0
- package/src/jsonld/website.ts +58 -0
- package/src/llms.ts +160 -0
- package/src/meta/content.ts +190 -0
- package/src/meta/index.ts +432 -0
- package/src/meta/robots.ts +212 -0
- package/src/meta/share-image.ts +232 -0
- package/src/meta/tag.ts +133 -0
- package/src/meta/verification.ts +53 -0
- package/src/money.ts +237 -0
- package/src/project.ts +249 -0
- package/src/redirects.ts +266 -0
- package/src/robots.ts +80 -0
- package/src/routes/define.ts +412 -0
- package/src/routes/family.ts +251 -0
- package/src/routes/resolve.ts +266 -0
- package/src/site/api.ts +354 -0
- package/src/site/create.ts +660 -0
- package/src/site/index.ts +32 -0
- package/src/site/page.ts +148 -0
- package/src/sitemap.ts +257 -0
- package/src/types.ts +160 -0
- package/src/url.ts +144 -0
- package/src/warn.ts +88 -0
- package/src/xml.ts +103 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { LocalesOf, SiteConfigShape } from "../config.ts";
|
|
2
|
+
import type { IsNever, NoExcessKeys, StringKeys } from "../types.ts";
|
|
3
|
+
import type {
|
|
4
|
+
EntryPlaceholders,
|
|
5
|
+
MismatchedLocales,
|
|
6
|
+
OverridePlaceholderMismatch,
|
|
7
|
+
PlaceholderMismatch,
|
|
8
|
+
} from "./placeholders.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `true` when `Catalog` declares every key in `Required`; otherwise an object
|
|
12
|
+
* type naming the ones it does not.
|
|
13
|
+
*
|
|
14
|
+
* Annotate a `const … = true` with it. A missing message then fails at that
|
|
15
|
+
* declaration, and the error text lists exactly which keys are absent, rather
|
|
16
|
+
* than surfacing later as a page with no `<title>`:
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* export const routeMessagesAreComplete: CatalogCovers<
|
|
20
|
+
* typeof baseMessages,
|
|
21
|
+
* `route.${RouteId}.${"nav" | "title"}`
|
|
22
|
+
* > = true;
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* The pattern earns its keep over a plain `Required extends StringKeys<Catalog>`
|
|
26
|
+
* constraint, which reports only that the constraint was not satisfied and
|
|
27
|
+
* leaves you to work out which of the keys is missing.
|
|
28
|
+
*
|
|
29
|
+
* lib supplies the mechanism and never the key names: which messages a project
|
|
30
|
+
* considers mandatory is that project's convention to state.
|
|
31
|
+
*/
|
|
32
|
+
export type CatalogCovers<Catalog, Required extends string> =
|
|
33
|
+
IsNever<Exclude<Required, StringKeys<Catalog>>> extends true
|
|
34
|
+
? true
|
|
35
|
+
: {
|
|
36
|
+
readonly __MISSING_MESSAGES__: Exclude<
|
|
37
|
+
Required,
|
|
38
|
+
StringKeys<Catalog>
|
|
39
|
+
>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** A base message: every locale is mandatory. */
|
|
43
|
+
export type BaseEntry<L extends string> = Readonly<Record<L, string>>;
|
|
44
|
+
export type BaseCatalog<L extends string> = Readonly<
|
|
45
|
+
Record<string, BaseEntry<L>>
|
|
46
|
+
>;
|
|
47
|
+
|
|
48
|
+
/** A project override: any subset of locales for an existing key. */
|
|
49
|
+
export type OverrideEntry<L extends string> = Readonly<
|
|
50
|
+
Partial<Record<L, string>>
|
|
51
|
+
>;
|
|
52
|
+
export type OverrideCatalog<L extends string> = Readonly<
|
|
53
|
+
Record<string, OverrideEntry<L>>
|
|
54
|
+
>;
|
|
55
|
+
|
|
56
|
+
/** The locale union a catalog covers, read back off its own entries. */
|
|
57
|
+
export type LocalesOfCatalog<Catalog> = StringKeys<
|
|
58
|
+
Catalog[StringKeys<Catalog>]
|
|
59
|
+
>;
|
|
60
|
+
|
|
61
|
+
type SelfConsistent<E, K> = [
|
|
62
|
+
MismatchedLocales<E, EntryPlaceholders<E>>,
|
|
63
|
+
] extends [never]
|
|
64
|
+
? unknown
|
|
65
|
+
: PlaceholderMismatch<
|
|
66
|
+
K & string,
|
|
67
|
+
MismatchedLocales<E, EntryPlaceholders<E>> & string
|
|
68
|
+
>;
|
|
69
|
+
|
|
70
|
+
type ValidateBase<T> = { [K in keyof T]: SelfConsistent<T[K], K> };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Rejects locale keys the site does not ship.
|
|
74
|
+
*
|
|
75
|
+
* `Record<L, string>` alone does not catch these: the entry type is inferred
|
|
76
|
+
* from the literal rather than checked against a fixed target, so an extra key
|
|
77
|
+
* structurally satisfies the constraint and would sit in the catalog unread.
|
|
78
|
+
*/
|
|
79
|
+
type NoExtraLocales<T, L extends string> = {
|
|
80
|
+
[K in keyof T]: NoExcessKeys<T[K], L>;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Declares the base copy.
|
|
85
|
+
*
|
|
86
|
+
* The config argument is what supplies the locale union — pass `site.config.ts`
|
|
87
|
+
* and the locales follow, with no type arguments to write.
|
|
88
|
+
*
|
|
89
|
+
* Enforced at compile time:
|
|
90
|
+
* - every key defines every locale the site ships, and no others;
|
|
91
|
+
* - every locale of a key uses exactly the same `{placeholders}`.
|
|
92
|
+
*/
|
|
93
|
+
export function defineMessages<
|
|
94
|
+
const C extends SiteConfigShape,
|
|
95
|
+
const T extends BaseCatalog<LocalesOf<C>>,
|
|
96
|
+
>(
|
|
97
|
+
// Read for its type only: it is how `L` is inferred without a type argument.
|
|
98
|
+
_config: C,
|
|
99
|
+
catalog: T & ValidateBase<T> & NoExtraLocales<T, LocalesOf<C>>
|
|
100
|
+
): T {
|
|
101
|
+
return catalog;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Everything a copy overlay must satisfy — and the only place it is stated.
|
|
106
|
+
*
|
|
107
|
+
* The mirror of `RouteOverlayKeys` / `RouteOverlayShape`, for the same reason:
|
|
108
|
+
* `defineMessageOverrides` and `defineProject` both accept an overlay, and a
|
|
109
|
+
* check added to one and not the other makes that one silently the safer place
|
|
110
|
+
* to write copy. Both name these, so a new rule reaches both.
|
|
111
|
+
*
|
|
112
|
+
* Excess keys go in the constraint, where a typo'd message id reports on that
|
|
113
|
+
* key; the placeholder and locale rules go in the parameter, because they map
|
|
114
|
+
* the whole object.
|
|
115
|
+
*/
|
|
116
|
+
export type MessageOverlayKeys<Base, T> = NoExcessKeys<T, StringKeys<Base>>;
|
|
117
|
+
|
|
118
|
+
/** @see {@link MessageOverlayKeys} — the parameter-position half. */
|
|
119
|
+
export type ValidateOverrideCatalog<
|
|
120
|
+
T,
|
|
121
|
+
Base,
|
|
122
|
+
L extends string,
|
|
123
|
+
> = ValidateOverrides<T, Base> & NoExtraLocales<T, L>;
|
|
124
|
+
|
|
125
|
+
type ValidateOverrides<T, Base> = {
|
|
126
|
+
[K in keyof T]: K extends keyof Base
|
|
127
|
+
? [MismatchedLocales<T[K], EntryPlaceholders<Base[K]>>] extends [never]
|
|
128
|
+
? unknown
|
|
129
|
+
: OverridePlaceholderMismatch<
|
|
130
|
+
K & string,
|
|
131
|
+
MismatchedLocales<T[K], EntryPlaceholders<Base[K]>> & string
|
|
132
|
+
>
|
|
133
|
+
: unknown;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Declares a project's copy overlay: any subset of keys, any subset of locales.
|
|
138
|
+
*
|
|
139
|
+
* Both the message ids and the locales are inferred from the base catalog you
|
|
140
|
+
* pass in, so overriding one locale of one string needs no type arguments.
|
|
141
|
+
*
|
|
142
|
+
* Enforced at compile time:
|
|
143
|
+
* - the key must exist in the base catalog, so a typo is rejected rather than
|
|
144
|
+
* silently becoming a string nothing reads;
|
|
145
|
+
* - the replacement must use exactly the `{placeholders}` of the base message,
|
|
146
|
+
* so every existing `t()` call site stays correct.
|
|
147
|
+
*/
|
|
148
|
+
export function defineMessageOverrides<
|
|
149
|
+
const C extends SiteConfigShape,
|
|
150
|
+
const Base extends BaseCatalog<LocalesOf<C>>,
|
|
151
|
+
const T extends OverrideCatalog<LocalesOf<C>> & MessageOverlayKeys<Base, T>,
|
|
152
|
+
>(
|
|
153
|
+
// Read for its type only, and taken first like every other definer here.
|
|
154
|
+
// It supplies the locale universe directly rather than having it inferred
|
|
155
|
+
// from the catalog's entries: `LocalesOfCatalog` reads the locales actually
|
|
156
|
+
// *used*, which is the same union for a well-formed catalog and a narrower
|
|
157
|
+
// one for a partial or empty draft.
|
|
158
|
+
//
|
|
159
|
+
// Note what this does not do: `config` declares which locales the site has,
|
|
160
|
+
// not which ones a project publishes. Rejecting copy for a language a given
|
|
161
|
+
// deployment has switched off is `MessageOverrides`' job, because only the
|
|
162
|
+
// project knows its own `enabledLocales`.
|
|
163
|
+
_config: C,
|
|
164
|
+
_base: Base,
|
|
165
|
+
overrides: T & ValidateOverrides<T, Base> & NoExtraLocales<T, LocalesOf<C>>
|
|
166
|
+
): T {
|
|
167
|
+
return overrides;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The shape of one project's copy overrides: known keys, published locales.
|
|
172
|
+
*
|
|
173
|
+
* For annotating the object itself, which is what puts the error on the line
|
|
174
|
+
* that is wrong:
|
|
175
|
+
*
|
|
176
|
+
* ```ts
|
|
177
|
+
* const overrides = {
|
|
178
|
+
* "site.cta": { "en-US": "Book a room" },
|
|
179
|
+
* } satisfies MessageOverrides<typeof defaultMessages, EnabledLocale>;
|
|
180
|
+
*
|
|
181
|
+
* export const overrideMessages = defineMessageOverrides(
|
|
182
|
+
* config,
|
|
183
|
+
* defaultMessages,
|
|
184
|
+
* overrides
|
|
185
|
+
* );
|
|
186
|
+
* ```
|
|
187
|
+
*
|
|
188
|
+
* The two halves do different jobs and both are needed. This type knows the
|
|
189
|
+
* project's locale set, so it rejects copy for a language that is never built —
|
|
190
|
+
* but it cannot compare an override's *text* with the default's, so it cannot
|
|
191
|
+
* see a dropped `{placeholder}`. `defineMessageOverrides` is the reverse: it
|
|
192
|
+
* reads both texts, and knows nothing about which locales this deployment
|
|
193
|
+
* publishes. Annotate with one, wrap in the other.
|
|
194
|
+
*/
|
|
195
|
+
export type MessageOverrides<Catalog, L extends string> = {
|
|
196
|
+
readonly [K in StringKeys<Catalog>]?: Readonly<Partial<Record<L, string>>>;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** Merges a project's override catalog over the base catalog, locale by locale. */
|
|
200
|
+
export function mergeCatalog<L extends string>(
|
|
201
|
+
base: BaseCatalog<L>,
|
|
202
|
+
overrides: OverrideCatalog<L>
|
|
203
|
+
): Readonly<Record<string, Readonly<Record<L, string>>>> {
|
|
204
|
+
const merged: Record<string, Record<string, string>> = {};
|
|
205
|
+
for (const [key, entry] of Object.entries(base)) {
|
|
206
|
+
merged[key] = { ...entry };
|
|
207
|
+
}
|
|
208
|
+
for (const [key, entry] of Object.entries(overrides)) {
|
|
209
|
+
const target = merged[key];
|
|
210
|
+
// Unreachable via the typed API: override keys are constrained to base keys.
|
|
211
|
+
if (target === undefined) continue;
|
|
212
|
+
for (const [locale, text] of Object.entries(entry)) {
|
|
213
|
+
if (typeof text === "string") target[locale] = text;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return merged as Readonly<Record<string, Readonly<Record<L, string>>>>;
|
|
217
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placeholder analysis over message templates. Pure types — no locale or copy
|
|
3
|
+
* data of any kind lives in `lib/`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Digit, Letter, StringKeys } from "../types.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One character a placeholder name may be spelled with.
|
|
10
|
+
*
|
|
11
|
+
* **This is the type-level half of `PLACEHOLDER` in `translate.ts`, and the two
|
|
12
|
+
* have to agree.** They are a hundred lines apart in different files and each
|
|
13
|
+
* reads correct alone, which is how they came to differ: this side used to
|
|
14
|
+
* accept anything between braces while the regex substituted only these, so a
|
|
15
|
+
* message written `"Use {b c} here"` compiled into a *required* `t()` parameter
|
|
16
|
+
* named `"b c"` that the runtime never consumed — the call site was forced to
|
|
17
|
+
* pass a value and the brace still shipped in the markup. The whole point of
|
|
18
|
+
* this machinery is that a literal brace cannot reach a page.
|
|
19
|
+
*
|
|
20
|
+
* `Letter` is uppercase only, so its lowercase twin is spelled out via
|
|
21
|
+
* `Lowercase`.
|
|
22
|
+
*/
|
|
23
|
+
type NameChar = Letter | Lowercase<Letter> | Digit | "_";
|
|
24
|
+
|
|
25
|
+
/** Whether every character of `S` may appear in a placeholder name. */
|
|
26
|
+
type IsName<S extends string> = S extends `${infer Head}${infer Tail}`
|
|
27
|
+
? Head extends NameChar
|
|
28
|
+
? Tail extends ""
|
|
29
|
+
? true
|
|
30
|
+
: IsName<Tail>
|
|
31
|
+
: false
|
|
32
|
+
: false;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extracts `{placeholder}` names from a message template as a union of literals.
|
|
36
|
+
*
|
|
37
|
+
* `Placeholders<'Hi {name}, {n} new'>` -> `'name' | 'n'`
|
|
38
|
+
* `Placeholders<'Hello'>` -> `never`
|
|
39
|
+
* `Placeholders<'Literal {{name}}'>` -> `never` — the escape, not a name
|
|
40
|
+
* `Placeholders<string>` -> `never`
|
|
41
|
+
*
|
|
42
|
+
* An escaped brace asks for nothing, which falls out of `IsName` rather than
|
|
43
|
+
* needing a case of its own: the candidate extracted from `{{name}}` is
|
|
44
|
+
* `"{name"`, and `{` is not a `NameChar`. The two halves agree by the same rule
|
|
45
|
+
* from opposite ends.
|
|
46
|
+
*
|
|
47
|
+
* What this cannot say is that a *malformed* brace is an error — `render` in
|
|
48
|
+
* `translate.ts` throws on `"{color: red}"`, and this only declines to ask for
|
|
49
|
+
* a parameter. That asymmetry is deliberate: the type's job is to get the
|
|
50
|
+
* call site's arguments right, and the parser's is to refuse copy nobody meant.
|
|
51
|
+
* Both fail the build, one at compile time and one the first time the message
|
|
52
|
+
* renders.
|
|
53
|
+
*
|
|
54
|
+
* Recursion is per character of the name rather than of the message, so it is
|
|
55
|
+
* bounded by how long a placeholder is spelled and not by how long the copy runs.
|
|
56
|
+
*/
|
|
57
|
+
export type Placeholders<S extends string> =
|
|
58
|
+
S extends `${string}{${infer Name}}${infer Rest}`
|
|
59
|
+
? (IsName<Name> extends true ? Name : never) | Placeholders<Rest>
|
|
60
|
+
: never;
|
|
61
|
+
|
|
62
|
+
/** The message strings present on an entry, as a union of string literals. */
|
|
63
|
+
export type TextOf<E> = Extract<E[keyof E], string>;
|
|
64
|
+
|
|
65
|
+
/** Every placeholder used by any locale of an entry. */
|
|
66
|
+
export type EntryPlaceholders<E> = Placeholders<TextOf<E>>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The locales of `E` whose placeholder set differs from `Expected`.
|
|
70
|
+
*
|
|
71
|
+
* This is what turns a half-updated translation into a compile error instead of
|
|
72
|
+
* a literal `{name}` rendered on a production page.
|
|
73
|
+
*/
|
|
74
|
+
export type MismatchedLocales<E, Expected extends string> = {
|
|
75
|
+
[L in StringKeys<E>]: [
|
|
76
|
+
| Exclude<Expected, Placeholders<Extract<E[L], string>>>
|
|
77
|
+
| Exclude<Placeholders<Extract<E[L], string>>, Expected>,
|
|
78
|
+
] extends [never]
|
|
79
|
+
? never
|
|
80
|
+
: L;
|
|
81
|
+
}[StringKeys<E>];
|
|
82
|
+
|
|
83
|
+
/** Editor-facing error when placeholders drift between locales of one message. */
|
|
84
|
+
export interface PlaceholderMismatch<Key extends string, L extends string> {
|
|
85
|
+
readonly __PLACEHOLDER_MISMATCH__: `Message "${Key}" uses different {placeholders} in locale "${L}" than in its other locales`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Editor-facing error when an override drops or invents a placeholder. */
|
|
89
|
+
export interface OverridePlaceholderMismatch<
|
|
90
|
+
Key extends string,
|
|
91
|
+
L extends string,
|
|
92
|
+
> {
|
|
93
|
+
readonly __OVERRIDE_PLACEHOLDER_MISMATCH__: `Override of "${Key}" in locale "${L}" must use exactly the same {placeholders} as the base message`;
|
|
94
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { StringKeys } from "../types.ts";
|
|
2
|
+
import type { EntryPlaceholders } from "./placeholders.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What may be spelled between the braces — the pair of `NameChar` in
|
|
6
|
+
* `placeholders.ts`, which decides the same thing at the type level.
|
|
7
|
+
*
|
|
8
|
+
* Change one and change the other. They answer the same question from opposite
|
|
9
|
+
* ends, and when they disagreed the type demanded a parameter the runtime never
|
|
10
|
+
* consumed, so the call site filled it in and the brace shipped anyway. Kept as
|
|
11
|
+
* a character class rather than a hand-rolled check precisely so it still reads
|
|
12
|
+
* as the obvious mirror of the union over there.
|
|
13
|
+
*/
|
|
14
|
+
const NAME = /^[a-zA-Z0-9_]+$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Splits a template into text and placeholders, and refuses anything else.
|
|
18
|
+
*
|
|
19
|
+
* A parser rather than one `replace` over `/\{(name)\}/g`, and the difference
|
|
20
|
+
* is what happens to a brace that is *not* a placeholder. The pattern simply
|
|
21
|
+
* did not match one, so `"CSS: {color: red}"` passed through as text — which
|
|
22
|
+
* looks like an escape hatch and is not one. Nothing declared that brace
|
|
23
|
+
* literal; it survived because it happened to contain a space. Rename the
|
|
24
|
+
* example to `{color}` and the same copy starts demanding a parameter.
|
|
25
|
+
*
|
|
26
|
+
* So the escape is explicit — `{{` and `}}`, as `format!` and `str.format` use
|
|
27
|
+
* — and anything else is an error naming the message and telling you to double
|
|
28
|
+
* the brace. Every brace in a template is now a decision someone made rather
|
|
29
|
+
* than a decision made for them by a character class.
|
|
30
|
+
*
|
|
31
|
+
* A lone `}` stays text. Only `{` opens anything, so there is nothing for an
|
|
32
|
+
* unmatched closer to be ambiguous about, and rejecting it would fail copy that
|
|
33
|
+
* is merely writing a bracket.
|
|
34
|
+
*/
|
|
35
|
+
function render(
|
|
36
|
+
template: string,
|
|
37
|
+
at: string,
|
|
38
|
+
value: (name: string, whole: string) => string
|
|
39
|
+
): string {
|
|
40
|
+
let out = "";
|
|
41
|
+
let index = 0;
|
|
42
|
+
|
|
43
|
+
while (index < template.length) {
|
|
44
|
+
const char = template[index] as string;
|
|
45
|
+
|
|
46
|
+
if (char === "{" && template[index + 1] === "{") {
|
|
47
|
+
out += "{";
|
|
48
|
+
index += 2;
|
|
49
|
+
} else if (char === "}" && template[index + 1] === "}") {
|
|
50
|
+
out += "}";
|
|
51
|
+
index += 2;
|
|
52
|
+
} else if (char === "{") {
|
|
53
|
+
const close = template.indexOf("}", index + 1);
|
|
54
|
+
const name = close === -1 ? "" : template.slice(index + 1, close);
|
|
55
|
+
if (close === -1 || !NAME.test(name)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`${at} has a "{" that opens neither a placeholder nor an escape: ${describeBrace(template, index, close)}. A placeholder is {name} — letters, digits and underscores. To write a literal brace, double it: "{{".`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
out += value(name, template.slice(index, close + 1));
|
|
61
|
+
index = close + 1;
|
|
62
|
+
} else {
|
|
63
|
+
out += char;
|
|
64
|
+
index += 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The offending brace, quoted, so the error points at something findable. */
|
|
71
|
+
function describeBrace(template: string, open: number, close: number): string {
|
|
72
|
+
const end = close === -1 ? Math.min(open + 20, template.length) : close + 1;
|
|
73
|
+
return `"${template.slice(open, end)}"`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type MergedCatalog<L extends string> = Readonly<
|
|
77
|
+
Record<string, Readonly<Record<L, string>>>
|
|
78
|
+
>;
|
|
79
|
+
|
|
80
|
+
/** Untyped translation lookup, wrapped below in a key-aware signature. */
|
|
81
|
+
export type RawTranslate = (
|
|
82
|
+
key: string,
|
|
83
|
+
params?: Readonly<Record<string, string>>
|
|
84
|
+
) => string;
|
|
85
|
+
|
|
86
|
+
export interface RawTranslateOptions<L extends string> {
|
|
87
|
+
readonly catalog: MergedCatalog<L>;
|
|
88
|
+
readonly locale: L;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Builds the runtime half of `t()`.
|
|
93
|
+
*
|
|
94
|
+
* Every failure here is a build-time failure by design: a static site should not
|
|
95
|
+
* ship a page with a literal `{name}` in the markup, so a missing message or an
|
|
96
|
+
* unfilled placeholder throws rather than degrading.
|
|
97
|
+
*/
|
|
98
|
+
export function createRawTranslate<L extends string>(
|
|
99
|
+
options: RawTranslateOptions<L>
|
|
100
|
+
): RawTranslate {
|
|
101
|
+
const { catalog, locale } = options;
|
|
102
|
+
|
|
103
|
+
return (key, params) => {
|
|
104
|
+
const entry = catalog[key];
|
|
105
|
+
if (entry === undefined) {
|
|
106
|
+
throw new Error(`Unknown message key "${key}".`);
|
|
107
|
+
}
|
|
108
|
+
const template = entry[locale];
|
|
109
|
+
if (typeof template !== "string") {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Message "${key}" has no copy for locale "${locale}".`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return render(
|
|
115
|
+
template,
|
|
116
|
+
`Message "${key}" (${locale})`,
|
|
117
|
+
(name, whole) => {
|
|
118
|
+
// `Object.hasOwn` rather than reading the index straight, for the
|
|
119
|
+
// reason `familyGuard` uses it: `__proto__` is a legal name by the
|
|
120
|
+
// pattern above, so `params["__proto__"]` finds `Object.prototype`
|
|
121
|
+
// on any plain object, is not `undefined`, and would be substituted
|
|
122
|
+
// into the page as "[object Object]" instead of throwing.
|
|
123
|
+
const value =
|
|
124
|
+
params !== undefined && Object.hasOwn(params, name)
|
|
125
|
+
? params[name]
|
|
126
|
+
: undefined;
|
|
127
|
+
if (value === undefined) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Message "${key}" (${locale}) is missing a value for "${whole}".`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The `{placeholders}` message `K` needs from the caller. */
|
|
139
|
+
export type MessageParamsOf<
|
|
140
|
+
Catalog,
|
|
141
|
+
K extends keyof Catalog,
|
|
142
|
+
> = EntryPlaceholders<Catalog[K]>;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `[key]` for a message with no placeholders, `[key, params]` otherwise.
|
|
146
|
+
*
|
|
147
|
+
* The tuple wrappers around `MessageParamsOf` stop the conditional from
|
|
148
|
+
* distributing, so a union of keys is answered once rather than per member.
|
|
149
|
+
*/
|
|
150
|
+
export type TranslateArgsOf<Catalog, K extends StringKeys<Catalog>> = [
|
|
151
|
+
MessageParamsOf<Catalog, K>,
|
|
152
|
+
] extends [never]
|
|
153
|
+
? [key: K]
|
|
154
|
+
: [key: K, params: Readonly<Record<MessageParamsOf<Catalog, K>, string>>];
|
|
155
|
+
|
|
156
|
+
export type TranslateFor<Catalog> = <K extends StringKeys<Catalog>>(
|
|
157
|
+
...args: TranslateArgsOf<Catalog, K>
|
|
158
|
+
) => string;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Builds the typed `t()` factory for one catalog type.
|
|
162
|
+
*
|
|
163
|
+
* Curried for the usual reason — `Catalog` must be supplied explicitly while the
|
|
164
|
+
* locale stays inferred, and TypeScript has no partial type-argument inference.
|
|
165
|
+
* `Catalog` is the *base* catalog's type (it carries the literal strings the
|
|
166
|
+
* placeholders are read from); the value passed in is the merged runtime
|
|
167
|
+
* catalog, which only needs to be indexable by key and locale.
|
|
168
|
+
*/
|
|
169
|
+
export function createTranslateFactory<Catalog, L extends string>() {
|
|
170
|
+
return function createTranslate(
|
|
171
|
+
catalog: MergedCatalog<L>,
|
|
172
|
+
locale: L
|
|
173
|
+
): TranslateFor<Catalog> {
|
|
174
|
+
const raw = createRawTranslate({ catalog, locale });
|
|
175
|
+
|
|
176
|
+
function t<K extends StringKeys<Catalog>>(
|
|
177
|
+
...args: TranslateArgsOf<Catalog, K>
|
|
178
|
+
): string {
|
|
179
|
+
// The conditional tuple cannot be destructured directly; the runtime
|
|
180
|
+
// shape is always `[key]` or `[key, params]`.
|
|
181
|
+
const [key, params] = args as [
|
|
182
|
+
K,
|
|
183
|
+
Readonly<Record<string, string>>?,
|
|
184
|
+
];
|
|
185
|
+
return raw(key, params);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return t;
|
|
189
|
+
};
|
|
190
|
+
}
|
package/src/image.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a bundled image looks like to this library.
|
|
3
|
+
*
|
|
4
|
+
* Its own module because both the head tags and the structured data need it,
|
|
5
|
+
* and `meta/` already imports from `jsonld/` — leaving it in `meta/` would have
|
|
6
|
+
* the graph depend on the head to describe a picture.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A bundled image asset.
|
|
11
|
+
*
|
|
12
|
+
* Structurally Astro's `ImageMetadata`, so an imported image assigns directly.
|
|
13
|
+
* `src` matters: a bundler rewrites imported images to hashed paths like
|
|
14
|
+
* `/_astro/hero.abc123.png`, which no convention can predict — it must be read
|
|
15
|
+
* off the asset, never derived.
|
|
16
|
+
*/
|
|
17
|
+
export interface ImageAsset {
|
|
18
|
+
readonly src: string;
|
|
19
|
+
/**
|
|
20
|
+
* Required, and always present on a bundler's own image metadata.
|
|
21
|
+
*
|
|
22
|
+
* Crawlers use these to lay out a preview without fetching the image first;
|
|
23
|
+
* omitting them is a slower, flakier preview. An external URL therefore has
|
|
24
|
+
* to state its own size rather than being passed as a bare string.
|
|
25
|
+
*/
|
|
26
|
+
readonly width: number;
|
|
27
|
+
readonly height: number;
|
|
28
|
+
readonly format?: string;
|
|
29
|
+
}
|