@escape-game-over/atlas 0.1.24 → 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 +27 -44
- 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 +13 -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 +13 -58
- 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 +42 -35
- package/src/astro/index.ts +2 -9
- package/src/astro/markup.ts +6 -6
- package/src/astro/site-routes.ts +9 -15
- package/src/config.ts +23 -36
- package/src/content/index.ts +1 -1
- package/src/content/marks.ts +13 -13
- package/src/content/rich.ts +26 -42
- package/src/hours.ts +48 -11
- package/src/i18n/define.ts +14 -74
- package/src/index.ts +40 -57
- package/src/meta/index.ts +7 -13
- package/src/meta/share-image.ts +2 -26
- 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/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
|
/**
|
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>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Internal to `Image.astro`. No script: `command="show-modal"` opens the dialog
|
|
4
|
+
* and `closedby="any"` lets a click outside close it. The large image is lazy
|
|
5
|
+
* inside a closed dialog, so it is fetched on open.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getImage } from "astro:assets";
|
|
9
|
+
import type { ImageMetadata } from "astro";
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
readonly src: ImageMetadata;
|
|
13
|
+
readonly alt: string;
|
|
14
|
+
readonly closeLabel: string;
|
|
15
|
+
/** On the `<dialog>`. */
|
|
16
|
+
readonly class?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { src, alt, closeLabel, class: dialogClass } = Astro.props;
|
|
20
|
+
const large = await getImage({
|
|
21
|
+
src,
|
|
22
|
+
width: Math.min(src.width, 2048),
|
|
23
|
+
format: "webp",
|
|
24
|
+
});
|
|
25
|
+
// The file's URL carries Astro's content hash, so the id is the same every
|
|
26
|
+
// build. The same image zoomed twice on a page opening one dialog is harmless.
|
|
27
|
+
const id = `atlas-zoom${large.src.replace(/[^\w-]/g, "-")}`;
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
commandfor={id}
|
|
33
|
+
command="show-modal"
|
|
34
|
+
style="padding:0;border:0;background:none;cursor:zoom-in"
|
|
35
|
+
>
|
|
36
|
+
<slot />
|
|
37
|
+
</button>
|
|
38
|
+
<dialog
|
|
39
|
+
id={id}
|
|
40
|
+
closedby="any"
|
|
41
|
+
class={dialogClass}
|
|
42
|
+
aria-label={alt}
|
|
43
|
+
>
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
commandfor={id}
|
|
47
|
+
command="close"
|
|
48
|
+
aria-label={closeLabel}
|
|
49
|
+
>
|
|
50
|
+
×
|
|
51
|
+
</button>
|
|
52
|
+
<img
|
|
53
|
+
src={large.src}
|
|
54
|
+
width={large.attributes.width}
|
|
55
|
+
height={large.attributes.height}
|
|
56
|
+
alt={alt}
|
|
57
|
+
loading="lazy"
|
|
58
|
+
decoding="async"
|
|
59
|
+
style="display:block;max-width:100%;max-height:85vh;height:auto"
|
|
60
|
+
>
|
|
61
|
+
</dialog>
|
package/src/astro/client.ts
CHANGED
|
@@ -5,19 +5,29 @@
|
|
|
5
5
|
* import { filters, searchBox } from "@escape-game-over/atlas/client";
|
|
6
6
|
* ```
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* component's contract is the one thing both halves need, and it stays on
|
|
11
|
-
* `astro/markup`, which touches nothing.
|
|
8
|
+
* The only import path for browser code. Nothing here touches the DOM at
|
|
9
|
+
* import, so a component's contract can be imported from frontmatter too.
|
|
12
10
|
*/
|
|
13
11
|
|
|
14
12
|
export * from "./background-video.ts";
|
|
15
13
|
export * from "./carousel.ts";
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
export {
|
|
15
|
+
applyConsent,
|
|
16
|
+
type ConsentChoice,
|
|
17
|
+
type ConsentRecord,
|
|
18
|
+
type ConsentStoreOptions,
|
|
19
|
+
consent,
|
|
20
|
+
consentApplies,
|
|
21
|
+
consentStore,
|
|
22
|
+
} from "./consent.ts";
|
|
23
|
+
export { type Connect, type ElementRoles, element } from "./element.ts";
|
|
20
24
|
export * from "./filters.ts";
|
|
21
25
|
export * from "./filters-view.ts";
|
|
22
|
-
export
|
|
26
|
+
export {
|
|
27
|
+
type Component,
|
|
28
|
+
type Marked,
|
|
29
|
+
type Markup,
|
|
30
|
+
marker,
|
|
31
|
+
markup,
|
|
32
|
+
} from "./markup.ts";
|
|
23
33
|
export * from "./youtube.ts";
|