@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,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
+ }