@escape-game-over/atlas 0.1.78 → 0.1.80
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/package.json +2 -1
- package/src/analytics/storage.ts +23 -0
- package/src/astro/BackgroundVideo.astro +151 -0
- package/src/astro/Document.astro +6 -0
- package/src/astro/background-video-element.ts +38 -0
- package/src/astro/background-video.ts +66 -3
- package/src/astro/site-icons.ts +61 -0
- package/src/index.ts +1 -0
- package/src/meta/index.ts +9 -11
- package/src/meta/share-image.ts +26 -13
- package/src/site/create.ts +6 -7
- package/src/site/page.ts +7 -0
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.80",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
|
|
6
6
|
"private": false,
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"./astro/images": "./src/astro/images.ts",
|
|
18
18
|
"./client": "./src/astro/client.ts",
|
|
19
19
|
"./astro/auto-dialog": "./src/astro/AutoDialog.astro",
|
|
20
|
+
"./astro/background-video": "./src/astro/BackgroundVideo.astro",
|
|
20
21
|
"./astro/consent-banner": "./src/astro/ConsentBanner.astro",
|
|
21
22
|
"./astro/document": "./src/astro/Document.astro",
|
|
22
23
|
"./astro/element": "./src/astro/AtlasElement.astro",
|
package/src/analytics/storage.ts
CHANGED
|
@@ -289,6 +289,29 @@ export function compareStoredItems(a: StoredItem, b: StoredItem): number {
|
|
|
289
289
|
);
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Each row with what the project says it is for — a message key, usually,
|
|
294
|
+
* since the words are the project's and in every locale.
|
|
295
|
+
*
|
|
296
|
+
* Throws, naming every row without one, so a vendor switched on, or a row a
|
|
297
|
+
* new Atlas adds, fails the build until the site has said what it is for: a
|
|
298
|
+
* notice that lists a cookie without a purpose is the one thing it must not
|
|
299
|
+
* publish. `purposes` may name rows this deployment does not have; a site
|
|
300
|
+
* keeps its wording when a vendor is switched off.
|
|
301
|
+
*/
|
|
302
|
+
export function withPurposes<K>(
|
|
303
|
+
rows: readonly StoredItem[],
|
|
304
|
+
purposes: Partial<Record<StoredId, K>>
|
|
305
|
+
): readonly (StoredItem & { readonly purpose: K })[] {
|
|
306
|
+
const missing = rows.filter((row) => purposes[row.id] === undefined);
|
|
307
|
+
if (missing.length > 0) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`The storage table has no purpose for ${missing.map((row) => `"${row.id}"`).join(", ")}. Add one for each to the purposes given to withPurposes.`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
return rows.map((row) => ({ ...row, purpose: purposes[row.id] as K }));
|
|
313
|
+
}
|
|
314
|
+
|
|
292
315
|
/**
|
|
293
316
|
* Everything this deployment keeps on a device: Atlas's own keys, then each
|
|
294
317
|
* loaded vendor's.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A decorative, silent loop over its own poster, with the pause control WCAG
|
|
4
|
+
* 2.2.2 asks for. Playback is `backgroundVideo`'s; this is the markup it needs,
|
|
5
|
+
* which is where a hero quietly goes wrong:
|
|
6
|
+
*
|
|
7
|
+
* - The poster is a `<picture>`, not the `poster` attribute, so each
|
|
8
|
+
* orientation gets its own still, cut to size. It is usually the largest
|
|
9
|
+
* paint, so it loads eagerly and first.
|
|
10
|
+
* - `preload="none"`: nothing of the loop is fetched until it starts, which is
|
|
11
|
+
* after `load` — `metadata` alone was enough to undo that wait.
|
|
12
|
+
* - `muted`, `loop`, `playsinline`, and hidden from assistive technology.
|
|
13
|
+
* - The control stays hidden until a cut suits the viewport, and names what it
|
|
14
|
+
* will do — `play` or `pause`, the site's own words.
|
|
15
|
+
*
|
|
16
|
+
* The site gives the element its size and position, the control its look, and
|
|
17
|
+
* its two icons as the `playing` slot (shown while it plays: a pause icon) and
|
|
18
|
+
* the `paused` slot.
|
|
19
|
+
*
|
|
20
|
+
* ```astro
|
|
21
|
+
* <BackgroundVideo
|
|
22
|
+
* sources={[{ src: webm, type: 'video/webm; codecs="vp9"' }]}
|
|
23
|
+
* posters={[{ image: portraitStill, media: "(orientation: portrait)" }, { image: still }]}
|
|
24
|
+
* play={t("hero.play")}
|
|
25
|
+
* pause={t("hero.pause")}
|
|
26
|
+
* class="absolute inset-0"
|
|
27
|
+
* buttonClass="absolute bottom-4 right-4 …"
|
|
28
|
+
* >
|
|
29
|
+
* <Icon slot="playing" name="lucide:pause" />
|
|
30
|
+
* <Icon slot="paused" name="lucide:play" />
|
|
31
|
+
* </BackgroundVideo>
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
import { getImage } from "astro:assets";
|
|
35
|
+
import type { HTMLAttributes } from "astro/types";
|
|
36
|
+
import type { NonEmpty } from "../types.ts";
|
|
37
|
+
import AtlasElement from "./AtlasElement.astro";
|
|
38
|
+
import type { VideoPoster, VideoSource } from "./background-video.ts";
|
|
39
|
+
import { backgroundVideoElement } from "./background-video-element.ts";
|
|
40
|
+
|
|
41
|
+
type Props = Omit<HTMLAttributes<"div">, "children"> & {
|
|
42
|
+
readonly sources: NonEmpty<VideoSource>;
|
|
43
|
+
/** Stills by viewport, as `<picture>` reads them: the last has no `media`. */
|
|
44
|
+
readonly posters: NonEmpty<VideoPoster>;
|
|
45
|
+
/** The control's label while paused. */
|
|
46
|
+
readonly play: string;
|
|
47
|
+
/** The control's label while playing. */
|
|
48
|
+
readonly pause: string;
|
|
49
|
+
readonly buttonClass?: string;
|
|
50
|
+
/** For the poster's `srcset`. Defaults to `100vw`, a full-bleed background. */
|
|
51
|
+
readonly sizes?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const {
|
|
55
|
+
sources,
|
|
56
|
+
posters,
|
|
57
|
+
play,
|
|
58
|
+
pause,
|
|
59
|
+
buttonClass,
|
|
60
|
+
sizes = "100vw",
|
|
61
|
+
...attrs
|
|
62
|
+
} = Astro.props;
|
|
63
|
+
|
|
64
|
+
const fallback = posters[posters.length - 1];
|
|
65
|
+
if (fallback?.media !== undefined) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
"BackgroundVideo: the last poster is the fallback, and takes no `media`."
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (posters.slice(0, -1).some((poster) => poster.media === undefined)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
"BackgroundVideo: every poster but the last needs a `media`; one without would always win."
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const cut = await Promise.all(
|
|
77
|
+
posters.map(async (poster) => ({
|
|
78
|
+
media: poster.media,
|
|
79
|
+
image: await getImage({
|
|
80
|
+
src: poster.image,
|
|
81
|
+
widths: [...(poster.widths ?? [960, 1440, 1920])],
|
|
82
|
+
format: "webp",
|
|
83
|
+
}),
|
|
84
|
+
}))
|
|
85
|
+
);
|
|
86
|
+
const still = cut[cut.length - 1];
|
|
87
|
+
|
|
88
|
+
// Structural, so inline: the still under the video, both covering the element.
|
|
89
|
+
const cover = "width:100%;height:100%;object-fit:cover";
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
<script src="./background-video-element.ts" />
|
|
93
|
+
|
|
94
|
+
<AtlasElement
|
|
95
|
+
of={backgroundVideoElement}
|
|
96
|
+
data-play={play}
|
|
97
|
+
data-pause={pause}
|
|
98
|
+
{...attrs}
|
|
99
|
+
>
|
|
100
|
+
<picture>
|
|
101
|
+
{cut.slice(0, -1).map((poster) => (
|
|
102
|
+
<source
|
|
103
|
+
media={poster.media}
|
|
104
|
+
srcset={poster.image.srcSet.attribute}
|
|
105
|
+
sizes={sizes}
|
|
106
|
+
/>
|
|
107
|
+
))}
|
|
108
|
+
<img
|
|
109
|
+
src={still?.image.src}
|
|
110
|
+
srcset={still?.image.srcSet.attribute}
|
|
111
|
+
sizes={sizes}
|
|
112
|
+
alt=""
|
|
113
|
+
fetchpriority="high"
|
|
114
|
+
style={`position:absolute;inset:0;${cover}`}
|
|
115
|
+
>
|
|
116
|
+
</picture>
|
|
117
|
+
<video
|
|
118
|
+
style={`position:relative;${cover}`}
|
|
119
|
+
muted
|
|
120
|
+
loop
|
|
121
|
+
playsinline
|
|
122
|
+
preload="none"
|
|
123
|
+
aria-hidden="true"
|
|
124
|
+
tabindex="-1"
|
|
125
|
+
>
|
|
126
|
+
{sources.map((source) => (
|
|
127
|
+
<source
|
|
128
|
+
src={source.src}
|
|
129
|
+
media={source.media}
|
|
130
|
+
type={source.type}
|
|
131
|
+
/>
|
|
132
|
+
))}
|
|
133
|
+
</video>
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
data-video-toggle
|
|
137
|
+
hidden
|
|
138
|
+
aria-label={pause}
|
|
139
|
+
class={buttonClass}
|
|
140
|
+
>
|
|
141
|
+
<span data-video-playing>
|
|
142
|
+
<slot name="playing" />
|
|
143
|
+
</span>
|
|
144
|
+
<span
|
|
145
|
+
data-video-paused
|
|
146
|
+
hidden
|
|
147
|
+
>
|
|
148
|
+
<slot name="paused" />
|
|
149
|
+
</span>
|
|
150
|
+
</button>
|
|
151
|
+
</AtlasElement>
|
package/src/astro/Document.astro
CHANGED
|
@@ -30,11 +30,13 @@
|
|
|
30
30
|
import { Font } from "astro:assets";
|
|
31
31
|
import type { HTMLAttributes } from "astro/types";
|
|
32
32
|
import type { Fonts } from "../fonts.ts";
|
|
33
|
+
import { iconLinks } from "../meta/index.ts";
|
|
33
34
|
import type { PageMeta } from "../site/index.ts";
|
|
34
35
|
import MetaTags from "./MetaTags.astro";
|
|
35
36
|
import RememberCampaign from "./RememberCampaign.astro";
|
|
36
37
|
import RememberLocale from "./RememberLocale.astro";
|
|
37
38
|
import ScrollRestore from "./ScrollRestore.astro";
|
|
39
|
+
import { siteIcons } from "./site-icons.ts";
|
|
38
40
|
|
|
39
41
|
interface Props {
|
|
40
42
|
readonly meta: PageMeta;
|
|
@@ -65,6 +67,9 @@ if (meta.consentVendors.length > 0 && !Astro.slots.has("consent")) {
|
|
|
65
67
|
`${meta.consentVendors.join(", ")} ${meta.consentVendors.length === 1 ? "needs" : "need"} permission, but this page has no consent banner. Pass one to Document: <ConsentBanner slot="consent" …>.`
|
|
66
68
|
);
|
|
67
69
|
}
|
|
70
|
+
|
|
71
|
+
// Here rather than in `meta.tags`: cutting the icon to size needs the build.
|
|
72
|
+
const icons = iconLinks(await siteIcons(meta.icon));
|
|
68
73
|
---
|
|
69
74
|
|
|
70
75
|
<!doctype html>
|
|
@@ -76,6 +81,7 @@ if (meta.consentVendors.length > 0 && !Astro.slots.has("consent")) {
|
|
|
76
81
|
<head>
|
|
77
82
|
<slot name="head-start" />
|
|
78
83
|
<MetaTags tags={meta.tags} />
|
|
84
|
+
<MetaTags tags={icons} />
|
|
79
85
|
{rememberLocale && <RememberLocale />}
|
|
80
86
|
{Object.entries(fonts).map(([key, font]) => (
|
|
81
87
|
<Font
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Internal to `BackgroundVideo.astro`: binds its markup to `backgroundVideo`.
|
|
2
|
+
import { type BackgroundVideo, backgroundVideo } from "./background-video.ts";
|
|
3
|
+
import { element } from "./element.ts";
|
|
4
|
+
import { data, ref } from "./ref.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Each root's loop, kept across a re-attach: the reader's pause belongs to the
|
|
8
|
+
* video, not to one connection of the element.
|
|
9
|
+
*/
|
|
10
|
+
const loops = new WeakMap<HTMLElement, BackgroundVideo>();
|
|
11
|
+
|
|
12
|
+
export const backgroundVideoElement = element(
|
|
13
|
+
"atlas-background-video",
|
|
14
|
+
({ root, signal }) => {
|
|
15
|
+
const play = data(root, "play");
|
|
16
|
+
const pause = data(root, "pause");
|
|
17
|
+
const button = ref<HTMLButtonElement>(root, "[data-video-toggle]");
|
|
18
|
+
const whenPlaying = ref<HTMLElement>(root, "[data-video-playing]");
|
|
19
|
+
const whenPaused = ref<HTMLElement>(root, "[data-video-paused]");
|
|
20
|
+
|
|
21
|
+
const loop =
|
|
22
|
+
loops.get(root) ??
|
|
23
|
+
backgroundVideo({
|
|
24
|
+
onChange: ({ playing, playable }) => {
|
|
25
|
+
// Nothing to pause where no cut suits this viewport.
|
|
26
|
+
button.hidden = !playable;
|
|
27
|
+
button.setAttribute("aria-label", playing ? pause : play);
|
|
28
|
+
whenPlaying.hidden = !playing;
|
|
29
|
+
whenPaused.hidden = playing;
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
loops.set(root, loop);
|
|
33
|
+
|
|
34
|
+
button.addEventListener("click", () => loop.toggle(), { signal });
|
|
35
|
+
|
|
36
|
+
return loop.attach(ref<HTMLVideoElement>(root, "video"));
|
|
37
|
+
}
|
|
38
|
+
);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A decorative background video's playback — not its markup, and not its button.
|
|
3
|
+
* `BackgroundVideo.astro` is both, built on this; reach for this directly only
|
|
4
|
+
* for markup that component cannot write.
|
|
3
5
|
*
|
|
4
6
|
* In `astro/` because it reaches for `matchMedia` and a media element, which the
|
|
5
7
|
* core is type-checked without.
|
|
@@ -26,7 +28,12 @@
|
|
|
26
28
|
* quietly wrong:
|
|
27
29
|
*
|
|
28
30
|
* - **Reduced motion starts it paused.** A reader who asked their system for
|
|
29
|
-
* less motion should not get a full-width loop behind the headline.
|
|
31
|
+
* less motion should not get a full-width loop behind the headline. So does
|
|
32
|
+
* Data Saver, where the browser says so: a loop is megabytes nobody asked for.
|
|
33
|
+
* - **Not before the page has loaded.** A hero loop is the heaviest thing on
|
|
34
|
+
* the page, and started at once it shares the connection with the poster —
|
|
35
|
+
* usually the largest paint — and with everything else the page needs first.
|
|
36
|
+
* It starts on `load`, unless the reader pressed play before that.
|
|
30
37
|
* - **A way to stop it.** WCAG 2.2.2 asks for one on anything that moves for
|
|
31
38
|
* more than five seconds, and a loop moves forever. The control is the
|
|
32
39
|
* project's; the state it shows is this.
|
|
@@ -45,9 +52,32 @@
|
|
|
45
52
|
* `aria-hidden="true"` and `tabindex="-1"`. `autoplay` is better left out: this
|
|
46
53
|
* starts the video itself, and without this script there is no control to stop
|
|
47
54
|
* it with, while with the attribute a reduced-motion reader sees the first
|
|
48
|
-
* frames move before this gets to pause them
|
|
55
|
+
* frames move before this gets to pause them, and the download starts before
|
|
56
|
+
* the page has loaded. Left in, everything else still works. `preload="none"`
|
|
57
|
+
* or `"metadata"` keeps the browser from fetching the loop on its own.
|
|
49
58
|
*/
|
|
50
59
|
|
|
60
|
+
/** One file of a `BackgroundVideo`, as a `<source>`. */
|
|
61
|
+
export interface VideoSource {
|
|
62
|
+
readonly src: string;
|
|
63
|
+
/**
|
|
64
|
+
* With its codecs — `video/webm; codecs="vp9"` — so a browser skips what
|
|
65
|
+
* it cannot play without fetching it to find out.
|
|
66
|
+
*/
|
|
67
|
+
readonly type: string;
|
|
68
|
+
/** The viewports this cut is for; the first matching source wins. */
|
|
69
|
+
readonly media?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One still of a `BackgroundVideo`'s poster, as a `<picture>` source. */
|
|
73
|
+
export interface VideoPoster {
|
|
74
|
+
readonly image: ImageMetadata;
|
|
75
|
+
/** The widths to cut, for `srcset`. Defaults to 960, 1440 and 1920. */
|
|
76
|
+
readonly widths?: readonly number[];
|
|
77
|
+
/** The viewports this still is for. Absent on the last, the fallback. */
|
|
78
|
+
readonly media?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
51
81
|
export interface BackgroundVideoState {
|
|
52
82
|
/** Whether it is playing now: the element's own `paused`, inverted. */
|
|
53
83
|
readonly playing: boolean;
|
|
@@ -99,6 +129,27 @@ export interface BackgroundVideo {
|
|
|
99
129
|
|
|
100
130
|
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
|
|
101
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Whether the video starts paused when the reader has not chosen: they asked
|
|
134
|
+
* their system for less motion, or for less data. Data Saver is Chromium's,
|
|
135
|
+
* the only engine that says; elsewhere this is reduced motion alone.
|
|
136
|
+
*/
|
|
137
|
+
function stillByDefault(): boolean {
|
|
138
|
+
const connection = (
|
|
139
|
+
navigator as Navigator & { connection?: { saveData?: boolean } }
|
|
140
|
+
).connection;
|
|
141
|
+
return matchMedia(REDUCED_MOTION).matches || connection?.saveData === true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Runs `then` once the page has loaded: now if it has, else on `load`. */
|
|
145
|
+
function whenLoaded(then: () => void, signal: AbortSignal): void {
|
|
146
|
+
if (document.readyState === "complete") {
|
|
147
|
+
then();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
addEventListener("load", then, { once: true, signal });
|
|
151
|
+
}
|
|
152
|
+
|
|
102
153
|
/**
|
|
103
154
|
* `HTMLMediaElement.NETWORK_NO_SOURCE`, as a literal.
|
|
104
155
|
*
|
|
@@ -278,7 +329,19 @@ export function backgroundVideo(
|
|
|
278
329
|
);
|
|
279
330
|
}
|
|
280
331
|
|
|
281
|
-
|
|
332
|
+
if (chosen !== undefined) {
|
|
333
|
+
apply(chosen);
|
|
334
|
+
} else if (stillByDefault()) {
|
|
335
|
+
apply(false);
|
|
336
|
+
} else {
|
|
337
|
+
// Not before the page has loaded: a loop of a few megabytes
|
|
338
|
+
// started now shares the connection with the poster the reader
|
|
339
|
+
// is waiting for, and on a phone the poster loses.
|
|
340
|
+
whenLoaded(() => {
|
|
341
|
+
// Unless the reader chose meanwhile, or it moved on.
|
|
342
|
+
if (video === element && chosen === undefined) apply(true);
|
|
343
|
+
}, signal);
|
|
344
|
+
}
|
|
282
345
|
sync();
|
|
283
346
|
|
|
284
347
|
return () => {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site icon, cut to size. `Document`'s, and in `astro/` because cutting
|
|
3
|
+
* needs `astro:assets`, which only exists inside the build.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getImage } from "astro:assets";
|
|
7
|
+
import type { ImageAsset } from "../image.ts";
|
|
8
|
+
import type { SiteIcons } from "../meta/index.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The favicon sizes, for `rel="icon"`.
|
|
12
|
+
*
|
|
13
|
+
* 48 for the tab: a browser scales it down to 16 or 32 itself, sharply, and 48
|
|
14
|
+
* is also the smallest Google takes for a search result. 192 for everything
|
|
15
|
+
* that shows the icon large — a search result on a dense screen, an Android
|
|
16
|
+
* home screen. A browser picks between them from `sizes`, fetching one.
|
|
17
|
+
*/
|
|
18
|
+
const FAVICON_SIZES = [48, 192] as const;
|
|
19
|
+
|
|
20
|
+
/** Apple's size for `apple-touch-icon`, which it scales down from. */
|
|
21
|
+
const TOUCH_SIZE = 180;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `icon` at `size` square, as PNG.
|
|
25
|
+
*
|
|
26
|
+
* Never larger than the source: Astro's image service does not enlarge, so a
|
|
27
|
+
* size past the source's comes back as the source, and is reported at its
|
|
28
|
+
* own width rather than the one asked for.
|
|
29
|
+
*/
|
|
30
|
+
async function cut(icon: ImageAsset, size: number): Promise<ImageAsset> {
|
|
31
|
+
if (size >= icon.width) return icon;
|
|
32
|
+
const image = await getImage({
|
|
33
|
+
src: icon as ImageMetadata,
|
|
34
|
+
width: size,
|
|
35
|
+
height: size,
|
|
36
|
+
format: "png",
|
|
37
|
+
});
|
|
38
|
+
return { src: image.src, width: size, height: size, format: "png" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every icon a page links, from the project's one square source.
|
|
43
|
+
*
|
|
44
|
+
* Cut here rather than linked as given because the source is sized for the
|
|
45
|
+
* largest use — 480px is common — and a browser fetches a favicon on every
|
|
46
|
+
* first visit, before anything it actually shows. Linked as-is, that was the
|
|
47
|
+
* same tens-of-kilobytes PNG for a 16px tab.
|
|
48
|
+
*/
|
|
49
|
+
export async function siteIcons(icon: ImageAsset): Promise<SiteIcons> {
|
|
50
|
+
const favicons = await Promise.all(
|
|
51
|
+
FAVICON_SIZES.map((size) => cut(icon, size))
|
|
52
|
+
);
|
|
53
|
+
return {
|
|
54
|
+
// A small source makes two sizes one; linking it twice says nothing.
|
|
55
|
+
favicons: favicons.filter(
|
|
56
|
+
(each, index) =>
|
|
57
|
+
favicons.findIndex((other) => other.src === each.src) === index
|
|
58
|
+
),
|
|
59
|
+
touch: await cut(icon, TOUCH_SIZE),
|
|
60
|
+
};
|
|
61
|
+
}
|
package/src/index.ts
CHANGED
package/src/meta/index.ts
CHANGED
|
@@ -29,7 +29,6 @@ import {
|
|
|
29
29
|
notFoundAnalytics,
|
|
30
30
|
} from "../analytics/index.ts";
|
|
31
31
|
import type { ResolvedLocaleMeta } from "../config.ts";
|
|
32
|
-
import type { ImageAsset } from "../image.ts";
|
|
33
32
|
import {
|
|
34
33
|
article,
|
|
35
34
|
type BreadcrumbStep,
|
|
@@ -41,7 +40,6 @@ import type { HttpsUrl } from "../url.ts";
|
|
|
41
40
|
import type { MetaContentBase, PageKind, ThemeColor } from "./content.ts";
|
|
42
41
|
import { robotsContent, warnAboutRobots } from "./robots.ts";
|
|
43
42
|
import {
|
|
44
|
-
iconLinks,
|
|
45
43
|
type OgImage,
|
|
46
44
|
warnAboutShareImage,
|
|
47
45
|
warnIfClipped,
|
|
@@ -60,8 +58,10 @@ export type { RobotsDirective, RobotsPolicy } from "./robots.ts";
|
|
|
60
58
|
export {
|
|
61
59
|
fitDescription,
|
|
62
60
|
fitTitle,
|
|
61
|
+
iconLinks,
|
|
63
62
|
resolveShareImage,
|
|
64
63
|
type ShareImage,
|
|
64
|
+
type SiteIcons,
|
|
65
65
|
} from "./share-image.ts";
|
|
66
66
|
export type { MetaTag } from "./tag.ts";
|
|
67
67
|
export type { SiteVerification } from "./verification.ts";
|
|
@@ -153,8 +153,6 @@ interface MetaInputDerived<L extends string> extends ChromeInput {
|
|
|
153
153
|
* from the chrome that they left.
|
|
154
154
|
*/
|
|
155
155
|
export interface ChromeInput {
|
|
156
|
-
/** The site's square icon, used for every icon link. */
|
|
157
|
-
readonly icon: ImageAsset;
|
|
158
156
|
/**
|
|
159
157
|
* Tints the browser UI — Android Chrome's bar, iOS Safari, an installed PWA.
|
|
160
158
|
* Two values emit one tag per `prefers-color-scheme`.
|
|
@@ -184,18 +182,18 @@ export interface DocumentTags {
|
|
|
184
182
|
}
|
|
185
183
|
|
|
186
184
|
/**
|
|
187
|
-
* What the browser dresses its own furniture with: the
|
|
188
|
-
*
|
|
185
|
+
* What the browser dresses its own furniture with: the tint of the address
|
|
186
|
+
* bar, and which schemes the page renders form controls for. The tab's icon is
|
|
187
|
+
* furniture too, but it has to be cut to size first, which only the build can
|
|
188
|
+
* do: `Document` emits it, from `PageMeta.icon`.
|
|
189
189
|
*
|
|
190
190
|
* Pulled out of `buildMeta` because the 404 needs exactly this and nothing else
|
|
191
191
|
* around it. Sharing the code is the point rather than a convenience: these are
|
|
192
|
-
* the tags a reader sees *as* the site
|
|
193
|
-
*
|
|
194
|
-
* and two copies of a four-branch block is how one of them quietly stops
|
|
195
|
-
* matching the other.
|
|
192
|
+
* the tags a reader sees *as* the site, and two copies of a four-branch block
|
|
193
|
+
* is how one of them quietly stops matching the other.
|
|
196
194
|
*/
|
|
197
195
|
function chromeTags(input: ChromeInput): MetaTag[] {
|
|
198
|
-
const tags: MetaTag[] = [
|
|
196
|
+
const tags: MetaTag[] = [];
|
|
199
197
|
|
|
200
198
|
if (input.colorScheme !== undefined) {
|
|
201
199
|
tags.push(meta({ name: "color-scheme", content: input.colorScheme }));
|
package/src/meta/share-image.ts
CHANGED
|
@@ -222,26 +222,39 @@ export function resolveShareImage(
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
/**
|
|
225
|
-
* The icon
|
|
225
|
+
* The site icon, cut to the sizes its readers ask for — see `siteIcons` in
|
|
226
|
+
* `astro/`, which cuts them from the one square source a project gives.
|
|
227
|
+
*/
|
|
228
|
+
export interface SiteIcons {
|
|
229
|
+
/** For `rel="icon"`, smallest first: the tab's, and the search result's. */
|
|
230
|
+
readonly favicons: readonly ImageAsset[];
|
|
231
|
+
/** For `apple-touch-icon`, which Apple scales from 180. */
|
|
232
|
+
readonly touch: ImageAsset;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The icon links: each favicon, and the touch icon.
|
|
226
237
|
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
238
|
+
* Built together rather than left to a layout to remember the second `rel`.
|
|
239
|
+
* `sizes` is stated so a browser picks without fetching each file to find out
|
|
240
|
+
* what it is choosing between — which, with the source served as-is, was a
|
|
241
|
+
* 480px PNG of tens of kilobytes for a 16px tab.
|
|
231
242
|
*
|
|
232
243
|
* The href stays as written. Unlike `og:image` these are read by a browser that
|
|
233
244
|
* already has the page, so root-relative is correct and making it absolute would
|
|
234
245
|
* only hard-code the origin.
|
|
235
246
|
*/
|
|
236
|
-
export function iconLinks(
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
247
|
+
export function iconLinks(icons: SiteIcons): MetaTag[] {
|
|
248
|
+
const attrs = (icon: ImageAsset) => {
|
|
249
|
+
const mime = imageMime(icon.format);
|
|
250
|
+
return {
|
|
251
|
+
href: icon.src,
|
|
252
|
+
sizes: `${icon.width}x${icon.height}`,
|
|
253
|
+
...(mime !== undefined ? { type: mime } : {}),
|
|
254
|
+
};
|
|
242
255
|
};
|
|
243
256
|
return [
|
|
244
|
-
link({ rel: "icon", ...attrs }),
|
|
245
|
-
link({ rel: "apple-touch-icon", ...attrs }),
|
|
257
|
+
...icons.favicons.map((icon) => link({ rel: "icon", ...attrs(icon) })),
|
|
258
|
+
link({ rel: "apple-touch-icon", ...attrs(icons.touch) }),
|
|
246
259
|
];
|
|
247
260
|
}
|
package/src/site/create.ts
CHANGED
|
@@ -326,18 +326,17 @@ export function createSite<
|
|
|
326
326
|
// Umami only, tagged so the misses read on their own — the point
|
|
327
327
|
// of measuring this page is finding a redirect somebody forgot,
|
|
328
328
|
// not counting the people who mistype. See `notFoundAnalytics`.
|
|
329
|
-
//
|
|
330
|
-
// `checkedIcon()` rather than `project.icon`: the 404 is held to the
|
|
331
|
-
// same square-and-PNG rules as every other page. It is also the one
|
|
332
|
-
// page that could plausibly render before any other, so letting it
|
|
333
|
-
// read the icon unchecked would move where a bad one is caught.
|
|
334
329
|
tags: buildNotFoundMeta({
|
|
335
330
|
title,
|
|
336
331
|
analytics: project.analytics,
|
|
337
|
-
icon: checkedIcon(),
|
|
338
332
|
themeColor: project.themeColor,
|
|
339
333
|
colorScheme: project.colorScheme,
|
|
340
334
|
}),
|
|
335
|
+
// `checkedIcon()` rather than `project.icon`: the 404 is held to the
|
|
336
|
+
// same square-and-PNG rules as every other page. It is also the one
|
|
337
|
+
// page that could plausibly render before any other, so letting it
|
|
338
|
+
// read the icon unchecked would move where a bad one is caught.
|
|
339
|
+
icon: checkedIcon(),
|
|
341
340
|
// Nothing. The body channel exists for Tag Manager's `<noscript>`,
|
|
342
341
|
// and no Google tag reaches this page.
|
|
343
342
|
bodyTags: [],
|
|
@@ -407,7 +406,6 @@ export function createSite<
|
|
|
407
406
|
),
|
|
408
407
|
siteName: project.siteName,
|
|
409
408
|
twitterSite: project.twitterSite,
|
|
410
|
-
icon: checkedIcon(),
|
|
411
409
|
verification: project.verification,
|
|
412
410
|
analytics: project.analytics,
|
|
413
411
|
llmsUrl,
|
|
@@ -419,6 +417,7 @@ export function createSite<
|
|
|
419
417
|
dir: localeMeta[locale].dir,
|
|
420
418
|
tags: document.head,
|
|
421
419
|
bodyTags: document.body,
|
|
420
|
+
icon: checkedIcon(),
|
|
422
421
|
consentVendors: consentVendors(project.analytics),
|
|
423
422
|
};
|
|
424
423
|
}
|
package/src/site/page.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ImageAsset } from "../image.ts";
|
|
1
2
|
import type { MetaContent, MetaTag } from "../meta/index.ts";
|
|
2
3
|
import type { HttpsUrl, UrlPath } from "../url.ts";
|
|
3
4
|
|
|
@@ -65,6 +66,12 @@ export interface PageMeta {
|
|
|
65
66
|
* both, and cannot put either in the other's place.
|
|
66
67
|
*/
|
|
67
68
|
readonly bodyTags: MetaTag[];
|
|
69
|
+
/**
|
|
70
|
+
* The site's square icon, as the project gave it. `Document` cuts it to
|
|
71
|
+
* each size a browser asks for and links those; the source itself, often
|
|
72
|
+
* hundreds of pixels across, is never linked.
|
|
73
|
+
*/
|
|
74
|
+
readonly icon: ImageAsset;
|
|
68
75
|
/** The vendors that need a consent banner on this page; `Document` fails without one. */
|
|
69
76
|
readonly consentVendors: readonly string[];
|
|
70
77
|
}
|