@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 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.5",
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): string {
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): string {
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
- export function mailtoHref(email: EmailAddress): string {
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
+ }