@escape-game-over/atlas 0.1.24 → 0.1.26
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 +96 -193
- package/docs/rich-text.md +18 -20
- package/package.json +9 -15
- 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/AtlasElement.astro +26 -0
- package/src/astro/ConsentBanner.astro +25 -0
- package/src/astro/ConsentElement.astro +67 -0
- package/src/astro/Document.astro +44 -0
- package/src/astro/Image.astro +101 -0
- package/src/astro/MetaTags.astro +30 -40
- package/src/astro/RichText.astro +88 -0
- package/src/astro/Zoom.astro +61 -0
- package/src/astro/client.ts +18 -9
- package/src/astro/consent.ts +20 -0
- package/src/astro/dev-log.ts +8 -14
- package/src/astro/element.ts +76 -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/ref.ts +46 -0
- 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/src/astro/markup.ts +0 -656
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,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Renders a custom element with the root attribute: what a stylesheet gives
|
|
4
|
+
* every root its display by, and what `element` checks for in dev.
|
|
5
|
+
*
|
|
6
|
+
* ```astro
|
|
7
|
+
* <AtlasElement of={trailer} data-video={video} class="…">…</AtlasElement>
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
import type { HTMLAttributes } from "astro/types";
|
|
11
|
+
import { type ElementTag, ROOT_ATTRIBUTE } from "./element.ts";
|
|
12
|
+
|
|
13
|
+
interface Props extends HTMLAttributes<"div"> {
|
|
14
|
+
readonly of: ElementTag;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { of, ...rest } = Astro.props;
|
|
18
|
+
const Tag = of.tag;
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
<Tag
|
|
22
|
+
{...rest}
|
|
23
|
+
{...{ [ROOT_ATTRIBUTE]: "" }}
|
|
24
|
+
>
|
|
25
|
+
<slot />
|
|
26
|
+
</Tag>
|
|
@@ -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,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** Internal to `ConsentBanner.astro`, so its script ships only where it renders. */
|
|
3
|
+
import type { HTMLAttributes } from "astro/types";
|
|
4
|
+
import AtlasElement from "./AtlasElement.astro";
|
|
5
|
+
import { consentBanner } from "./consent.ts";
|
|
6
|
+
|
|
7
|
+
type Props = HTMLAttributes<"div">;
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<AtlasElement
|
|
11
|
+
of={consentBanner}
|
|
12
|
+
{...Astro.props}
|
|
13
|
+
hidden
|
|
14
|
+
role="dialog"
|
|
15
|
+
aria-modal="false"
|
|
16
|
+
>
|
|
17
|
+
<slot />
|
|
18
|
+
</AtlasElement>
|
|
19
|
+
|
|
20
|
+
<script>
|
|
21
|
+
import {
|
|
22
|
+
applyConsent,
|
|
23
|
+
CONSENT_ANSWER,
|
|
24
|
+
CONSENT_REOPEN,
|
|
25
|
+
consentApplies,
|
|
26
|
+
consentStore,
|
|
27
|
+
} from "./consent.ts";
|
|
28
|
+
import { element } from "./element.ts";
|
|
29
|
+
import { refs } from "./ref.ts";
|
|
30
|
+
|
|
31
|
+
element("atlas-consent", ({ host, signal }) => {
|
|
32
|
+
// The head script left no consent hook: nothing here sets a cookie.
|
|
33
|
+
if (!consentApplies()) return;
|
|
34
|
+
|
|
35
|
+
const store = consentStore();
|
|
36
|
+
const show = (): void => {
|
|
37
|
+
host.hidden = false;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
for (const button of refs(host, `[${CONSENT_ANSWER}]`)) {
|
|
41
|
+
const choice = button.getAttribute(CONSENT_ANSWER);
|
|
42
|
+
if (choice !== "granted" && choice !== "denied") {
|
|
43
|
+
throw new Error(`${CONSENT_ANSWER} is "${choice}"`);
|
|
44
|
+
}
|
|
45
|
+
button.addEventListener(
|
|
46
|
+
"click",
|
|
47
|
+
() => {
|
|
48
|
+
store.record(choice);
|
|
49
|
+
host.hidden = true;
|
|
50
|
+
},
|
|
51
|
+
{ signal }
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Outside the banner — withdrawing must be as easy as granting.
|
|
56
|
+
for (const button of refs(document, `[${CONSENT_REOPEN}]`)) {
|
|
57
|
+
button.hidden = false;
|
|
58
|
+
button.addEventListener("click", show, { signal });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Applied, not re-recorded: recording stamps today's date, and an answer
|
|
62
|
+
// renewed on every page view never expires.
|
|
63
|
+
const stored = store.read();
|
|
64
|
+
if (stored === undefined) show();
|
|
65
|
+
else applyConsent(stored.choice);
|
|
66
|
+
});
|
|
67
|
+
</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,101 @@
|
|
|
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
|
+
<Picture {...picture} />
|
|
100
|
+
</Zoom>
|
|
101
|
+
)}
|
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
|
|
@@ -48,27 +25,40 @@ const { tags } = Astro.props;
|
|
|
48
25
|
// again as HTML would turn each quote into an entity and leave a block no
|
|
49
26
|
// parser reads.
|
|
50
27
|
if (tag.kind === "script") {
|
|
51
|
-
|
|
28
|
+
return (
|
|
29
|
+
<script
|
|
30
|
+
type={tag.type}
|
|
31
|
+
is:inline
|
|
32
|
+
set:html={tag.content}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
52
35
|
}
|
|
53
36
|
// `is:inline` so the bundler leaves a third-party URL alone, and `defer`
|
|
54
37
|
// because a script that blocks the parser costs the thing it measures.
|
|
55
38
|
if (tag.kind === "externalScript") {
|
|
56
|
-
|
|
39
|
+
return (
|
|
40
|
+
<script
|
|
41
|
+
is:inline
|
|
42
|
+
defer
|
|
43
|
+
src={tag.src}
|
|
44
|
+
{...tag.attrs}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
57
47
|
}
|
|
58
48
|
// Tag Manager's fallback, and only ever in the body list. Hidden the way
|
|
59
49
|
// Google's own snippet hides it — a zero-sized iframe nothing can see.
|
|
60
50
|
if (tag.kind === "noscriptFrame") {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
51
|
+
return (
|
|
52
|
+
<noscript>
|
|
53
|
+
<iframe
|
|
54
|
+
src={tag.src}
|
|
55
|
+
title='Google Tag Manager'
|
|
56
|
+
height='0'
|
|
57
|
+
width='0'
|
|
58
|
+
style='display:none;visibility:hidden'
|
|
59
|
+
/>
|
|
60
|
+
</noscript>
|
|
61
|
+
);
|
|
72
62
|
}
|
|
73
63
|
if (tag.kind === "meta") return <meta {...tag.attrs} />;
|
|
74
64
|
// Named rather than left to a trailing `return`, which used to catch
|
|
@@ -82,4 +72,4 @@ const { tags } = Astro.props;
|
|
|
82
72
|
// copied into every project.
|
|
83
73
|
const unhandled: never = tag;
|
|
84
74
|
throw new Error(`Unhandled meta tag: ${JSON.stringify(unhandled)}`);
|
|
85
|
-
|
|
75
|
+
})}
|
|
@@ -0,0 +1,88 @@
|
|
|
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. Only
|
|
5
|
+
* the runs — the caller writes the element they sit in.
|
|
6
|
+
*
|
|
7
|
+
* ```astro
|
|
8
|
+
* <p class="…">
|
|
9
|
+
* <RichText spans={rich("about.intro", { venue: "contact" })} variants={{ accent: "…" }} />
|
|
10
|
+
* </p>
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
import type { RichText } from "../content/index.ts";
|
|
14
|
+
import { warn } from "../warn.ts";
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
readonly spans: RichText;
|
|
18
|
+
/** `[v:name]` → classes. */
|
|
19
|
+
readonly variants?: Readonly<Record<string, string>>;
|
|
20
|
+
/** `[v:name]` → attributes, so a script can find that run. */
|
|
21
|
+
readonly attrs?: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
|
22
|
+
/** Classes on every link: `[a:]`, `[mail]` and `[tel]`. */
|
|
23
|
+
readonly link?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { spans, variants = {}, attrs = {}, link }: Props = Astro.props;
|
|
27
|
+
|
|
28
|
+
// The words still render: losing the sentence is worse than losing its style.
|
|
29
|
+
// A run with attributes and no class is a script's hook, not a missing style.
|
|
30
|
+
const variantClass = (variant: string): string | undefined => {
|
|
31
|
+
if (!Object.hasOwn(variants, variant) && !Object.hasOwn(attrs, variant)) {
|
|
32
|
+
warn(
|
|
33
|
+
Astro.url.pathname,
|
|
34
|
+
`[v:${variant}] has no class in RichText's variants and no attrs.`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return Object.hasOwn(variants, variant) ? variants[variant] : undefined;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const variantAttrs = (variant: string) =>
|
|
41
|
+
Object.hasOwn(attrs, variant) ? attrs[variant] : undefined;
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
{spans.map((span) => {
|
|
45
|
+
switch (span.kind) {
|
|
46
|
+
case "text":
|
|
47
|
+
return span.text;
|
|
48
|
+
case "bold":
|
|
49
|
+
return <strong>{span.text}</strong>;
|
|
50
|
+
case "variant":
|
|
51
|
+
return (
|
|
52
|
+
<span
|
|
53
|
+
{...variantAttrs(span.variant)}
|
|
54
|
+
class={variantClass(span.variant)}>
|
|
55
|
+
{span.text}
|
|
56
|
+
</span>
|
|
57
|
+
);
|
|
58
|
+
case "break":
|
|
59
|
+
return <br />;
|
|
60
|
+
case "link":
|
|
61
|
+
return span.to === "external" ? (
|
|
62
|
+
<a
|
|
63
|
+
href={span.href}
|
|
64
|
+
class={link}
|
|
65
|
+
target='_blank'
|
|
66
|
+
rel='noopener noreferrer'>
|
|
67
|
+
{span.text}
|
|
68
|
+
</a>
|
|
69
|
+
) : (
|
|
70
|
+
<a
|
|
71
|
+
href={span.href}
|
|
72
|
+
class={link}>
|
|
73
|
+
{span.text}
|
|
74
|
+
</a>
|
|
75
|
+
);
|
|
76
|
+
case "mail":
|
|
77
|
+
case "tel":
|
|
78
|
+
return (
|
|
79
|
+
<a
|
|
80
|
+
href={span.href}
|
|
81
|
+
class={link}>
|
|
82
|
+
{span.text}
|
|
83
|
+
</a>
|
|
84
|
+
);
|
|
85
|
+
default:
|
|
86
|
+
return span satisfies never;
|
|
87
|
+
}
|
|
88
|
+
})}
|
|
@@ -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,28 @@
|
|
|
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 {
|
|
24
|
+
type Connect,
|
|
25
|
+
type ElementContext,
|
|
26
|
+
type ElementTag,
|
|
27
|
+
element,
|
|
28
|
+
} from "./element.ts";
|
|
20
29
|
export * from "./filters.ts";
|
|
21
30
|
export * from "./filters-view.ts";
|
|
22
|
-
export
|
|
31
|
+
export { data, ref, refs } from "./ref.ts";
|
|
23
32
|
export * from "./youtube.ts";
|