@escape-game-over/atlas 0.1.5 → 0.1.6

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.
@@ -0,0 +1,550 @@
1
+ import {
2
+ e164Of,
3
+ isEmailAddress,
4
+ type MailtoUrl,
5
+ mailtoHref,
6
+ type TelUrl,
7
+ } from "../contact.ts";
8
+ import type { TextOf } from "../i18n/placeholders.ts";
9
+ import type { MessageParamsOf, TranslateArgsOf } from "../i18n/translate.ts";
10
+ import {
11
+ lookupTemplate,
12
+ type MergedCatalog,
13
+ render,
14
+ } from "../i18n/translate.ts";
15
+ import type { StringKeys } from "../types.ts";
16
+ import {
17
+ type Hash,
18
+ type HttpsUrl,
19
+ isHash,
20
+ isHttpsUrl,
21
+ isUrlPath,
22
+ joinUrl,
23
+ type UrlPath,
24
+ } from "../url.ts";
25
+ import { type LinkNames, type ParsedSpan, parseMarks } from "./marks.ts";
26
+
27
+ /**
28
+ * Copy resolved into styled runs: what a renderer is handed, and the only shape
29
+ * it ever sees.
30
+ *
31
+ * Every string here is finished — translated, placeholders filled — and every
32
+ * link is a URL rather than something that still needs a route table. That is
33
+ * the seam: lib owns which runs exist and what they say, the project owns what
34
+ * they look like. A renderer is a `switch` over `kind` and nothing else, so
35
+ * adding a run type is a compile error at every renderer rather than a silent
36
+ * gap. The shape this replaces answered an unknown kind with a runtime `throw`
37
+ * and a `default: return null`, which is the same mistake made twice.
38
+ */
39
+ export type Span =
40
+ | { readonly kind: "text"; readonly text: string }
41
+ /** `<strong>`. Emphasis a screen reader announces, not a font weight. */
42
+ | { readonly kind: "bold"; readonly text: string }
43
+ /**
44
+ * A run the project styles, named by the copy and mapped by the renderer.
45
+ *
46
+ * The escape hatch that keeps presentation out of lib, and out of content.
47
+ * The data this replaced wrote a CSS value into every run — 419 copies of
48
+ * one brand variable, 144 of `white`, and a dozen stragglers spelling the
49
+ * same red four different ways. Those are two *roles* wearing a colour's
50
+ * clothes, and a role is what a variant names: `accent`, `inverse`, `small`.
51
+ * The renderer holds one map from those to classes, so restyling is one
52
+ * edit rather than a sweep through fifty-four deployments' copy.
53
+ */
54
+ | {
55
+ readonly kind: "styled";
56
+ readonly text: string;
57
+ readonly variant: string;
58
+ }
59
+ /**
60
+ * A link, in the three shapes a destination actually comes in.
61
+ *
62
+ * **A union rather than an optional `url` and an `external` flag**, for the
63
+ * reason `PageKind` is one: those two fields could disagree, and `external:
64
+ * true` with no absolute URL is a state that means nothing and would still
65
+ * type-check. Here the discriminant *is* the answer — a consumer that needs
66
+ * an absolute URL narrows on `to` and gets one, or is told plainly that this
67
+ * destination has none and why.
68
+ *
69
+ * Both forms are carried where both exist, for the reason `Crumb` carries
70
+ * both: a link in the page wants the path, because an absolute one sends a
71
+ * developer from localhost to the production host mid-click, while
72
+ * structured data and anything else without a document around it requires
73
+ * the absolute URL.
74
+ */
75
+ | {
76
+ readonly kind: "link";
77
+ readonly text: string;
78
+ /** A page on this site. Root-relative, and absolute alongside it. */
79
+ readonly to: "internal";
80
+ readonly href: UrlPath;
81
+ readonly url: HttpsUrl;
82
+ }
83
+ | {
84
+ readonly kind: "link";
85
+ readonly text: string;
86
+ /** Somewhere else. Already absolute, so both forms are one string. */
87
+ readonly to: "external";
88
+ readonly href: HttpsUrl;
89
+ readonly url: HttpsUrl;
90
+ }
91
+ | {
92
+ readonly kind: "link";
93
+ readonly text: string;
94
+ /**
95
+ * An element of whatever page this is rendered on.
96
+ *
97
+ * **No `url`, and that is not a gap.** `rich()` is given a locale, not
98
+ * a route, so it does not know the page a fragment belongs to — and an
99
+ * absolute URL invented here would point somewhere real and wrong. A
100
+ * caller that has the page can build one: `joinUrl(site.urlFor(id,
101
+ * locale), href)`.
102
+ */
103
+ readonly to: "anchor";
104
+ readonly href: Hash;
105
+ }
106
+ | {
107
+ readonly kind: "email";
108
+ readonly text: string;
109
+ readonly href: MailtoUrl;
110
+ }
111
+ | {
112
+ readonly kind: "phone";
113
+ readonly text: string;
114
+ readonly href: TelUrl;
115
+ }
116
+ | { readonly kind: "break" };
117
+
118
+ /** A paragraph, as runs. */
119
+ export type RichText = readonly Span[];
120
+
121
+ /**
122
+ * What a `[a:slot]` may be filled with, told apart by its first character.
123
+ *
124
+ * Four things, one field, because they are already distinguishable and a tagged
125
+ * object would make the call site state twice what it says once. `#` scrolls,
126
+ * `/` is a path on this origin, `https://` leaves the site, and anything else is
127
+ * a route id — which is the form to prefer, because only that one survives a
128
+ * slug being retranslated or a page moving. Same rule as `redirects()`: a target
129
+ * names a route, not a URL.
130
+ *
131
+ * **This is why the destination is an argument and not part of the copy.**
132
+ * `RouteId` here is the caller's union of routes the project actually builds, so
133
+ * `{ venue: "contct" }` is a compile error on the line that wrote it — and a
134
+ * route the project has switched off is refused just as flatly. Spelled into a
135
+ * translation instead, it was an unchecked string until the day that message
136
+ * rendered, repeated once per language, and sitting in a file whose reviewers
137
+ * are translators rather than developers.
138
+ */
139
+ export type LinkTarget<RouteId extends string> =
140
+ | RouteId
141
+ /**
142
+ * A route id and a fragment on it: `"challenges#booking"`.
143
+ *
144
+ * Here because a bare `#booking` means *this* page, so without it there was
145
+ * no way to point at a section of another one without dropping to a raw
146
+ * path — and a raw path is the one form the route table cannot check. The
147
+ * fragment is appended by `pathFor`, the same way `LinkOptions.hash` is
148
+ * everywhere else, so it lands after the page number and the query rather
149
+ * than being concatenated on by this module.
150
+ */
151
+ | `${RouteId}#${string}`
152
+ | UrlPath
153
+ | HttpsUrl
154
+ | Hash;
155
+
156
+ /**
157
+ * Resolves a route id, and any fragment on it, to this locale's path.
158
+ *
159
+ * Supplied by `createSite`. `hash` comes without its `#`, matching
160
+ * `LinkOptions.hash`, which is what ultimately consumes it.
161
+ */
162
+ export type LinkResolver = (
163
+ routeId: string,
164
+ at: string,
165
+ hash?: string
166
+ ) => UrlPath;
167
+
168
+ export interface RichTextOptions<L extends string> {
169
+ readonly catalog: MergedCatalog<L>;
170
+ readonly locale: L;
171
+ readonly link: LinkResolver;
172
+ /**
173
+ * This deployment's origin, for the absolute half of a link.
174
+ *
175
+ * Taken here rather than left to each consumer to prepend, because a
176
+ * consumer that joins an origin to a path by hand is the one that produces
177
+ * `https://example.com/about` on a good day and `https://example.com//about`
178
+ * on a bad one. `joinUrl` already answers it once.
179
+ */
180
+ readonly origin: HttpsUrl;
181
+ }
182
+
183
+ /** The link slots message `K` declares, e.g. `"venue"` from `[a:venue]`. */
184
+ export type LinkNamesOf<Catalog, K extends keyof Catalog> = LinkNames<
185
+ TextOf<Catalog[K]>
186
+ >;
187
+
188
+ /**
189
+ * What `rich()` asks for: a string per `{placeholder}`, a destination per
190
+ * `[a:slot]`.
191
+ *
192
+ * One object with two kinds of value in it, rather than two parameters, because
193
+ * a call site does not think of them differently — both are things the sentence
194
+ * needs and the catalog cannot know. The types differ where it matters:
195
+ * a placeholder takes any string, and a slot takes a `LinkTarget`, so a route
196
+ * this project does not build is a compile error at the line that wrote it.
197
+ */
198
+ export type RichParamsOf<
199
+ Catalog,
200
+ K extends StringKeys<Catalog>,
201
+ RouteId extends string,
202
+ > = Readonly<Record<MessageParamsOf<Catalog, K>, string>> &
203
+ Readonly<Record<LinkNamesOf<Catalog, K>, LinkTarget<RouteId>>>;
204
+
205
+ /**
206
+ * `[key]` for a message that needs nothing, `[key, params]` otherwise.
207
+ *
208
+ * The mirror of `TranslateArgsOf`, extended with the slots — and the tuple
209
+ * wrappers do the same job there: they stop the conditional distributing, so a
210
+ * union of keys is answered once rather than per member.
211
+ */
212
+ export type RichArgsOf<
213
+ Catalog,
214
+ K extends StringKeys<Catalog>,
215
+ RouteId extends string,
216
+ > = [MessageParamsOf<Catalog, K> | LinkNamesOf<Catalog, K>] extends [never]
217
+ ? [key: K]
218
+ : [key: K, params: RichParamsOf<Catalog, K, RouteId>];
219
+
220
+ /**
221
+ * `rich()`, keyed like `t()` and asking for a little more.
222
+ *
223
+ * Generic over the routes as well as the catalog, which is what lets a link
224
+ * slot be typed. `createSite` supplies both.
225
+ */
226
+ export type RichTextFor<Catalog, RouteId extends string> = <
227
+ K extends StringKeys<Catalog>,
228
+ >(
229
+ ...args: RichArgsOf<Catalog, K, RouteId>
230
+ ) => RichText;
231
+
232
+ /** `plain()`, keyed and parameterised exactly as `t()` is. */
233
+ export type PlainTextFor<Catalog> = <K extends StringKeys<Catalog>>(
234
+ ...args: TranslateArgsOf<Catalog, K>
235
+ ) => string;
236
+
237
+ /**
238
+ * Builds the runtime half of `rich()`.
239
+ *
240
+ * **Marks are parsed before placeholders are substituted, and that order is
241
+ * load-bearing.** A value arriving from config — a venue name, a price, a phone
242
+ * number — is data, not copy, and data that could open a mark would be a
243
+ * project injecting styling into its own sentences by accident. Parsing first
244
+ * means `{phone}` can hold anything at all and still lands as text.
245
+ *
246
+ * It also means a mark can wrap a placeholder, which is the case that motivated
247
+ * it: `"Call us on [tel]{phone}[/tel]"` keeps the number in config where it
248
+ * belongs and still gets a `tel:` link, without copy ever spelling the digits.
249
+ */
250
+ export function createRichText<
251
+ Catalog,
252
+ L extends string,
253
+ RouteId extends string = string,
254
+ >(options: RichTextOptions<L>): RichTextFor<Catalog, RouteId> {
255
+ const { catalog, locale, link, origin } = options;
256
+
257
+ return <K extends StringKeys<Catalog>>(
258
+ ...args: RichArgsOf<Catalog, K, RouteId>
259
+ ): RichText => {
260
+ // The conditional tuple cannot be destructured directly; the runtime
261
+ // shape is always `[key]` or `[key, params]`. As in `createTranslate`.
262
+ // Placeholder values and link targets are both strings once here — the
263
+ // distinction they are worth making is entirely a compile-time one.
264
+ const [key, params] = args as [K, Readonly<Record<string, string>>?];
265
+ const at = `Message "${String(key)}" (${locale})`;
266
+ const template = lookupTemplate(catalog, locale, String(key));
267
+
268
+ return parseMarks(template, at).map((span) =>
269
+ resolve(span, at, params, link, origin)
270
+ );
271
+ };
272
+ }
273
+
274
+ function resolve(
275
+ span: ParsedSpan,
276
+ at: string,
277
+ params: Readonly<Record<string, string>> | undefined,
278
+ link: LinkResolver,
279
+ origin: HttpsUrl
280
+ ): Span {
281
+ if (span.kind === "break") return span;
282
+ const text = fill(span.text, at, params);
283
+
284
+ switch (span.kind) {
285
+ case "text":
286
+ case "bold":
287
+ return { kind: span.kind, text };
288
+ case "styled":
289
+ return { kind: "styled", text, variant: span.variant };
290
+ case "link":
291
+ return resolveLink(
292
+ text,
293
+ targetFor(span.name, at, params),
294
+ at,
295
+ link,
296
+ origin
297
+ );
298
+ case "email":
299
+ return { kind: "email", text, href: mailto(text, at) };
300
+ case "phone":
301
+ return { kind: "phone", text, href: tel(text, at) };
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Substitutes `{placeholders}` into one run's text.
307
+ *
308
+ * Per run rather than over the whole template, which is what makes a mark able
309
+ * to wrap a placeholder: the marks are already gone by the time this sees the
310
+ * text, so a value can hold anything at all — including a `[` — and still lands
311
+ * as words. Shared by `rich()` and `plain()` so both fill a message the same
312
+ * way, and both refuse an unfilled brace rather than shipping it to a page.
313
+ */
314
+ function fill(
315
+ template: string,
316
+ at: string,
317
+ params: Readonly<Record<string, string>> | undefined
318
+ ): string {
319
+ return render(template, at, (name, whole) => {
320
+ // `Object.hasOwn` rather than reading the index straight, for the reason
321
+ // `createRawTranslate` uses it — see there.
322
+ const value =
323
+ params !== undefined && Object.hasOwn(params, name)
324
+ ? params[name]
325
+ : undefined;
326
+ if (value === undefined) {
327
+ throw new Error(`${at} is missing a value for "${whole}".`);
328
+ }
329
+ return value;
330
+ });
331
+ }
332
+
333
+ /**
334
+ * The destination a slot was filled with.
335
+ *
336
+ * `Object.hasOwn` rather than reading the index straight, for the reason
337
+ * `createRawTranslate` uses it: `__proto__` is a legal slot name by `SLOT`, so
338
+ * a plain object would answer with `Object.prototype` — not `undefined`, and so
339
+ * not caught below.
340
+ *
341
+ * The type already demands this, and it is checked anyway for the case the type
342
+ * cannot see: a key that arrived as a plain `string`. Same division of labour
343
+ * as an unfilled `{placeholder}`.
344
+ */
345
+ function targetFor(
346
+ name: string,
347
+ at: string,
348
+ params: Readonly<Record<string, string>> | undefined
349
+ ): string {
350
+ const target =
351
+ params !== undefined && Object.hasOwn(params, name)
352
+ ? params[name]
353
+ : undefined;
354
+ if (target === undefined) {
355
+ throw new Error(
356
+ `${at} has no destination for the link slot "[a:${name}]". Pass one: rich(key, { ${name}: "some-route-id" }).`
357
+ );
358
+ }
359
+ return target;
360
+ }
361
+
362
+ function resolveLink(
363
+ text: string,
364
+ target: string,
365
+ at: string,
366
+ link: LinkResolver,
367
+ origin: HttpsUrl
368
+ ): Span {
369
+ if (isHash(target)) {
370
+ return { kind: "link", text, to: "anchor", href: target };
371
+ }
372
+ // `external` by the shape it was written in rather than by comparing hosts:
373
+ // a project may well link to a sibling deployment on its own domain, and
374
+ // that is still leaving this site — a different build, a different bundle,
375
+ // and a `target`/`rel` decision the renderer should get to make.
376
+ if (isHttpsUrl(target)) {
377
+ return {
378
+ kind: "link",
379
+ text,
380
+ to: "external",
381
+ href: target,
382
+ url: target,
383
+ };
384
+ }
385
+ // Refused rather than passed through, for the reason `absoluteUrl` refuses
386
+ // it: an `http://` link is a downgrade browsers increasingly decline to
387
+ // follow, and it looks exactly like a working one until someone clicks.
388
+ if (target.startsWith("http://")) {
389
+ throw new Error(
390
+ `${at} links to "${target}". An http:// link is a downgrade — write it as https://.`
391
+ );
392
+ }
393
+ // A raw path is taken as written, fragment and all — it is the escape hatch
394
+ // for a URL this project does not own a route for, and lib has nothing to
395
+ // check it against.
396
+ if (isUrlPath(target)) {
397
+ return {
398
+ kind: "link",
399
+ text,
400
+ to: "internal",
401
+ href: target,
402
+ url: joinUrl(origin, target),
403
+ };
404
+ }
405
+
406
+ // Everything left is a route id, optionally with a fragment on it. Split at
407
+ // the first `#`: a fragment cannot contain one, so anything after the second
408
+ // would be part of the fragment rather than a second cut.
409
+ const cut = target.indexOf("#");
410
+ const routeId = cut === -1 ? target : target.slice(0, cut);
411
+ const hash = cut === -1 ? undefined : target.slice(cut + 1);
412
+
413
+ // `"contact#"` is a link to the top of a page written as though it were a
414
+ // link to something on it. Refused rather than resolved to a bare `#`,
415
+ // which is what the copy would have got and not what it asked for.
416
+ if (hash === "") {
417
+ throw new Error(
418
+ `${at} links to "${target}", which names a fragment and then does not say which. Drop the "#" to link to the page.`
419
+ );
420
+ }
421
+
422
+ const path = link(routeId, at, hash);
423
+ return {
424
+ kind: "link",
425
+ text,
426
+ to: "internal",
427
+ href: path,
428
+ url: joinUrl(origin, path),
429
+ };
430
+ }
431
+
432
+ /**
433
+ * The `mailto:` for an address written in copy.
434
+ *
435
+ * `isEmailAddress` and `mailtoHref` rather than a check and a template here:
436
+ * what counts as an address, and what scheme it gets, are already decided in
437
+ * `contact.ts` — and a second opinion about either is a second opinion that
438
+ * eventually differs. The guard is what lets `mailtoHref` be called at all: an
439
+ * address arriving out of a message is a `string`, and casting past that would
440
+ * assert exactly the thing worth testing.
441
+ *
442
+ * The failure it catches is the one nobody notices, because the link still
443
+ * works: a `mailto:` with nothing in the To: field opens a mail client, and the
444
+ * message goes nowhere.
445
+ */
446
+ function mailto(text: string, at: string): MailtoUrl {
447
+ if (!isEmailAddress(text)) {
448
+ throw new Error(
449
+ `${at} marks "${text}" as an email address, and it has no "@".`
450
+ );
451
+ }
452
+ return mailtoHref(text);
453
+ }
454
+
455
+ /**
456
+ * The `tel:` for a number written in copy.
457
+ *
458
+ * `e164Of` holds the rule — only punctuation goes, no leading zero is touched —
459
+ * beside `e164`, which answers the same question for a structured
460
+ * `PhoneNumber`. See it for why that zero is not lib's to strip.
461
+ *
462
+ * What stays here is the wording of the failure, because only this side knows
463
+ * the number came out of a *message* and can say which one to go and fix.
464
+ */
465
+ function tel(text: string, at: string): TelUrl {
466
+ const dialled = e164Of(text);
467
+ if (dialled === undefined) {
468
+ throw new Error(
469
+ `${at} marks "${text}" as a phone number, and it is not in international form. Write it with a country code, e.g. "+30 210 0000000" — a local number does not dial from abroad.`
470
+ );
471
+ }
472
+ return `tel:${dialled}`;
473
+ }
474
+
475
+ /**
476
+ * The words alone, for everywhere that takes a string rather than markup.
477
+ *
478
+ * The reason this whole module sits in lib rather than in each project. A meta
479
+ * description, an `llms.txt` summary and a structured-data `description` all
480
+ * want the same sentence the page renders, and every one of them takes a plain
481
+ * string — so without this each project flattens the runs by hand, and they
482
+ * drift. The implementation this replaces did exactly that: a chain of `if`s
483
+ * per kind, `return ""` for the ones it could not render, and a `throw` on
484
+ * anything it had not been taught.
485
+ *
486
+ * A `[br]` becomes a space, because that is what it is once the markup is gone,
487
+ * and runs of whitespace collapse — copy split across a line break otherwise
488
+ * arrives with a double space in the middle of a `<meta>` tag.
489
+ */
490
+ export function plain(rich: RichText): string {
491
+ return collapse(
492
+ rich.map((span) => (span.kind === "break" ? " " : span.text))
493
+ );
494
+ }
495
+
496
+ /**
497
+ * Runs of whitespace become one, and the ends are trimmed.
498
+ *
499
+ * Copy split across a `[br]` otherwise arrives with a double space in the
500
+ * middle of a `<meta>` tag, which is the sort of thing nobody notices until it
501
+ * is quoted back in a search result.
502
+ */
503
+ function collapse(parts: readonly string[]): string {
504
+ return parts.join("").replace(/\s+/g, " ").trim();
505
+ }
506
+
507
+ /**
508
+ * The same, straight from a message key — `plain(rich(…))` without the nesting.
509
+ *
510
+ * Which sounds like sugar and is mostly about where it gets used. The callers
511
+ * that want words rather than runs are the ones furthest from a renderer:
512
+ * `llms()`'s `describe`, a `<meta name="description">`, a structured-data
513
+ * `description`. Those are already assembling several strings at once, and
514
+ * `plain(rich("about.intro", { company }))` reads as two operations there when
515
+ * it is one question — what does this message say.
516
+ *
517
+ * It also answers that question for a message with no marks at all, which `t()`
518
+ * would too. That overlap is deliberate: a description built this way keeps
519
+ * working on the day someone adds emphasis to the sentence, where `t()` would
520
+ * start throwing. Reach for `t()` when the copy is structurally plain and
521
+ * should stay that way — a button label, an `aria-label` — and for this when
522
+ * the answer is prose.
523
+ *
524
+ * **It asks for `{placeholders}` and not for link destinations**, which is why
525
+ * it resolves the message itself rather than calling `rich()` and flattening.
526
+ * A `tel:` this discards is not worth building, and a route id it discards is
527
+ * not worth demanding — `llms.txt` would otherwise have to name a destination
528
+ * for every link in every description in order to throw them all away. So
529
+ * `RichArgsOf` is `rich()`'s and `TranslateArgsOf` is this one's: what a caller
530
+ * is asked for follows from what comes out.
531
+ */
532
+ export function createPlainText<Catalog, L extends string>(
533
+ options: RichTextOptions<L>
534
+ ): PlainTextFor<Catalog> {
535
+ const { catalog, locale } = options;
536
+
537
+ return <K extends StringKeys<Catalog>>(
538
+ ...args: TranslateArgsOf<Catalog, K>
539
+ ): string => {
540
+ const [key, params] = args as [K, Readonly<Record<string, string>>?];
541
+ const at = `Message "${String(key)}" (${locale})`;
542
+ const template = lookupTemplate(catalog, locale, String(key));
543
+
544
+ return collapse(
545
+ parseMarks(template, at).map((span) =>
546
+ span.kind === "break" ? " " : fill(span.text, at, params)
547
+ )
548
+ );
549
+ };
550
+ }
@@ -1,7 +1,7 @@
1
1
  import type { LocalesOf, SiteConfigShape } from "../config.ts";
2
2
  import type { IsNever, NoExcessKeys, StringKeys } from "../types.ts";
3
3
  import type {
4
- EntryPlaceholders,
4
+ EntryTokens,
5
5
  MismatchedLocales,
6
6
  OverridePlaceholderMismatch,
7
7
  PlaceholderMismatch,
@@ -58,13 +58,13 @@ export type LocalesOfCatalog<Catalog> = StringKeys<
58
58
  Catalog[StringKeys<Catalog>]
59
59
  >;
60
60
 
61
- type SelfConsistent<E, K> = [
62
- MismatchedLocales<E, EntryPlaceholders<E>>,
63
- ] extends [never]
61
+ type SelfConsistent<E, K> = [MismatchedLocales<E, EntryTokens<E>>] extends [
62
+ never,
63
+ ]
64
64
  ? unknown
65
65
  : PlaceholderMismatch<
66
66
  K & string,
67
- MismatchedLocales<E, EntryPlaceholders<E>> & string
67
+ MismatchedLocales<E, EntryTokens<E>> & string
68
68
  >;
69
69
 
70
70
  type ValidateBase<T> = { [K in keyof T]: SelfConsistent<T[K], K> };
@@ -88,7 +88,8 @@ type NoExtraLocales<T, L extends string> = {
88
88
  *
89
89
  * Enforced at compile time:
90
90
  * - every key defines every locale the site ships, and no others;
91
- * - every locale of a key uses exactly the same `{placeholders}`.
91
+ * - every locale of a key uses exactly the same `{placeholders}` and `[marks]`,
92
+ * so a translation cannot drop the link or the emphasis the others carry.
92
93
  */
93
94
  export function defineMessages<
94
95
  const C extends SiteConfigShape,
@@ -124,11 +125,11 @@ export type ValidateOverrideCatalog<
124
125
 
125
126
  type ValidateOverrides<T, Base> = {
126
127
  [K in keyof T]: K extends keyof Base
127
- ? [MismatchedLocales<T[K], EntryPlaceholders<Base[K]>>] extends [never]
128
+ ? [MismatchedLocales<T[K], EntryTokens<Base[K]>>] extends [never]
128
129
  ? unknown
129
130
  : OverridePlaceholderMismatch<
130
131
  K & string,
131
- MismatchedLocales<T[K], EntryPlaceholders<Base[K]>> & string
132
+ MismatchedLocales<T[K], EntryTokens<Base[K]>> & string
132
133
  >
133
134
  : unknown;
134
135
  };
@@ -142,8 +143,8 @@ type ValidateOverrides<T, Base> = {
142
143
  * Enforced at compile time:
143
144
  * - the key must exist in the base catalog, so a typo is rejected rather than
144
145
  * 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.
146
+ * - the replacement must use exactly the `{placeholders}` and `[marks]` of the
147
+ * base message, so every existing `t()` and `rich()` call site stays correct.
147
148
  */
148
149
  export function defineMessageOverrides<
149
150
  const C extends SiteConfigShape,
@@ -3,6 +3,7 @@
3
3
  * data of any kind lives in `lib/`.
4
4
  */
5
5
 
6
+ import type { MarkTokens } from "../content/marks.ts";
6
7
  import type { Digit, Letter, StringKeys } from "../types.ts";
7
8
 
8
9
  /**
@@ -22,8 +23,15 @@ import type { Digit, Letter, StringKeys } from "../types.ts";
22
23
  */
23
24
  type NameChar = Letter | Lowercase<Letter> | Digit | "_";
24
25
 
25
- /** Whether every character of `S` may appear in a placeholder name. */
26
- type IsName<S extends string> = S extends `${infer Head}${infer Tail}`
26
+ /**
27
+ * Whether every character of `S` may appear in a placeholder name.
28
+ *
29
+ * Exported for `LinkNames` in `content/marks.ts`, which asks the same question
30
+ * about a `[a:slot]`: both become keys the call site writes in one object, so
31
+ * "what may a name be spelled with" has to have one answer, not two that agree
32
+ * until someone widens one of them.
33
+ */
34
+ export type IsName<S extends string> = S extends `${infer Head}${infer Tail}`
27
35
  ? Head extends NameChar
28
36
  ? Tail extends ""
29
37
  ? true
@@ -66,29 +74,48 @@ export type TextOf<E> = Extract<E[keyof E], string>;
66
74
  export type EntryPlaceholders<E> = Placeholders<TextOf<E>>;
67
75
 
68
76
  /**
69
- * The locales of `E` whose placeholder set differs from `Expected`.
77
+ * Everything structural in a template: its `{placeholders}` and its `[marks]`.
78
+ *
79
+ * The two are one question — what does this message *require*, over and above
80
+ * its words — and they are checked together because they fail together. A
81
+ * translation that drops `{name}` renders a literal brace; one that drops
82
+ * `[a:venue]` renders a sentence with no link in it. Both are a translator
83
+ * having edited around the structure rather than inside it.
84
+ *
85
+ * Only the consistency check reads this. `MessageParamsOf` stays on
86
+ * `EntryPlaceholders`, because marks are spelled in the copy and are never
87
+ * arguments a call site passes.
88
+ */
89
+ export type Tokens<S extends string> = Placeholders<S> | MarkTokens<S>;
90
+
91
+ /** Every placeholder and mark used by any locale of an entry. */
92
+ export type EntryTokens<E> = Tokens<TextOf<E>>;
93
+
94
+ /**
95
+ * The locales of `E` whose placeholders or marks differ from `Expected`.
70
96
  *
71
97
  * This is what turns a half-updated translation into a compile error instead of
72
- * a literal `{name}` rendered on a production page.
98
+ * a literal `{name}` rendered on a production page — or, since marks joined it,
99
+ * a Greek paragraph that lost the link the English one carries.
73
100
  */
74
101
  export type MismatchedLocales<E, Expected extends string> = {
75
102
  [L in StringKeys<E>]: [
76
- | Exclude<Expected, Placeholders<Extract<E[L], string>>>
77
- | Exclude<Placeholders<Extract<E[L], string>>, Expected>,
103
+ | Exclude<Expected, Tokens<Extract<E[L], string>>>
104
+ | Exclude<Tokens<Extract<E[L], string>>, Expected>,
78
105
  ] extends [never]
79
106
  ? never
80
107
  : L;
81
108
  }[StringKeys<E>];
82
109
 
83
- /** Editor-facing error when placeholders drift between locales of one message. */
110
+ /** Editor-facing error when structure drifts between locales of one message. */
84
111
  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`;
112
+ readonly __PLACEHOLDER_MISMATCH__: `Message "${Key}" uses different {placeholders} or [marks] in locale "${L}" than in its other locales`;
86
113
  }
87
114
 
88
- /** Editor-facing error when an override drops or invents a placeholder. */
115
+ /** Editor-facing error when an override drops or invents one. */
89
116
  export interface OverridePlaceholderMismatch<
90
117
  Key extends string,
91
118
  L extends string,
92
119
  > {
93
- readonly __OVERRIDE_PLACEHOLDER_MISMATCH__: `Override of "${Key}" in locale "${L}" must use exactly the same {placeholders} as the base message`;
120
+ readonly __OVERRIDE_PLACEHOLDER_MISMATCH__: `Override of "${Key}" in locale "${L}" must use exactly the same {placeholders} and [marks] as the base message`;
94
121
  }