@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.
- package/docs/NOT-BUILT.md +83 -8
- package/package.json +3 -2
- package/src/contact.ts +91 -9
- package/src/content/index.ts +40 -0
- package/src/content/marks.ts +408 -0
- package/src/content/rich.ts +550 -0
- package/src/countries.ts +314 -0
- package/src/i18n/define.ts +11 -10
- package/src/i18n/placeholders.ts +37 -10
- package/src/i18n/translate.ts +34 -11
- package/src/index.ts +25 -0
- package/src/site/api.ts +31 -0
- package/src/site/create.ts +49 -0
- package/src/url.ts +20 -0
|
@@ -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
|
+
}
|