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