@escape-game-over/atlas 0.1.96 → 0.1.97

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,6 +167,34 @@ 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
171
+
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:
176
+
177
+ ```ts
178
+ const rich = site.rich(locale, { contact: "contact", rooms: "rooms" });
179
+
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
182
+ ```
183
+
184
+ Nothing is loosened:
185
+
186
+ - **A bound destination is checked where it is bound**, against the routes this
187
+ 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
+
195
+ 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.
197
+
170
198
  ## What the compiler holds in step
171
199
 
172
200
  Which marks a message uses is checked across locales by the same machinery that
@@ -266,7 +294,8 @@ class nor attrs still renders its words, and warns at build.
266
294
  | --------------------------------------------------- | --------------------------------------------------------------------- |
267
295
  | A locale dropping `[a:venue]` | compile error — the marks must match across a message |
268
296
  | `[a:venue]` in one locale, `[a:booking]` in another | compile error — the argument is part of the token |
269
- | `rich()` without a slot's target | compile error, then a build error naming the slot |
297
+ | `rich()` without a slot's target, unbound | compile error, then a build error naming the slot |
298
+ | A slot bound to an unbuilt route | compile error where it is bound |
270
299
  | A slot filled with an unbuilt route | compile error at the call site |
271
300
  | `[Mon-Fri]` | build error, listing the marks and the `[[` escape |
272
301
  | `[b]` never closed, `[/b]` never opened | build error naming the message and locale |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.96",
3
+ "version": "0.1.97",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -179,6 +179,12 @@ export interface RichTextOptions<L extends string> {
179
179
  * on a bad one. `joinUrl` already answers it once.
180
180
  */
181
181
  readonly origin: HttpsUrl;
182
+ /**
183
+ * Destinations bound once, for every message this `rich()` reads: a slot
184
+ * named here is filled without the call passing it. Already checked by
185
+ * the type that built this; strings by the time they reach the runtime.
186
+ */
187
+ readonly links?: Readonly<Record<string, string>>;
182
188
  }
183
189
 
184
190
  /** The link slots message `K` declares, e.g. `"venue"` from `[a:venue]`. */
@@ -200,8 +206,11 @@ export type RichParamsOf<
200
206
  Catalog,
201
207
  K extends StringKeys<Catalog>,
202
208
  RouteId extends string,
209
+ Bound extends string = never,
203
210
  > = Readonly<Record<MessageParamsOf<Catalog, K>, string>> &
204
- Readonly<Record<LinkNamesOf<Catalog, K>, LinkTarget<RouteId>>>;
211
+ Readonly<
212
+ Record<Exclude<LinkNamesOf<Catalog, K>, Bound>, LinkTarget<RouteId>>
213
+ >;
205
214
 
206
215
  /**
207
216
  * `[key]` for a message that needs nothing, `[key, params]` otherwise.
@@ -214,20 +223,26 @@ export type RichArgsOf<
214
223
  Catalog,
215
224
  K extends StringKeys<Catalog>,
216
225
  RouteId extends string,
217
- > = [MessageParamsOf<Catalog, K> | LinkNamesOf<Catalog, K>] extends [never]
226
+ Bound extends string = never,
227
+ > = [
228
+ MessageParamsOf<Catalog, K> | Exclude<LinkNamesOf<Catalog, K>, Bound>,
229
+ ] extends [never]
218
230
  ? [key: K]
219
- : [key: K, params: RichParamsOf<Catalog, K, RouteId>];
231
+ : [key: K, params: RichParamsOf<Catalog, K, RouteId, Bound>];
220
232
 
221
233
  /**
222
234
  * `rich()`, keyed like `t()` and asking for a little more.
223
235
  *
224
236
  * Generic over the routes as well as the catalog, which is what lets a link
225
- * slot be typed. `createSite` supplies both.
237
+ * slot be typed. `createSite` supplies both. `Bound` names the slots bound
238
+ * when this `rich()` was made, which a call no longer passes.
226
239
  */
227
- export type RichTextFor<Catalog, RouteId extends string> = <
228
- K extends StringKeys<Catalog>,
229
- >(
230
- ...args: RichArgsOf<Catalog, K, RouteId>
240
+ export type RichTextFor<
241
+ Catalog,
242
+ RouteId extends string,
243
+ Bound extends string = never,
244
+ > = <K extends StringKeys<Catalog>>(
245
+ ...args: RichArgsOf<Catalog, K, RouteId, Bound>
231
246
  ) => RichText;
232
247
 
233
248
  /** `site.plain()`, keyed and parameterised exactly as `t()` is. */
@@ -252,11 +267,16 @@ export function createRichText<
252
267
  Catalog,
253
268
  L extends string,
254
269
  RouteId extends string = string,
255
- >(options: RichTextOptions<L>): RichTextFor<Catalog, RouteId> {
256
- const { catalog, locale, link, origin } = options;
270
+ Bound extends string = never,
271
+ >(
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;
257
277
 
258
278
  return <K extends StringKeys<Catalog>>(
259
- ...args: RichArgsOf<Catalog, K, RouteId>
279
+ ...args: RichArgsOf<Catalog, K, RouteId, Bound>
260
280
  ): RichText => {
261
281
  // The conditional tuple cannot be destructured directly; the runtime
262
282
  // shape is always `[key]` or `[key, params]`. As in `createTranslate`.
@@ -267,7 +287,7 @@ export function createRichText<
267
287
  const template = lookupTemplate(catalog, locale, String(key));
268
288
 
269
289
  return parseMarks(template, at).map((span) =>
270
- resolve(span, at, params, link, origin)
290
+ resolve(span, at, params, links, link, origin)
271
291
  );
272
292
  };
273
293
  }
@@ -276,6 +296,7 @@ function resolve(
276
296
  span: ParsedSpan,
277
297
  at: string,
278
298
  params: Readonly<Record<string, string>> | undefined,
299
+ links: Readonly<Record<string, string>> | undefined,
279
300
  link: LinkResolver,
280
301
  origin: HttpsUrl
281
302
  ): Span {
@@ -291,7 +312,7 @@ function resolve(
291
312
  case "link":
292
313
  return resolveLink(
293
314
  text,
294
- targetFor(span.name, at, params),
315
+ targetFor(span.name, at, params, links),
295
316
  at,
296
317
  link,
297
318
  origin
@@ -346,15 +367,22 @@ function fill(
346
367
  function targetFor(
347
368
  name: string,
348
369
  at: string,
349
- params: Readonly<Record<string, string>> | undefined
370
+ params: Readonly<Record<string, string>> | undefined,
371
+ links: Readonly<Record<string, string>> | undefined
350
372
  ): string {
351
- const target =
352
- params !== undefined && Object.hasOwn(params, name)
353
- ? params[name]
354
- : undefined;
373
+ // The call's own destination first, then one bound for every message.
374
+ const target = (() => {
375
+ if (params !== undefined && Object.hasOwn(params, name)) {
376
+ return params[name];
377
+ }
378
+ if (links !== undefined && Object.hasOwn(links, name)) {
379
+ return links[name];
380
+ }
381
+ return undefined;
382
+ })();
355
383
  if (target === undefined) {
356
384
  throw new Error(
357
- `${at} has no destination for the link slot "[a:${name}]". Pass one: rich(key, { ${name}: "some-route-id" }).`
385
+ `${at} has no destination for the link slot "[a:${name}]". Pass one: rich(key, { ${name}: "some-route-id" }), or bind it: site.rich(locale, { ${name}: "some-route-id" }).`
358
386
  );
359
387
  }
360
388
  return target;
package/src/site/api.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { ResolvedLocaleMeta } from "../config.ts";
2
- import type { PlainTextFor, RichTextFor } from "../content/index.ts";
2
+ import type {
3
+ LinkTarget,
4
+ PlainTextFor,
5
+ RichTextFor,
6
+ } from "../content/index.ts";
3
7
  import type { GeneratedFile } from "../file.ts";
4
8
  import type { PublicFilePath } from "../files.ts";
5
9
  import type { Fonts } from "../fonts.ts";
@@ -174,8 +178,17 @@ export interface Site<
174
178
  * Bound to a locale here rather than taking one per call, because a link
175
179
  * inside the copy resolves to *this* language's path, and a `rich()` that
176
180
  * took the locale late would be a second place to get that wrong.
181
+ *
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.
177
187
  */
178
- rich(locale: L): RichTextFor<Catalog, RouteId>;
188
+ rich<const Bound extends string = never>(
189
+ locale: L,
190
+ links?: Readonly<Record<Bound, LinkTarget<RouteId>>>
191
+ ): RichTextFor<Catalog, RouteId, Bound>;
179
192
 
180
193
  /**
181
194
  * A locale-bound `plain()`: the same catalog, read as words alone.
@@ -9,6 +9,7 @@ import {
9
9
  createPlainText,
10
10
  createRichText,
11
11
  type LinkResolver,
12
+ type LinkTarget,
12
13
  } from "../content/index.ts";
13
14
  import type { GeneratedFile } from "../file.ts";
14
15
  import type { PublicFilePath } from "../files.ts";
@@ -837,12 +838,16 @@ export function createSite<
837
838
  routes: enabledRouteIds,
838
839
 
839
840
  translate: (locale) => translateFactory(catalog, locale),
840
- rich: (locale) =>
841
- createRichText<Full, L, RouteId>({
841
+ rich: <const Bound extends string = never>(
842
+ locale: L,
843
+ links?: Readonly<Record<Bound, LinkTarget<RouteId>>>
844
+ ) =>
845
+ createRichText<Full, L, RouteId, Bound>({
842
846
  catalog,
843
847
  locale,
844
848
  link: richLink(locale),
845
849
  origin: siteUrl,
850
+ ...(links === undefined ? {} : { links }),
846
851
  }),
847
852
  plain: (locale) =>
848
853
  createPlainText<Full, L>({