website-xp-phone 2.2.11 → 2.3.0

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.
@@ -1,9 +1,15 @@
1
1
  {
2
2
  "source": "https://soundcloud.com/akinevz",
3
- "generatedAt": "2026-10-23T21:05:10.086Z",
3
+ "generatedAt": "2026-10-24T11:54:46.192Z",
4
4
  "asOfUploadingTrackCount": 91,
5
5
  "trackCount": 91,
6
6
  "tracks": [
7
+ {
8
+ "owner": "akinevz",
9
+ "path": "/akinevz/roam",
10
+ "title": "Roam",
11
+ "url": "https://soundcloud.com/akinevz/roam"
12
+ },
7
13
  {
8
14
  "owner": "akinevz",
9
15
  "path": "/akinevz/outer",
@@ -66,9 +72,9 @@
66
72
  },
67
73
  {
68
74
  "owner": "akinevz",
69
- "path": "/akinevz/atrophy-cultism",
70
- "title": "Atrophy Cultism",
71
- "url": "https://soundcloud.com/akinevz/atrophy-cultism"
75
+ "path": "/akinevz/fragments",
76
+ "title": "Marrowful Fragments",
77
+ "url": "https://soundcloud.com/akinevz/fragments"
72
78
  },
73
79
  {
74
80
  "owner": "akinevz",
@@ -84,15 +90,15 @@
84
90
  },
85
91
  {
86
92
  "owner": "akinevz",
87
- "path": "/akinevz/diagonal",
88
- "title": "Diagonal Constant",
89
- "url": "https://soundcloud.com/akinevz/diagonal"
93
+ "path": "/akinevz/atrophy",
94
+ "title": "Atrophy Cultism",
95
+ "url": "https://soundcloud.com/akinevz/atrophy"
90
96
  },
91
97
  {
92
98
  "owner": "akinevz",
93
- "path": "/akinevz/bloody-bits",
99
+ "path": "/akinevz/dresscode",
94
100
  "title": "Memento Mori",
95
- "url": "https://soundcloud.com/akinevz/bloody-bits"
101
+ "url": "https://soundcloud.com/akinevz/dresscode"
96
102
  },
97
103
  {
98
104
  "owner": "akinevz",
@@ -109,7 +115,7 @@
109
115
  {
110
116
  "owner": "akinevz",
111
117
  "path": "/akinevz/calculus",
112
- "title": "Reticulated Contraventions",
118
+ "title": "Calculated Reception",
113
119
  "url": "https://soundcloud.com/akinevz/calculus"
114
120
  },
115
121
  {
@@ -126,9 +132,9 @@
126
132
  },
127
133
  {
128
134
  "owner": "akinevz",
129
- "path": "/akinevz/frozen-glove",
135
+ "path": "/akinevz/frozen-scream",
130
136
  "title": "Freezing Point",
131
- "url": "https://soundcloud.com/akinevz/frozen-glove"
137
+ "url": "https://soundcloud.com/akinevz/frozen-scream"
132
138
  },
133
139
  {
134
140
  "owner": "akinevz",
@@ -181,7 +187,7 @@
181
187
  {
182
188
  "owner": "akinevz",
183
189
  "path": "/akinevz/crow",
184
- "title": "Crow 3",
190
+ "title": "Crow",
185
191
  "url": "https://soundcloud.com/akinevz/crow"
186
192
  },
187
193
  {
@@ -229,7 +235,7 @@
229
235
  {
230
236
  "owner": "akinevz",
231
237
  "path": "/akinevz/pein",
232
- "title": "Spectral Pain",
238
+ "title": "Er",
233
239
  "url": "https://soundcloud.com/akinevz/pein"
234
240
  },
235
241
  {
@@ -268,12 +274,6 @@
268
274
  "title": "Open Door",
269
275
  "url": "https://soundcloud.com/akinevz/open-door"
270
276
  },
271
- {
272
- "owner": "akinevz",
273
- "path": "/akinevz/wat",
274
- "title": "Er",
275
- "url": "https://soundcloud.com/akinevz/wat"
276
- },
277
277
  {
278
278
  "owner": "akinevz",
279
279
  "path": "/akinevz/warrer-style",
@@ -558,6 +558,12 @@
558
558
  "profileImageUrl": null,
559
559
  "trackCount": 58,
560
560
  "tracks": [
561
+ {
562
+ "owner": "akinevz",
563
+ "path": "/akinevz/roam",
564
+ "title": "Roam",
565
+ "url": "https://soundcloud.com/akinevz/roam"
566
+ },
561
567
  {
562
568
  "owner": "akinevz",
563
569
  "path": "/akinevz/outer",
@@ -620,9 +626,9 @@
620
626
  },
621
627
  {
622
628
  "owner": "akinevz",
623
- "path": "/akinevz/atrophy-cultism",
624
- "title": "Atrophy Cultism",
625
- "url": "https://soundcloud.com/akinevz/atrophy-cultism"
629
+ "path": "/akinevz/fragments",
630
+ "title": "Marrowful Fragments",
631
+ "url": "https://soundcloud.com/akinevz/fragments"
626
632
  },
627
633
  {
628
634
  "owner": "akinevz",
@@ -638,15 +644,15 @@
638
644
  },
639
645
  {
640
646
  "owner": "akinevz",
641
- "path": "/akinevz/diagonal",
642
- "title": "Diagonal Constant",
643
- "url": "https://soundcloud.com/akinevz/diagonal"
647
+ "path": "/akinevz/atrophy",
648
+ "title": "Atrophy Cultism",
649
+ "url": "https://soundcloud.com/akinevz/atrophy"
644
650
  },
645
651
  {
646
652
  "owner": "akinevz",
647
- "path": "/akinevz/bloody-bits",
653
+ "path": "/akinevz/dresscode",
648
654
  "title": "Memento Mori",
649
- "url": "https://soundcloud.com/akinevz/bloody-bits"
655
+ "url": "https://soundcloud.com/akinevz/dresscode"
650
656
  },
651
657
  {
652
658
  "owner": "akinevz",
@@ -663,7 +669,7 @@
663
669
  {
664
670
  "owner": "akinevz",
665
671
  "path": "/akinevz/calculus",
666
- "title": "Reticulated Contraventions",
672
+ "title": "Calculated Reception",
667
673
  "url": "https://soundcloud.com/akinevz/calculus"
668
674
  },
669
675
  {
@@ -680,9 +686,9 @@
680
686
  },
681
687
  {
682
688
  "owner": "akinevz",
683
- "path": "/akinevz/frozen-glove",
689
+ "path": "/akinevz/frozen-scream",
684
690
  "title": "Freezing Point",
685
- "url": "https://soundcloud.com/akinevz/frozen-glove"
691
+ "url": "https://soundcloud.com/akinevz/frozen-scream"
686
692
  },
687
693
  {
688
694
  "owner": "akinevz",
@@ -735,7 +741,7 @@
735
741
  {
736
742
  "owner": "akinevz",
737
743
  "path": "/akinevz/crow",
738
- "title": "Crow 3",
744
+ "title": "Crow",
739
745
  "url": "https://soundcloud.com/akinevz/crow"
740
746
  },
741
747
  {
@@ -783,7 +789,7 @@
783
789
  {
784
790
  "owner": "akinevz",
785
791
  "path": "/akinevz/pein",
786
- "title": "Spectral Pain",
792
+ "title": "Er",
787
793
  "url": "https://soundcloud.com/akinevz/pein"
788
794
  },
789
795
  {
@@ -822,12 +828,6 @@
822
828
  "title": "Open Door",
823
829
  "url": "https://soundcloud.com/akinevz/open-door"
824
830
  },
825
- {
826
- "owner": "akinevz",
827
- "path": "/akinevz/wat",
828
- "title": "Er",
829
- "url": "https://soundcloud.com/akinevz/wat"
830
- },
831
831
  {
832
832
  "owner": "akinevz",
833
833
  "path": "/akinevz/warrer-style",
package/src/lib/embeds.ts CHANGED
@@ -2,7 +2,10 @@ export type EmbedSpec = {
2
2
  provider: "spotify" | "youtube" | "soundcloud";
3
3
  label: string;
4
4
  src: string;
5
- height: number;
5
+ /** Fixed pixel height for fixed-aspect players (Spotify, SoundCloud).
6
+ * Omitted for YouTube: those players are 16:9 and size themselves from
7
+ * their width via CSS (.embed-youtube). */
8
+ height?: number;
6
9
  };
7
10
 
8
11
  const SPOTIFY_TYPES = new Set([
@@ -14,6 +17,41 @@ const SPOTIFY_TYPES = new Set([
14
17
  "show",
15
18
  ]);
16
19
  const SPOTIFY_HEIGHT = 152;
20
+ const YOUTUBE_ID = /^[A-Za-z0-9_-]+$/;
21
+
22
+ /**
23
+ * Extracts a video id from common YouTube URL forms:
24
+ * https://youtu.be/<id>
25
+ * https://www.youtube.com/watch?v=<id>
26
+ * https://www.youtube.com/embed/<id> (also /shorts/, /live/ and /v/)
27
+ * www./m./music. subdomains and youtube-nocookie.com are accepted.
28
+ * Returns null for anything else — including any link carrying a ?list=
29
+ * parameter: it points at a playlist (often unlisted), and every caller
30
+ * renders those as plain links instead of embeds.
31
+ */
32
+ export function youtubeVideoId(url: string): string | null {
33
+ let parsed: URL;
34
+ try {
35
+ parsed = new URL(url);
36
+ } catch {
37
+ return null;
38
+ }
39
+ if (parsed.searchParams.get("list")) return null;
40
+ const host = parsed.hostname.replace(/^(www|m|music)\./, "");
41
+ if (host === "youtu.be") {
42
+ const id = parsed.pathname.slice(1).split("/")[0];
43
+ return id && YOUTUBE_ID.test(id) ? id : null;
44
+ }
45
+ if (host !== "youtube.com" && host !== "youtube-nocookie.com") return null;
46
+ if (parsed.pathname === "/watch") {
47
+ const id = parsed.searchParams.get("v");
48
+ return id && YOUTUBE_ID.test(id) ? id : null;
49
+ }
50
+ const embed = parsed.pathname.match(
51
+ /^\/(?:embed|shorts|live|v)\/([A-Za-z0-9_-]+)/,
52
+ );
53
+ return embed?.[1] ?? null;
54
+ }
17
55
 
18
56
  function parse(url: string): URL | null {
19
57
  try {
@@ -72,23 +110,12 @@ export function resolveEmbed(
72
110
  };
73
111
  }
74
112
 
75
- if (
76
- host === "youtu.be" ||
77
- host === "youtube.com" ||
78
- host === "www.youtube.com"
79
- ) {
80
- // Playlists are often unlisted, so they fall back to a plain link.
81
- if (parsed.searchParams.get("list")) return null;
82
- const id =
83
- host === "youtu.be"
84
- ? parsed.pathname.slice(1)
85
- : parsed.searchParams.get("v");
86
- if (!id) return null;
113
+ const youtubeId = youtubeVideoId(url);
114
+ if (youtubeId) {
87
115
  return {
88
116
  provider: "youtube",
89
117
  label: "YouTube",
90
- src: `https://www.youtube.com/embed/${id}`,
91
- height: SPOTIFY_HEIGHT,
118
+ src: `https://www.youtube.com/embed/${youtubeId}`,
92
119
  };
93
120
  }
94
121
 
@@ -96,14 +123,6 @@ export function resolveEmbed(
96
123
  }
97
124
 
98
125
  export function youTubeThumbnail(url: string): string | null {
99
- const parsed = parse(url);
100
- if (!parsed || parsed.searchParams.get("list")) return null;
101
- const host = parsed.hostname;
102
- const id =
103
- host === "youtu.be"
104
- ? parsed.pathname.slice(1)
105
- : host === "youtube.com" || host === "www.youtube.com"
106
- ? parsed.searchParams.get("v")
107
- : null;
126
+ const id = youtubeVideoId(url);
108
127
  return id ? `https://i.ytimg.com/vi/${id}/maxresdefault.jpg` : null;
109
128
  }
@@ -1,34 +1,21 @@
1
1
  import Markdown from "react-markdown";
2
2
  import rehypeRaw from "rehype-raw";
3
- import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
3
+ import rehypeSanitize from "rehype-sanitize";
4
4
  import { Link } from "react-router-dom";
5
+ import { rehypeYoutubeEmbed } from "@/lib/rehypeYoutubeEmbed";
6
+ import { sanitizeSchema } from "@/lib/sanitizeSchema";
5
7
  import { CLIPPY_LINK, onClippyTriggerClick } from "./clippy";
6
8
 
7
- const schema = {
8
- ...defaultSchema,
9
- tagNames: [...(defaultSchema.tagNames ?? []), "iframe"],
10
- attributes: {
11
- ...defaultSchema.attributes,
12
- img: [...(defaultSchema.attributes?.img ?? []), "style", "loading"],
13
- iframe: [
14
- "src",
15
- "width",
16
- "height",
17
- "title",
18
- "allow",
19
- "allowFullScreen",
20
- "loading",
21
- "style",
22
- ],
23
- },
24
- };
25
-
26
9
  type Props = { children: string; inline?: boolean };
27
10
 
28
11
  export function Md({ children }: Props) {
29
12
  return (
30
13
  <Markdown
31
- rehypePlugins={[rehypeRaw, [rehypeSanitize, schema]]}
14
+ rehypePlugins={[
15
+ rehypeRaw,
16
+ rehypeYoutubeEmbed,
17
+ [rehypeSanitize, sanitizeSchema],
18
+ ]}
32
19
  components={{
33
20
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
34
21
  a({ href, children: label, node: _node, ...rest }) {
@@ -0,0 +1,65 @@
1
+ import type { Element, ElementContent, Root } from "hast";
2
+ import { visit } from "unist-util-visit";
3
+ import { youtubeVideoId } from "./embeds.ts";
4
+
5
+ /**
6
+ * Keeps the embed inside its container (the window body) at any width while
7
+ * preserving the player's 16:9 shape, and centres it when the body is wider
8
+ * than the cap. The width/height attributes stay as a no-CSS fallback and
9
+ * give the browser the intrinsic ratio before styles load.
10
+ */
11
+ const RESPONSIVE_STYLE =
12
+ "width: 100%; max-width: 560px; height: auto; aspect-ratio: 16 / 9;" +
13
+ " display: block; margin: 0 auto;";
14
+
15
+ /** The fallback title for embeds whose link text is just the bare URL
16
+ * (markdown autolinks) — a URL reads poorly as a player title. */
17
+ const FALLBACK_TITLE = "YouTube video";
18
+
19
+ function textContent(node: ElementContent): string {
20
+ if (node.type === "text") return node.value;
21
+ if (node.type === "element") return node.children.map(textContent).join("");
22
+ return "";
23
+ }
24
+
25
+ /** Uses the link's own text as the accessible player title when it is
26
+ * human-readable ([my talk](…)), falling back for bare autolinks (<…>). */
27
+ function linkTitle(node: Element, href: string): string {
28
+ const text = textContent(node).trim();
29
+ return text && text.toLowerCase() !== href.toLowerCase()
30
+ ? text
31
+ : FALLBACK_TITLE;
32
+ }
33
+
34
+ /** Rehype plugin: replace YouTube links with responsive 16:9 embeds. */
35
+ export function rehypeYoutubeEmbed() {
36
+ return (tree: Root) => {
37
+ visit(tree, "element", (node, index, parent) => {
38
+ if (!parent || index === undefined || node.tagName !== "a") return;
39
+ const href = node.properties?.href;
40
+ if (typeof href !== "string") return;
41
+ const id = youtubeVideoId(href);
42
+ if (!id) return;
43
+ parent.children[index] = {
44
+ type: "element",
45
+ tagName: "iframe",
46
+ properties: {
47
+ src: `https://www.youtube-nocookie.com/embed/${id}`,
48
+ title: linkTitle(node, href),
49
+ width: "560",
50
+ height: "315",
51
+ loading: "lazy",
52
+ style: RESPONSIVE_STYLE,
53
+ // YouTube's player rejects embeds without a usable referrer
54
+ // (error 153). The site sends Referrer-Policy: no-referrer, so
55
+ // the iframe needs its own policy to send its origin.
56
+ referrerPolicy: "strict-origin-when-cross-origin",
57
+ allow:
58
+ "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
59
+ allowFullScreen: true,
60
+ },
61
+ children: [],
62
+ };
63
+ });
64
+ };
65
+ }
@@ -0,0 +1,32 @@
1
+ import { defaultSchema } from "rehype-sanitize";
2
+
3
+ /**
4
+ * Sanitize schema shared by every Markdown pipeline (src/lib/markdown.tsx)
5
+ * and its test mirror (test/youtubeEmbed.test.js), so the pipeline and the
6
+ * tests can never drift apart.
7
+ *
8
+ * On top of the defaults it allows:
9
+ * - <iframe> (YouTube embeds via rehypeYoutubeEmbed, or hand-written raw
10
+ * HTML), including the attributes those embeds need — `style` for
11
+ * responsive sizing, `referrerPolicy` for YouTube's error-153 fix;
12
+ * - `style` and `loading` on <img> (inline content images).
13
+ */
14
+ export const sanitizeSchema = {
15
+ ...defaultSchema,
16
+ tagNames: [...(defaultSchema.tagNames ?? []), "iframe"],
17
+ attributes: {
18
+ ...defaultSchema.attributes,
19
+ img: [...(defaultSchema.attributes?.img ?? []), "style", "loading"],
20
+ iframe: [
21
+ "src",
22
+ "width",
23
+ "height",
24
+ "title",
25
+ "allow",
26
+ "allowFullScreen",
27
+ "loading",
28
+ "style",
29
+ "referrerPolicy",
30
+ ],
31
+ },
32
+ };
@@ -398,3 +398,10 @@ main.feef69-page {
398
398
  max-width: 100% !important;
399
399
  object-fit: contain;
400
400
  }
401
+
402
+ /* Same for inline iframe embeds in content (markdown YouTube links carry
403
+ their own responsive style; this also caps hand-written <iframe>s in raw
404
+ HTML). Music-page players are sized by their own rules and excluded. */
405
+ .window-body iframe:not(.embed) {
406
+ max-width: 100%;
407
+ }
@@ -383,18 +383,13 @@ form#form > label {
383
383
 
384
384
  /* In a maximized "centered" window the content hugs the bottom left corner
385
385
  and grows upward and rightward as the space allows. */
386
- .window.maximized[data-theme~="centered"]
387
- > .maximized-window-body
388
- > ul {
386
+ .window.maximized[data-theme~="centered"] > .maximized-window-body > ul {
389
387
  display: flex;
390
388
  flex-direction: column;
391
389
  justify-content: flex-end;
392
390
  }
393
391
 
394
- .window.maximized[data-theme~="centered"]
395
- > .maximized-window-body
396
- > ul
397
- > li {
392
+ .window.maximized[data-theme~="centered"] > .maximized-window-body > ul > li {
398
393
  justify-content: flex-start;
399
394
  }
400
395
 
@@ -485,6 +480,14 @@ form#form > label {
485
480
  }
486
481
  }
487
482
 
483
+ /* YouTube players are 16:9 — size them from their width instead of a
484
+ fixed pixel height (or the mobile square above, which letterboxes
485
+ video). Declared after the media block so it wins the cascade. */
486
+ .embed-youtube {
487
+ aspect-ratio: 16 / 9;
488
+ height: auto;
489
+ }
490
+
488
491
  .music-favourite-link-window ul {
489
492
  min-width: 100%;
490
493
  }
@@ -0,0 +1,73 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { test } from "node:test";
4
+
5
+ const pkg = JSON.parse(readFileSync("package.json", "utf8"));
6
+ const scripts = pkg.scripts;
7
+ const renderYaml = readFileSync("render.yaml", "utf8");
8
+ const ciYml = readFileSync(".github/workflows/ci.yml", "utf8");
9
+
10
+ /**
11
+ * The deploy end (Render) must stay cheap and side-effect-free: it builds
12
+ * the committed tree and statically gates on the committed drift report.
13
+ * Everything expensive or stateful — content refresh, checksums, live
14
+ * baseline fetches — belongs to `build:gated` (CI, build:local) only.
15
+ */
16
+ test("the deploy build neither regenerates content nor runs the checksums pipeline", () => {
17
+ const build = scripts.build;
18
+ assert.match(build, /vite build/);
19
+ assert.doesNotMatch(build, /checksums/);
20
+ assert.doesNotMatch(build, /refresh/);
21
+ });
22
+
23
+ test("build:gated is the full pipeline: build + checksums", () => {
24
+ assert.equal(scripts["build:gated"], "npm run build && npm run checksums");
25
+ });
26
+
27
+ test("build:local refreshes soundcloud, then gated build, then the gate", () => {
28
+ const local = scripts["build:local"];
29
+ assert.match(local, /refresh:content/);
30
+ assert.match(local, /refresh:soundcloud/);
31
+ const refreshAt = local.indexOf("refresh:soundcloud");
32
+ const gatedAt = local.indexOf("build:gated");
33
+ const gateAt = local.indexOf("verify:policy");
34
+ assert.ok(refreshAt !== -1 && gatedAt !== -1 && gateAt !== -1);
35
+ assert.ok(refreshAt < gatedAt, "content refresh must precede the build");
36
+ assert.ok(gatedAt < gateAt, "the gate must consume the fresh drift report");
37
+ });
38
+
39
+ test("build:local commits the refreshed snapshot before the dev preview", () => {
40
+ // npm run dev blocks forever; if it ran before `git add -A && git commit`
41
+ // the refreshed snapshots (soundcloud.json, checksums, drift report)
42
+ // would never be committed and the deployed site would trail releases.
43
+ const local = scripts["build:local"];
44
+ const commitAt = local.indexOf("git commit");
45
+ const devAt = local.indexOf("npm run dev");
46
+ assert.ok(commitAt !== -1 && devAt !== -1);
47
+ assert.ok(commitAt < devAt, "commit must precede the blocking dev server");
48
+ });
49
+
50
+ test("check:ci mirrors the CI subset with the gated build", () => {
51
+ assert.match(scripts["check:ci"], /build:gated/);
52
+ assert.match(scripts["check:ci"], /verify:policy/);
53
+ });
54
+
55
+ test("CI runs the gated build so the drift report is recomputed there", () => {
56
+ assert.match(ciYml, /npm run build:gated/);
57
+ });
58
+
59
+ test("Render statically gates on the uploaded report before any install", () => {
60
+ const commandLine = renderYaml
61
+ .split("\n")
62
+ .find((line) => line.trim().startsWith("buildCommand:"));
63
+ assert.ok(commandLine, "render.yaml must declare a buildCommand");
64
+ // The static gate runs before npm install: a failing report aborts the
65
+ // deploy before spending any real compute.
66
+ const gateAt = commandLine.indexOf("npm run verify:policy");
67
+ const installAt = commandLine.indexOf("npm install");
68
+ assert.ok(gateAt !== -1, "deploy must statically check the drift report");
69
+ assert.ok(installAt !== -1 && gateAt < installAt);
70
+ // And it must not re-run the pipeline it gated.
71
+ assert.doesNotMatch(commandLine, /checksums/);
72
+ assert.doesNotMatch(commandLine, /refresh/);
73
+ });
@@ -0,0 +1,89 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ resolveEmbed,
5
+ youtubeVideoId,
6
+ youTubeThumbnail,
7
+ } from "../src/lib/embeds.ts";
8
+
9
+ test("youtubeVideoId parses every common URL form", () => {
10
+ const cases = [
11
+ ["https://youtu.be/n_L-E8K_gcg", "n_L-E8K_gcg"],
12
+ ["https://www.youtube.com/watch?v=n_L-E8K_gcg", "n_L-E8K_gcg"],
13
+ ["https://youtube.com/watch?v=n_L-E8K_gcg&t=30s", "n_L-E8K_gcg"],
14
+ ["https://m.youtube.com/watch?v=n_L-E8K_gcg", "n_L-E8K_gcg"],
15
+ ["https://music.youtube.com/watch?v=n_L-E8K_gcg", "n_L-E8K_gcg"],
16
+ ["https://www.youtube.com/embed/n_L-E8K_gcg", "n_L-E8K_gcg"],
17
+ ["https://www.youtube.com/shorts/n_L-E8K_gcg", "n_L-E8K_gcg"],
18
+ ["https://www.youtube.com/live/n_L-E8K_gcg", "n_L-E8K_gcg"],
19
+ ["https://www.youtube-nocookie.com/embed/n_L-E8K_gcg", "n_L-E8K_gcg"],
20
+ ];
21
+ for (const [url, id] of cases) {
22
+ assert.equal(youtubeVideoId(url), id, url);
23
+ }
24
+ });
25
+
26
+ test("youtubeVideoId rejects playlists, other sites and junk", () => {
27
+ const rejects = [
28
+ "not a url",
29
+ "/relative/path",
30
+ "#clippy",
31
+ "https://example.com/watch?v=n_L-E8K_gcg",
32
+ "https://www.youtube.com/playlist?list=PLAOwLA0fz3HQCtJ5",
33
+ "https://www.youtube.com/watch?v=n_L-E8K_gcg&list=PLAOwLA0fz3HQCtJ5",
34
+ "https://youtu.be/n_L-E8K_gcg?list=PLAOwLA0fz3HQCtJ5",
35
+ "https://www.youtube.com/watch", // no v
36
+ "https://www.youtube.com/watch?v=not a valid id",
37
+ ];
38
+ for (const url of rejects) {
39
+ assert.equal(youtubeVideoId(url), null, url);
40
+ }
41
+ });
42
+
43
+ test("resolveEmbed keeps playlists as plain links (they are often unlisted)", () => {
44
+ assert.equal(
45
+ resolveEmbed("https://www.youtube.com/watch?v=x&list=PLAOwLA0fz3HQCtJ5"),
46
+ null,
47
+ );
48
+ assert.equal(
49
+ resolveEmbed("https://www.youtube.com/playlist?list=PLAOwLA0fz3HQCtJ5"),
50
+ null,
51
+ );
52
+ });
53
+
54
+ test("resolveEmbed maps youtube videos to embed cards", () => {
55
+ const spec = resolveEmbed("https://youtu.be/n_L-E8K_gcg");
56
+ assert.equal(spec?.provider, "youtube");
57
+ assert.equal(spec?.label, "YouTube");
58
+ assert.equal(spec?.src, "https://www.youtube.com/embed/n_L-E8K_gcg");
59
+ // No fixed height: 16:9 players size from their width via CSS.
60
+ assert.equal(spec?.height, undefined);
61
+ });
62
+
63
+ test("resolveEmbed gives fixed-aspect players a fixed height", () => {
64
+ assert.equal(resolveEmbed("https://open.spotify.com/track/abc")?.height, 152);
65
+ assert.equal(
66
+ resolveEmbed("https://soundcloud.com/artist/track")?.height,
67
+ 250,
68
+ );
69
+ });
70
+
71
+ test("youTubeThumbnail builds the maxres thumbnail for video URLs only", () => {
72
+ assert.equal(
73
+ youTubeThumbnail("https://www.youtube.com/watch?v=n_L-E8K_gcg"),
74
+ "https://i.ytimg.com/vi/n_L-E8K_gcg/maxresdefault.jpg",
75
+ );
76
+ assert.equal(
77
+ youTubeThumbnail("https://youtu.be/n_L-E8K_gcg"),
78
+ "https://i.ytimg.com/vi/n_L-E8K_gcg/maxresdefault.jpg",
79
+ );
80
+ // Shorts now get thumbnails too (the old parser missed them).
81
+ assert.equal(
82
+ youTubeThumbnail("https://www.youtube.com/shorts/n_L-E8K_gcg"),
83
+ "https://i.ytimg.com/vi/n_L-E8K_gcg/maxresdefault.jpg",
84
+ );
85
+ assert.equal(
86
+ youTubeThumbnail("https://www.youtube.com/playlist?list=PLAOwLA0fz3HQCtJ5"),
87
+ null,
88
+ );
89
+ });