@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.
- package/docs/NOT-BUILT.md +67 -0
- package/package.json +3 -2
- package/src/contact.ts +84 -3
- package/src/content/index.ts +40 -0
- package/src/content/marks.ts +408 -0
- package/src/content/rich.ts +550 -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 +20 -0
- package/src/site/api.ts +31 -0
- package/src/site/create.ts +49 -0
- package/src/url.ts +20 -0
package/docs/NOT-BUILT.md
CHANGED
|
@@ -227,6 +227,73 @@ type could — a property that is not valid for the type it sits on.
|
|
|
227
227
|
|
|
228
228
|
## Answers that will not change on their own
|
|
229
229
|
|
|
230
|
+
### Should a paragraph be authored as an array of translation keys?
|
|
231
|
+
|
|
232
|
+
**No. One sentence is one message, and the styling lives in the copy as
|
|
233
|
+
`[marks]`. `content/` carries the parser, the runs and `plain()`; nothing
|
|
234
|
+
carries a `ContentItem[]`.**
|
|
235
|
+
|
|
236
|
+
This is the shape the sibling B2C template uses, and it is the one thing from
|
|
237
|
+
there that was deliberately not ported — so the reasoning is here, because
|
|
238
|
+
reading those configs is what prompts the question. A paragraph is written as an
|
|
239
|
+
array of `{ type, content }` objects, each `content` a translation key, and the
|
|
240
|
+
renderer concatenates them in the order the array was typed:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
subtitle: [
|
|
244
|
+
{ type: "text", content: "hero.subtitle_text_1" },
|
|
245
|
+
{ type: "bold", content: "hero.subtitle_text_2" },
|
|
246
|
+
{ type: "text", content: "hero.subtitle_text_3" },
|
|
247
|
+
// …through _7
|
|
248
|
+
]
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**It fixes two things copy is not allowed to fix.** Word order, which differs
|
|
252
|
+
per language — the array says the bold clause comes second, in every locale,
|
|
253
|
+
forever. And the *number of runs*, which differs more: a clause English spends
|
|
254
|
+
four fragments on is one word in Greek, and there is no way to write that in an
|
|
255
|
+
array whose length was decided by whoever typed the English. The extra keys get
|
|
256
|
+
padded with empty strings or the spare ones go untranslated, and either way the
|
|
257
|
+
translator is editing around a data structure instead of writing a sentence.
|
|
258
|
+
|
|
259
|
+
The `spaceAfter: true` flag on those objects is the tell. It exists because the
|
|
260
|
+
runs are concatenated with nothing between them, so a sentence split across keys
|
|
261
|
+
needs someone to remember which fragments end in a space — a per-fragment
|
|
262
|
+
decision that is invisible until it renders. With one message the spaces are
|
|
263
|
+
just in the copy.
|
|
264
|
+
|
|
265
|
+
What replaces it is a mark in the message: `"Book [b]up to six[/b] at
|
|
266
|
+
[a:venue]our venue[/a]."`. Each language moves them where its grammar wants
|
|
267
|
+
them, `rich()` returns the runs, and the renderer styles them. The mark set is
|
|
268
|
+
compared across locales by the machinery that already compares `{placeholders}`
|
|
269
|
+
— see `Tokens` in `i18n/placeholders.ts` — so a translation that drops the link
|
|
270
|
+
fails the build rather than the page. That check is the reason marks are safe to
|
|
271
|
+
put in copy at all, and it is what the array shape genuinely did buy: with the
|
|
272
|
+
structure in config, no locale *could* lose a link. Marks had to earn that back.
|
|
273
|
+
|
|
274
|
+
**A mark names a slot, never a destination**, and that distinction is the second
|
|
275
|
+
thing the array shape got right and the first version of this got wrong. `venue`
|
|
276
|
+
above is a name the call site fills — `rich(key, { venue: "contact" })` — so the
|
|
277
|
+
route id is checked against the route table like every other link in the package,
|
|
278
|
+
it is written once rather than once per language, and an external URL never
|
|
279
|
+
enters a file that translators edit. Copy holding `[a:https://…]` in ten
|
|
280
|
+
languages is ten unchecked copies of an address, and the eleventh is the one
|
|
281
|
+
somebody "corrects".
|
|
282
|
+
|
|
283
|
+
**What is still fine to build.** Composing runs in code: a `Span[]` is the
|
|
284
|
+
resolved type, already plain strings and finished hrefs, so a project assembling
|
|
285
|
+
a line from config values — a price, a phone number, a room name — builds one
|
|
286
|
+
directly and needs nothing from `rich()`. And a list is still an array: spans are
|
|
287
|
+
inline, so bullets are `readonly RichText[]` in the project's own config, which
|
|
288
|
+
is what the old `bulletList` run should become.
|
|
289
|
+
|
|
290
|
+
**What is not a reason, and was the strongest argument for porting it.** That
|
|
291
|
+
around 6,300 authored runs across fifty-four deployments would migrate verbatim.
|
|
292
|
+
They would — and they would carry the fragmentation with them into a codebase
|
|
293
|
+
whose whole claim is that a translation cannot silently go wrong. The migration
|
|
294
|
+
is happening once; this is the moment it costs least to fix, and every store
|
|
295
|
+
collapsed to marks is a store whose copy a translator can finally read.
|
|
296
|
+
|
|
230
297
|
### Should lib carry the consent *banner* as well as the tags?
|
|
231
298
|
|
|
232
299
|
**No — and the line between the two halves is the point.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@escape-game-over/atlas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
|
|
6
6
|
"private": false,
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"scripts": {
|
|
40
40
|
"test": "npm run test:types && npm run test:unit && npm run test:examples",
|
|
41
41
|
"test:types": "tsc --noEmit && astro check --root checks/astro",
|
|
42
|
-
"test:unit": "vitest run",
|
|
42
|
+
"test:unit": "vitest run --coverage",
|
|
43
43
|
"test:watch": "vitest",
|
|
44
44
|
"test:examples": "npm run test --workspaces",
|
|
45
45
|
"lint": "biome check .",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@biomejs/biome": "2.5.10",
|
|
57
57
|
"@types/node": "26.3.0",
|
|
58
|
+
"@vitest/coverage-istanbul": "4.1.11",
|
|
58
59
|
"astro": "7.2.6",
|
|
59
60
|
"typescript": "6.0.3",
|
|
60
61
|
"vitest": "4.1.11"
|
package/src/contact.ts
CHANGED
|
@@ -82,6 +82,30 @@ export function formatPhone(phone: PhoneNumber): string {
|
|
|
82
82
|
return `+${phone.code} ${phone.number}`;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* A number in E.164: a plus, then digits.
|
|
87
|
+
*
|
|
88
|
+
* Named for the reason `HttpsUrl` is — so that everything built from one
|
|
89
|
+
* inherits the guarantee rather than re-stating it. `TelUrl` is a template on
|
|
90
|
+
* this, so a `tel:` cannot be assembled from a number that never had a country
|
|
91
|
+
* code, which is the whole thing `e164Of` is checking for.
|
|
92
|
+
*/
|
|
93
|
+
export type E164 = `+${string}`;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A `tel:` link, as a shape.
|
|
97
|
+
*
|
|
98
|
+
* Beside `E164` and `EmailAddress` rather than in `url.ts` with `HttpsUrl` and
|
|
99
|
+
* `UrlPath`: those two are addresses of *pages*, and half of what lib does is
|
|
100
|
+
* build them from a route table. These are neither — they are the two schemes
|
|
101
|
+
* this file produces from a phone number and an address, and they belong with
|
|
102
|
+
* the values they are made of.
|
|
103
|
+
*/
|
|
104
|
+
export type TelUrl = `tel:${E164}`;
|
|
105
|
+
|
|
106
|
+
/** A `mailto:` link, as a shape. @see {@link TelUrl} */
|
|
107
|
+
export type MailtoUrl = `mailto:${EmailAddress}`;
|
|
108
|
+
|
|
85
109
|
/**
|
|
86
110
|
* The number as a phone dials it: `tel:+390600000000`.
|
|
87
111
|
*
|
|
@@ -93,7 +117,7 @@ export function formatPhone(phone: PhoneNumber): string {
|
|
|
93
117
|
* and nothing is removed, so this and `formatPhone` are two renderings of one
|
|
94
118
|
* value rather than two opinions about it.
|
|
95
119
|
*/
|
|
96
|
-
export function telHref(phone: PhoneNumber):
|
|
120
|
+
export function telHref(phone: PhoneNumber): TelUrl {
|
|
97
121
|
return `tel:${e164(phone)}`;
|
|
98
122
|
}
|
|
99
123
|
|
|
@@ -118,14 +142,71 @@ export function telHref(phone: PhoneNumber): string {
|
|
|
118
142
|
* `replace(/^0+/, "")` also took *every* leading zero rather than one trunk
|
|
119
143
|
* digit, so `007…` became `7…`.
|
|
120
144
|
*/
|
|
121
|
-
export function e164(phone: PhoneNumber):
|
|
145
|
+
export function e164(phone: PhoneNumber): E164 {
|
|
122
146
|
return `+${phone.code}${phone.number.replace(/\D/g, "")}`;
|
|
123
147
|
}
|
|
124
148
|
|
|
149
|
+
/**
|
|
150
|
+
* The same, for a number that arrives already written out: `"+39 06 0000 0000"`
|
|
151
|
+
* → `"+390600000000"`. `undefined` if it is not in international form.
|
|
152
|
+
*
|
|
153
|
+
* Beside `e164` rather than wherever it is called, because the two answer one
|
|
154
|
+
* question from different inputs and the *rule* is the paragraph above: only
|
|
155
|
+
* punctuation goes, nothing is added, and no leading zero is touched. Written
|
|
156
|
+
* out twice, that rule survives in one copy and quietly stops being true in the
|
|
157
|
+
* other — and the version that stops being true is the one nobody re-reads,
|
|
158
|
+
* because both keep producing a `tel:` that looks fine.
|
|
159
|
+
*
|
|
160
|
+
* The structured form is the one to prefer where there is a choice: `code` and
|
|
161
|
+
* `number` cannot be run together wrongly, and `formatPhone` renders the same
|
|
162
|
+
* value for a reader. This is for the case where there is no choice — a number
|
|
163
|
+
* inside a sentence, where the copy is what holds it.
|
|
164
|
+
*
|
|
165
|
+
* **International form is required, and that is the whole check.** A local
|
|
166
|
+
* number dials from inside the country and silently fails everywhere else,
|
|
167
|
+
* which for a venue is precisely the visitor worth reaching. It is also
|
|
168
|
+
* invisible on the page, because the number *reads* correctly — so the refusal
|
|
169
|
+
* has to happen here, where the `+` either is or is not.
|
|
170
|
+
*/
|
|
171
|
+
export function e164Of(written: string): E164 | undefined {
|
|
172
|
+
if (!written.trimStart().startsWith("+")) return undefined;
|
|
173
|
+
return `+${written.replace(/\D/g, "")}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
125
176
|
/** An email address, which is only ever used whole. */
|
|
126
177
|
export type EmailAddress = `${string}@${string}`;
|
|
127
178
|
|
|
128
|
-
|
|
179
|
+
/**
|
|
180
|
+
* The runtime half of `EmailAddress`, written as a guard so the check that
|
|
181
|
+
* refuses is also the check that narrows — as `isHttpsUrl` is for `HttpsUrl`.
|
|
182
|
+
*
|
|
183
|
+
* Needed because an address does not always arrive as a literal. One written
|
|
184
|
+
* into copy and pulled back out by `rich()` is a `string` as far as the types
|
|
185
|
+
* are concerned, and without this the call site either casts — asserting what it
|
|
186
|
+
* has not tested — or invents its own idea of what an address looks like, which
|
|
187
|
+
* is how two of them end up disagreeing.
|
|
188
|
+
*
|
|
189
|
+
* **`includes("@")` is exactly what the type says, and not less.** The shape is
|
|
190
|
+
* `` `${string}@${string}` ``, whose two halves may each be empty, so it admits
|
|
191
|
+
* `"@x"` and `"x@"` — and so does this. That is the point rather than a gap:
|
|
192
|
+
* the pair agree, and a stricter runtime check would start rejecting values the
|
|
193
|
+
* compiler had already accepted. Validating an address properly is famously not
|
|
194
|
+
* a regex, and a half-strict one here would only move where the disagreement
|
|
195
|
+
* lives. The `mailto:` either opens with something in the To: field or it does
|
|
196
|
+
* not, which is the failure this is guarding against.
|
|
197
|
+
*/
|
|
198
|
+
export function isEmailAddress(value: string): value is EmailAddress {
|
|
199
|
+
return value.includes("@");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The `mailto:` for an address.
|
|
204
|
+
*
|
|
205
|
+
* The return type is narrowed rather than left as `string` so that anything
|
|
206
|
+
* carrying one — `Span`'s email run, say — can demand a scheme it did not have
|
|
207
|
+
* to build itself.
|
|
208
|
+
*/
|
|
209
|
+
export function mailtoHref(email: EmailAddress): MailtoUrl {
|
|
129
210
|
return `mailto:${email}`;
|
|
130
211
|
}
|
|
131
212
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy that carries styling, without carrying markup.
|
|
3
|
+
*
|
|
4
|
+
* A paragraph is one message, marked up in the copy itself — see `marks.ts` for
|
|
5
|
+
* why it cannot be an array of keys — and this module turns it into runs a
|
|
6
|
+
* project renders however it likes. Lib owns which runs exist and what they say;
|
|
7
|
+
* the project owns what they look like.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* // config/messages.ts
|
|
11
|
+
* "about.intro": {
|
|
12
|
+
* "en-US": "Book [v:accent]up to six[/v] at [a:venue]our venue[/a].",
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* // a view
|
|
16
|
+
* const rich = site.rich(locale);
|
|
17
|
+
* <RichText spans={rich("about.intro")} />
|
|
18
|
+
*
|
|
19
|
+
* // the same sentence, where a string is what fits
|
|
20
|
+
* description: plain(rich("about.intro"))
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* There is no authored `ContentItem[]`. Where a project needs to compose runs
|
|
24
|
+
* itself — a price, a number pulled from config — it builds `Span[]` directly,
|
|
25
|
+
* which is already the resolved shape and needs nothing from here.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export { assertNoMarks, type ParsedSpan, parseMarks } from "./marks.ts";
|
|
29
|
+
export {
|
|
30
|
+
createPlainText,
|
|
31
|
+
createRichText,
|
|
32
|
+
type LinkResolver,
|
|
33
|
+
type LinkTarget,
|
|
34
|
+
type PlainTextFor,
|
|
35
|
+
plain,
|
|
36
|
+
type RichText,
|
|
37
|
+
type RichTextFor,
|
|
38
|
+
type RichTextOptions,
|
|
39
|
+
type Span,
|
|
40
|
+
} from "./rich.ts";
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parser that turns one message into styled runs.
|
|
3
|
+
*
|
|
4
|
+
* The whole reason this file exists rather than a `ContentItem[]` in config:
|
|
5
|
+
* **a sentence is one message**. The shape this replaces authored a paragraph
|
|
6
|
+
* as an array of translation keys — `hero.subtitle_text_1` through `_7` — and
|
|
7
|
+
* concatenated them in the order the array was written. That fixes two things
|
|
8
|
+
* copy is not allowed to fix. Word order, which differs per language, and the
|
|
9
|
+
* *number of runs*, which differs more: a clause English spends four fragments
|
|
10
|
+
* on is one word in Greek, and there is no way to write that in an array whose
|
|
11
|
+
* length was decided by whoever typed the English. The missing fragments get
|
|
12
|
+
* padded with empty strings or the spare ones get dropped, and either way the
|
|
13
|
+
* translator is editing around a data structure instead of writing a sentence.
|
|
14
|
+
*
|
|
15
|
+
* So the marks live *in the copy*, where the translator can move them:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* "about.intro": {
|
|
19
|
+
* "en-US": "Book [v:accent]up to six players[/v] at [a:venue]our venue[/a].",
|
|
20
|
+
* "el-GR": "Κλείσε [a:venue]στον χώρο μας[/a] [v:accent]έως έξι παίκτες[/v].",
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* rich("about.intro", { venue: "contact" }) // the destination, stated once
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* One key, one sentence, and each language orders and splits it however it
|
|
27
|
+
* needs to. What cannot drift is *which* marks a message uses: the same
|
|
28
|
+
* machinery that holds `{placeholders}` in step across locales sees these too,
|
|
29
|
+
* so a translation that loses the link fails the build rather than the page.
|
|
30
|
+
*
|
|
31
|
+
* This is deliberately a parser and not a `replace` over a pattern, for the
|
|
32
|
+
* reason `render` in `i18n/translate.ts` is one: a `[` that opens nothing is a
|
|
33
|
+
* mistake, not an escape hatch, and it is refused here rather than shipped to
|
|
34
|
+
* a page as literal text. `[[` and `]]` are the escapes, matching `{{` and `}}`
|
|
35
|
+
* one file over.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { IsName } from "../i18n/placeholders.ts";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One run of a message, still holding its `{placeholders}`.
|
|
42
|
+
*
|
|
43
|
+
* The unresolved half of `Span`: a link knows the *name* of the slot it fills
|
|
44
|
+
* and nothing about where that goes, because copy does not hold addresses —
|
|
45
|
+
* see `[a:]` below. `rich.ts` finishes the job.
|
|
46
|
+
*/
|
|
47
|
+
export type ParsedSpan =
|
|
48
|
+
| { readonly kind: "text"; readonly text: string }
|
|
49
|
+
| { readonly kind: "bold"; readonly text: string }
|
|
50
|
+
| {
|
|
51
|
+
readonly kind: "styled";
|
|
52
|
+
readonly text: string;
|
|
53
|
+
readonly variant: string;
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
readonly kind: "link";
|
|
57
|
+
readonly text: string;
|
|
58
|
+
/** The slot to fill, e.g. `venue` in `[a:venue]`. Never a URL. */
|
|
59
|
+
readonly name: string;
|
|
60
|
+
}
|
|
61
|
+
| { readonly kind: "email"; readonly text: string }
|
|
62
|
+
| { readonly kind: "phone"; readonly text: string }
|
|
63
|
+
| { readonly kind: "break" };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The marks, and what each produces.
|
|
67
|
+
*
|
|
68
|
+
* Short names because a translator reads past them to the words, and every
|
|
69
|
+
* character between `[` and `]` is noise in a sentence they are trying to keep
|
|
70
|
+
* natural. Long enough to guess at from context, which `[b]` and `[a]` are and
|
|
71
|
+
* `[x]` would not be.
|
|
72
|
+
*
|
|
73
|
+
* `bold` is the one presentational mark lib names outright, because it is not
|
|
74
|
+
* presentational: it is `<strong>`, it survives into plain text as emphasis a
|
|
75
|
+
* screen reader announces, and 952 of the runs in the data this replaced were
|
|
76
|
+
* exactly it. Everything else that was a *style* — a colour, a size — is `[v:]`
|
|
77
|
+
* and carries a name the project's renderer maps. See `Span.variant`.
|
|
78
|
+
*/
|
|
79
|
+
const MARKS = {
|
|
80
|
+
b: "bold",
|
|
81
|
+
v: "styled",
|
|
82
|
+
a: "link",
|
|
83
|
+
mail: "email",
|
|
84
|
+
tel: "phone",
|
|
85
|
+
} as const;
|
|
86
|
+
|
|
87
|
+
type MarkName = keyof typeof MARKS;
|
|
88
|
+
|
|
89
|
+
/** The marks whose meaning is incomplete without an argument. */
|
|
90
|
+
const NEEDS_ARGUMENT: ReadonlySet<string> = new Set<MarkName>(["v", "a"]);
|
|
91
|
+
|
|
92
|
+
/** Written `[br]`, closed by nothing, and the only mark that wraps no words. */
|
|
93
|
+
const VOID_MARK = "br";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The type-level half of `MARKS`, kept beside it rather than a file away.
|
|
97
|
+
*
|
|
98
|
+
* **Change one and change the other**, exactly as `NAME` and `NameChar` say to
|
|
99
|
+
* — and deliberately not the way they are arranged. Those two decide the same
|
|
100
|
+
* thing from opposite ends of the package and drifted apart once, which is the
|
|
101
|
+
* mistake this avoids by keeping the pair in view of each other. The runtime
|
|
102
|
+
* table is above; the union is here.
|
|
103
|
+
*/
|
|
104
|
+
type OpenMark = "b" | "br" | "mail" | "tel" | `v:${string}` | `a:${string}`;
|
|
105
|
+
|
|
106
|
+
type CloseMark = "/b" | "/v" | "/a" | "/mail" | "/tel";
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Every mark a template uses, as a union of the literal tokens it spells.
|
|
110
|
+
*
|
|
111
|
+
* The counterpart of `Placeholders` in `i18n/placeholders.ts`, and there for
|
|
112
|
+
* exactly the same failure: a message whose translation quietly lost something
|
|
113
|
+
* structural. That file's whole subject is a `{name}` dropped from one locale
|
|
114
|
+
* and rendered as a literal brace; this is a translator dropping `[a:venue]`
|
|
115
|
+
* and the Greek page losing the link altogether — worse, because nothing is
|
|
116
|
+
* left on the page to notice.
|
|
117
|
+
*
|
|
118
|
+
* **The argument is part of the token, on purpose.** `[a:venue]` and
|
|
119
|
+
* `[a:booking]` are different tokens, so a translation cannot fill a different
|
|
120
|
+
* slot from the one the other locales fill, and `[v:accent]` cannot become
|
|
121
|
+
* `[v:inverse]` in one language. Those are decisions the copy makes once, not
|
|
122
|
+
* per locale.
|
|
123
|
+
*
|
|
124
|
+
* An escaped `[[b]]` asks for nothing, and falls out rather than needing a case:
|
|
125
|
+
* the candidate inferred from it is `"[b"`, which is not a member of either
|
|
126
|
+
* union. Same trick `Placeholders` plays on `{{name}}`.
|
|
127
|
+
*
|
|
128
|
+
* Multiplicity is not carried — two `[b]` runs and one look alike here — which
|
|
129
|
+
* is the limit `Placeholders` has with two `{name}`s and is worth the same
|
|
130
|
+
* trade.
|
|
131
|
+
*/
|
|
132
|
+
export type MarkTokens<S extends string> =
|
|
133
|
+
S extends `${string}[${infer Token}]${infer Rest}`
|
|
134
|
+
?
|
|
135
|
+
| (Token extends OpenMark | CloseMark ? `[${Token}]` : never)
|
|
136
|
+
| MarkTokens<Rest>
|
|
137
|
+
: never;
|
|
138
|
+
|
|
139
|
+
/** Distributes over the token union, which `MarkTokens<S>` would not do inline. */
|
|
140
|
+
type SlotOfToken<T> = T extends `[a:${infer Name}]`
|
|
141
|
+
? IsName<Name> extends true
|
|
142
|
+
? Name
|
|
143
|
+
: never
|
|
144
|
+
: never;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The link slots a message declares, as a union of their names.
|
|
148
|
+
*
|
|
149
|
+
* What `rich()` turns into required arguments: `"See [a:venue]us[/a]"` asks the
|
|
150
|
+
* call site for `{ venue: … }`, and the type of that value is a `LinkTarget`
|
|
151
|
+
* over the routes this project actually builds. **That is the whole reason
|
|
152
|
+
* copy names a slot rather than a destination.** A route id written into a
|
|
153
|
+
* translation is a string until the day that message renders; written at the
|
|
154
|
+
* call site it is checked against the route table like every other link in the
|
|
155
|
+
* package, it is stated once instead of once per language, and an external URL
|
|
156
|
+
* stays out of the copy a translator is editing.
|
|
157
|
+
*
|
|
158
|
+
* Read off `MarkTokens` rather than scanning the template again, which is not a
|
|
159
|
+
* shortcut: the scan is where an escaped `[[a:x]]` gets correctly ignored, and a
|
|
160
|
+
* second pattern that skipped straight to `[a:` would find the `x` inside it and
|
|
161
|
+
* demand an argument for a link that does not exist.
|
|
162
|
+
*/
|
|
163
|
+
export type LinkNames<S extends string> = SlotOfToken<MarkTokens<S>>;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The names a `[a:name]` slot may be spelled with.
|
|
167
|
+
*
|
|
168
|
+
* Exactly `NAME` in `i18n/translate.ts`, because a slot name is the same kind
|
|
169
|
+
* of thing a `{placeholder}` name is: a key the *call site* writes, in
|
|
170
|
+
* `rich(key, { venue: "contact" })`. A hyphen there forces quoting and reads as
|
|
171
|
+
* a mistake, so it is not allowed, and `LinkNames` below reuses `IsName` rather
|
|
172
|
+
* than restating the rule — the two cannot drift because there is one of them.
|
|
173
|
+
*
|
|
174
|
+
* A character class rather than a hand-rolled check, for the reason `NAME` is
|
|
175
|
+
* one. Speed is not the argument either way: both run at build time on a word.
|
|
176
|
+
*/
|
|
177
|
+
const SLOT = /^[a-zA-Z0-9_]+$/;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* What may be spelled in a `[v:name]` variant.
|
|
181
|
+
*
|
|
182
|
+
* **`SLOT` plus the hyphen, and the difference is the point.** A variant is
|
|
183
|
+
* never written in TypeScript at all — copy spells it and the renderer looks it
|
|
184
|
+
* up — and what it usually mirrors is a CSS custom property or a design token,
|
|
185
|
+
* which are kebab by convention. `[v:on-dark]` is the natural spelling and
|
|
186
|
+
* `[v:on_dark]` is the one that would have to be explained. A slot name is a
|
|
187
|
+
* call-site key and a variant is not, so they answer the hyphen differently.
|
|
188
|
+
*/
|
|
189
|
+
const VARIANT = /^[a-zA-Z0-9_-]+$/;
|
|
190
|
+
|
|
191
|
+
/** The offending mark, quoted, so the error points at something findable. */
|
|
192
|
+
function describeMark(template: string, open: number, close: number): string {
|
|
193
|
+
const end = close === -1 ? Math.min(open + 20, template.length) : close + 1;
|
|
194
|
+
return `"${template.slice(open, end)}"`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Splits a message into runs, and refuses anything malformed.
|
|
199
|
+
*
|
|
200
|
+
* `at` names the message and locale, exactly as `render` is given it, so a
|
|
201
|
+
* failure says which string to go and fix rather than only that one is wrong.
|
|
202
|
+
*
|
|
203
|
+
* Marks do not nest. `[b]bold [v:accent]and red[/v][/b]` is refused rather than
|
|
204
|
+
* flattened, because the resolved span is a single kind and there is nowhere
|
|
205
|
+
* for the second one to go — a run is bold *or* it carries a variant. The shape
|
|
206
|
+
* this replaced had the same limit and answered it by inventing a `boldUnderline`
|
|
207
|
+
* kind, which was used once in fifty-four deployments; a renderer that wants the
|
|
208
|
+
* combination should give a variant both properties and name it once.
|
|
209
|
+
*/
|
|
210
|
+
export function parseMarks(
|
|
211
|
+
template: string,
|
|
212
|
+
at: string
|
|
213
|
+
): readonly ParsedSpan[] {
|
|
214
|
+
const spans: ParsedSpan[] = [];
|
|
215
|
+
let text = "";
|
|
216
|
+
let open:
|
|
217
|
+
| { readonly name: MarkName; readonly argument: string }
|
|
218
|
+
| undefined;
|
|
219
|
+
let openedAt = 0;
|
|
220
|
+
let index = 0;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Emits whatever has accumulated, as plain text or as the open mark's run.
|
|
224
|
+
*
|
|
225
|
+
* Empty runs are dropped rather than emitted: `[b][/b]` is copy someone
|
|
226
|
+
* half-deleted, and a span with no words renders an empty element that
|
|
227
|
+
* still takes up a renderer's `switch`.
|
|
228
|
+
*/
|
|
229
|
+
function flush(): void {
|
|
230
|
+
if (text === "") return;
|
|
231
|
+
spans.push(
|
|
232
|
+
open === undefined ? { kind: "text", text } : run(open, text)
|
|
233
|
+
);
|
|
234
|
+
text = "";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function run(
|
|
238
|
+
mark: { readonly name: MarkName; readonly argument: string },
|
|
239
|
+
content: string
|
|
240
|
+
): ParsedSpan {
|
|
241
|
+
switch (MARKS[mark.name]) {
|
|
242
|
+
case "bold":
|
|
243
|
+
return { kind: "bold", text: content };
|
|
244
|
+
case "styled":
|
|
245
|
+
return {
|
|
246
|
+
kind: "styled",
|
|
247
|
+
text: content,
|
|
248
|
+
variant: mark.argument,
|
|
249
|
+
};
|
|
250
|
+
case "link":
|
|
251
|
+
return {
|
|
252
|
+
kind: "link",
|
|
253
|
+
text: content,
|
|
254
|
+
name: mark.argument,
|
|
255
|
+
};
|
|
256
|
+
case "email":
|
|
257
|
+
return { kind: "email", text: content };
|
|
258
|
+
case "phone":
|
|
259
|
+
return { kind: "phone", text: content };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
while (index < template.length) {
|
|
264
|
+
const char = template[index] as string;
|
|
265
|
+
|
|
266
|
+
if (char === "[" && template[index + 1] === "[") {
|
|
267
|
+
text += "[";
|
|
268
|
+
index += 2;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (char === "]" && template[index + 1] === "]") {
|
|
272
|
+
text += "]";
|
|
273
|
+
index += 2;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
// A lone `]` stays text, for the reason a lone `}` does in `render`:
|
|
277
|
+
// only `[` opens anything, so there is nothing an unmatched closer
|
|
278
|
+
// could be ambiguous about, and refusing it would fail copy that is
|
|
279
|
+
// merely writing a bracket.
|
|
280
|
+
if (char !== "[") {
|
|
281
|
+
text += char;
|
|
282
|
+
index += 1;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const close = template.indexOf("]", index + 1);
|
|
287
|
+
if (close === -1) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`${at} has a "[" that is never closed: ${describeMark(template, index, close)}. To write a literal bracket, double it: "[[".`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
const inner = template.slice(index + 1, close);
|
|
293
|
+
|
|
294
|
+
if (inner.startsWith("/")) {
|
|
295
|
+
const name = inner.slice(1);
|
|
296
|
+
if (open === undefined) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
`${at} closes [${name}] without opening it: ${describeMark(template, index, close)}.`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (name !== open.name) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`${at} opens [${open.name}] and closes [${name}]: ${describeMark(template, index, close)}. Marks do not nest, so the one that opens is the one that must close.`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
flush();
|
|
307
|
+
open = undefined;
|
|
308
|
+
index = close + 1;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (inner === VOID_MARK) {
|
|
313
|
+
flush();
|
|
314
|
+
spans.push({ kind: "break" });
|
|
315
|
+
index = close + 1;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const separator = inner.indexOf(":");
|
|
320
|
+
const name = separator === -1 ? inner : inner.slice(0, separator);
|
|
321
|
+
const argument = separator === -1 ? "" : inner.slice(separator + 1);
|
|
322
|
+
|
|
323
|
+
if (!Object.hasOwn(MARKS, name)) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`${at} has a "[" that opens neither a mark nor an escape: ${describeMark(template, index, close)}. The marks are ${markList()}. To write a literal bracket, double it: "[[".`
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
const mark = name as MarkName;
|
|
329
|
+
|
|
330
|
+
if (open !== undefined) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`${at} opens [${mark}] inside [${open.name}]: ${describeMark(template, index, close)}. Marks do not nest — close the first, or give one variant both properties and name it once.`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
if (NEEDS_ARGUMENT.has(mark)) {
|
|
336
|
+
if (argument === "") {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`${at} has ${describeMark(template, index, close)} with nothing after the colon. ${argumentHint(mark)}`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
if (mark === "v" && !VARIANT.test(argument)) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
`${at} has a variant that is not a name: ${describeMark(template, index, close)}. A variant is letters, digits, hyphens and underscores — it is looked up by the renderer, not printed.`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
// Catches the shape this used to *require*: a route id, a path or a
|
|
347
|
+
// URL written straight into the copy. Naming it here rather than
|
|
348
|
+
// letting it through as an odd slot name, because a translation
|
|
349
|
+
// carried over from the old form fails on the address itself.
|
|
350
|
+
if (mark === "a" && !SLOT.test(argument)) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`${at} has a link slot that is not a name: ${describeMark(template, index, close)}. ${argumentHint(mark)}`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
} else if (separator !== -1) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`${at} passes an argument to a mark that takes none: ${describeMark(template, index, close)}. Write "[${mark}]".`
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
flush();
|
|
362
|
+
open = { name: mark, argument };
|
|
363
|
+
openedAt = index;
|
|
364
|
+
index = close + 1;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (open !== undefined) {
|
|
368
|
+
throw new Error(
|
|
369
|
+
`${at} opens [${open.name}] and never closes it: ${describeMark(template, openedAt, template.indexOf("]", openedAt))}. Close it with "[/${open.name}]".`
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
flush();
|
|
373
|
+
return spans;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function markList(): string {
|
|
377
|
+
return `${Object.keys(MARKS)
|
|
378
|
+
.map((name) => `[${name}]`)
|
|
379
|
+
.join(", ")} and [${VOID_MARK}]`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function argumentHint(mark: MarkName): string {
|
|
383
|
+
return mark === "a"
|
|
384
|
+
? `A link names a slot the call site fills, not an address: write "[a:venue]" and pass rich(key, { venue: "contact" }). Copy holds no route ids or URLs — they belong where the route table can check them, and where they are written once rather than once per language.`
|
|
385
|
+
: `A variant names a style the renderer knows, e.g. "[v:accent]".`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Refuses a message that carries marks, for the callers that can only print it.
|
|
390
|
+
*
|
|
391
|
+
* The parser runs rather than a pattern, so a malformed mark fails here with
|
|
392
|
+
* the same error it would fail with anywhere else — `"[b]bold"` is a mistake
|
|
393
|
+
* whichever function reads it, and swallowing that to answer a yes-or-no
|
|
394
|
+
* question would leave `t()` printing the bracket it was written to refuse.
|
|
395
|
+
*
|
|
396
|
+
* `"Open [Mon-Fri]"` passes: that is a bracket nobody meant as a mark, `Mon-Fri`
|
|
397
|
+
* is not one of the five, and the parser has already said so.
|
|
398
|
+
*/
|
|
399
|
+
export function assertNoMarks(template: string, at: string): void {
|
|
400
|
+
const marked = parseMarks(template, at).some(
|
|
401
|
+
(span) => span.kind !== "text"
|
|
402
|
+
);
|
|
403
|
+
if (marked) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`${at} carries marks, and t() can only print them. Read it with rich(), or with plain(rich(…)) for the words alone.`
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
}
|