@escape-game-over/atlas 0.1.6 → 0.1.9
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/README.md +54 -4
- package/docs/NOT-BUILT.md +29 -0
- package/docs/checks.md +21 -21
- package/docs/client-scripts.md +9 -0
- package/docs/rich-text.md +261 -0
- package/package.json +4 -4
- package/src/analytics/google.ts +45 -7
- package/src/analytics/index.ts +38 -1
- package/src/analytics/tags.ts +27 -0
- package/src/analytics/umami.ts +5 -0
- package/src/content/marks.ts +11 -6
- package/src/index.ts +1 -0
- package/src/meta/tag.ts +9 -0
package/README.md
CHANGED
|
@@ -105,9 +105,10 @@ export default defineProject(config, baseMessages, baseRoutes, {...})
|
|
|
105
105
|
export const site = createSite(config, baseMessages, baseRoutes, project)
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
-
Step 4 returns `t()`, `
|
|
109
|
-
`
|
|
110
|
-
`entries`, `sitemap()`, `robots()`, `llms()` and
|
|
108
|
+
Step 4 returns `t()`, `rich()`, `plain()`, `pathFor()`, `urlFor()`, `fileUrl()`,
|
|
109
|
+
`alternatesFor()`, `localeLinksFor()`, `metaFor()`, `breadcrumbFor()`,
|
|
110
|
+
`staticPaths()`, `routes`, `entries`, `sitemap()`, `robots()`, `llms()` and
|
|
111
|
+
`redirects()` already wired.
|
|
111
112
|
|
|
112
113
|
`t()` is bound to a locale and knows each message's `{placeholders}` from its
|
|
113
114
|
text:
|
|
@@ -125,6 +126,49 @@ t("footer.copyright", { company: "A", year: "1", x: "" }) // ✗ {x} is not a pl
|
|
|
125
126
|
Nothing is auto-injected. Site-wide values are ordinary placeholders, passed
|
|
126
127
|
explicitly.
|
|
127
128
|
|
|
129
|
+
## A sentence is one message
|
|
130
|
+
|
|
131
|
+
Copy that needs emphasis, a link or a line break carries **marks**, and stays one
|
|
132
|
+
key — so each language orders and splits it however it needs to, rather than
|
|
133
|
+
around an array of `hero.subtitle_text_1…_7` whose length was fixed by whoever
|
|
134
|
+
typed the English.
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
"about.intro": {
|
|
138
|
+
"en-US": "Book [v:accent]up to six players[/v] at [a:venue]our venue[/a].",
|
|
139
|
+
"el-GR": "Κλείσε [a:venue]στον χώρο μας[/a] [v:accent]έως έξι παίκτες[/v].",
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
const rich = site.rich(locale)
|
|
145
|
+
rich("about.intro", { venue: "contact" }) // the destination, stated once
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
| Mark | Short for | Is |
|
|
149
|
+
| ----------------- | ----------- | ---------------------------------------- |
|
|
150
|
+
| `[b]…[/b]` | **b**old | `<strong>` — emphasis, not a font weight |
|
|
151
|
+
| `[v:accent]…[/v]` | **v**ariant | a role the project's renderer maps |
|
|
152
|
+
| `[a:venue]…[/a]` | **a**nchor | a link **slot** the call site fills |
|
|
153
|
+
| `[mail]…[/mail]` | `mailto:` | a `mailto:` derived from the words |
|
|
154
|
+
| `[tel]…[/tel]` | `tel:` | a `tel:` derived from the words |
|
|
155
|
+
| `[br]` | **br**eak | a line break |
|
|
156
|
+
| `[[` and `]]` | an escape | a literal `[` and `]` |
|
|
157
|
+
|
|
158
|
+
Four of the six are HTML's own tags or schemes, so there is little to memorise.
|
|
159
|
+
Only `[v:]` is Atlas's, and it is the one that has to be: HTML has no tag for "a
|
|
160
|
+
role this project names", which is precisely the decision being kept out of the
|
|
161
|
+
copy.
|
|
162
|
+
|
|
163
|
+
`rich()` returns `Span[]` — translated text with resolved `href`s — and the
|
|
164
|
+
project owns the `switch` that draws them. Copy never holds a colour, a class, a
|
|
165
|
+
route id or a URL. A slot is filled at the call site, where it is checked against
|
|
166
|
+
the routes this deployment builds and written once instead of once per language.
|
|
167
|
+
|
|
168
|
+
`site.plain(locale)` reads the same message as words alone, for a
|
|
169
|
+
`<meta description>` or `llms.txt`; `t()` refuses a message with marks rather
|
|
170
|
+
than printing the brackets. See [`docs/rich-text.md`](docs/rich-text.md).
|
|
171
|
+
|
|
128
172
|
## What the build emits, and who asks for it
|
|
129
173
|
|
|
130
174
|
| Output | Comes from | Needs from the project |
|
|
@@ -159,6 +203,8 @@ regression-tested by an `@ts-expect-error` suite.
|
|
|
159
203
|
| A locale of a message using different `{placeholders}` than its siblings | error, naming the locale |
|
|
160
204
|
| An override for a key that is not in the default catalog | error — typos can't become dead strings |
|
|
161
205
|
| An override that drops or invents a `{placeholder}` | error — existing `t()` calls stay valid |
|
|
206
|
+
| A locale of a message using different marks than its siblings | error — a lost `[a:venue]` fails the build |
|
|
207
|
+
| Filling a `[a:slot]` with a route this project does not build | error at the call site |
|
|
162
208
|
| Calling `t()` without a required placeholder, or with a wrong/extra name | error |
|
|
163
209
|
| A route that does not state `enabled` | error — a page is never built by implication |
|
|
164
210
|
| A slug or message naming a locale the site doesn't ship | error |
|
|
@@ -313,7 +359,8 @@ src/ the package — see the export map above for what is reachable
|
|
|
313
359
|
jsonld/ the @graph — one file per node, each linked to Google's docs
|
|
314
360
|
i18n/ defineMessages(), t(), placeholder extraction
|
|
315
361
|
routes/ defineRoutes(), routeFamily(), merging and collisions
|
|
316
|
-
analytics/ Umami and Google,
|
|
362
|
+
analytics/ Umami and Google, the Consent Mode defaults, and whether a
|
|
363
|
+
visitor has to be asked anything at all
|
|
317
364
|
astro/ the only framework-aware code, plus the one component
|
|
318
365
|
bin/ the `atlas` CLI
|
|
319
366
|
tests/ runtime behaviour — what a merge resolves to, what a builder rejects
|
|
@@ -401,6 +448,9 @@ upstream, and each explains a rule that would otherwise read as an oversight.
|
|
|
401
448
|
|
|
402
449
|
- [`docs/checks.md`](docs/checks.md) — every guarantee, where it is enforced, and
|
|
403
450
|
how the type tests and runtime tests divide the work.
|
|
451
|
+
- [`docs/rich-text.md`](docs/rich-text.md) — every mark copy may carry, what each
|
|
452
|
+
produces, why a link names a slot rather than an address, and the renderer
|
|
453
|
+
contract on the other side of `Span`.
|
|
404
454
|
- [`docs/share-images.md`](docs/share-images.md) — what each social surface
|
|
405
455
|
crops, and why one 1200×630 image serves all of them.
|
|
406
456
|
- [`docs/client-scripts.md`](docs/client-scripts.md) — the browser half: what
|
package/docs/NOT-BUILT.md
CHANGED
|
@@ -312,6 +312,13 @@ visitor changes their mind. Those are product and legal decisions that differ
|
|
|
312
312
|
per deployment, and a template that guessed at them would be guessing at
|
|
313
313
|
somebody's compliance.
|
|
314
314
|
|
|
315
|
+
**One part of it is lib's, and is built as of August 2026: whether there should
|
|
316
|
+
be a banner at all.** `consentRequired(analytics)`, with the reasoning in
|
|
317
|
+
`src/analytics/index.ts`. It sits here rather than in each project because it is
|
|
318
|
+
a question about *tags* — which lib owns — wearing the costume of a question
|
|
319
|
+
about banners, and because the obvious check is wrong in both directions.
|
|
320
|
+
Everything above about the banner itself still stands.
|
|
321
|
+
|
|
315
322
|
The seam between the halves is a global — `CONSENT_UPDATE_GLOBAL` — because
|
|
316
323
|
that is the only place they *can* meet: the head script runs before any module
|
|
317
324
|
exists. lib emits an updater covering exactly the signals it defaulted, so a
|
|
@@ -326,6 +333,28 @@ Note the shape sibling projects use, and do not copy it: they group
|
|
|
326
333
|
key. Verification is a `<meta>` proving ownership; analytics is a script. One
|
|
327
334
|
config block, two different kinds of thing.
|
|
328
335
|
|
|
336
|
+
### Should lib preload the analytics scripts, not just open the connection?
|
|
337
|
+
|
|
338
|
+
**No — and the connection is already opened, which is the part worth having.**
|
|
339
|
+
`preconnect`, built as of August 2026; see `AnalyticsTag` in
|
|
340
|
+
`src/analytics/tags.ts` for how and why.
|
|
341
|
+
|
|
342
|
+
The reason to stop there is priority. A preload does not merely start a fetch
|
|
343
|
+
earlier, it raises what that fetch competes with — and what would be raised here
|
|
344
|
+
is a third-party analytics script, against the image the page is judged on. The
|
|
345
|
+
tag would arrive sooner by making the LCP arrive later, which is a worse trade
|
|
346
|
+
than the one being fixed. A handshake has no such cost: it is latency with
|
|
347
|
+
nothing else contending for it, so moving it earlier takes nothing from
|
|
348
|
+
anything.
|
|
349
|
+
|
|
350
|
+
Two traps live with this, both spelled out where the code is. A preconnect is
|
|
351
|
+
reused only by a request whose CORS mode matches it, so one marked
|
|
352
|
+
`crossorigin` for a classic `<script src>` warms nothing while looking entirely
|
|
353
|
+
correct — fonts are the opposite case, and the two must not be copied between.
|
|
354
|
+
And Tag Manager's loader URL is written *by* the inline block, so nothing in the
|
|
355
|
+
markup names that origin until the script has run, which is what makes the
|
|
356
|
+
connection worth stating rather than leaving to the preload scanner.
|
|
357
|
+
|
|
329
358
|
### Should a room be an `Event`?
|
|
330
359
|
|
|
331
360
|
**No.**
|
package/docs/checks.md
CHANGED
|
@@ -105,27 +105,27 @@ They are **excluded from the type check**, because they import `vitest` and that
|
|
|
105
105
|
command's promise is that the package type-checks with no ambient types and no
|
|
106
106
|
dependencies at all. Vitest type-checks them itself when it runs them.
|
|
107
107
|
|
|
108
|
-
| File | What it pins
|
|
109
|
-
| -------------------------- |
|
|
110
|
-
| `meta.test.ts` | the `robots` tag a policy builds, the pinned `twitter:card`, share-image warnings, length warnings, `fileUrl`
|
|
111
|
-
| `not-found.test.ts` | that a 404 carries the site's icon and theme colour, still names no canonical, and is held to the same icon rules
|
|
112
|
-
| `redirects.test.ts` | rule resolution for all three target kinds, the five rejections, the Cloudflare 2000-rule cap
|
|
113
|
-
| `sitemap.test.ts` | splitting into an index plus numbered parts, and that the entry keeps its name either way
|
|
114
|
-
| `merge.test.ts` | catalog and route overlay precedence — per locale, never per key
|
|
115
|
-
| `llms.test.ts` | section grouping, owners, heading and link precedence, and the undescribed fallback
|
|
116
|
-
| `public-files.test.ts` | walking `public/`, what is skipped, and why the declaration must be a module
|
|
117
|
-
| `breadcrumbs.test.ts` | the trail, a dropped ancestor, `orphanSegments`, and how a gap is reported
|
|
118
|
-
| `jsonld/*.test.ts` | that a `</script>` in any value cannot close the block, each node's shape and `@id`, and how a price table becomes offers
|
|
119
|
-
| `analytics.test.ts` | Umami's attributes, its three-state booleans,
|
|
120
|
-
| `google-analytics.test.ts` | that consent is denied first and precedes every tag, one loader for many ids,
|
|
121
|
-
| `contact.test.ts` | the E.164 a `tel:` needs — trunk zero dropped, spacing stripped — and the displayed form kept
|
|
122
|
-
| `hours.test.ts` | collapsing a week into runs, week start changing the answer, and every impossible week that throws
|
|
123
|
-
| `money.test.ts` | a bare count widened to a band, the span of a table, and the gaps and overlaps that throw
|
|
124
|
-
| `url.test.ts` | joining an origin to a path exactly once, and normalising the origin an `@id` is built from
|
|
125
|
-
| `warn.test.ts` | the shared prefix, and reducing a URL to its path
|
|
126
|
-
| `filters.test.ts` | folding both sides of a query, how each kind narrows, the four URL decisions, and attach/detach
|
|
127
|
-
| `filters-view.test.ts` | the write-back into the input and that it is skipped when unchanged, clear taking focus, and the nesting order
|
|
128
|
-
| `build-cache.test.ts` | that an unset `CI_ASTRO_CACHE` yields nothing, so Astro's own `cacheDir` default survives
|
|
108
|
+
| File | What it pins |
|
|
109
|
+
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
110
|
+
| `meta.test.ts` | the `robots` tag a policy builds, the pinned `twitter:card`, share-image warnings, length warnings, `fileUrl` |
|
|
111
|
+
| `not-found.test.ts` | that a 404 carries the site's icon and theme colour, still names no canonical, and is held to the same icon rules |
|
|
112
|
+
| `redirects.test.ts` | rule resolution for all three target kinds, the five rejections, the Cloudflare 2000-rule cap |
|
|
113
|
+
| `sitemap.test.ts` | splitting into an index plus numbered parts, and that the entry keeps its name either way |
|
|
114
|
+
| `merge.test.ts` | catalog and route overlay precedence — per locale, never per key |
|
|
115
|
+
| `llms.test.ts` | section grouping, owners, heading and link precedence, and the undescribed fallback |
|
|
116
|
+
| `public-files.test.ts` | walking `public/`, what is skipped, and why the declaration must be a module |
|
|
117
|
+
| `breadcrumbs.test.ts` | the trail, a dropped ancestor, `orphanSegments`, and how a gap is reported |
|
|
118
|
+
| `jsonld/*.test.ts` | that a `</script>` in any value cannot close the block, each node's shape and `@id`, and how a price table becomes offers |
|
|
119
|
+
| `analytics.test.ts` | Umami's attributes, its three-state booleans, the `domains` list that would record nothing, and that `consentRequired` answers exactly when a tag was emitted |
|
|
120
|
+
| `google-analytics.test.ts` | that consent is denied first and precedes every tag, one loader for many ids, dead `UA-` properties, and the preconnect — once, ahead of the block that writes the loader's URL, never `crossorigin` |
|
|
121
|
+
| `contact.test.ts` | the E.164 a `tel:` needs — trunk zero dropped, spacing stripped — and the displayed form kept |
|
|
122
|
+
| `hours.test.ts` | collapsing a week into runs, week start changing the answer, and every impossible week that throws |
|
|
123
|
+
| `money.test.ts` | a bare count widened to a band, the span of a table, and the gaps and overlaps that throw |
|
|
124
|
+
| `url.test.ts` | joining an origin to a path exactly once, and normalising the origin an `@id` is built from |
|
|
125
|
+
| `warn.test.ts` | the shared prefix, and reducing a URL to its path |
|
|
126
|
+
| `filters.test.ts` | folding both sides of a query, how each kind narrows, the four URL decisions, and attach/detach |
|
|
127
|
+
| `filters-view.test.ts` | the write-back into the input and that it is skipped when unchanged, clear taking focus, and the nesting order |
|
|
128
|
+
| `build-cache.test.ts` | that an unset `CI_ASTRO_CACHE` yields nothing, so Astro's own `cacheDir` default survives |
|
|
129
129
|
|
|
130
130
|
The fixture carries two nested families for `breadcrumbs.test.ts`, differing in
|
|
131
131
|
the only way that changes behaviour: `guides` has a route owning its bare slug
|
package/docs/client-scripts.md
CHANGED
|
@@ -35,6 +35,15 @@ attribute name in this package has to be matched by any consumer.
|
|
|
35
35
|
| `./astro/dom` | scoped `one`/`all` lookups, typed | the selectors |
|
|
36
36
|
| `./astro/dev-log` | a panel a failed wiring can announce itself in, in dev only | calling it behind `import.meta.env.DEV` |
|
|
37
37
|
|
|
38
|
+
`./astro/consent` is the only one of these with a build-time half. Its
|
|
39
|
+
`consentApplies()` reports whether there is a Google tag to consent to, which a
|
|
40
|
+
banner has to know — but it can only answer once it has been downloaded, so a
|
|
41
|
+
project that asks it alone ships the banner's script to every page to be told,
|
|
42
|
+
three requests later, that there was nothing to ask about. `consentRequired()`
|
|
43
|
+
from the package root is the same question at build time: false, and the banner
|
|
44
|
+
never reaches the HTML. Gate the render on that and keep the runtime check for
|
|
45
|
+
the banner that is rendered. See NOT-BUILT.md on where the halves divide.
|
|
46
|
+
|
|
38
47
|
## Lifetimes are the recurring bug
|
|
39
48
|
|
|
40
49
|
Every module here that binds a listener hands back the undo, and the undo is
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# Rich text: the marks a message may carry
|
|
2
|
+
|
|
3
|
+
`t()` prints a message. `rich()` reads the marks *in* one and hands back runs —
|
|
4
|
+
so a sentence with emphasis, a link and a line break in it stays **one message**,
|
|
5
|
+
which is the only shape a translator can reorder.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
"about.intro": {
|
|
9
|
+
"en-US": "Book [v:accent]up to six players[/v] at [a:venue]our venue[/a].",
|
|
10
|
+
"el-GR": "Κλείσε [a:venue]στον χώρο μας[/a] [v:accent]έως έξι παίκτες[/v].",
|
|
11
|
+
}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
const rich = site.rich(locale);
|
|
16
|
+
rich("about.intro", { venue: "contact" }) // the destination, stated once
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Greek moves the link to the front and spends fewer runs on the clause. Neither is
|
|
20
|
+
something the copy is allowed to ask permission for, and neither is expressible
|
|
21
|
+
as an array of `hero.subtitle_text_1…_7` keys glued together in the order someone
|
|
22
|
+
typed the English. See [`src/content/marks.ts`](../src/content/marks.ts) for that
|
|
23
|
+
argument at length.
|
|
24
|
+
|
|
25
|
+
## The marks
|
|
26
|
+
|
|
27
|
+
Five marks, one void mark, two escapes. That is the whole vocabulary.
|
|
28
|
+
|
|
29
|
+
| Written | Short for | Run | Argument | Notes |
|
|
30
|
+
| ---------------- | ----------- | ---------------------------------- | -------- | -------------------------------------------- |
|
|
31
|
+
| `[b]…[/b]` | **b**old | `{ kind: "bold" }` | — | `<strong>` — emphasis, not a font weight |
|
|
32
|
+
| `[v:name]…[/v]` | **v**ariant | `{ kind: "styled", variant }` | required | the renderer maps `name` to classes |
|
|
33
|
+
| `[a:slot]…[/a]` | **a**nchor | `{ kind: "link", to, href, url? }` | required | the **call site** says where `slot` goes |
|
|
34
|
+
| `[mail]…[/mail]` | `mailto:` | `{ kind: "email", href }` | — | the wrapped text must be the address |
|
|
35
|
+
| `[tel]…[/tel]` | `tel:` | `{ kind: "phone", href }` | — | the wrapped text must be the number |
|
|
36
|
+
| `[br]` | **br**eak | `{ kind: "break" }` | — | wraps nothing, closed by nothing |
|
|
37
|
+
| `[[` and `]]` | an escape | literal `[` and `]` | — | the escapes, matching `{{` and `}}` in `t()` |
|
|
38
|
+
|
|
39
|
+
**Four of the six are HTML's own**, which is the whole memorisation trick: `b`,
|
|
40
|
+
`a` and `br` are the tags they replace, and `mail` and `tel` are the schemes they
|
|
41
|
+
produce. Only `v` is Atlas's, and it is the one that has to be — HTML has no tag
|
|
42
|
+
for "a role this project names", because that is exactly the decision being kept
|
|
43
|
+
out of the copy.
|
|
44
|
+
|
|
45
|
+
They are short because a translator reads past them to the words, and every
|
|
46
|
+
character between `[` and `]` is noise in a sentence someone is trying to keep
|
|
47
|
+
natural. They are not so short as to be unguessable — `[b]` and `[a]` read off a
|
|
48
|
+
sentence the way their tags do, and `[x]` would not.
|
|
49
|
+
|
|
50
|
+
`[b]` is the one presentational-looking mark lib names outright, and it is not
|
|
51
|
+
presentational — it is `<strong>`, it survives into plain text as emphasis a
|
|
52
|
+
screen reader announces. Everything that is genuinely a *style* is `[v:]`.
|
|
53
|
+
|
|
54
|
+
## An argument is a role or a slot, never a value
|
|
55
|
+
|
|
56
|
+
| | `[v:name]` | `[a:name]` |
|
|
57
|
+
| ----------- | ------------------------------------------ | ------------------------------- |
|
|
58
|
+
| Names | a style the renderer knows | a slot the call site fills |
|
|
59
|
+
| Spelled | `[a-zA-Z0-9_-]+` | `[a-zA-Z0-9_]+` — **no hyphen** |
|
|
60
|
+
| Resolved by | the project's `Record<string, string>` map | `rich(key, { name: … })` |
|
|
61
|
+
| Never holds | a colour, a class, a size | a route id, a path or a URL |
|
|
62
|
+
|
|
63
|
+
The hyphen is the difference and it is deliberate. A variant usually mirrors a
|
|
64
|
+
CSS custom property or a design token, which are kebab by convention, and
|
|
65
|
+
`[v:on-dark]` is the natural spelling. A slot name is a key someone writes in
|
|
66
|
+
TypeScript — `rich(key, { venue: "contact" })` — where a hyphen forces quoting
|
|
67
|
+
and reads as a mistake.
|
|
68
|
+
|
|
69
|
+
**A link names a slot rather than a destination**, which is the rule worth
|
|
70
|
+
understanding before anything else here. A route id written into a translation is
|
|
71
|
+
an unchecked string until the day that message renders, repeated once per
|
|
72
|
+
language, sitting in a file whose reviewers are translators. Written at the call
|
|
73
|
+
site it is checked against the routes this deployment actually builds:
|
|
74
|
+
`{ venue: "contct" }` is a compile error on the line that wrote it, and so is a
|
|
75
|
+
route the project has switched off.
|
|
76
|
+
|
|
77
|
+
Copy that spells an address gets told so by name:
|
|
78
|
+
|
|
79
|
+
```txt
|
|
80
|
+
Message "about.intro" (en-US) has a link slot that is not a name: "[a:/contact]".
|
|
81
|
+
A link names a slot the call site fills, not an address: write "[a:venue]" and
|
|
82
|
+
pass rich(key, { venue: "contact" }).
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Every `[` opens a mark or is doubled
|
|
86
|
+
|
|
87
|
+
There is no third option, and a bracket that means neither is refused rather than
|
|
88
|
+
printed:
|
|
89
|
+
|
|
90
|
+
```txt
|
|
91
|
+
Message "hours.line" (en-US) has a "[" that opens neither a mark nor an escape:
|
|
92
|
+
"[Mon-Fri]". The marks are [b], [v], [a], [mail], [tel] and [br]. To write a
|
|
93
|
+
literal bracket, double it: "[[".
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`"Open [[Mon-Fri]] only."` is what that copy meant, and it renders `Open
|
|
97
|
+
[Mon-Fri] only.` This is the same call `render` makes on a stray `{`: a
|
|
98
|
+
half-written mark is a mistake, not an escape hatch, and shipping it to a page as
|
|
99
|
+
literal text is the failure nobody notices until it is quoted in a search result.
|
|
100
|
+
|
|
101
|
+
A **lone `]` stays text** — only `[` opens anything, so there is nothing an
|
|
102
|
+
unmatched closer could be ambiguous about, and refusing it would fail copy that
|
|
103
|
+
is merely writing a bracket.
|
|
104
|
+
|
|
105
|
+
An escaped `[[a:x]]` asks for nothing: it is not a mark, so it demands no slot
|
|
106
|
+
and fills none.
|
|
107
|
+
|
|
108
|
+
## Marks do not nest
|
|
109
|
+
|
|
110
|
+
One mark is open at a time. `[b]bold [v:accent]and red[/v][/b]` is refused rather
|
|
111
|
+
than flattened, because a run is bold *or* it carries a variant and there is
|
|
112
|
+
nowhere for the second one to go.
|
|
113
|
+
|
|
114
|
+
If the combination is wanted, it is a role: give one variant both properties and
|
|
115
|
+
name it once. (The shape this replaces answered the same limit by inventing a
|
|
116
|
+
`boldUnderline` kind, used once in fifty-four deployments.)
|
|
117
|
+
|
|
118
|
+
`[br]` is the exception that is not one — it wraps nothing, so it splits the run
|
|
119
|
+
without closing the mark:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
parseMarks("[b]One[br]Two[/b]")
|
|
123
|
+
// [ bold "One", break, bold "Two" ]
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
An empty run is dropped rather than emitted: `[b][/b]` produces nothing at all,
|
|
127
|
+
because copy someone half-deleted should not render an empty element that still
|
|
128
|
+
takes up a renderer's `switch`.
|
|
129
|
+
|
|
130
|
+
## Marks are parsed before `{placeholders}` are filled
|
|
131
|
+
|
|
132
|
+
That order is load-bearing in both directions:
|
|
133
|
+
|
|
134
|
+
- **A value can never inject a mark.** A venue name, a price or a phone number
|
|
135
|
+
arriving from config is data, not copy — by the time it lands, the marks are
|
|
136
|
+
already gone, so `{phone}` can hold a `[` and still renders as text.
|
|
137
|
+
- **A mark can wrap a placeholder.** `"Call us on [tel]{phone}[/tel]"` keeps the
|
|
138
|
+
number in config where it belongs and still gets a `tel:` link, without copy
|
|
139
|
+
ever spelling the digits.
|
|
140
|
+
|
|
141
|
+
## Where a link actually goes
|
|
142
|
+
|
|
143
|
+
The slot's value is a `LinkTarget`, told apart by its first character:
|
|
144
|
+
|
|
145
|
+
| Passed | `span.to` | `href` | `url` |
|
|
146
|
+
| ---------------------- | ---------- | ----------------------------- | ------------ |
|
|
147
|
+
| `"contact"` | `internal` | this locale's path | absolute |
|
|
148
|
+
| `"challenges#booking"` | `internal` | that path, fragment appended | absolute |
|
|
149
|
+
| `"/press/kit"` | `internal` | taken as written | absolute |
|
|
150
|
+
| `"https://…"` | `external` | the URL | the same URL |
|
|
151
|
+
| `"#booking"` | `anchor` | the fragment | **absent** |
|
|
152
|
+
| `"http://…"` | — | refused — write it `https://` | |
|
|
153
|
+
|
|
154
|
+
Prefer the route id. It is the only form that survives a slug being retranslated
|
|
155
|
+
or a page moving; a raw path is the escape hatch for a URL this project owns no
|
|
156
|
+
route for, and lib has nothing to check it against.
|
|
157
|
+
|
|
158
|
+
`external` is decided by the shape the target was written in, not by comparing
|
|
159
|
+
hosts — a sibling deployment on the same domain is still leaving this site, and
|
|
160
|
+
the `target`/`rel` decision is the renderer's.
|
|
161
|
+
|
|
162
|
+
An `anchor` carries **no `url`, and that is not a gap**: `rich()` is given a
|
|
163
|
+
locale, not a route, so it does not know which page a bare `#booking` belongs to.
|
|
164
|
+
A caller that does can build one — `joinUrl(site.urlFor(id, locale), href)`.
|
|
165
|
+
|
|
166
|
+
`[mail]` and `[tel]` derive their `href` from the words themselves, so the
|
|
167
|
+
address or number is written once. The number must be in international form —
|
|
168
|
+
`"+30 210 0000000"` — because a local one does not dial from abroad.
|
|
169
|
+
|
|
170
|
+
## What the compiler holds in step
|
|
171
|
+
|
|
172
|
+
Which marks a message uses is checked across locales by the same machinery that
|
|
173
|
+
holds `{placeholders}` in step, so a translation that quietly loses `[a:venue]`
|
|
174
|
+
fails the build rather than the page.
|
|
175
|
+
|
|
176
|
+
**The argument is part of the token, on purpose.** `[a:venue]` and `[a:booking]`
|
|
177
|
+
are different tokens, so one language cannot fill a different slot from the
|
|
178
|
+
others, and `[v:accent]` cannot become `[v:inverse]` in the Greek. Those are
|
|
179
|
+
decisions the copy makes once, not per locale.
|
|
180
|
+
|
|
181
|
+
Multiplicity is not carried — two `[b]` runs and one look alike to the type,
|
|
182
|
+
which is the same limit `Placeholders` has with two `{name}`s and the same trade.
|
|
183
|
+
|
|
184
|
+
The link slots a message declares become required arguments:
|
|
185
|
+
`"See [a:venue]us[/a]"` asks the call site for `{ venue: LinkTarget }`, typed
|
|
186
|
+
over the routes this project builds.
|
|
187
|
+
|
|
188
|
+
## `t()` refuses a message with marks in it
|
|
189
|
+
|
|
190
|
+
`t()` can only print, so it would print the brackets. It throws instead, and says
|
|
191
|
+
what to do:
|
|
192
|
+
|
|
193
|
+
```txt
|
|
194
|
+
Message "about.intro" (en-US) carries marks, and t() can only print them.
|
|
195
|
+
Read it with rich(), or with plain(rich(…)) for the words alone.
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
A malformed mark fails there too, with the same error it would give anywhere
|
|
199
|
+
else — `"[b]bold"` is a mistake whichever function reads it.
|
|
200
|
+
|
|
201
|
+
## The same sentence as words
|
|
202
|
+
|
|
203
|
+
`site.plain(locale)` gives the message with the markup gone: for a
|
|
204
|
+
`<meta name="description">`, an `llms.txt` summary, a structured-data
|
|
205
|
+
`description` — everywhere that takes a string rather than markup. `[br]` becomes
|
|
206
|
+
a space and runs of whitespace collapse, so copy split across a line break does
|
|
207
|
+
not arrive with a double space in the middle of a `<meta>` tag.
|
|
208
|
+
|
|
209
|
+
**It asks for `{placeholders}` and not for link destinations.** A `tel:` it would
|
|
210
|
+
discard is not worth building, and `llms.txt` should not have to name a route for
|
|
211
|
+
every link in every description in order to throw them all away.
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
const plainText = site.plain(locale);
|
|
215
|
+
plainText("about.intro", { company }) // no `venue`, no `ask`
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`plain(rich(…))` is the same answer when the runs are already in hand.
|
|
219
|
+
|
|
220
|
+
Reach for `t()` when the copy is structurally plain and should stay that way — a
|
|
221
|
+
button label, an `aria-label`. Reach for `plain()` when the answer is prose: it
|
|
222
|
+
keeps working on the day someone adds emphasis to the sentence, where `t()` would
|
|
223
|
+
start throwing.
|
|
224
|
+
|
|
225
|
+
## The renderer half
|
|
226
|
+
|
|
227
|
+
lib decides which runs exist and what they say; the project decides what they
|
|
228
|
+
look like. A renderer is a `switch` over `kind` and nothing else — every string
|
|
229
|
+
is already translated, every `href` already resolved.
|
|
230
|
+
|
|
231
|
+
Make the `switch` exhaustive. `Span` is a closed union, so `satisfies never` on
|
|
232
|
+
the fallthrough turns a run type added in lib into a compile error in the
|
|
233
|
+
project, which is the failure a `default: return null` cannot have — a new kind
|
|
234
|
+
rendering as nothing at all, on every page, silently.
|
|
235
|
+
|
|
236
|
+
[`examples/b2c/src/components/RichText.astro`](../examples/b2c/src/components/RichText.astro)
|
|
237
|
+
is one to copy and restyle. The part worth keeping is its `VARIANTS` map: it is
|
|
238
|
+
the only place a role becomes a colour, so a rebrand is that object rather than a
|
|
239
|
+
sweep through every deployment's sentences. It falls back to no classes for a
|
|
240
|
+
variant it does not know — losing the sentence is the worse failure.
|
|
241
|
+
|
|
242
|
+
## What fails, and where
|
|
243
|
+
|
|
244
|
+
| Written | Result |
|
|
245
|
+
| --------------------------------------------------- | --------------------------------------------------------------------- |
|
|
246
|
+
| A locale dropping `[a:venue]` | compile error — the marks must match across a message |
|
|
247
|
+
| `[a:venue]` in one locale, `[a:booking]` in another | compile error — the argument is part of the token |
|
|
248
|
+
| `rich()` without a slot's target | compile error, then a build error naming the slot |
|
|
249
|
+
| A slot filled with an unbuilt route | compile error at the call site |
|
|
250
|
+
| `[Mon-Fri]` | build error, listing the marks and the `[[` escape |
|
|
251
|
+
| `[b]` never closed, `[/b]` never opened | build error naming the message and locale |
|
|
252
|
+
| `[b]a[v:x]b[/v][/b]` | build error — marks do not nest |
|
|
253
|
+
| `[v:]` or `[a:]` with nothing after the colon | build error, saying what the argument is |
|
|
254
|
+
| `[a:/contact]` — an address in the copy | build error — a slot is a name |
|
|
255
|
+
| `[v:on dark]` — a variant that is not a name | build error |
|
|
256
|
+
| `[b:x]` — an argument on a mark that takes none | build error |
|
|
257
|
+
| `[mail]` around text with no `@` | build error — a `mailto:` with an empty To: still opens a mail client |
|
|
258
|
+
| `[tel]` around a local number | build error — write it with a country code |
|
|
259
|
+
| `http://` as a link target | build error — an `http://` link is a downgrade |
|
|
260
|
+
| `"contact#"` — a fragment that names nothing | build error |
|
|
261
|
+
| A marked message read with `t()` | build error, pointing at `rich()` and `plain()` |
|
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.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
|
|
6
6
|
"private": false,
|
|
@@ -53,10 +53,10 @@
|
|
|
53
53
|
"typescript": ">=5"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@biomejs/biome": "2.5.
|
|
57
|
-
"@types/node": "26.
|
|
56
|
+
"@biomejs/biome": "2.5.12",
|
|
57
|
+
"@types/node": "26.4.1",
|
|
58
58
|
"@vitest/coverage-istanbul": "4.1.11",
|
|
59
|
-
"astro": "7.2.
|
|
59
|
+
"astro": "7.2.10",
|
|
60
60
|
"typescript": "6.0.3",
|
|
61
61
|
"vitest": "4.1.11"
|
|
62
62
|
}
|
package/src/analytics/google.ts
CHANGED
|
@@ -243,8 +243,42 @@ function consentCalls(defaults: readonly ConsentDefaults[]): string {
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
/**
|
|
246
|
-
*
|
|
247
|
-
*
|
|
246
|
+
* The one host every Google tag here is served from.
|
|
247
|
+
*
|
|
248
|
+
* Named because it is stated four times below — the loader's `src`, the
|
|
249
|
+
* container bootstrap written into the inline block, the `<noscript>` iframe,
|
|
250
|
+
* and the connection opened ahead of all three. The preconnect is only worth
|
|
251
|
+
* anything while it names the origin the scripts actually use, and three
|
|
252
|
+
* hand-written copies of a hostname is how it stops.
|
|
253
|
+
*/
|
|
254
|
+
const TAG_ORIGIN = "https://www.googletagmanager.com";
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Whether a `google` block comes to any tag at all.
|
|
258
|
+
*
|
|
259
|
+
* The condition `googleScripts` returns early on, given a name because two
|
|
260
|
+
* callers need the same answer. A consumer deciding whether to render a consent
|
|
261
|
+
* banner is asking exactly this: a block carrying neither `tagIds` nor
|
|
262
|
+
* `containerIds` emits nothing, sets no cookies, and needs no permission — and
|
|
263
|
+
* a banner shown for it asks a visitor to agree to something that was never
|
|
264
|
+
* written. See `consentRequired` in `analytics/index.ts`.
|
|
265
|
+
*
|
|
266
|
+
* A type predicate, so the caller below keeps its narrowing: `true` implies
|
|
267
|
+
* the block is there, which is the only direction anything relies on.
|
|
268
|
+
*/
|
|
269
|
+
export function googleEmits(
|
|
270
|
+
google: GoogleSettings | undefined
|
|
271
|
+
): google is GoogleSettings {
|
|
272
|
+
if (google === undefined) return false;
|
|
273
|
+
return (
|
|
274
|
+
(google.tagIds ?? []).length > 0 ||
|
|
275
|
+
(google.containerIds ?? []).length > 0
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Google's tags: a connection and one inline block and the loader in the head,
|
|
281
|
+
* Tag Manager's `<noscript>` fallback in the body.
|
|
248
282
|
*
|
|
249
283
|
* One block rather than several because `dataLayer` is a queue — consent, the
|
|
250
284
|
* timestamp, every `config` and Tag Manager's own bootstrap can all be pushed
|
|
@@ -263,11 +297,10 @@ export function googleScripts(
|
|
|
263
297
|
at: string
|
|
264
298
|
): AnalyticsTags {
|
|
265
299
|
const nothing: AnalyticsTags = { head: [], body: [] };
|
|
266
|
-
if (google
|
|
300
|
+
if (!googleEmits(google)) return nothing;
|
|
267
301
|
|
|
268
302
|
const tags = google.tagIds ?? [];
|
|
269
303
|
const containers = google.containerIds ?? [];
|
|
270
|
-
if (tags.length === 0 && containers.length === 0) return nothing;
|
|
271
304
|
|
|
272
305
|
// An id in the wrong field is the silent failure here: it is configured,
|
|
273
306
|
// it is emitted, and it reports nowhere — which reads as a quiet week.
|
|
@@ -312,7 +345,7 @@ export function googleScripts(
|
|
|
312
345
|
// itself, so it runs after the consent calls already queued above.
|
|
313
346
|
...containers.map(
|
|
314
347
|
(id) =>
|
|
315
|
-
`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='
|
|
348
|
+
`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='${TAG_ORIGIN}/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f)})(window,document,'script','dataLayer',${literal(id)});`
|
|
316
349
|
),
|
|
317
350
|
].join("");
|
|
318
351
|
|
|
@@ -330,13 +363,18 @@ export function googleScripts(
|
|
|
330
363
|
const first = tags[0];
|
|
331
364
|
return {
|
|
332
365
|
head: [
|
|
366
|
+
// Ahead of the inline block, which is the only position that buys
|
|
367
|
+
// anything: for a container the loader's URL is written *by* that
|
|
368
|
+
// block, so this is the only mention of the origin the browser can
|
|
369
|
+
// act on before the script has run. See `AnalyticsTag`.
|
|
370
|
+
{ kind: "preconnect", origin: TAG_ORIGIN },
|
|
333
371
|
{ kind: "inline", content: inline },
|
|
334
372
|
...(first === undefined
|
|
335
373
|
? []
|
|
336
374
|
: [
|
|
337
375
|
{
|
|
338
376
|
kind: "external" as const,
|
|
339
|
-
src:
|
|
377
|
+
src: `${TAG_ORIGIN}/gtag/js?id=${encodeURIComponent(first)}`,
|
|
340
378
|
attributes: {},
|
|
341
379
|
},
|
|
342
380
|
]),
|
|
@@ -345,7 +383,7 @@ export function googleScripts(
|
|
|
345
383
|
// fallback, because `gtag.js` is the only way it collects anything.
|
|
346
384
|
body: containers.map((id) => ({
|
|
347
385
|
kind: "noscriptFrame" as const,
|
|
348
|
-
src:
|
|
386
|
+
src: `${TAG_ORIGIN}/ns.html?id=${encodeURIComponent(id)}`,
|
|
349
387
|
})),
|
|
350
388
|
};
|
|
351
389
|
}
|
package/src/analytics/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HttpsUrl } from "../url.ts";
|
|
2
|
-
import { type GoogleSettings, googleScripts } from "./google.ts";
|
|
2
|
+
import { type GoogleSettings, googleEmits, googleScripts } from "./google.ts";
|
|
3
3
|
import type { AnalyticsTag, AnalyticsTags } from "./tags.ts";
|
|
4
4
|
import {
|
|
5
5
|
checkUmamiDomains,
|
|
@@ -68,6 +68,43 @@ export function analyticsScripts(
|
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Whether this deployment loads anything a visitor has to be asked about.
|
|
73
|
+
*
|
|
74
|
+
* The build-time twin of `consentApplies()` in `astro/consent.ts`, which asks
|
|
75
|
+
* the same question in the browser by looking for the global the Google tag
|
|
76
|
+
* defines. That one cannot answer it early enough to be useful: a consent
|
|
77
|
+
* banner's script has to be downloaded, parsed and run before it can report
|
|
78
|
+
* that there was nothing to consent to — three requests to learn that none of
|
|
79
|
+
* them were needed. Asked here, a project leaves the banner out of the HTML.
|
|
80
|
+
*
|
|
81
|
+
* Vendor by vendor rather than `analytics.google !== undefined`, because that
|
|
82
|
+
* is not the question. A `google` block carrying no ids emits no tag and sets
|
|
83
|
+
* no cookies, and a banner shown for it asks a visitor to agree to something
|
|
84
|
+
* that was never written — which teaches them their answer does not matter.
|
|
85
|
+
* `googleEmits` is the same predicate `googleScripts` returns early on, so the
|
|
86
|
+
* two cannot disagree.
|
|
87
|
+
*
|
|
88
|
+
* **`VENDORS` is exhaustive on purpose.** Adding a key to `AnalyticsSettings`
|
|
89
|
+
* stops this file compiling until someone says whether that vendor needs
|
|
90
|
+
* permission — which is the failure worth having, since the alternative is a
|
|
91
|
+
* new vendor silently setting cookies behind a banner that never appears.
|
|
92
|
+
*/
|
|
93
|
+
export function consentRequired(
|
|
94
|
+
analytics: AnalyticsSettings | undefined
|
|
95
|
+
): boolean {
|
|
96
|
+
if (analytics === undefined) return false;
|
|
97
|
+
|
|
98
|
+
const VENDORS: Readonly<Record<keyof AnalyticsSettings, boolean>> = {
|
|
99
|
+
// Cookieless by design — nothing to permit, nothing to withdraw.
|
|
100
|
+
umami: false,
|
|
101
|
+
// Consent Mode, but only once there is a tag reading it.
|
|
102
|
+
google: googleEmits(analytics.google),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return Object.values(VENDORS).some(Boolean);
|
|
106
|
+
}
|
|
107
|
+
|
|
71
108
|
/**
|
|
72
109
|
* The tag a 404 grouped under, so the misses can be read on their own.
|
|
73
110
|
*
|
package/src/analytics/tags.ts
CHANGED
|
@@ -29,6 +29,33 @@ export type AnalyticsTag =
|
|
|
29
29
|
| {
|
|
30
30
|
readonly kind: "noscriptFrame";
|
|
31
31
|
readonly src: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* An origin to open a connection to before anything asks it for bytes.
|
|
35
|
+
*
|
|
36
|
+
* Every vendor here is served from a host that is not the site's, so the
|
|
37
|
+
* first tag to load pays a DNS lookup, a TCP handshake and a TLS
|
|
38
|
+
* negotiation before a single byte of script arrives. Tag Manager is the
|
|
39
|
+
* worst case: its loader is written by the inline block above, so the
|
|
40
|
+
* origin appears nowhere a preload scanner can read it and the connection
|
|
41
|
+
* cannot begin until that script has run.
|
|
42
|
+
*
|
|
43
|
+
* An origin rather than a URL, and `preconnect` rather than `preload`,
|
|
44
|
+
* because the handshake is the part worth moving and the fetch is not.
|
|
45
|
+
* Preloading a tag would raise a third-party analytics script to the
|
|
46
|
+
* priority of the things the page is drawn from — it would arrive sooner
|
|
47
|
+
* by making the image beside it arrive later.
|
|
48
|
+
*
|
|
49
|
+
* **No `crossorigin`, and that is load-bearing.** A preconnect is reused
|
|
50
|
+
* only by a request whose CORS mode matches it, and every script here is
|
|
51
|
+
* fetched as a classic `<script src>`, which is not a CORS request. One
|
|
52
|
+
* carrying `crossorigin` would open a second connection that nothing uses,
|
|
53
|
+
* warm nothing, and look entirely correct in the markup. Fonts are the
|
|
54
|
+
* opposite case and do need it, which is why this is worth stating.
|
|
55
|
+
*/
|
|
56
|
+
| {
|
|
57
|
+
readonly kind: "preconnect";
|
|
58
|
+
readonly origin: string;
|
|
32
59
|
};
|
|
33
60
|
|
|
34
61
|
/**
|
package/src/analytics/umami.ts
CHANGED
|
@@ -248,6 +248,11 @@ export function umamiScripts(
|
|
|
248
248
|
};
|
|
249
249
|
|
|
250
250
|
return [
|
|
251
|
+
// Before the script that needs it. A `src.` subdomain reads as
|
|
252
|
+
// first-party and is not one to the network stack: it is a separate
|
|
253
|
+
// origin, and the tracker pays its full handshake before it can start.
|
|
254
|
+
// See `AnalyticsTag` for why this is a preconnect and not a preload.
|
|
255
|
+
{ kind: "preconnect", origin: umami.host },
|
|
251
256
|
{
|
|
252
257
|
kind: "external",
|
|
253
258
|
src: at(umami.tracker?.path ?? "/script.js"),
|
package/src/content/marks.ts
CHANGED
|
@@ -65,10 +65,13 @@ export type ParsedSpan =
|
|
|
65
65
|
/**
|
|
66
66
|
* The marks, and what each produces.
|
|
67
67
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
68
|
+
* `b` is bold, `v` is variant, `a` is anchor; `mail` and `tel` are the schemes
|
|
69
|
+
* they produce, and `br` is the break below — four of the six borrowed from HTML
|
|
70
|
+
* outright, so `v` is the only one anybody has to learn, and it is the one HTML
|
|
71
|
+
* has no tag for. Short names beyond that because a translator
|
|
72
|
+
* reads past them to the words, and every character between `[` and `]` is noise
|
|
73
|
+
* in a sentence they are trying to keep natural. Long enough to guess at from
|
|
74
|
+
* context, which `[b]` and `[a]` are and `[x]` would not be.
|
|
72
75
|
*
|
|
73
76
|
* `bold` is the one presentational mark lib names outright, because it is not
|
|
74
77
|
* presentational: it is `<strong>`, it survives into plain text as emphasis a
|
|
@@ -393,8 +396,10 @@ function argumentHint(mark: MarkName): string {
|
|
|
393
396
|
* whichever function reads it, and swallowing that to answer a yes-or-no
|
|
394
397
|
* question would leave `t()` printing the bracket it was written to refuse.
|
|
395
398
|
*
|
|
396
|
-
* `"Open [Mon-Fri]"` passes:
|
|
397
|
-
*
|
|
399
|
+
* `"Open [[Mon-Fri]] only"` passes: a doubled bracket is an escape rather than a
|
|
400
|
+
* mark, so every run is text and `t()` prints it. Undoubled it fails, and fails
|
|
401
|
+
* in the parser — `[Mon-Fri]` opens neither a mark nor an escape, which is not a
|
|
402
|
+
* question about who is reading the message.
|
|
398
403
|
*/
|
|
399
404
|
export function assertNoMarks(template: string, at: string): void {
|
|
400
405
|
const marked = parseMarks(template, at).some(
|
package/src/index.ts
CHANGED
package/src/meta/tag.ts
CHANGED
|
@@ -92,6 +92,15 @@ export function asMetaTag(script: AnalyticsTag): MetaTag {
|
|
|
92
92
|
if (script.kind === "noscriptFrame") {
|
|
93
93
|
return { kind: "noscriptFrame", src: script.src };
|
|
94
94
|
}
|
|
95
|
+
if (script.kind === "preconnect") {
|
|
96
|
+
// A `link` rather than a kind of its own: the head vocabulary already
|
|
97
|
+
// has the shape, and a preconnect is a link in every sense the renderer
|
|
98
|
+
// cares about. See `AnalyticsTag` for why no `crossorigin`.
|
|
99
|
+
return {
|
|
100
|
+
kind: "link",
|
|
101
|
+
attrs: { rel: "preconnect", href: script.origin },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
95
104
|
const unhandled: never = script;
|
|
96
105
|
throw new Error(`Unhandled analytics tag: ${JSON.stringify(unhandled)}`);
|
|
97
106
|
}
|