@escape-game-over/atlas 0.1.4 → 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,314 @@
1
+ /**
2
+ * Every country a form can offer, as ISO 3166-1 alpha-2.
3
+ *
4
+ * Codes rather than names, which is the point on a multilingual site:
5
+ * `countriesFor` turns each one into the reader's own word for it and sorts
6
+ * them the way that language sorts. A list of English names would put "Czech
7
+ * Republic" in front of a Romanian reader and file it under C.
8
+ *
9
+ * The code is also what a form should submit, so an enquiry from Germany files
10
+ * the same whether it was picked as "Deutschland" or "Germany".
11
+ *
12
+ * Derived from ICU's region list rather than typed out: every two-letter code
13
+ * it answers to, less the withdrawn and transitional reservations it still
14
+ * knows (`SU`, `YU`, `DD`, `ZR`, `AN`, `UK`…) and the aggregates that are not
15
+ * countries (`EU`, `UN`, `QO`…). 250 entries: the 249 ISO assigns, plus `XK`
16
+ * for Kosovo, which is user-assigned and in common use.
17
+ *
18
+ * **This is a list, not a policy.** It says which codes are well-formed, not
19
+ * which countries a business will trade with — that is a per-project answer and
20
+ * belongs in the project, by filtering this.
21
+ */
22
+ export const COUNTRY_CODES = [
23
+ "AD",
24
+ "AE",
25
+ "AF",
26
+ "AG",
27
+ "AI",
28
+ "AL",
29
+ "AM",
30
+ "AO",
31
+ "AQ",
32
+ "AR",
33
+ "AS",
34
+ "AT",
35
+ "AU",
36
+ "AW",
37
+ "AX",
38
+ "AZ",
39
+ "BA",
40
+ "BB",
41
+ "BD",
42
+ "BE",
43
+ "BF",
44
+ "BG",
45
+ "BH",
46
+ "BI",
47
+ "BJ",
48
+ "BL",
49
+ "BM",
50
+ "BN",
51
+ "BO",
52
+ "BQ",
53
+ "BR",
54
+ "BS",
55
+ "BT",
56
+ "BV",
57
+ "BW",
58
+ "BY",
59
+ "BZ",
60
+ "CA",
61
+ "CC",
62
+ "CD",
63
+ "CF",
64
+ "CG",
65
+ "CH",
66
+ "CI",
67
+ "CK",
68
+ "CL",
69
+ "CM",
70
+ "CN",
71
+ "CO",
72
+ "CR",
73
+ "CU",
74
+ "CV",
75
+ "CW",
76
+ "CX",
77
+ "CY",
78
+ "CZ",
79
+ "DE",
80
+ "DJ",
81
+ "DK",
82
+ "DM",
83
+ "DO",
84
+ "DZ",
85
+ "EC",
86
+ "EE",
87
+ "EG",
88
+ "EH",
89
+ "ER",
90
+ "ES",
91
+ "ET",
92
+ "FI",
93
+ "FJ",
94
+ "FK",
95
+ "FM",
96
+ "FO",
97
+ "FR",
98
+ "GA",
99
+ "GB",
100
+ "GD",
101
+ "GE",
102
+ "GF",
103
+ "GG",
104
+ "GH",
105
+ "GI",
106
+ "GL",
107
+ "GM",
108
+ "GN",
109
+ "GP",
110
+ "GQ",
111
+ "GR",
112
+ "GS",
113
+ "GT",
114
+ "GU",
115
+ "GW",
116
+ "GY",
117
+ "HK",
118
+ "HM",
119
+ "HN",
120
+ "HR",
121
+ "HT",
122
+ "HU",
123
+ "ID",
124
+ "IE",
125
+ "IL",
126
+ "IM",
127
+ "IN",
128
+ "IO",
129
+ "IQ",
130
+ "IR",
131
+ "IS",
132
+ "IT",
133
+ "JE",
134
+ "JM",
135
+ "JO",
136
+ "JP",
137
+ "KE",
138
+ "KG",
139
+ "KH",
140
+ "KI",
141
+ "KM",
142
+ "KN",
143
+ "KP",
144
+ "KR",
145
+ "KW",
146
+ "KY",
147
+ "KZ",
148
+ "LA",
149
+ "LB",
150
+ "LC",
151
+ "LI",
152
+ "LK",
153
+ "LR",
154
+ "LS",
155
+ "LT",
156
+ "LU",
157
+ "LV",
158
+ "LY",
159
+ "MA",
160
+ "MC",
161
+ "MD",
162
+ "ME",
163
+ "MF",
164
+ "MG",
165
+ "MH",
166
+ "MK",
167
+ "ML",
168
+ "MM",
169
+ "MN",
170
+ "MO",
171
+ "MP",
172
+ "MQ",
173
+ "MR",
174
+ "MS",
175
+ "MT",
176
+ "MU",
177
+ "MV",
178
+ "MW",
179
+ "MX",
180
+ "MY",
181
+ "MZ",
182
+ "NA",
183
+ "NC",
184
+ "NE",
185
+ "NF",
186
+ "NG",
187
+ "NI",
188
+ "NL",
189
+ "NO",
190
+ "NP",
191
+ "NR",
192
+ "NU",
193
+ "NZ",
194
+ "OM",
195
+ "PA",
196
+ "PE",
197
+ "PF",
198
+ "PG",
199
+ "PH",
200
+ "PK",
201
+ "PL",
202
+ "PM",
203
+ "PN",
204
+ "PR",
205
+ "PS",
206
+ "PT",
207
+ "PW",
208
+ "PY",
209
+ "QA",
210
+ "RE",
211
+ "RO",
212
+ "RS",
213
+ "RU",
214
+ "RW",
215
+ "SA",
216
+ "SB",
217
+ "SC",
218
+ "SD",
219
+ "SE",
220
+ "SG",
221
+ "SH",
222
+ "SI",
223
+ "SJ",
224
+ "SK",
225
+ "SL",
226
+ "SM",
227
+ "SN",
228
+ "SO",
229
+ "SR",
230
+ "SS",
231
+ "ST",
232
+ "SV",
233
+ "SX",
234
+ "SY",
235
+ "SZ",
236
+ "TC",
237
+ "TD",
238
+ "TF",
239
+ "TG",
240
+ "TH",
241
+ "TJ",
242
+ "TK",
243
+ "TL",
244
+ "TM",
245
+ "TN",
246
+ "TO",
247
+ "TR",
248
+ "TT",
249
+ "TV",
250
+ "TW",
251
+ "TZ",
252
+ "UA",
253
+ "UG",
254
+ "UM",
255
+ "US",
256
+ "UY",
257
+ "UZ",
258
+ "VA",
259
+ "VC",
260
+ "VE",
261
+ "VG",
262
+ "VI",
263
+ "VN",
264
+ "VU",
265
+ "WF",
266
+ "WS",
267
+ "XK",
268
+ "YE",
269
+ "YT",
270
+ "ZA",
271
+ "ZM",
272
+ "ZW",
273
+ ] as const;
274
+
275
+ /**
276
+ * An ISO 3166-1 alpha-2 country code: `"IT"`, `"GR"`, `"RO"`.
277
+ *
278
+ * Read off the list above rather than described as a shape. `${Letter}${Letter}`
279
+ * admits all 676 two-letter strings, of which some 400 are not countries, so it
280
+ * accepted `"XX"` and every typo that happened to be two capitals. This accepts
281
+ * the 250 that exist and nothing else — including refusing the withdrawn codes
282
+ * (`SU`, `YU`, `AN`), which look plausible and are the ones worth catching.
283
+ *
284
+ * A name — `"Italy"`, `"Ιταλία"` — is a translation of a country rather than an
285
+ * identifier for one, and belongs in the sentence a page prints. `countriesFor`
286
+ * is what turns one of these into that.
287
+ */
288
+ export type CountryCode = (typeof COUNTRY_CODES)[number];
289
+
290
+ /** One country, in one language. */
291
+ export interface NamedCountry {
292
+ readonly code: CountryCode;
293
+ readonly name: string;
294
+ }
295
+
296
+ /**
297
+ * The list as one locale reads it: each code with its own name, A to Z in that
298
+ * language.
299
+ *
300
+ * Both halves are locale-dependent, so neither can be cached across locales —
301
+ * "Ägypten" sorts second in German and "Egypt" fifth in English, and a build
302
+ * that sorted once would ship one language's order to all of them.
303
+ *
304
+ * `Intl.DisplayNames` falls back to the code itself for anything it does not
305
+ * know, which is what keeps this total: an option always has a label.
306
+ */
307
+ export function countriesFor(locale: string): readonly NamedCountry[] {
308
+ const names = new Intl.DisplayNames([locale], { type: "region" });
309
+ const collator = new Intl.Collator(locale);
310
+ return COUNTRY_CODES.map((code) => ({
311
+ code,
312
+ name: names.of(code) ?? code,
313
+ })).sort((a, b) => collator.compare(a.name, b.name));
314
+ }
@@ -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
  }
@@ -1,3 +1,4 @@
1
+ import { assertNoMarks } from "../content/marks.ts";
1
2
  import type { StringKeys } from "../types.ts";
2
3
  import type { EntryPlaceholders } from "./placeholders.ts";
3
4
 
@@ -32,7 +33,7 @@ const NAME = /^[a-zA-Z0-9_]+$/;
32
33
  * unmatched closer to be ambiguous about, and rejecting it would fail copy that
33
34
  * is merely writing a bracket.
34
35
  */
35
- function render(
36
+ export function render(
36
37
  template: string,
37
38
  at: string,
38
39
  value: (name: string, whole: string) => string
@@ -88,12 +89,42 @@ export interface RawTranslateOptions<L extends string> {
88
89
  readonly locale: L;
89
90
  }
90
91
 
92
+ /**
93
+ * One message's raw copy, before anything is substituted or parsed.
94
+ *
95
+ * Shared with `content/rich.ts`, which needs the template *unrendered* — marks
96
+ * are parsed out of it first and placeholders filled per run afterwards, so it
97
+ * cannot go through `t()`. Both failures are stated once here rather than in
98
+ * each caller, which is what keeps the two error messages identical.
99
+ */
100
+ export function lookupTemplate<L extends string>(
101
+ catalog: MergedCatalog<L>,
102
+ locale: L,
103
+ key: string
104
+ ): string {
105
+ const entry = catalog[key];
106
+ if (entry === undefined) {
107
+ throw new Error(`Unknown message key "${key}".`);
108
+ }
109
+ const template = entry[locale];
110
+ if (typeof template !== "string") {
111
+ throw new Error(`Message "${key}" has no copy for locale "${locale}".`);
112
+ }
113
+ return template;
114
+ }
115
+
91
116
  /**
92
117
  * Builds the runtime half of `t()`.
93
118
  *
94
119
  * Every failure here is a build-time failure by design: a static site should not
95
120
  * ship a page with a literal `{name}` in the markup, so a missing message or an
96
121
  * unfilled placeholder throws rather than degrading.
122
+ *
123
+ * That rule is why a message carrying `[marks]` is refused here too. `t()`
124
+ * returns a string, so the only thing it could do with them is print them, and
125
+ * `[v:accent]Game Over[/v]` rendered into a `<title>` is the literal-brace
126
+ * failure wearing different brackets. `rich()` reads those, and `plain()` gives
127
+ * back the sentence without them.
97
128
  */
98
129
  export function createRawTranslate<L extends string>(
99
130
  options: RawTranslateOptions<L>
@@ -101,16 +132,8 @@ export function createRawTranslate<L extends string>(
101
132
  const { catalog, locale } = options;
102
133
 
103
134
  return (key, params) => {
104
- const entry = catalog[key];
105
- if (entry === undefined) {
106
- throw new Error(`Unknown message key "${key}".`);
107
- }
108
- const template = entry[locale];
109
- if (typeof template !== "string") {
110
- throw new Error(
111
- `Message "${key}" has no copy for locale "${locale}".`
112
- );
113
- }
135
+ const template = lookupTemplate(catalog, locale, key);
136
+ assertNoMarks(template, `Message "${key}" (${locale})`);
114
137
  return render(
115
138
  template,
116
139
  `Message "${key}" (${locale})`,
package/src/index.ts CHANGED
@@ -45,12 +45,17 @@ export {
45
45
  type CallingCode,
46
46
  type Coordinates,
47
47
  type CountryCode,
48
+ type E164,
48
49
  type EmailAddress,
49
50
  e164,
51
+ e164Of,
50
52
  formatPhone,
53
+ isEmailAddress,
54
+ type MailtoUrl,
51
55
  mailtoHref,
52
56
  type PhoneNumber,
53
57
  type PostalAddress,
58
+ type TelUrl,
54
59
  telHref,
55
60
  } from "./contact.ts";
56
61
  export {
@@ -60,6 +65,24 @@ export {
60
65
  type MailEndpoint,
61
66
  sendContactMessage,
62
67
  } from "./contact-form.ts";
68
+ // `parseMarks`, `assertNoMarks` and `createRichText` are deliberately not here.
69
+ // They are how lib gets from a message to a `Span[]`, and a consumer holding
70
+ // them has a second `rich()` that is not bound to the site's route table — so
71
+ // a slot filled with a route id resolves against nothing there. `site.rich(locale)` is
72
+ // the only way in, and `Span` is what a renderer switches on.
73
+ export {
74
+ type LinkTarget,
75
+ type PlainTextFor,
76
+ plain,
77
+ type RichText,
78
+ type RichTextFor,
79
+ type Span,
80
+ } from "./content/index.ts";
81
+ export {
82
+ COUNTRY_CODES,
83
+ countriesFor,
84
+ type NamedCountry,
85
+ } from "./countries.ts";
63
86
  export type { GeneratedFile } from "./file.ts";
64
87
  export type { PublicFilePath, PublicFileRegistry } from "./files.ts";
65
88
  export {
@@ -222,7 +245,9 @@ export type { Sitemap, SitemapConfig } from "./sitemap.ts";
222
245
  export type { IsoDate, Letter, StringKeys } from "./types.ts";
223
246
  export {
224
247
  absoluteUrl,
248
+ type Hash,
225
249
  type HttpsUrl,
250
+ isHash,
226
251
  isHttpsUrl,
227
252
  isUrlPath,
228
253
  joinUrl,
package/src/site/api.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedLocaleMeta } from "../config.ts";
2
+ import type { PlainTextFor, RichTextFor } from "../content/index.ts";
2
3
  import type { GeneratedFile } from "../file.ts";
3
4
  import type { PublicFilePath } from "../files.ts";
4
5
  import type { TranslateFor } from "../i18n/translate.ts";
@@ -161,6 +162,36 @@ export interface Site<
161
162
  /** A locale-bound `t()`. */
162
163
  translate(locale: L): TranslateFor<Catalog>;
163
164
 
165
+ /**
166
+ * A locale-bound `rich()`: the same catalog, read as styled runs.
167
+ *
168
+ * For copy that is one sentence with a bold clause, an accent colour or a
169
+ * link in the middle of it. The marks live in the message so every language
170
+ * can move them — see `content/marks.ts`, which is mostly about why they
171
+ * cannot live in config instead.
172
+ *
173
+ * Bound to a locale here rather than taking one per call, because a link
174
+ * inside the copy resolves to *this* language's path, and a `rich()` that
175
+ * took the locale late would be a second place to get that wrong.
176
+ */
177
+ rich(locale: L): RichTextFor<Catalog, RouteId>;
178
+
179
+ /**
180
+ * A locale-bound `plain()`: the same catalog, read as words alone.
181
+ *
182
+ * For everywhere a string is what fits and markup is not — `llms()`'s
183
+ * `describe`, a page description, a structured-data `description`. Marks are
184
+ * dropped, a `[br]` becomes a space, and the result is the sentence a reader
185
+ * would hear.
186
+ *
187
+ * The reason it exists next to `translate` rather than being folded into it:
188
+ * `t()` refuses a message carrying marks, because the only thing it could do
189
+ * with them is print them. This is the reading that copes, so a description
190
+ * does not break the day someone emphasises a clause in the sentence it
191
+ * quotes.
192
+ */
193
+ plain(locale: L): PlainTextFor<Catalog>;
194
+
164
195
  /** Absolute path of a route in one locale, e.g. `/el/sxetika-me-emas`. */
165
196
  pathFor(id: RouteId, locale: L, options?: LinkOptions): UrlPath;
166
197
  /** The same, prefixed with the site origin — for canonicals and sitemaps. */
@@ -4,6 +4,11 @@ import {
4
4
  mergeLocaleMeta,
5
5
  type SiteConfigShape,
6
6
  } from "../config.ts";
7
+ import {
8
+ createPlainText,
9
+ createRichText,
10
+ type LinkResolver,
11
+ } from "../content/index.ts";
7
12
  import type { GeneratedFile } from "../file.ts";
8
13
  import type { PublicFilePath } from "../files.ts";
9
14
  import { type BaseCatalog, mergeCatalog } from "../i18n/define.ts";
@@ -729,6 +734,36 @@ export function createSite<
729
734
  });
730
735
  }
731
736
 
737
+ /**
738
+ * How a slot filled with a route id becomes this locale's path.
739
+ *
740
+ * `pathFor` already refuses an unknown or disabled route, and its message is
741
+ * right for a call site that named the id — but a mark comes from a
742
+ * *translation*, so the useful part is which message to open. That is what
743
+ * this adds; the original is kept as the cause rather than restated, since
744
+ * "disabled for this project" and "unknown" are different fixes.
745
+ */
746
+ function richLink(locale: L): LinkResolver {
747
+ return (routeId, at, hash) => {
748
+ try {
749
+ // Through `pathFor` rather than concatenated on afterwards, so
750
+ // a fragment lands after the page segment and the query — the
751
+ // one order that is right — and so `#` is added in exactly one
752
+ // place in this package.
753
+ return pathFor(
754
+ routeId as RouteId,
755
+ locale,
756
+ hash === undefined ? undefined : { hash }
757
+ );
758
+ } catch (cause) {
759
+ throw new Error(
760
+ `${at} links to "${routeId}", which is not a page this project builds. A link target is a route id — optionally with a fragment, as "about#team" — a "/path", an https:// URL, or a "#fragment" on the page it is rendered on.`,
761
+ { cause }
762
+ );
763
+ }
764
+ };
765
+ }
766
+
732
767
  return {
733
768
  locales,
734
769
  defaultLocale,
@@ -740,6 +775,20 @@ export function createSite<
740
775
  routes: enabledRouteIds,
741
776
 
742
777
  translate: (locale) => translateFactory(catalog, locale),
778
+ rich: (locale) =>
779
+ createRichText<Catalog, L, RouteId>({
780
+ catalog,
781
+ locale,
782
+ link: richLink(locale),
783
+ origin: siteUrl,
784
+ }),
785
+ plain: (locale) =>
786
+ createPlainText<Catalog, L>({
787
+ catalog,
788
+ locale,
789
+ link: richLink(locale),
790
+ origin: siteUrl,
791
+ }),
743
792
 
744
793
  pathFor,
745
794
  urlFor,