@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
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
|
-
|
|
183
|
+
**`CountryCode` now does. `CurrencyCode` still does not, for the reason that
|
|
184
|
+
used to cover both.**
|
|
184
185
|
|
|
185
|
-
Both
|
|
186
|
-
and `` `${Letter}${Letter}` `` — so `"XYZ"` and `"ZZ"` type-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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.
|
|
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 {
|
|
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):
|
|
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):
|
|
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
|
-
|
|
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
|
-
*
|
|
214
|
+
* Re-exported so an address and its country code stay one import.
|
|
133
215
|
*
|
|
134
|
-
*
|
|
135
|
-
* `
|
|
136
|
-
*
|
|
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
|
|
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";
|