@escape-game-over/atlas 0.1.97 → 0.1.99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/rich-text.md +23 -14
- package/package.json +1 -1
- package/src/content/index.ts +1 -0
- package/src/content/rich.ts +68 -37
- package/src/hours.ts +93 -19
- package/src/i18n/translate.ts +56 -19
- package/src/index.ts +3 -0
- package/src/site/api.ts +25 -12
- package/src/site/create.ts +18 -8
package/docs/rich-text.md
CHANGED
|
@@ -167,33 +167,42 @@ A caller that does can build one — `joinUrl(site.urlFor(id, locale), href)`.
|
|
|
167
167
|
address or number is written once. The number must be in international form —
|
|
168
168
|
`"+30 210 0000000"` — because a local one does not dial from abroad.
|
|
169
169
|
|
|
170
|
-
## Binding
|
|
170
|
+
## Binding slots and values once
|
|
171
171
|
|
|
172
172
|
A view that renders many messages through one call — a FAQ page reading every
|
|
173
|
-
answer in a loop — cannot pass a slot
|
|
174
|
-
|
|
175
|
-
it. Bind
|
|
173
|
+
answer in a loop, a description per route — cannot pass a slot or a value to
|
|
174
|
+
some messages and not others: a message that declares none refuses the
|
|
175
|
+
argument, and one that does demands it. Bind them when the reader is made:
|
|
176
176
|
|
|
177
177
|
```ts
|
|
178
|
-
const rich = site.rich(locale, {
|
|
178
|
+
const rich = site.rich(locale, {
|
|
179
|
+
links: { contact: "contact", rooms: "rooms" },
|
|
180
|
+
values: { hours: "Mon–Fri 10:00–22:00" },
|
|
181
|
+
});
|
|
179
182
|
|
|
180
|
-
rich("faq.hours.a"); // "…
|
|
181
|
-
rich("faq.age.a"); // no slots, still no argument
|
|
183
|
+
rich("faq.hours.a"); // "…open {hours}, see [a:contact]Contact[/a]." needs nothing
|
|
184
|
+
rich("faq.age.a"); // no slots or placeholders, still no argument
|
|
182
185
|
```
|
|
183
186
|
|
|
187
|
+
`site.translate(locale, { values })` and `site.plain(locale, { values })` take
|
|
188
|
+
the same `values`; they have no links to bind.
|
|
189
|
+
|
|
184
190
|
Nothing is loosened:
|
|
185
191
|
|
|
186
192
|
- **A bound destination is checked where it is bound**, against the routes this
|
|
187
193
|
project builds, as a call's would be.
|
|
188
|
-
- **
|
|
189
|
-
error without it — for a single key and for a union of keys alike, so
|
|
190
|
-
over
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
for a key that arrived as a plain `string
|
|
194
|
+
- **Whatever is left unbound is still required** at the call, and still a
|
|
195
|
+
compile error without it — for a single key and for a union of keys alike, so
|
|
196
|
+
a loop over messages fails to compile if one names a slot or placeholder
|
|
197
|
+
nobody bound.
|
|
198
|
+
- **A bound name is not offered to the call.** Passing it again is a compile
|
|
199
|
+
error; for a key that arrived as a plain `string`, the call's own wins.
|
|
200
|
+
- **A bound name nothing uses is fine.** A reader binds what its messages may
|
|
201
|
+
use, not what each one does.
|
|
202
|
+
- **A value is still text.** It cannot open a mark, bound or passed.
|
|
194
203
|
|
|
195
204
|
Copy still names a slot, never an address. Binding moves where the destination
|
|
196
|
-
is written — to the one place that renders the copy — not into
|
|
205
|
+
or value is written — to the one place that renders the copy — not into it.
|
|
197
206
|
|
|
198
207
|
## What the compiler holds in step
|
|
199
208
|
|
package/package.json
CHANGED
package/src/content/index.ts
CHANGED
package/src/content/rich.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
lookupTemplate,
|
|
12
12
|
type MergedCatalog,
|
|
13
13
|
render,
|
|
14
|
+
valueFor,
|
|
14
15
|
} from "../i18n/translate.ts";
|
|
15
16
|
import type { StringKeys } from "../types.ts";
|
|
16
17
|
import {
|
|
@@ -185,6 +186,22 @@ export interface RichTextOptions<L extends string> {
|
|
|
185
186
|
* the type that built this; strings by the time they reach the runtime.
|
|
186
187
|
*/
|
|
187
188
|
readonly links?: Readonly<Record<string, string>>;
|
|
189
|
+
/** Placeholder values bound the same way. */
|
|
190
|
+
readonly values?: Readonly<Record<string, string>>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* What a `rich()` binds when it is made: link destinations and placeholder
|
|
195
|
+
* values that every message read through it may use without the call passing
|
|
196
|
+
* them. `plain()` and `t()` take the `values` half.
|
|
197
|
+
*/
|
|
198
|
+
export interface MessageBindings<
|
|
199
|
+
RouteId extends string,
|
|
200
|
+
Links extends string,
|
|
201
|
+
Values extends string,
|
|
202
|
+
> {
|
|
203
|
+
readonly links?: Readonly<Record<Links, LinkTarget<RouteId>>>;
|
|
204
|
+
readonly values?: Readonly<Record<Values, string>>;
|
|
188
205
|
}
|
|
189
206
|
|
|
190
207
|
/** The link slots message `K` declares, e.g. `"venue"` from `[a:venue]`. */
|
|
@@ -206,10 +223,11 @@ export type RichParamsOf<
|
|
|
206
223
|
Catalog,
|
|
207
224
|
K extends StringKeys<Catalog>,
|
|
208
225
|
RouteId extends string,
|
|
209
|
-
|
|
210
|
-
|
|
226
|
+
Links extends string = never,
|
|
227
|
+
Values extends string = never,
|
|
228
|
+
> = Readonly<Record<Exclude<MessageParamsOf<Catalog, K>, Values>, string>> &
|
|
211
229
|
Readonly<
|
|
212
|
-
Record<Exclude<LinkNamesOf<Catalog, K>,
|
|
230
|
+
Record<Exclude<LinkNamesOf<Catalog, K>, Links>, LinkTarget<RouteId>>
|
|
213
231
|
>;
|
|
214
232
|
|
|
215
233
|
/**
|
|
@@ -223,31 +241,36 @@ export type RichArgsOf<
|
|
|
223
241
|
Catalog,
|
|
224
242
|
K extends StringKeys<Catalog>,
|
|
225
243
|
RouteId extends string,
|
|
226
|
-
|
|
244
|
+
Links extends string = never,
|
|
245
|
+
Values extends string = never,
|
|
227
246
|
> = [
|
|
228
|
-
|
|
247
|
+
| Exclude<MessageParamsOf<Catalog, K>, Values>
|
|
248
|
+
| Exclude<LinkNamesOf<Catalog, K>, Links>,
|
|
229
249
|
] extends [never]
|
|
230
250
|
? [key: K]
|
|
231
|
-
: [key: K, params: RichParamsOf<Catalog, K, RouteId,
|
|
251
|
+
: [key: K, params: RichParamsOf<Catalog, K, RouteId, Links, Values>];
|
|
232
252
|
|
|
233
253
|
/**
|
|
234
254
|
* `rich()`, keyed like `t()` and asking for a little more.
|
|
235
255
|
*
|
|
236
256
|
* Generic over the routes as well as the catalog, which is what lets a link
|
|
237
|
-
* slot be typed. `createSite` supplies both. `
|
|
238
|
-
* when this `rich()` was made, which a call no longer passes.
|
|
257
|
+
* slot be typed. `createSite` supplies both. `Links` and `Values` name what
|
|
258
|
+
* was bound when this `rich()` was made, which a call no longer passes.
|
|
239
259
|
*/
|
|
240
260
|
export type RichTextFor<
|
|
241
261
|
Catalog,
|
|
242
262
|
RouteId extends string,
|
|
243
|
-
|
|
263
|
+
Links extends string = never,
|
|
264
|
+
Values extends string = never,
|
|
244
265
|
> = <K extends StringKeys<Catalog>>(
|
|
245
|
-
...args: RichArgsOf<Catalog, K, RouteId,
|
|
266
|
+
...args: RichArgsOf<Catalog, K, RouteId, Links, Values>
|
|
246
267
|
) => RichText;
|
|
247
268
|
|
|
248
269
|
/** `site.plain()`, keyed and parameterised exactly as `t()` is. */
|
|
249
|
-
export type PlainTextFor<Catalog
|
|
250
|
-
|
|
270
|
+
export type PlainTextFor<Catalog, Values extends string = never> = <
|
|
271
|
+
K extends StringKeys<Catalog>,
|
|
272
|
+
>(
|
|
273
|
+
...args: TranslateArgsOf<Catalog, K, Values>
|
|
251
274
|
) => string;
|
|
252
275
|
|
|
253
276
|
/**
|
|
@@ -267,16 +290,15 @@ export function createRichText<
|
|
|
267
290
|
Catalog,
|
|
268
291
|
L extends string,
|
|
269
292
|
RouteId extends string = string,
|
|
270
|
-
|
|
293
|
+
Links extends string = never,
|
|
294
|
+
Values extends string = never,
|
|
271
295
|
>(
|
|
272
|
-
options: RichTextOptions<L> &
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
): RichTextFor<Catalog, RouteId, Bound> {
|
|
276
|
-
const { catalog, locale, link, origin, links } = options;
|
|
296
|
+
options: RichTextOptions<L> & MessageBindings<RouteId, Links, Values>
|
|
297
|
+
): RichTextFor<Catalog, RouteId, Links, Values> {
|
|
298
|
+
const { catalog, locale, link, origin, links, values } = options;
|
|
277
299
|
|
|
278
300
|
return <K extends StringKeys<Catalog>>(
|
|
279
|
-
...args: RichArgsOf<Catalog, K, RouteId,
|
|
301
|
+
...args: RichArgsOf<Catalog, K, RouteId, Links, Values>
|
|
280
302
|
): RichText => {
|
|
281
303
|
// The conditional tuple cannot be destructured directly; the runtime
|
|
282
304
|
// shape is always `[key]` or `[key, params]`. As in `createTranslate`.
|
|
@@ -287,7 +309,7 @@ export function createRichText<
|
|
|
287
309
|
const template = lookupTemplate(catalog, locale, String(key));
|
|
288
310
|
|
|
289
311
|
return parseMarks(template, at).map((span) =>
|
|
290
|
-
resolve(span, at, params, links, link, origin)
|
|
312
|
+
resolve(span, at, params, { links, values }, link, origin)
|
|
291
313
|
);
|
|
292
314
|
};
|
|
293
315
|
}
|
|
@@ -296,12 +318,15 @@ function resolve(
|
|
|
296
318
|
span: ParsedSpan,
|
|
297
319
|
at: string,
|
|
298
320
|
params: Readonly<Record<string, string>> | undefined,
|
|
299
|
-
|
|
321
|
+
bound: {
|
|
322
|
+
readonly links: Readonly<Record<string, string>> | undefined;
|
|
323
|
+
readonly values: Readonly<Record<string, string>> | undefined;
|
|
324
|
+
},
|
|
300
325
|
link: LinkResolver,
|
|
301
326
|
origin: HttpsUrl
|
|
302
327
|
): Span {
|
|
303
328
|
if (span.kind === "break") return span;
|
|
304
|
-
const text = fill(span.text, at, params);
|
|
329
|
+
const text = fill(span.text, at, params, bound.values);
|
|
305
330
|
|
|
306
331
|
switch (span.kind) {
|
|
307
332
|
case "text":
|
|
@@ -312,7 +337,7 @@ function resolve(
|
|
|
312
337
|
case "link":
|
|
313
338
|
return resolveLink(
|
|
314
339
|
text,
|
|
315
|
-
targetFor(span.name, at, params, links),
|
|
340
|
+
targetFor(span.name, at, params, bound.links),
|
|
316
341
|
at,
|
|
317
342
|
link,
|
|
318
343
|
origin
|
|
@@ -336,17 +361,15 @@ function resolve(
|
|
|
336
361
|
function fill(
|
|
337
362
|
template: string,
|
|
338
363
|
at: string,
|
|
339
|
-
params: Readonly<Record<string, string>> | undefined
|
|
364
|
+
params: Readonly<Record<string, string>> | undefined,
|
|
365
|
+
values: Readonly<Record<string, string>> | undefined
|
|
340
366
|
): string {
|
|
341
367
|
return render(template, at, (name, whole) => {
|
|
342
|
-
|
|
343
|
-
// `createRawTranslate` uses it — see there.
|
|
344
|
-
const value =
|
|
345
|
-
params !== undefined && Object.hasOwn(params, name)
|
|
346
|
-
? params[name]
|
|
347
|
-
: undefined;
|
|
368
|
+
const value = valueFor(name, params, values);
|
|
348
369
|
if (value === undefined) {
|
|
349
|
-
throw new Error(
|
|
370
|
+
throw new Error(
|
|
371
|
+
`${at} is missing a value for "${whole}". Pass it, or bind it: { values: { ${name}: "…" } }.`
|
|
372
|
+
);
|
|
350
373
|
}
|
|
351
374
|
return value;
|
|
352
375
|
});
|
|
@@ -586,13 +609,19 @@ function collapse(parts: readonly string[]): string {
|
|
|
586
609
|
* `RichArgsOf` is `rich()`'s and `TranslateArgsOf` is this one's: what a caller
|
|
587
610
|
* is asked for follows from what comes out.
|
|
588
611
|
*/
|
|
589
|
-
export function createPlainText<
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
612
|
+
export function createPlainText<
|
|
613
|
+
Catalog,
|
|
614
|
+
L extends string,
|
|
615
|
+
Values extends string = never,
|
|
616
|
+
>(
|
|
617
|
+
options: RichTextOptions<L> & {
|
|
618
|
+
readonly values?: Readonly<Record<Values, string>>;
|
|
619
|
+
}
|
|
620
|
+
): PlainTextFor<Catalog, Values> {
|
|
621
|
+
const { catalog, locale, values } = options;
|
|
593
622
|
|
|
594
623
|
return <K extends StringKeys<Catalog>>(
|
|
595
|
-
...args: TranslateArgsOf<Catalog, K>
|
|
624
|
+
...args: TranslateArgsOf<Catalog, K, Values>
|
|
596
625
|
): string => {
|
|
597
626
|
const [key, params] = args as [K, Readonly<Record<string, string>>?];
|
|
598
627
|
const at = `Message "${String(key)}" (${locale})`;
|
|
@@ -600,7 +629,9 @@ export function createPlainText<Catalog, L extends string>(
|
|
|
600
629
|
|
|
601
630
|
return collapse(
|
|
602
631
|
parseMarks(template, at).map((span) =>
|
|
603
|
-
span.kind === "break"
|
|
632
|
+
span.kind === "break"
|
|
633
|
+
? " "
|
|
634
|
+
: fill(span.text, at, params, values)
|
|
604
635
|
)
|
|
605
636
|
);
|
|
606
637
|
};
|
package/src/hours.ts
CHANGED
|
@@ -217,6 +217,71 @@ export function formatHours(
|
|
|
217
217
|
): readonly FormattedHours[] {
|
|
218
218
|
const between = format.between ?? "–";
|
|
219
219
|
const and = format.and ?? ", ";
|
|
220
|
+
const { dayName, time } = printer(format);
|
|
221
|
+
|
|
222
|
+
return groupHours(hours, format.weekStart).map((group) => ({
|
|
223
|
+
days: group.runs.map((run) => runName(run, dayName, between)).join(and),
|
|
224
|
+
weekdays: group.days,
|
|
225
|
+
times: timesOf(group, format.closed, time, between, and),
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* How to say a week in a sentence: `HoursFormat` without the separators it
|
|
231
|
+
* picks from `Intl`, plus the one `Intl` has no word for.
|
|
232
|
+
*/
|
|
233
|
+
export interface HoursSentenceFormat extends Omit<HoursFormat, "and"> {
|
|
234
|
+
/**
|
|
235
|
+
* Between one group of days and the next: `"; "` in English, `"؛ "` in
|
|
236
|
+
* Arabic. Copy, so the caller translates it; `Intl` has no list form
|
|
237
|
+
* for it.
|
|
238
|
+
*/
|
|
239
|
+
readonly separator: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The week as one sentence, for copy that quotes it: `Monday–Thursday and
|
|
244
|
+
* Sunday 14:00–23:30; Friday–Saturday 14:00–00:45`.
|
|
245
|
+
*
|
|
246
|
+
* Days and times join with the locale's own "and" (`Intl.ListFormat`), so a
|
|
247
|
+
* sentence in Arabic reads `الاثنين–الخميس والأحد` rather than borrowing an
|
|
248
|
+
* English comma. No closing full stop: the copy around it decides that.
|
|
249
|
+
*/
|
|
250
|
+
export function hoursSentence(
|
|
251
|
+
hours: WeeklyHours,
|
|
252
|
+
format: HoursSentenceFormat
|
|
253
|
+
): string {
|
|
254
|
+
const between = format.between ?? "–";
|
|
255
|
+
const { dayName, time } = printer(format);
|
|
256
|
+
const list = new Intl.ListFormat(format.locale, {
|
|
257
|
+
type: "conjunction",
|
|
258
|
+
style: "long",
|
|
259
|
+
});
|
|
260
|
+
const joined = (parts: readonly string[]) => list.format(parts);
|
|
261
|
+
|
|
262
|
+
return groupHours(hours, format.weekStart)
|
|
263
|
+
.map((group) => {
|
|
264
|
+
const days = joined(
|
|
265
|
+
group.runs.map((run) => runName(run, dayName, between))
|
|
266
|
+
);
|
|
267
|
+
const times =
|
|
268
|
+
group.hours === "closed"
|
|
269
|
+
? format.closed
|
|
270
|
+
: joined(
|
|
271
|
+
group.hours.map(
|
|
272
|
+
(range) =>
|
|
273
|
+
`${time(range.opens)}${between}${time(range.closes)}`
|
|
274
|
+
)
|
|
275
|
+
);
|
|
276
|
+
return `${days} ${times}`;
|
|
277
|
+
})
|
|
278
|
+
.join(format.separator);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Day names and times as `format` prints them. */
|
|
282
|
+
function printer(
|
|
283
|
+
format: Pick<HoursFormat, "locale" | "weekday" | "hourCycle">
|
|
284
|
+
) {
|
|
220
285
|
// 2024-01-01 was a Monday, so `WEEK[i]` falls on the 1st plus `i`.
|
|
221
286
|
const names = new Intl.DateTimeFormat(format.locale, {
|
|
222
287
|
weekday: format.weekday ?? "short",
|
|
@@ -247,26 +312,35 @@ export function formatHours(
|
|
|
247
312
|
)
|
|
248
313
|
)
|
|
249
314
|
);
|
|
315
|
+
return { dayName, time };
|
|
316
|
+
}
|
|
250
317
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
318
|
+
/** A run of one day prints as one name — `Sun`, not `Sun–Sun`. */
|
|
319
|
+
function runName(
|
|
320
|
+
[first, last]: HoursGroup["runs"][number],
|
|
321
|
+
dayName: (day: Weekday) => string,
|
|
322
|
+
between: string
|
|
323
|
+
): string {
|
|
324
|
+
return first === last
|
|
325
|
+
? dayName(first)
|
|
326
|
+
: `${dayName(first)}${between}${dayName(last)}`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function timesOf(
|
|
330
|
+
group: HoursGroup,
|
|
331
|
+
closed: string,
|
|
332
|
+
time: (at: TimeOfDay) => string,
|
|
333
|
+
between: string,
|
|
334
|
+
and: string
|
|
335
|
+
): string {
|
|
336
|
+
return group.hours === "closed"
|
|
337
|
+
? closed
|
|
338
|
+
: group.hours
|
|
339
|
+
.map(
|
|
340
|
+
(range) =>
|
|
341
|
+
`${time(range.opens)}${between}${time(range.closes)}`
|
|
342
|
+
)
|
|
343
|
+
.join(and);
|
|
270
344
|
}
|
|
271
345
|
|
|
272
346
|
/** Minutes since midnight, for comparing two times. */
|
package/src/i18n/translate.ts
CHANGED
|
@@ -87,6 +87,12 @@ export type RawTranslate = (
|
|
|
87
87
|
export interface RawTranslateOptions<L extends string> {
|
|
88
88
|
readonly catalog: MergedCatalog<L>;
|
|
89
89
|
readonly locale: L;
|
|
90
|
+
/**
|
|
91
|
+
* Values bound once, for every message this `t()` reads: a placeholder
|
|
92
|
+
* named here is filled without the call passing it. Checked by the type
|
|
93
|
+
* that built this; strings by the time they reach the runtime.
|
|
94
|
+
*/
|
|
95
|
+
readonly values?: Readonly<Record<string, string>>;
|
|
90
96
|
}
|
|
91
97
|
|
|
92
98
|
/**
|
|
@@ -129,7 +135,7 @@ export function lookupTemplate<L extends string>(
|
|
|
129
135
|
export function createRawTranslate<L extends string>(
|
|
130
136
|
options: RawTranslateOptions<L>
|
|
131
137
|
): RawTranslate {
|
|
132
|
-
const { catalog, locale } = options;
|
|
138
|
+
const { catalog, locale, values } = options;
|
|
133
139
|
|
|
134
140
|
return (key, params) => {
|
|
135
141
|
const template = lookupTemplate(catalog, locale, key);
|
|
@@ -143,13 +149,10 @@ export function createRawTranslate<L extends string>(
|
|
|
143
149
|
// pattern above, so `params["__proto__"]` finds `Object.prototype`
|
|
144
150
|
// on any plain object, is not `undefined`, and would be substituted
|
|
145
151
|
// into the page as "[object Object]" instead of throwing.
|
|
146
|
-
const value =
|
|
147
|
-
params !== undefined && Object.hasOwn(params, name)
|
|
148
|
-
? params[name]
|
|
149
|
-
: undefined;
|
|
152
|
+
const value = valueFor(name, params, values);
|
|
150
153
|
if (value === undefined) {
|
|
151
154
|
throw new Error(
|
|
152
|
-
`Message "${key}" (${locale}) is missing a value for "${whole}".`
|
|
155
|
+
`Message "${key}" (${locale}) is missing a value for "${whole}". Pass it, or bind it: site.translate(locale, { values: { ${name}: "…" } }).`
|
|
153
156
|
);
|
|
154
157
|
}
|
|
155
158
|
return value;
|
|
@@ -158,6 +161,24 @@ export function createRawTranslate<L extends string>(
|
|
|
158
161
|
};
|
|
159
162
|
}
|
|
160
163
|
|
|
164
|
+
/**
|
|
165
|
+
* A placeholder's value: the call's own first, then one bound for every
|
|
166
|
+
* message. `undefined` when neither has it, for the caller to report.
|
|
167
|
+
*/
|
|
168
|
+
export function valueFor(
|
|
169
|
+
name: string,
|
|
170
|
+
params: Readonly<Record<string, string>> | undefined,
|
|
171
|
+
values: Readonly<Record<string, string>> | undefined
|
|
172
|
+
): string | undefined {
|
|
173
|
+
if (params !== undefined && Object.hasOwn(params, name)) {
|
|
174
|
+
return params[name];
|
|
175
|
+
}
|
|
176
|
+
if (values !== undefined && Object.hasOwn(values, name)) {
|
|
177
|
+
return values[name];
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
161
182
|
/** The `{placeholders}` message `K` needs from the caller. */
|
|
162
183
|
export type MessageParamsOf<
|
|
163
184
|
Catalog,
|
|
@@ -165,19 +186,30 @@ export type MessageParamsOf<
|
|
|
165
186
|
> = EntryPlaceholders<Catalog[K]>;
|
|
166
187
|
|
|
167
188
|
/**
|
|
168
|
-
* `[key]` for a message with
|
|
189
|
+
* `[key]` for a message with nothing left to pass, `[key, params]` otherwise.
|
|
190
|
+
* `Bound` names the placeholders bound when the `t()` was made, which a call
|
|
191
|
+
* no longer passes.
|
|
169
192
|
*
|
|
170
193
|
* The tuple wrappers around `MessageParamsOf` stop the conditional from
|
|
171
194
|
* distributing, so a union of keys is answered once rather than per member.
|
|
172
195
|
*/
|
|
173
|
-
export type TranslateArgsOf<
|
|
174
|
-
|
|
175
|
-
|
|
196
|
+
export type TranslateArgsOf<
|
|
197
|
+
Catalog,
|
|
198
|
+
K extends StringKeys<Catalog>,
|
|
199
|
+
Bound extends string = never,
|
|
200
|
+
> = [Exclude<MessageParamsOf<Catalog, K>, Bound>] extends [never]
|
|
176
201
|
? [key: K]
|
|
177
|
-
: [
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
202
|
+
: [
|
|
203
|
+
key: K,
|
|
204
|
+
params: Readonly<
|
|
205
|
+
Record<Exclude<MessageParamsOf<Catalog, K>, Bound>, string>
|
|
206
|
+
>,
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
export type TranslateFor<Catalog, Bound extends string = never> = <
|
|
210
|
+
K extends StringKeys<Catalog>,
|
|
211
|
+
>(
|
|
212
|
+
...args: TranslateArgsOf<Catalog, K, Bound>
|
|
181
213
|
) => string;
|
|
182
214
|
|
|
183
215
|
/**
|
|
@@ -190,14 +222,19 @@ export type TranslateFor<Catalog> = <K extends StringKeys<Catalog>>(
|
|
|
190
222
|
* catalog, which only needs to be indexable by key and locale.
|
|
191
223
|
*/
|
|
192
224
|
export function createTranslateFactory<Catalog, L extends string>() {
|
|
193
|
-
return function createTranslate(
|
|
225
|
+
return function createTranslate<Bound extends string = never>(
|
|
194
226
|
catalog: MergedCatalog<L>,
|
|
195
|
-
locale: L
|
|
196
|
-
|
|
197
|
-
|
|
227
|
+
locale: L,
|
|
228
|
+
values?: Readonly<Record<Bound, string>>
|
|
229
|
+
): TranslateFor<Catalog, Bound> {
|
|
230
|
+
const raw = createRawTranslate({
|
|
231
|
+
catalog,
|
|
232
|
+
locale,
|
|
233
|
+
...(values === undefined ? {} : { values }),
|
|
234
|
+
});
|
|
198
235
|
|
|
199
236
|
function t<K extends StringKeys<Catalog>>(
|
|
200
|
-
...args: TranslateArgsOf<Catalog, K>
|
|
237
|
+
...args: TranslateArgsOf<Catalog, K, Bound>
|
|
201
238
|
): string {
|
|
202
239
|
// The conditional tuple cannot be destructured directly; the runtime
|
|
203
240
|
// shape is always `[key]` or `[key, params]`.
|
package/src/index.ts
CHANGED
|
@@ -100,6 +100,7 @@ export {
|
|
|
100
100
|
export {
|
|
101
101
|
html,
|
|
102
102
|
type LinkTarget,
|
|
103
|
+
type MessageBindings,
|
|
103
104
|
type PlainTextFor,
|
|
104
105
|
plain,
|
|
105
106
|
type RichText,
|
|
@@ -122,6 +123,8 @@ export {
|
|
|
122
123
|
groupHours,
|
|
123
124
|
type HoursFormat,
|
|
124
125
|
type HoursGroup,
|
|
126
|
+
type HoursSentenceFormat,
|
|
127
|
+
hoursSentence,
|
|
125
128
|
type OpenStatus,
|
|
126
129
|
openStatus,
|
|
127
130
|
type TimeOfDay,
|
package/src/site/api.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ResolvedLocaleMeta } from "../config.ts";
|
|
2
2
|
import type {
|
|
3
|
-
|
|
3
|
+
MessageBindings,
|
|
4
4
|
PlainTextFor,
|
|
5
5
|
RichTextFor,
|
|
6
6
|
} from "../content/index.ts";
|
|
@@ -164,8 +164,14 @@ export interface Site<
|
|
|
164
164
|
*/
|
|
165
165
|
readonly routes: readonly RouteId[];
|
|
166
166
|
|
|
167
|
-
/**
|
|
168
|
-
|
|
167
|
+
/**
|
|
168
|
+
* A locale-bound `t()`. `bind.values` fills those placeholders for every
|
|
169
|
+
* message, as `rich()`'s does; the rest are still the call's to pass.
|
|
170
|
+
*/
|
|
171
|
+
translate<const Values extends string = never>(
|
|
172
|
+
locale: L,
|
|
173
|
+
bind?: { readonly values?: Readonly<Record<Values, string>> }
|
|
174
|
+
): TranslateFor<Catalog, Values>;
|
|
169
175
|
|
|
170
176
|
/**
|
|
171
177
|
* A locale-bound `rich()`: the same catalog, read as styled runs.
|
|
@@ -179,16 +185,20 @@ export interface Site<
|
|
|
179
185
|
* inside the copy resolves to *this* language's path, and a `rich()` that
|
|
180
186
|
* took the locale late would be a second place to get that wrong.
|
|
181
187
|
*
|
|
182
|
-
* `
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
188
|
+
* `bind` fixes link destinations and placeholder values once, for every
|
|
189
|
+
* message read through it — for a view that renders many messages through
|
|
190
|
+
* one call: `{ links: { contact: "contact" }, values: { hours } }` lets any
|
|
191
|
+
* of them write `[a:contact]` or `{hours}` with nothing passed per call.
|
|
192
|
+
* Bound targets are checked here, as a call's would be; whatever is left
|
|
193
|
+
* unbound is still required at the call, and a compile error without it.
|
|
187
194
|
*/
|
|
188
|
-
rich<
|
|
195
|
+
rich<
|
|
196
|
+
const Links extends string = never,
|
|
197
|
+
const Values extends string = never,
|
|
198
|
+
>(
|
|
189
199
|
locale: L,
|
|
190
|
-
|
|
191
|
-
): RichTextFor<Catalog, RouteId,
|
|
200
|
+
bind?: MessageBindings<RouteId, Links, Values>
|
|
201
|
+
): RichTextFor<Catalog, RouteId, Links, Values>;
|
|
192
202
|
|
|
193
203
|
/**
|
|
194
204
|
* A locale-bound `plain()`: the same catalog, read as words alone.
|
|
@@ -204,7 +214,10 @@ export interface Site<
|
|
|
204
214
|
* does not break the day someone emphasises a clause in the sentence it
|
|
205
215
|
* quotes.
|
|
206
216
|
*/
|
|
207
|
-
plain
|
|
217
|
+
plain<const Values extends string = never>(
|
|
218
|
+
locale: L,
|
|
219
|
+
bind?: { readonly values?: Readonly<Record<Values, string>> }
|
|
220
|
+
): PlainTextFor<Catalog, Values>;
|
|
208
221
|
|
|
209
222
|
/** Absolute path of a route in one locale, e.g. `/el/sxetika-me-emas`. */
|
|
210
223
|
pathFor(id: RouteId, locale: L, options?: LinkOptions): UrlPath;
|
package/src/site/create.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
createPlainText,
|
|
10
10
|
createRichText,
|
|
11
11
|
type LinkResolver,
|
|
12
|
-
type
|
|
12
|
+
type MessageBindings,
|
|
13
13
|
} from "../content/index.ts";
|
|
14
14
|
import type { GeneratedFile } from "../file.ts";
|
|
15
15
|
import type { PublicFilePath } from "../files.ts";
|
|
@@ -837,24 +837,34 @@ export function createSite<
|
|
|
837
837
|
fonts,
|
|
838
838
|
routes: enabledRouteIds,
|
|
839
839
|
|
|
840
|
-
translate:
|
|
841
|
-
rich: <const Bound extends string = never>(
|
|
840
|
+
translate: <const Values extends string = never>(
|
|
842
841
|
locale: L,
|
|
843
|
-
|
|
842
|
+
bind?: { readonly values?: Readonly<Record<Values, string>> }
|
|
843
|
+
) => translateFactory<Values>(catalog, locale, bind?.values),
|
|
844
|
+
rich: <
|
|
845
|
+
const Links extends string = never,
|
|
846
|
+
const Values extends string = never,
|
|
847
|
+
>(
|
|
848
|
+
locale: L,
|
|
849
|
+
bind?: MessageBindings<RouteId, Links, Values>
|
|
844
850
|
) =>
|
|
845
|
-
createRichText<Full, L, RouteId,
|
|
851
|
+
createRichText<Full, L, RouteId, Links, Values>({
|
|
846
852
|
catalog,
|
|
847
853
|
locale,
|
|
848
854
|
link: richLink(locale),
|
|
849
855
|
origin: siteUrl,
|
|
850
|
-
...
|
|
856
|
+
...bind,
|
|
851
857
|
}),
|
|
852
|
-
plain: (
|
|
853
|
-
|
|
858
|
+
plain: <const Values extends string = never>(
|
|
859
|
+
locale: L,
|
|
860
|
+
bind?: { readonly values?: Readonly<Record<Values, string>> }
|
|
861
|
+
) =>
|
|
862
|
+
createPlainText<Full, L, Values>({
|
|
854
863
|
catalog,
|
|
855
864
|
locale,
|
|
856
865
|
link: richLink(locale),
|
|
857
866
|
origin: siteUrl,
|
|
867
|
+
...bind,
|
|
858
868
|
}),
|
|
859
869
|
|
|
860
870
|
pathFor,
|