@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 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 a slot once
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 to some messages and not others: a
174
- message that declares no slot refuses the argument, and one that does demands
175
- it. Bind the slots when the `rich()` is made instead:
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, { contact: "contact", rooms: "rooms" });
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"); // "…on our [a:contact]Contact page[/a]." needs nothing more
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
- - **A slot left unbound is still required** at the call, and still a compile
189
- error without it — for a single key and for a union of keys alike, so a loop
190
- over answers fails to compile if one answer names a slot nobody bound.
191
- - **A call's own destination wins** over a bound one, should it pass the same
192
- slot; the type does not offer a bound slot to the call, so this only matters
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 the copy.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.97",
3
+ "version": "0.1.99",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -32,6 +32,7 @@ export {
32
32
  html,
33
33
  type LinkResolver,
34
34
  type LinkTarget,
35
+ type MessageBindings,
35
36
  type PlainTextFor,
36
37
  plain,
37
38
  type RichText,
@@ -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
- Bound extends string = never,
210
- > = Readonly<Record<MessageParamsOf<Catalog, K>, string>> &
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>, Bound>, LinkTarget<RouteId>>
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
- Bound extends string = never,
244
+ Links extends string = never,
245
+ Values extends string = never,
227
246
  > = [
228
- MessageParamsOf<Catalog, K> | Exclude<LinkNamesOf<Catalog, K>, Bound>,
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, Bound>];
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. `Bound` names the slots bound
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
- Bound extends string = never,
263
+ Links extends string = never,
264
+ Values extends string = never,
244
265
  > = <K extends StringKeys<Catalog>>(
245
- ...args: RichArgsOf<Catalog, K, RouteId, Bound>
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> = <K extends StringKeys<Catalog>>(
250
- ...args: TranslateArgsOf<Catalog, K>
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
- Bound extends string = never,
293
+ Links extends string = never,
294
+ Values extends string = never,
271
295
  >(
272
- options: RichTextOptions<L> & {
273
- readonly links?: Readonly<Record<Bound, LinkTarget<RouteId>>>;
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, Bound>
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
- links: Readonly<Record<string, string>> | undefined,
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
- // `Object.hasOwn` rather than reading the index straight, for the reason
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(`${at} is missing a value for "${whole}".`);
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<Catalog, L extends string>(
590
- options: RichTextOptions<L>
591
- ): PlainTextFor<Catalog> {
592
- const { catalog, locale } = options;
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" ? " " : fill(span.text, at, params)
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
- return groupHours(hours, format.weekStart).map((group) => ({
252
- days: group.runs
253
- .map(([first, last]) =>
254
- first === last
255
- ? dayName(first)
256
- : `${dayName(first)}${between}${dayName(last)}`
257
- )
258
- .join(and),
259
- weekdays: group.days,
260
- times:
261
- group.hours === "closed"
262
- ? format.closed
263
- : group.hours
264
- .map(
265
- (range) =>
266
- `${time(range.opens)}${between}${time(range.closes)}`
267
- )
268
- .join(and),
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. */
@@ -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 no placeholders, `[key, params]` otherwise.
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<Catalog, K extends StringKeys<Catalog>> = [
174
- MessageParamsOf<Catalog, K>,
175
- ] extends [never]
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
- : [key: K, params: Readonly<Record<MessageParamsOf<Catalog, K>, string>>];
178
-
179
- export type TranslateFor<Catalog> = <K extends StringKeys<Catalog>>(
180
- ...args: TranslateArgsOf<Catalog, K>
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
- ): TranslateFor<Catalog> {
197
- const raw = createRawTranslate({ catalog, locale });
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
- LinkTarget,
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
- /** A locale-bound `t()`. */
168
- translate(locale: L): TranslateFor<Catalog>;
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
- * `links` binds slots once for every message read through it, for a view
183
- * that renders many messages through one call: `{ contact: "contact" }`
184
- * lets any of them write `[a:contact]` with nothing passed per call. The
185
- * targets are checked here, as a call's would be; a slot left unbound is
186
- * still required at the call, and still a compile error without it.
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<const Bound extends string = never>(
195
+ rich<
196
+ const Links extends string = never,
197
+ const Values extends string = never,
198
+ >(
189
199
  locale: L,
190
- links?: Readonly<Record<Bound, LinkTarget<RouteId>>>
191
- ): RichTextFor<Catalog, RouteId, Bound>;
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(locale: L): PlainTextFor<Catalog>;
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;
@@ -9,7 +9,7 @@ import {
9
9
  createPlainText,
10
10
  createRichText,
11
11
  type LinkResolver,
12
- type LinkTarget,
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: (locale) => translateFactory(catalog, locale),
841
- rich: <const Bound extends string = never>(
840
+ translate: <const Values extends string = never>(
842
841
  locale: L,
843
- links?: Readonly<Record<Bound, LinkTarget<RouteId>>>
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, Bound>({
851
+ createRichText<Full, L, RouteId, Links, Values>({
846
852
  catalog,
847
853
  locale,
848
854
  link: richLink(locale),
849
855
  origin: siteUrl,
850
- ...(links === undefined ? {} : { links }),
856
+ ...bind,
851
857
  }),
852
- plain: (locale) =>
853
- createPlainText<Full, L>({
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,