@cube-drone/marquee-turbolink 0.1.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/src/plugins.ts ADDED
@@ -0,0 +1,156 @@
1
+ // The default plugin set: everything obvious that needs no network at all.
2
+ // Media kinds by extension, YouTube and Spotify by URL shape (their embed
3
+ // URLs are derivable, no fetch required). All decline below level=full -
4
+ // the plain-link floor is the right "title" for a player.
5
+
6
+ import { escapeAttr } from "./escape.ts";
7
+ import type { TurbolinkPlugin } from "./index.ts";
8
+
9
+ function extension(target: string): string {
10
+ const path = target.split(/[?#]/, 1)[0]!;
11
+ return path.includes(".") ? path.slice(path.lastIndexOf(".") + 1).toLowerCase() : "";
12
+ }
13
+
14
+ const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "svg"]);
15
+ const AUDIO_EXT = new Set(["mp3", "ogg", "wav", "flac", "m4a"]);
16
+ const VIDEO_EXT = new Set(["mp4", "webm"]);
17
+
18
+ // Shared style chunks: plugins that emit the same classes reference the
19
+ // same constant, and turbolinkStyles() dedupes by content.
20
+
21
+ const frameCss = `.mq-turbolink-frame {
22
+ display: block;
23
+ border: 0;
24
+ width: 100%;
25
+ max-width: 32rem;
26
+ border-radius: 0.375rem;
27
+ }
28
+ .mq-frame-video { aspect-ratio: 16 / 9; }`;
29
+
30
+ const mediaCss = `.mq-turbolink-image {
31
+ display: block;
32
+ max-width: 20rem;
33
+ max-height: 20rem;
34
+ border-radius: 0.375rem;
35
+ }
36
+ .mq-turbolink-audio,
37
+ .mq-turbolink-video {
38
+ display: block;
39
+ width: 100%;
40
+ max-width: 32rem;
41
+ }
42
+ .mq-turbolink-video { border-radius: 0.375rem; }`;
43
+
44
+ export const imagePlugin: TurbolinkPlugin = {
45
+ name: "image",
46
+ css: mediaCss,
47
+ match: (t) => IMAGE_EXT.has(extension(t)),
48
+ render(target, { level }) {
49
+ if (level !== "full") {
50
+ return null;
51
+ }
52
+ const href = escapeAttr(target);
53
+ return `<a class="mq-turbolink-media" href="${href}"><img class="mq-turbolink-image" src="${href}" alt="" loading="lazy"></a>`;
54
+ },
55
+ };
56
+
57
+ export const audioPlugin: TurbolinkPlugin = {
58
+ name: "audio",
59
+ css: mediaCss,
60
+ match: (t) => AUDIO_EXT.has(extension(t)),
61
+ render(target, { level }) {
62
+ if (level !== "full") {
63
+ return null;
64
+ }
65
+ return `<audio class="mq-turbolink-audio" controls src="${escapeAttr(target)}"></audio>`;
66
+ },
67
+ };
68
+
69
+ export const videoPlugin: TurbolinkPlugin = {
70
+ name: "video",
71
+ css: mediaCss,
72
+ match: (t) => VIDEO_EXT.has(extension(t)),
73
+ render(target, { level }) {
74
+ if (level !== "full") {
75
+ return null;
76
+ }
77
+ return `<video class="mq-turbolink-video" controls src="${escapeAttr(target)}"></video>`;
78
+ },
79
+ };
80
+
81
+ const YOUTUBE = /(?:youtube\.com\/watch\?(?:[^#\s]*&)?v=|youtu\.be\/)([A-Za-z0-9_-]{5,20})/;
82
+
83
+ export const youtubePlugin: TurbolinkPlugin = {
84
+ name: "youtube",
85
+ css: frameCss,
86
+ match: (t) => YOUTUBE.test(t),
87
+ render(target, { level }) {
88
+ if (level !== "full") {
89
+ return null;
90
+ }
91
+ const id = YOUTUBE.exec(target)![1]!;
92
+ // nocookie domain: the least-tracking spelling of a playable embed.
93
+ // referrerpolicy: since late 2025 YouTube refuses embeds (error 153)
94
+ // unless the page identifies itself via Referer - this attribute keeps
95
+ // stricter site-wide policies from stripping it. Pages with no origin
96
+ // at all (file://) can't satisfy YouTube regardless; serve over http.
97
+ return `<iframe class="mq-turbolink-frame mq-frame-video" src="https://www.youtube-nocookie.com/embed/${escapeAttr(id)}" title="YouTube video" loading="lazy" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`;
98
+ },
99
+ };
100
+
101
+ const SPOTIFY = /open\.spotify\.com\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/;
102
+
103
+ export const spotifyPlugin: TurbolinkPlugin = {
104
+ name: "spotify",
105
+ css: frameCss,
106
+ match: (t) => SPOTIFY.test(t),
107
+ render(target, { level }) {
108
+ if (level !== "full") {
109
+ return null;
110
+ }
111
+ const m = SPOTIFY.exec(target)!;
112
+ const height = m[1] === "track" || m[1] === "episode" ? 152 : 352;
113
+ return `<iframe class="mq-turbolink-frame" src="https://open.spotify.com/embed/${m[1]}/${escapeAttr(m[2]!)}" height="${height}" title="Spotify player" loading="lazy"></iframe>`;
114
+ },
115
+ };
116
+
117
+ const GMAPS_COORDS =
118
+ /google\.[a-z.]+\/maps\/\S*@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(\d+(?:\.\d+)?)z/;
119
+
120
+ /** Matches a Google Maps link that carries coordinates - and renders the
121
+ * same spot as an OpenStreetMap embed. Google retired its keyless embed
122
+ * endpoint, and this is cozier anyway: authors paste the link they have,
123
+ * readers get the map that doesn't track them. Links without coordinates
124
+ * decline to the plain-link floor. */
125
+ export const mapsPlugin: TurbolinkPlugin = {
126
+ name: "maps",
127
+ css: frameCss,
128
+ match: (t) => GMAPS_COORDS.test(t),
129
+ render(target, { level }) {
130
+ if (level !== "full") {
131
+ return null;
132
+ }
133
+ const m = GMAPS_COORDS.exec(target)!;
134
+ const lat = Number(m[1]);
135
+ const lon = Number(m[2]);
136
+ const zoom = Math.min(Math.max(Number(m[3]), 1), 19);
137
+ const dLon = 360 / 2 ** zoom;
138
+ const dLat = dLon * 0.4;
139
+ const bbox = [lon - dLon, lat - dLat, lon + dLon, lat + dLat]
140
+ .map((n) => n.toFixed(5))
141
+ .join("%2C");
142
+ const marker = `${lat.toFixed(5)}%2C${lon.toFixed(5)}`;
143
+ return `<iframe class="mq-turbolink-frame mq-frame-map" src="https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&amp;layer=mapnik&amp;marker=${marker}" height="320" title="Map" loading="lazy"></iframe>`;
144
+ },
145
+ };
146
+
147
+ /** The fetchless defaults - safe to hand to any static build. OpenGraph
148
+ * (which fetches) is exported separately and opted into deliberately. */
149
+ export const defaultPlugins: TurbolinkPlugin[] = [
150
+ youtubePlugin,
151
+ spotifyPlugin,
152
+ mapsPlugin,
153
+ imagePlugin,
154
+ audioPlugin,
155
+ videoPlugin,
156
+ ];