@escape-game-over/atlas 0.1.23 → 0.1.25
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 +29 -45
- package/bin/use-project.mjs +18 -13
- package/docs/NOT-BUILT.md +1 -1
- package/docs/client-scripts.md +73 -141
- package/docs/rich-text.md +25 -20
- package/package.json +5 -12
- package/src/analytics/google.ts +6 -6
- package/src/analytics/index.ts +4 -3
- package/src/analytics/tags.ts +14 -60
- package/src/analytics/umami.ts +8 -8
- package/src/astro/ConsentBanner.astro +25 -0
- package/src/astro/ConsentElement.astro +61 -0
- package/src/astro/Document.astro +44 -0
- package/src/astro/Image.astro +102 -0
- package/src/astro/MetaTags.astro +3 -26
- package/src/astro/RichText.astro +71 -0
- package/src/astro/Zoom.astro +61 -0
- package/src/astro/client.ts +19 -9
- package/src/astro/consent.ts +20 -0
- package/src/astro/dev-log.ts +8 -14
- package/src/astro/element.ts +111 -112
- package/src/astro/filters-view.ts +48 -64
- package/src/astro/filters.ts +46 -37
- package/src/astro/images.ts +27 -26
- package/src/astro/index.ts +2 -9
- package/src/astro/markup.ts +6 -6
- package/src/astro/site-routes.ts +10 -15
- package/src/config.ts +23 -36
- package/src/content/index.ts +2 -1
- package/src/content/marks.ts +13 -13
- package/src/content/rich.ts +58 -29
- package/src/hours.ts +48 -11
- package/src/i18n/define.ts +14 -74
- package/src/index.ts +41 -57
- package/src/jsonld/faq.ts +2 -1
- package/src/jsonld/node.ts +4 -14
- package/src/meta/index.ts +7 -13
- package/src/meta/share-image.ts +6 -19
- package/src/meta/tag.ts +1 -45
- package/src/money.ts +161 -6
- package/src/project.ts +84 -73
- package/src/routes/define.ts +8 -44
- package/src/routes/resolve.ts +1 -1
- package/src/site/api.ts +7 -33
- package/src/site/create.ts +6 -10
- package/src/site/define.ts +120 -0
- package/src/site/index.ts +2 -5
- package/src/site/page.ts +4 -2
- package/src/sitemap.ts +2 -35
- package/src/warn.ts +16 -17
- package/src/astro/dom.ts +0 -35
package/docs/rich-text.md
CHANGED
|
@@ -29,10 +29,10 @@ Five marks, one void mark, two escapes. That is the whole vocabulary.
|
|
|
29
29
|
| Written | Short for | Run | Argument | Notes |
|
|
30
30
|
| ---------------- | ----------- | ---------------------------------- | -------- | -------------------------------------------- |
|
|
31
31
|
| `[b]…[/b]` | **b**old | `{ kind: "bold" }` | — | `<strong>` — emphasis, not a font weight |
|
|
32
|
-
| `[v:name]…[/v]` | **v**ariant | `{ kind: "
|
|
32
|
+
| `[v:name]…[/v]` | **v**ariant | `{ kind: "variant", variant }` | required | the renderer maps `name` to classes |
|
|
33
33
|
| `[a:slot]…[/a]` | **a**nchor | `{ kind: "link", to, href, url? }` | required | the **call site** says where `slot` goes |
|
|
34
|
-
| `[mail]…[/mail]` | `mailto:` | `{ kind: "
|
|
35
|
-
| `[tel]…[/tel]` | `tel:` | `{ kind: "
|
|
34
|
+
| `[mail]…[/mail]` | `mailto:` | `{ kind: "mail", href }` | — | the wrapped text must be the address |
|
|
35
|
+
| `[tel]…[/tel]` | `tel:` | `{ kind: "tel", href }` | — | the wrapped text must be the number |
|
|
36
36
|
| `[br]` | **br**eak | `{ kind: "break" }` | — | wraps nothing, closed by nothing |
|
|
37
37
|
| `[[` and `]]` | an escape | literal `[` and `]` | — | the escapes, matching `{{` and `}}` in `t()` |
|
|
38
38
|
|
|
@@ -192,7 +192,7 @@ what to do:
|
|
|
192
192
|
|
|
193
193
|
```txt
|
|
194
194
|
Message "about.intro" (en-US) carries marks, and t() can only print them.
|
|
195
|
-
Read it with rich(), or with plain(
|
|
195
|
+
Read it with rich(), or with site.plain() for the words alone.
|
|
196
196
|
```
|
|
197
197
|
|
|
198
198
|
A malformed mark fails there too, with the same error it would give anywhere
|
|
@@ -215,29 +215,34 @@ const plainText = site.plain(locale);
|
|
|
215
215
|
plainText("about.intro", { company }) // no `venue`, no `ask`
|
|
216
216
|
```
|
|
217
217
|
|
|
218
|
-
`plain(rich(…))` is the same answer when the runs are already in hand.
|
|
219
|
-
|
|
220
218
|
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
|
|
219
|
+
button label, an `aria-label`. Reach for `site.plain()` when the answer is prose: it
|
|
222
220
|
keeps working on the day someone adds emphasis to the sentence, where `t()` would
|
|
223
221
|
start throwing.
|
|
224
222
|
|
|
223
|
+
## The same sentence as inline HTML
|
|
224
|
+
|
|
225
|
+
`html(rich(…))` is for a structured-data field that accepts a little markup — a
|
|
226
|
+
`FAQPage` answer's `text`. It emits `<a>`, `<strong>` and `<br>`, links by the
|
|
227
|
+
absolute `url` because nothing reading it has a page to resolve a path against,
|
|
228
|
+
and escapes all the copy. A `[v:]` role and an in-page anchor come out as their
|
|
229
|
+
words.
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
faqPage([{ question, answer: html(rich("faq.city.a", { network: "network" })) }], at)
|
|
233
|
+
```
|
|
234
|
+
|
|
225
235
|
## The renderer half
|
|
226
236
|
|
|
227
|
-
|
|
228
|
-
look
|
|
229
|
-
|
|
237
|
+
`@escape-game-over/atlas/astro/rich-text` renders the runs. The site supplies the
|
|
238
|
+
look — a class per `[v:name]` and one for links — usually in a small wrapper
|
|
239
|
+
([`examples/b2c/src/components/RichText.astro`](../examples/b2c/src/components/RichText.astro)):
|
|
230
240
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
rendering as nothing at all, on every page, silently.
|
|
241
|
+
```astro
|
|
242
|
+
<RichText spans={rich("about.intro", { venue: "contact" })} variants={{ accent: "…" }} link="…" />
|
|
243
|
+
```
|
|
235
244
|
|
|
236
|
-
|
|
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.
|
|
245
|
+
A variant with no class still renders its words, and warns at build.
|
|
241
246
|
|
|
242
247
|
## What fails, and where
|
|
243
248
|
|
|
@@ -258,4 +263,4 @@ variant it does not know — losing the sentence is the worse failure.
|
|
|
258
263
|
| `[tel]` around a local number | build error — write it with a country code |
|
|
259
264
|
| `http://` as a link target | build error — an `http://` link is a downgrade |
|
|
260
265
|
| `"contact#"` — a fragment that names nothing | build error |
|
|
261
|
-
| A marked message read with `t()` | build error, pointing at `rich()` and `plain()`
|
|
266
|
+
| A marked message read with `t()` | build error, pointing at `rich()` and `site.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.25",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
|
|
6
6
|
"private": false,
|
|
@@ -15,18 +15,11 @@
|
|
|
15
15
|
".": "./src/index.ts",
|
|
16
16
|
"./astro": "./src/astro/index.ts",
|
|
17
17
|
"./astro/images": "./src/astro/images.ts",
|
|
18
|
-
"./astro/background-video": "./src/astro/background-video.ts",
|
|
19
|
-
"./astro/carousel": "./src/astro/carousel.ts",
|
|
20
18
|
"./client": "./src/astro/client.ts",
|
|
21
|
-
"./astro/consent": "./src/astro/
|
|
22
|
-
"./astro/
|
|
23
|
-
"./astro/
|
|
24
|
-
"./astro/
|
|
25
|
-
"./astro/filters": "./src/astro/filters.ts",
|
|
26
|
-
"./astro/filters-view": "./src/astro/filters-view.ts",
|
|
27
|
-
"./astro/markup": "./src/astro/markup.ts",
|
|
28
|
-
"./astro/meta-tags": "./src/astro/MetaTags.astro",
|
|
29
|
-
"./astro/youtube": "./src/astro/youtube.ts"
|
|
19
|
+
"./astro/consent-banner": "./src/astro/ConsentBanner.astro",
|
|
20
|
+
"./astro/document": "./src/astro/Document.astro",
|
|
21
|
+
"./astro/image": "./src/astro/Image.astro",
|
|
22
|
+
"./astro/rich-text": "./src/astro/RichText.astro"
|
|
30
23
|
},
|
|
31
24
|
"bin": {
|
|
32
25
|
"atlas": "bin/use-project.mjs"
|
package/src/analytics/google.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { warn } from "../warn.ts";
|
|
2
|
-
import { type AnalyticsTags, literal } from "./tags.ts";
|
|
2
|
+
import { type AnalyticsTags, literal, preconnect } from "./tags.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Google's tags — Analytics and Tag Manager — and the consent state they read.
|
|
@@ -366,16 +366,16 @@ export function googleScripts(
|
|
|
366
366
|
// Ahead of the inline block, which is the only position that buys
|
|
367
367
|
// anything: for a container the loader's URL is written *by* that
|
|
368
368
|
// block, so this is the only mention of the origin the browser can
|
|
369
|
-
// act on before the script has run.
|
|
370
|
-
|
|
371
|
-
{ kind: "
|
|
369
|
+
// act on before the script has run.
|
|
370
|
+
preconnect(TAG_ORIGIN),
|
|
371
|
+
{ kind: "script", content: inline },
|
|
372
372
|
...(first === undefined
|
|
373
373
|
? []
|
|
374
374
|
: [
|
|
375
375
|
{
|
|
376
|
-
kind: "
|
|
376
|
+
kind: "externalScript" as const,
|
|
377
377
|
src: `${TAG_ORIGIN}/gtag/js?id=${encodeURIComponent(first)}`,
|
|
378
|
-
|
|
378
|
+
attrs: {},
|
|
379
379
|
},
|
|
380
380
|
]),
|
|
381
381
|
],
|
package/src/analytics/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import type { MetaTag } from "../meta/tag.ts";
|
|
1
2
|
import type { HttpsUrl } from "../url.ts";
|
|
2
3
|
import { type GoogleSettings, googleEmits, googleScripts } from "./google.ts";
|
|
3
|
-
import type {
|
|
4
|
+
import type { AnalyticsTags } from "./tags.ts";
|
|
4
5
|
import {
|
|
5
6
|
checkUmamiDomains,
|
|
6
7
|
type UmamiSettings,
|
|
@@ -28,7 +29,7 @@ export {
|
|
|
28
29
|
type ConsentState,
|
|
29
30
|
type GoogleSettings,
|
|
30
31
|
} from "./google.ts";
|
|
31
|
-
export type {
|
|
32
|
+
export type { AnalyticsTags } from "./tags.ts";
|
|
32
33
|
export type {
|
|
33
34
|
UmamiReplay,
|
|
34
35
|
UmamiSettings,
|
|
@@ -134,6 +135,6 @@ export const NOT_FOUND_TAG = "404";
|
|
|
134
135
|
*/
|
|
135
136
|
export function notFoundAnalytics(
|
|
136
137
|
analytics: AnalyticsSettings | undefined
|
|
137
|
-
): readonly
|
|
138
|
+
): readonly MetaTag[] {
|
|
138
139
|
return umamiScripts(analytics?.umami, NOT_FOUND_TAG);
|
|
139
140
|
}
|
package/src/analytics/tags.ts
CHANGED
|
@@ -1,62 +1,16 @@
|
|
|
1
|
+
import { link, type MetaTag } from "../meta/tag.ts";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
*
|
|
4
|
+
* An origin to open a connection to before anything asks it for bytes: every
|
|
5
|
+
* vendor here is served from a host that is not the site's.
|
|
3
6
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* ordering something lib decides once, rather than something a layout could
|
|
9
|
-
* shuffle.
|
|
7
|
+
* **No `crossorigin`, and that is load-bearing.** A preconnect is reused only by
|
|
8
|
+
* a request whose CORS mode matches, and every script here is a classic
|
|
9
|
+
* `<script src>`. One carrying `crossorigin` would open a second connection
|
|
10
|
+
* that nothing uses.
|
|
10
11
|
*/
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
readonly kind: "external";
|
|
14
|
-
readonly src: string;
|
|
15
|
-
readonly attributes: Readonly<Record<string, string>>;
|
|
16
|
-
}
|
|
17
|
-
| {
|
|
18
|
-
readonly kind: "inline";
|
|
19
|
-
readonly content: string;
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* A hidden iframe inside `<noscript>` — Tag Manager's fallback, and so far
|
|
23
|
-
* only that.
|
|
24
|
-
*
|
|
25
|
-
* Structured rather than a string of HTML, for the reason `externalScript`
|
|
26
|
-
* is: a field holding markup is a passthrough, and lib does not have those.
|
|
27
|
-
* A `src` is all this shape can carry, so it is all anyone can put in it.
|
|
28
|
-
*/
|
|
29
|
-
| {
|
|
30
|
-
readonly kind: "noscriptFrame";
|
|
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;
|
|
59
|
-
};
|
|
12
|
+
export const preconnect = (origin: string): MetaTag =>
|
|
13
|
+
link({ rel: "preconnect", href: origin });
|
|
60
14
|
|
|
61
15
|
/**
|
|
62
16
|
* Where a tag goes. Two channels, because two tags genuinely differ.
|
|
@@ -67,8 +21,9 @@ export type AnalyticsTag =
|
|
|
67
21
|
* cannot put one where the other goes.
|
|
68
22
|
*/
|
|
69
23
|
export interface AnalyticsTags {
|
|
70
|
-
|
|
71
|
-
readonly
|
|
24
|
+
/** In order: Google's consent defaults must be queued before any tag reads them. */
|
|
25
|
+
readonly head: readonly MetaTag[];
|
|
26
|
+
readonly body: readonly MetaTag[];
|
|
72
27
|
}
|
|
73
28
|
|
|
74
29
|
/**
|
|
@@ -77,8 +32,7 @@ export interface AnalyticsTags {
|
|
|
77
32
|
* `JSON.stringify` handles quotes and backslashes. The `<` escape handles the
|
|
78
33
|
* one thing it cannot: a `</script` anywhere in the text ends the element,
|
|
79
34
|
* whatever JavaScript makes of it. `<` is a valid escape inside a JS
|
|
80
|
-
* string and invisible to anything reading the value
|
|
81
|
-
* `serializeJsonLd` strikes, for the same reason.
|
|
35
|
+
* string and invisible to anything reading the value, JSON parsers included.
|
|
82
36
|
*/
|
|
83
37
|
export const literal = (value: unknown): string =>
|
|
84
38
|
JSON.stringify(value).replaceAll("<", "\\u003c");
|
package/src/analytics/umami.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import type { MetaTag } from "../meta/tag.ts";
|
|
1
2
|
import { type HttpsUrl, joinUrl, type UrlPath } from "../url.ts";
|
|
2
3
|
import { warn } from "../warn.ts";
|
|
3
|
-
import
|
|
4
|
+
import { preconnect } from "./tags.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Umami's tracker — the script that counts pageviews.
|
|
@@ -225,7 +226,7 @@ export function checkUmamiDomains(
|
|
|
225
226
|
export function umamiScripts(
|
|
226
227
|
umami: UmamiSettings | undefined,
|
|
227
228
|
tag?: string
|
|
228
|
-
): readonly
|
|
229
|
+
): readonly MetaTag[] {
|
|
229
230
|
if (umami === undefined) return [];
|
|
230
231
|
|
|
231
232
|
// Through `joinUrl`, not concatenation. `HttpsUrl` cannot express "without
|
|
@@ -251,12 +252,11 @@ export function umamiScripts(
|
|
|
251
252
|
// Before the script that needs it. A `src.` subdomain reads as
|
|
252
253
|
// first-party and is not one to the network stack: it is a separate
|
|
253
254
|
// origin, and the tracker pays its full handshake before it can start.
|
|
254
|
-
|
|
255
|
-
{ kind: "preconnect", origin: umami.host },
|
|
255
|
+
preconnect(umami.host),
|
|
256
256
|
{
|
|
257
|
-
kind: "
|
|
257
|
+
kind: "externalScript",
|
|
258
258
|
src: at(umami.tracker?.path ?? "/script.js"),
|
|
259
|
-
|
|
259
|
+
attrs: stated({
|
|
260
260
|
...common,
|
|
261
261
|
// The one default lib overrides — see `UmamiTracker`. The
|
|
262
262
|
// others are left to Umami: `auto-track` and `auto-pageview`
|
|
@@ -281,9 +281,9 @@ export function umamiScripts(
|
|
|
281
281
|
? []
|
|
282
282
|
: [
|
|
283
283
|
{
|
|
284
|
-
kind: "
|
|
284
|
+
kind: "externalScript" as const,
|
|
285
285
|
src: at(umami.replay.path ?? "/recorder.js"),
|
|
286
|
-
|
|
286
|
+
attrs: stated(common),
|
|
287
287
|
},
|
|
288
288
|
]),
|
|
289
289
|
];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The consent banner's behaviour; the site supplies its markup and copy as the
|
|
4
|
+
* children, with `consent.accept` and `consent.decline` on its buttons.
|
|
5
|
+
*
|
|
6
|
+
* Renders nothing — and ships no script — when this site's analytics need no
|
|
7
|
+
* permission. Otherwise the banner starts hidden and shows only to a visitor
|
|
8
|
+
* with no answer on record, or when `consent.reopen` is pressed.
|
|
9
|
+
*/
|
|
10
|
+
import type { HTMLAttributes } from "astro/types";
|
|
11
|
+
import { type AnalyticsSettings, consentRequired } from "../analytics/index.ts";
|
|
12
|
+
import ConsentElement from "./ConsentElement.astro";
|
|
13
|
+
|
|
14
|
+
type Props = HTMLAttributes<"div"> & {
|
|
15
|
+
readonly analytics: AnalyticsSettings | undefined;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const { analytics, ...attrs } = Astro.props;
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
{consentRequired(analytics) && (
|
|
22
|
+
<ConsentElement {...attrs}>
|
|
23
|
+
<slot />
|
|
24
|
+
</ConsentElement>
|
|
25
|
+
)}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Internal to `ConsentBanner.astro`, so its script ships only where it renders. */
|
|
3
|
+
import type { HTMLAttributes } from "astro/types";
|
|
4
|
+
import { consentBanner } from "./consent.ts";
|
|
5
|
+
|
|
6
|
+
type Props = HTMLAttributes<"div">;
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<atlas-consent
|
|
10
|
+
{...Astro.props}
|
|
11
|
+
{...consentBanner.root}
|
|
12
|
+
hidden
|
|
13
|
+
role="dialog"
|
|
14
|
+
aria-modal="false"
|
|
15
|
+
>
|
|
16
|
+
<slot />
|
|
17
|
+
</atlas-consent>
|
|
18
|
+
|
|
19
|
+
<script>
|
|
20
|
+
import {
|
|
21
|
+
applyConsent,
|
|
22
|
+
CONSENT_ROLES,
|
|
23
|
+
consentApplies,
|
|
24
|
+
consentReopen,
|
|
25
|
+
consentStore,
|
|
26
|
+
} from "./consent.ts";
|
|
27
|
+
import { element } from "./element.ts";
|
|
28
|
+
|
|
29
|
+
element("atlas-consent", CONSENT_ROLES, (host, { answer }, signal) => {
|
|
30
|
+
// The head script left no consent hook: nothing here sets a cookie.
|
|
31
|
+
if (!consentApplies()) return;
|
|
32
|
+
|
|
33
|
+
const store = consentStore();
|
|
34
|
+
const show = (): void => {
|
|
35
|
+
host.hidden = false;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
for (const { element, values } of answer.all()) {
|
|
39
|
+
element.addEventListener(
|
|
40
|
+
"click",
|
|
41
|
+
() => {
|
|
42
|
+
store.record(values.choice);
|
|
43
|
+
host.hidden = true;
|
|
44
|
+
},
|
|
45
|
+
{ signal }
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Outside the banner — withdrawing must be as easy as granting.
|
|
50
|
+
for (const { element } of consentReopen.all(document)) {
|
|
51
|
+
element.hidden = false;
|
|
52
|
+
element.addEventListener("click", show, { signal });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Applied, not re-recorded: recording stamps today's date, and an answer
|
|
56
|
+
// renewed on every page view never expires.
|
|
57
|
+
const stored = store.read();
|
|
58
|
+
if (stored === undefined) show();
|
|
59
|
+
else applyConsent(stored.choice);
|
|
60
|
+
});
|
|
61
|
+
</script>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The page document: `<html lang dir>`, every head tag `metaFor` produced, and
|
|
4
|
+
* the body tags first thing in `<body>`, so no layout can forget one.
|
|
5
|
+
* `head-start` comes before every tag Atlas writes — a consent manager that must
|
|
6
|
+
* load ahead of the analytics goes there; `head` comes after.
|
|
7
|
+
*
|
|
8
|
+
* ```astro
|
|
9
|
+
* <Document meta={meta} html={{ class: "scroll-smooth" }} body={{ class: "…" }}>
|
|
10
|
+
* <Fragment slot="head"><link rel="preload" … /></Fragment>
|
|
11
|
+
* …page…
|
|
12
|
+
* </Document>
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
import type { HTMLAttributes } from "astro/types";
|
|
16
|
+
import type { PageMeta } from "../site/index.ts";
|
|
17
|
+
import MetaTags from "./MetaTags.astro";
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
readonly meta: PageMeta;
|
|
21
|
+
/** `lang` and `dir` are the page's locale's, from `meta`. */
|
|
22
|
+
readonly html?: Omit<HTMLAttributes<"html">, "lang" | "dir">;
|
|
23
|
+
readonly body?: HTMLAttributes<"body">;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { meta, html, body } = Astro.props;
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
<!doctype html>
|
|
30
|
+
<html
|
|
31
|
+
{...html}
|
|
32
|
+
lang={meta.lang}
|
|
33
|
+
dir={meta.dir}
|
|
34
|
+
>
|
|
35
|
+
<head>
|
|
36
|
+
<slot name="head-start" />
|
|
37
|
+
<MetaTags tags={meta.tags} />
|
|
38
|
+
<slot name="head" />
|
|
39
|
+
</head>
|
|
40
|
+
<body {...body}>
|
|
41
|
+
<MetaTags tags={meta.bodyTags} />
|
|
42
|
+
<slot />
|
|
43
|
+
</body>
|
|
44
|
+
</html>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* An imported image, as AVIF with a WebP fallback.
|
|
4
|
+
*
|
|
5
|
+
* - **Fixed size** — a logo, a flag: give the `width` or `height` it is drawn
|
|
6
|
+
* at. Served at 1× and 2×.
|
|
7
|
+
* - **Follows the layout** — a photo, a card, a background: give `sizes`. Files
|
|
8
|
+
* are made at standard device widths up to the source's own. A lazy image
|
|
9
|
+
* gets `auto` in front, so browsers that can measure the real width do.
|
|
10
|
+
*
|
|
11
|
+
* `zoom` opens the image larger in a dialog.
|
|
12
|
+
*/
|
|
13
|
+
import { Picture } from "astro:assets";
|
|
14
|
+
import type { ComponentProps } from "astro/types";
|
|
15
|
+
import Zoom from "./Zoom.astro";
|
|
16
|
+
|
|
17
|
+
interface Base {
|
|
18
|
+
readonly src: ImageMetadata;
|
|
19
|
+
/** Required; `""` only for decorative artwork. */
|
|
20
|
+
readonly alt: string;
|
|
21
|
+
/** Above the fold: loads eagerly, at high priority. */
|
|
22
|
+
readonly priority?: boolean;
|
|
23
|
+
readonly class?: string;
|
|
24
|
+
readonly zoom?: { readonly closeLabel: string; readonly class?: string };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Props = Base &
|
|
28
|
+
(
|
|
29
|
+
| {
|
|
30
|
+
readonly width: number;
|
|
31
|
+
readonly height?: never;
|
|
32
|
+
readonly sizes?: never;
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
readonly height: number;
|
|
36
|
+
readonly width?: never;
|
|
37
|
+
readonly sizes?: never;
|
|
38
|
+
}
|
|
39
|
+
| {
|
|
40
|
+
readonly sizes: string;
|
|
41
|
+
readonly width?: never;
|
|
42
|
+
readonly height?: never;
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/** Common device widths, as Astro uses for its own responsive images. */
|
|
47
|
+
const DEVICE_WIDTHS = [640, 750, 828, 1080, 1280, 1668, 1920, 2048, 2560, 3840];
|
|
48
|
+
|
|
49
|
+
const given = Astro.props;
|
|
50
|
+
const { src, alt, priority = false, zoom } = given;
|
|
51
|
+
|
|
52
|
+
const shape =
|
|
53
|
+
given.sizes !== undefined
|
|
54
|
+
? (() => {
|
|
55
|
+
// Up to the source, and the source itself as the widest.
|
|
56
|
+
const widths = [
|
|
57
|
+
...DEVICE_WIDTHS.filter((w) => w < src.width),
|
|
58
|
+
src.width,
|
|
59
|
+
];
|
|
60
|
+
return {
|
|
61
|
+
width: src.width,
|
|
62
|
+
height: src.height,
|
|
63
|
+
widths,
|
|
64
|
+
sizes: priority ? given.sizes : `auto, ${given.sizes}`,
|
|
65
|
+
};
|
|
66
|
+
})()
|
|
67
|
+
: (() => {
|
|
68
|
+
const aspect = src.height / src.width;
|
|
69
|
+
const width =
|
|
70
|
+
given.width ?? Math.round((given.height ?? 0) / aspect);
|
|
71
|
+
return {
|
|
72
|
+
width,
|
|
73
|
+
height: Math.round(width * aspect),
|
|
74
|
+
// Nothing wider than the source: Astro would only upscale it.
|
|
75
|
+
densities: width * 2 <= src.width ? [1, 2] : [1],
|
|
76
|
+
};
|
|
77
|
+
})();
|
|
78
|
+
|
|
79
|
+
const picture: ComponentProps<typeof Picture> = {
|
|
80
|
+
src,
|
|
81
|
+
alt,
|
|
82
|
+
...shape,
|
|
83
|
+
formats: ["avif", "webp"],
|
|
84
|
+
loading: priority ? "eager" : "lazy",
|
|
85
|
+
decoding: priority ? "sync" : "async",
|
|
86
|
+
fetchpriority: priority ? "high" : "auto",
|
|
87
|
+
class: given.class,
|
|
88
|
+
};
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
{zoom === undefined ? (
|
|
92
|
+
<Picture {...picture} />
|
|
93
|
+
) : (
|
|
94
|
+
<Zoom
|
|
95
|
+
src={src}
|
|
96
|
+
alt={alt}
|
|
97
|
+
closeLabel={zoom.closeLabel}
|
|
98
|
+
class={zoom.class}
|
|
99
|
+
>
|
|
100
|
+
<Picture {...picture} />
|
|
101
|
+
</Zoom>
|
|
102
|
+
)}
|
package/src/astro/MetaTags.astro
CHANGED
|
@@ -1,31 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* the union, this one out of it. Both enumerate the same kinds, and keeping
|
|
7
|
-
* them in different repositories is what let them drift — the converter had an
|
|
8
|
-
* exhaustiveness guard from the day it was written and this file did not, so a
|
|
9
|
-
* kind added to `MetaTag` would have rendered here as a bare `<meta>`.
|
|
10
|
-
*
|
|
11
|
-
* In `lib/astro/` rather than a project's `src/`, unlike every other component
|
|
12
|
-
* in this template, because it carries no design and no copy: no classes, no
|
|
13
|
-
* translated strings, nothing from `@config`. It is a `kind` → element switch,
|
|
14
|
-
* and the four things it knows are each a decision worth making once —
|
|
15
|
-
* `set:html` for pre-escaped JSON, `is:inline` so the bundler leaves a
|
|
16
|
-
* third-party URL alone, `defer` so measuring a page does not cost what it
|
|
17
|
-
* measures, and the zero-sized frame Tag Manager wants. Four chances for a
|
|
18
|
-
* repository to get one of them wrong.
|
|
19
|
-
*
|
|
20
|
-
* ```astro
|
|
21
|
-
* import MetaTags from "@escape-game-over/atlas/astro/meta-tags";
|
|
22
|
-
*
|
|
23
|
-
* <head><MetaTags tags={meta.tags} /></head>
|
|
24
|
-
* <body><MetaTags tags={meta.bodyTags} /> …
|
|
25
|
-
* ```
|
|
26
|
-
*
|
|
27
|
-
* Both lists, and in those places: `bodyTags` is usually empty and holds the
|
|
28
|
-
* one tag the head would ignore.
|
|
3
|
+
* One element per `MetaTag`. Internal to `Document.astro`: `set:html` for
|
|
4
|
+
* pre-escaped JSON, `is:inline` so the bundler leaves a third-party URL alone,
|
|
5
|
+
* `defer` so measuring a page does not cost what it measures.
|
|
29
6
|
*/
|
|
30
7
|
// Relative, like every other file in the package: the bare specifier is the
|
|
31
8
|
// consumer's name for this package, and depending on it here would make the
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Renders what `site.rich()` returns. The site supplies the look: a class per
|
|
4
|
+
* `[v:name]` variant and one for links; everything else is decided here.
|
|
5
|
+
*
|
|
6
|
+
* ```astro
|
|
7
|
+
* <RichText spans={rich("about.intro", { venue: "contact" })} variants={{ accent: "…" }} />
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
import type { HTMLTag } from "astro/types";
|
|
11
|
+
import type { RichText } from "../content/index.ts";
|
|
12
|
+
import { warn } from "../warn.ts";
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
readonly spans: RichText;
|
|
16
|
+
/** The element wrapping the runs. Defaults to `p`. */
|
|
17
|
+
readonly as?: HTMLTag;
|
|
18
|
+
readonly class?: string;
|
|
19
|
+
/** `[v:name]` → classes. */
|
|
20
|
+
readonly variants?: Readonly<Record<string, string>>;
|
|
21
|
+
/** Classes on every link: `[a:]`, `[mail]` and `[tel]`. */
|
|
22
|
+
readonly link?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const {
|
|
26
|
+
spans,
|
|
27
|
+
as: Tag = "p",
|
|
28
|
+
class: className,
|
|
29
|
+
variants = {},
|
|
30
|
+
link,
|
|
31
|
+
}: Props = Astro.props;
|
|
32
|
+
|
|
33
|
+
// The words still render: losing the sentence is worse than losing its style.
|
|
34
|
+
const variantClass = (variant: string): string | undefined => {
|
|
35
|
+
if (!Object.hasOwn(variants, variant)) {
|
|
36
|
+
warn(
|
|
37
|
+
Astro.url.pathname,
|
|
38
|
+
`[v:${variant}] has no class in RichText's variants.`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return variants[variant];
|
|
42
|
+
};
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
<Tag class={className}>
|
|
46
|
+
{spans.map((span) => {
|
|
47
|
+
switch (span.kind) {
|
|
48
|
+
case "text":
|
|
49
|
+
return span.text;
|
|
50
|
+
case "bold":
|
|
51
|
+
return <strong>{span.text}</strong>;
|
|
52
|
+
case "variant":
|
|
53
|
+
return <span class={variantClass(span.variant)}>{span.text}</span>;
|
|
54
|
+
case "break":
|
|
55
|
+
return <br />;
|
|
56
|
+
case "link":
|
|
57
|
+
return span.to === "external" ? (
|
|
58
|
+
<a href={span.href} class={link} target="_blank" rel="noopener noreferrer">
|
|
59
|
+
{span.text}
|
|
60
|
+
</a>
|
|
61
|
+
) : (
|
|
62
|
+
<a href={span.href} class={link}>{span.text}</a>
|
|
63
|
+
);
|
|
64
|
+
case "mail":
|
|
65
|
+
case "tel":
|
|
66
|
+
return <a href={span.href} class={link}>{span.text}</a>;
|
|
67
|
+
default:
|
|
68
|
+
return span satisfies never;
|
|
69
|
+
}
|
|
70
|
+
})}
|
|
71
|
+
</Tag>
|