@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 CHANGED
@@ -180,20 +180,28 @@ exactly the set displayed — never a number typed in beside a shorter list.
180
180
 
181
181
  ### Should `CurrencyCode` and `CountryCode` spell out the ISO lists?
182
182
 
183
- **Not now.**
183
+ **`CountryCode` now does. `CurrencyCode` still does not, for the reason that
184
+ used to cover both.**
184
185
 
185
- Both are shapes rather than vocabularies — `` `${Letter}${Letter}${Letter}` ``
186
- and `` `${Letter}${Letter}` `` — so `"XYZ"` and `"ZZ"` type-check. ISO 4217 has
186
+ Both were shapes rather than vocabularies — `` `${Letter}${Letter}${Letter}` ``
187
+ and `` `${Letter}${Letter}` `` — so `"XYZ"` and `"ZZ"` type-checked. ISO 4217 has
187
188
  around 180 active codes and ISO 3166-1 alpha-2 has 249; both are small, closed
188
189
  and effectively frozen, so enumerating them is possible in a way the [IANA
189
190
  language registry](https://www.iana.org/assignments/language-subtag-registry) is
190
191
  not — which is the case `LanguageTag` in `config.ts` declines and says why.
191
192
 
192
- Left as shapes because the mistake is not one anyone has made: a currency is
193
- written once per project, beside the prices it applies to, and a wrong one is
194
- visible the first time a page renders. That is a different risk from a typo in a
195
- directive nobody reads. Worth revisiting if a project ever computes a code
196
- rather than writing it.
193
+ What moved is not the risk, it is the reason to hold the list at all. A country
194
+ menu needs the codes *as data* `countriesFor` renders and sorts them per
195
+ locale and once the list exists, deriving the type from it is free and the
196
+ shape is strictly worse: it admitted 676 strings for 250 countries, including
197
+ the withdrawn codes (`SU`, `YU`, `AN`) that look plausible enough to be typed by
198
+ accident. So `CountryCode` is now `(typeof COUNTRY_CODES)[number]`, defined in
199
+ `countries.ts` and re-exported from `contact.ts` beside `PostalAddress`.
200
+
201
+ `CurrencyCode` stays a shape, because nothing needs a currency *list*: a
202
+ currency is written once per project, beside the prices it applies to, and a
203
+ wrong one is visible the first time a page renders. Enumerate it the day
204
+ something renders a currency picker, and not before.
197
205
 
198
206
  ### Should `@type` be checked against schema.org's vocabulary?
199
207
 
@@ -219,6 +227,73 @@ type could — a property that is not valid for the type it sits on.
219
227
 
220
228
  ## Answers that will not change on their own
221
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
+
222
297
  ### Should lib carry the consent *banner* as well as the tags?
223
298
 
224
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.4",
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
@@ -1,4 +1,5 @@
1
- import type { Digit, Letter } from "./types.ts";
1
+ import type { CountryCode } from "./countries.ts";
2
+ import type { Digit } from "./types.ts";
2
3
  import { warn } from "./warn.ts";
3
4
 
4
5
  /**
@@ -81,6 +82,30 @@ export function formatPhone(phone: PhoneNumber): string {
81
82
  return `+${phone.code} ${phone.number}`;
82
83
  }
83
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
+
84
109
  /**
85
110
  * The number as a phone dials it: `tel:+390600000000`.
86
111
  *
@@ -92,7 +117,7 @@ export function formatPhone(phone: PhoneNumber): string {
92
117
  * and nothing is removed, so this and `formatPhone` are two renderings of one
93
118
  * value rather than two opinions about it.
94
119
  */
95
- export function telHref(phone: PhoneNumber): string {
120
+ export function telHref(phone: PhoneNumber): TelUrl {
96
121
  return `tel:${e164(phone)}`;
97
122
  }
98
123
 
@@ -117,25 +142,82 @@ export function telHref(phone: PhoneNumber): string {
117
142
  * `replace(/^0+/, "")` also took *every* leading zero rather than one trunk
118
143
  * digit, so `007…` became `7…`.
119
144
  */
120
- export function e164(phone: PhoneNumber): string {
145
+ export function e164(phone: PhoneNumber): E164 {
121
146
  return `+${phone.code}${phone.number.replace(/\D/g, "")}`;
122
147
  }
123
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
+
124
176
  /** An email address, which is only ever used whole. */
125
177
  export type EmailAddress = `${string}@${string}`;
126
178
 
127
- 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 {
128
210
  return `mailto:${email}`;
129
211
  }
130
212
 
131
213
  /**
132
- * An ISO 3166-1 alpha-2 country code: `"IT"`, `"GR"`, `"RO"`.
214
+ * Re-exported so an address and its country code stay one import.
133
215
  *
134
- * Two uppercase letters, which is what `addressCountry` is read as. A name
135
- * `"Italy"`, `"Ιταλία"` is a translation of a country rather than an
136
- * identifier for one, and belongs in the sentence a page prints, not here.
216
+ * Defined beside the list it is read from see `countries.ts`. Imported at the
217
+ * top as well: `export … from` forwards a name without binding it here, and
218
+ * `PostalAddress` below needs it in scope.
137
219
  */
138
- export type CountryCode = `${Letter}${Letter}`;
220
+ export type { CountryCode };
139
221
 
140
222
  /**
141
223
  * Where a place is, to the precision a map needs.
@@ -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";