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.
- package/.github/workflows/ci.yml +2 -2
- package/README.md +20 -0
- package/build/env.ts +4 -1
- package/build/routeSkeletonsPlugin.ts +44 -2
- package/package.json +8 -5
- package/public/blog/random/hlwm.md +8 -0
- package/public/checksums.txt +63 -63
- package/public/documents/drift-report.json +30 -26
- package/render.yaml +11 -1
- package/scripts/mutable-assets.mjs +20 -22
- package/scripts/refresh-content.mjs +53 -32
- package/scripts/refresh-soundcloud.mjs +175 -136
- package/scripts/write-dist-checksums.mjs +48 -4
- package/src/content/soundcloud.json +39 -39
- package/src/lib/embeds.ts +43 -24
- package/src/lib/markdown.tsx +8 -21
- package/src/lib/rehypeYoutubeEmbed.ts +65 -0
- package/src/lib/sanitizeSchema.ts +32 -0
- package/src/styles/extras.css +7 -0
- package/src/styles/window.css +10 -7
- package/test/deploy-pipeline.test.js +73 -0
- package/test/embeds.test.js +89 -0
- package/test/mutable-assets.test.js +23 -4
- package/test/route-skeletons.test.js +38 -0
- package/test/youtubeEmbed.test.js +157 -0
- package/tsconfig.node.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/.opencode/cache/index.akinevz.html +0 -54
- package/public/addons/sidetip-0.1.19.zip +0 -0
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"source": "https://soundcloud.com/akinevz",
|
|
3
|
-
"generatedAt": "2026-10-
|
|
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/
|
|
70
|
-
"title": "
|
|
71
|
-
"url": "https://soundcloud.com/akinevz/
|
|
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/
|
|
88
|
-
"title": "
|
|
89
|
-
"url": "https://soundcloud.com/akinevz/
|
|
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/
|
|
99
|
+
"path": "/akinevz/dresscode",
|
|
94
100
|
"title": "Memento Mori",
|
|
95
|
-
"url": "https://soundcloud.com/akinevz/
|
|
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": "
|
|
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-
|
|
135
|
+
"path": "/akinevz/frozen-scream",
|
|
130
136
|
"title": "Freezing Point",
|
|
131
|
-
"url": "https://soundcloud.com/akinevz/frozen-
|
|
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
|
|
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": "
|
|
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/
|
|
624
|
-
"title": "
|
|
625
|
-
"url": "https://soundcloud.com/akinevz/
|
|
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/
|
|
642
|
-
"title": "
|
|
643
|
-
"url": "https://soundcloud.com/akinevz/
|
|
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/
|
|
653
|
+
"path": "/akinevz/dresscode",
|
|
648
654
|
"title": "Memento Mori",
|
|
649
|
-
"url": "https://soundcloud.com/akinevz/
|
|
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": "
|
|
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-
|
|
689
|
+
"path": "/akinevz/frozen-scream",
|
|
684
690
|
"title": "Freezing Point",
|
|
685
|
-
"url": "https://soundcloud.com/akinevz/frozen-
|
|
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
|
|
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": "
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
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/${
|
|
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
|
|
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
|
}
|
package/src/lib/markdown.tsx
CHANGED
|
@@ -1,34 +1,21 @@
|
|
|
1
1
|
import Markdown from "react-markdown";
|
|
2
2
|
import rehypeRaw from "rehype-raw";
|
|
3
|
-
import rehypeSanitize
|
|
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={[
|
|
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
|
+
};
|
package/src/styles/extras.css
CHANGED
|
@@ -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
|
+
}
|
package/src/styles/window.css
CHANGED
|
@@ -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
|
+
});
|