@escape-game-over/atlas 0.1.84 → 0.1.86

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 CHANGED
@@ -109,6 +109,10 @@ export const { project, redirects } = atlas.project(baseMessages, baseRoutes, {.
109
109
  export const site = atlas.site(baseMessages, baseRoutes, project)
110
110
  ```
111
111
 
112
+ A site that publishes one language under several regions writes the words once:
113
+ `atlas.perLanguage({ en: "Home", fr: "Accueil" })` fills in `en-GB`, `en-IE` and
114
+ `fr-FR` for one entry of `atlas.messages`, with placeholders still checked.
115
+
112
116
  Step 4 returns `t()`, `rich()`, `plain()`, `pathFor()`, `urlFor()`, `fileUrl()`,
113
117
  `localeLinksFor()`, `metaFor()`, `breadcrumbFor()`,
114
118
  `getStaticPaths()`, `routes`, `sitemap()`, `robots()` and `llms()` already
@@ -521,6 +521,12 @@ is what every embed needs and has no taste in:
521
521
  - **Focus moves to the player.** The button that had it has just left the page,
522
522
  which would drop a keyboard reader back at the top of the document.
523
523
 
524
+ Two choices stay with the project. `rel` lets the player suggest videos from
525
+ other channels, and is off by default, so `rel=0` keeps the suggestions to the
526
+ channel's own; YouTube has no setting that removes them altogether. `playsInline`
527
+ keeps the video in the page on an iPhone rather than going full screen on the
528
+ press.
529
+
524
530
  `frame` is called after all of that and before the iframe is in the page, so its
525
531
  word is the last one and nothing is drawn unstyled first.
526
532
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.84",
3
+ "version": "0.1.86",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -58,7 +58,7 @@
58
58
  "vite": ">= 8"
59
59
  },
60
60
  "devDependencies": {
61
- "@biomejs/biome": "2.6.4",
61
+ "@biomejs/biome": "2.6.5",
62
62
  "@types/node": "26.6.6",
63
63
  "@vitest/coverage-istanbul": "5.1.0",
64
64
  "astro": "7.4.5",
@@ -55,6 +55,19 @@ export interface YouTubeEmbedOptions {
55
55
  * player has no way to tell which video it is.
56
56
  */
57
57
  readonly title: string;
58
+ /**
59
+ * Whether the player may suggest videos from other channels. Off unless
60
+ * asked for: with `rel=0` it suggests only the channel's own, so a trailer
61
+ * on a project's page does not end on someone else's video.
62
+ *
63
+ * YouTube has no setting that removes the suggestions altogether.
64
+ */
65
+ readonly rel?: boolean;
66
+ /**
67
+ * Whether an iPhone plays the video in the page rather than taking it
68
+ * full screen on the press.
69
+ */
70
+ readonly playsInline?: boolean;
58
71
  /**
59
72
  * Anything the project wants on the iframe — classes, above all.
60
73
  *
@@ -106,7 +119,7 @@ const ALLOW =
106
119
  "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share";
107
120
 
108
121
  export function youtubeEmbed(options: YouTubeEmbedOptions): YouTubeEmbed {
109
- const { id, title, frame } = options;
122
+ const { id, title, rel = false, playsInline = false, frame } = options;
110
123
 
111
124
  if (!VIDEO_ID.test(id)) {
112
125
  throw new Error(
@@ -122,7 +135,10 @@ export function youtubeEmbed(options: YouTubeEmbedOptions): YouTubeEmbed {
122
135
  // `youtube-nocookie.com` rather than `youtube.com`: the same player, which
123
136
  // sets no cookie until the video is played — and here it is only ever
124
137
  // loaded to be played, so nothing is lost by it.
125
- const src = `https://www.youtube-nocookie.com/embed/${id}?autoplay=1`;
138
+ const url = new URL(`https://www.youtube-nocookie.com/embed/${id}`);
139
+ url.searchParams.set("autoplay", "1");
140
+ url.searchParams.set("rel", rel ? "1" : "0");
141
+ if (playsInline) url.searchParams.set("playsinline", "1");
126
142
 
127
143
  return {
128
144
  attach(trigger, container) {
@@ -133,7 +149,7 @@ export function youtubeEmbed(options: YouTubeEmbedOptions): YouTubeEmbed {
133
149
  () => {
134
150
  const iframe =
135
151
  container.ownerDocument.createElement("iframe");
136
- iframe.src = src;
152
+ iframe.src = url.href;
137
153
  iframe.title = title;
138
154
  iframe.allow = ALLOW;
139
155
  iframe.allowFullscreen = true;
@@ -0,0 +1,45 @@
1
+ /** `"en"` for `"en-GB"`: everything before the first dash. */
2
+ export type LanguageOf<L extends string> =
3
+ L extends `${infer Language}-${string}` ? Language : never;
4
+
5
+ /**
6
+ * Every locale, holding its language's text.
7
+ *
8
+ * Keeps each string's literal type, so a message built this way has its
9
+ * placeholders checked exactly like one written out by hand.
10
+ */
11
+ export type PerLanguage<L extends string, T> = {
12
+ readonly [K in L]: T[LanguageOf<K> & keyof T];
13
+ };
14
+
15
+ /**
16
+ * One message, written once per language and filled in for each of its tags.
17
+ *
18
+ * For a site that publishes one language under several regions, where `en-GB`
19
+ * and `en-IE` are different tags for `hreflang` but the same words. Checked here
20
+ * as well as in the types, because the cast below stops the compiler seeing
21
+ * this body, and a caller's `as any` would otherwise publish `undefined`.
22
+ */
23
+ export function expandLanguages<L extends string, T>(
24
+ locales: readonly L[],
25
+ texts: T
26
+ ): PerLanguage<L, T> {
27
+ const given = texts as Readonly<Record<string, string>>;
28
+ const languages = new Set(locales.map((tag) => tag.split("-")[0]));
29
+ for (const language of Object.keys(given)) {
30
+ if (!languages.has(language)) {
31
+ throw new Error(
32
+ `perLanguage: "${language}" is not a language this site declares.`
33
+ );
34
+ }
35
+ }
36
+ const entries = locales.map((tag) => {
37
+ const language = tag.split("-")[0] as string;
38
+ const text = given[language];
39
+ if (text === undefined) {
40
+ throw new Error(`perLanguage: no text for "${language}" (${tag}).`);
41
+ }
42
+ return [tag, text];
43
+ });
44
+ return Object.fromEntries(entries) as PerLanguage<L, T>;
45
+ }
@@ -10,6 +10,11 @@ import {
10
10
  type NoExtraLocales,
11
11
  type ValidateBase,
12
12
  } from "../i18n/define.ts";
13
+ import {
14
+ expandLanguages,
15
+ type LanguageOf,
16
+ type PerLanguage,
17
+ } from "../i18n/per-language.ts";
13
18
  import {
14
19
  bindRedirects,
15
20
  type DefinedProject,
@@ -86,6 +91,13 @@ export function defineSite<
86
91
  return defineMessages(config, catalog as never) as T;
87
92
  },
88
93
 
94
+ /** One message per language, filled in for each of that language's tags. */
95
+ perLanguage<const T extends Readonly<Record<LanguageOf<L>, string>>>(
96
+ texts: T & Readonly<Record<Exclude<keyof T, LanguageOf<L>>, never>>
97
+ ): PerLanguage<L, T> {
98
+ return expandLanguages(Object.keys(config.locales) as L[], texts);
99
+ },
100
+
89
101
  /** One deployment: what differs from the defaults. */
90
102
  project<
91
103
  const Catalog extends BaseCatalog<L>,