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