@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/LICENSE +373 -0
- package/README.md +134 -0
- package/dist/card.d.ts +11 -0
- package/dist/card.js +51 -0
- package/dist/escape.d.ts +2 -0
- package/dist/escape.js +9 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +99 -0
- package/dist/opengraph.d.ts +5 -0
- package/dist/opengraph.js +66 -0
- package/dist/plugins.d.ts +15 -0
- package/dist/plugins.js +138 -0
- package/package.json +43 -0
- package/src/card.ts +67 -0
- package/src/escape.ts +11 -0
- package/src/index.ts +146 -0
- package/src/opengraph.ts +75 -0
- package/src/plugins.ts +156 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// marquee-turbolink: link expanders as plugins.
|
|
2
|
+
//
|
|
3
|
+
// A plugin owns the *presentation* of the link kinds it recognizes - an
|
|
4
|
+
// image in a medium box, audio controls, a playable YouTube embed, a Spotify
|
|
5
|
+
// widget, a host platform's native content, the Binglebongle plugin of 2035.
|
|
6
|
+
// The
|
|
7
|
+
// Marquee renderer owns only the floor (a plain link, always) and the socket
|
|
8
|
+
// (Profile.turbolink). Embedders compose the chain they trust.
|
|
9
|
+
//
|
|
10
|
+
// Two phases, deliberately split:
|
|
11
|
+
// resolve() - async, MAY touch the network (OpenGraph fetches). Runs in a
|
|
12
|
+
// fetch-ahead pass (static rendering) or on mount (interactive).
|
|
13
|
+
// render() - sync, pure, deterministic given resolved data. This is what
|
|
14
|
+
// the renderer calls; nothing fetches mid-render, ever.
|
|
15
|
+
//
|
|
16
|
+
// Plugins are embedder-trusted code (like Profile.directive): author bytes
|
|
17
|
+
// only ever enter as the `target` string - escape everything you interpolate
|
|
18
|
+
// (escapeText/escapeAttr are exported for exactly that).
|
|
19
|
+
/** Everything the composed chain needs styled, once: the standard card's
|
|
20
|
+
* skin (renderCard is the library's keyhole, so its skin is baseline) plus
|
|
21
|
+
* each plugin's declared css, deduplicated. Emit it in a <style> block or
|
|
22
|
+
* write it to a bundle file at build time - either way it's one artifact
|
|
23
|
+
* derived from the same list you composed. */
|
|
24
|
+
export function turbolinkStyles(plugins) {
|
|
25
|
+
const chunks = new Set([cardCss]);
|
|
26
|
+
for (const plugin of plugins) {
|
|
27
|
+
if (plugin.css !== undefined) {
|
|
28
|
+
chunks.add(plugin.css);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return [...chunks].join("\n");
|
|
32
|
+
}
|
|
33
|
+
/** Compose a plugin chain into the shape Profile.turbolink wants. First
|
|
34
|
+
* plugin that matches AND renders wins. */
|
|
35
|
+
export function composeTurbolinks(plugins, resolved) {
|
|
36
|
+
return (target, level) => {
|
|
37
|
+
for (const plugin of plugins) {
|
|
38
|
+
if (!plugin.match(target)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const data = resolved?.get(`${plugin.name}\n${target}`);
|
|
42
|
+
const html = plugin.render(target, { level, data });
|
|
43
|
+
if (html !== null) {
|
|
44
|
+
return html;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** The fetch-ahead pass: run every matching plugin's resolve() over a
|
|
51
|
+
* target list, yielding the map composeTurbolinks() consumes. Targets
|
|
52
|
+
* resolve concurrently (wall-clock is the slowest fetch, not the sum);
|
|
53
|
+
* within one target, plugins still run in order - first resolver wins,
|
|
54
|
+
* like first renderer. */
|
|
55
|
+
export async function resolveTargets(targets, plugins) {
|
|
56
|
+
const resolved = new Map();
|
|
57
|
+
await Promise.all([...new Set(targets)].map(async (target) => {
|
|
58
|
+
for (const plugin of plugins) {
|
|
59
|
+
if (plugin.resolve === undefined || !plugin.match(target)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const data = await plugin.resolve(target);
|
|
64
|
+
if (data !== null && data !== undefined) {
|
|
65
|
+
resolved.set(`${plugin.name}\n${target}`, data);
|
|
66
|
+
break; // first resolver wins, like first renderer
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// a failed fetch is a plain link, not a failed render
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}));
|
|
74
|
+
return resolved;
|
|
75
|
+
}
|
|
76
|
+
/** Every turbolink target in a parsed document (bare-paragraph nodes and
|
|
77
|
+
* explicit :::turbolink directives) - the fetch-ahead pass's shopping list. */
|
|
78
|
+
export function turbolinkTargets(node) {
|
|
79
|
+
const targets = [];
|
|
80
|
+
(function walk(n) {
|
|
81
|
+
if (n.type === "turbolink") {
|
|
82
|
+
targets.push(n.target);
|
|
83
|
+
}
|
|
84
|
+
if (n.type === "directive" && n.name === "turbolink" && n.attrs["target"] !== undefined) {
|
|
85
|
+
targets.push(n.attrs["target"]);
|
|
86
|
+
}
|
|
87
|
+
if ("children" in n) {
|
|
88
|
+
for (const child of n.children) {
|
|
89
|
+
walk(child);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
})(node);
|
|
93
|
+
return targets;
|
|
94
|
+
}
|
|
95
|
+
import { cardCss } from "./card.js";
|
|
96
|
+
export { escapeAttr, escapeText } from "./escape.js";
|
|
97
|
+
export { cardCss, renderCard } from "./card.js";
|
|
98
|
+
export { audioPlugin, defaultPlugins, imagePlugin, mapsPlugin, spotifyPlugin, videoPlugin, youtubePlugin, } from "./plugins.js";
|
|
99
|
+
export { opengraphPlugin, parseOpenGraph } from "./opengraph.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type TurbolinkSummary } from "./card.ts";
|
|
2
|
+
import type { TurbolinkPlugin } from "./index.ts";
|
|
3
|
+
/** Pure and separately testable: html text in, summary out. */
|
|
4
|
+
export declare function parseOpenGraph(html: string): TurbolinkSummary | null;
|
|
5
|
+
export declare const opengraphPlugin: TurbolinkPlugin;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The OpenGraph fallback: the one default plugin that touches the network,
|
|
2
|
+
// and only ever in its resolve() phase - never during render. Compose it
|
|
3
|
+
// LAST so the shaped plugins (YouTube, media) win their own kinds.
|
|
4
|
+
import { renderCard } from "./card.js";
|
|
5
|
+
const UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
|
|
6
|
+
function decodeEntities(s) {
|
|
7
|
+
return s
|
|
8
|
+
.replaceAll("<", "<")
|
|
9
|
+
.replaceAll(">", ">")
|
|
10
|
+
.replaceAll(""", '"')
|
|
11
|
+
.replaceAll("'", "'")
|
|
12
|
+
.replaceAll("&", "&");
|
|
13
|
+
}
|
|
14
|
+
/** Pure and separately testable: html text in, summary out. */
|
|
15
|
+
export function parseOpenGraph(html) {
|
|
16
|
+
const head = html.slice(0, 65536); // metadata lives up top; stay bounded
|
|
17
|
+
const meta = (prop) => {
|
|
18
|
+
const tag = new RegExp(`<meta[^>]+(?:property|name)=["']${prop}["'][^>]*>`, "i").exec(head)?.[0];
|
|
19
|
+
if (tag === undefined) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const content = /content=["']([^"']*)["']/i.exec(tag)?.[1];
|
|
23
|
+
return content === undefined || content === "" ? undefined : decodeEntities(content);
|
|
24
|
+
};
|
|
25
|
+
const summary = {};
|
|
26
|
+
const title = meta("og:title") ?? /<title[^>]*>([^<]*)<\/title>/i.exec(head)?.[1]?.trim();
|
|
27
|
+
if (title !== undefined && title !== "") {
|
|
28
|
+
summary.title = decodeEntities(title);
|
|
29
|
+
}
|
|
30
|
+
const description = meta("og:description") ?? meta("description");
|
|
31
|
+
if (description !== undefined) {
|
|
32
|
+
summary.description = description;
|
|
33
|
+
}
|
|
34
|
+
const image = meta("og:image");
|
|
35
|
+
if (image !== undefined) {
|
|
36
|
+
summary.image = image;
|
|
37
|
+
}
|
|
38
|
+
const site = meta("og:site_name");
|
|
39
|
+
if (site !== undefined) {
|
|
40
|
+
summary.site = site;
|
|
41
|
+
}
|
|
42
|
+
return summary.title === undefined ? null : summary;
|
|
43
|
+
}
|
|
44
|
+
export const opengraphPlugin = {
|
|
45
|
+
name: "opengraph",
|
|
46
|
+
match: (t) => /^https?:\/\//.test(t),
|
|
47
|
+
async resolve(target) {
|
|
48
|
+
// Generous but finite: a hung server costs ten seconds, never a hung
|
|
49
|
+
// build. (A timed-out fetch throws; resolveTargets already treats a
|
|
50
|
+
// failed fetch as a plain link.)
|
|
51
|
+
const res = await fetch(target, {
|
|
52
|
+
headers: { "user-agent": UA },
|
|
53
|
+
signal: AbortSignal.timeout(10_000),
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return parseOpenGraph(await res.text());
|
|
59
|
+
},
|
|
60
|
+
render(target, { level, data }) {
|
|
61
|
+
if (data === undefined || data === null) {
|
|
62
|
+
return null; // nothing resolved: decline to the plain-link floor
|
|
63
|
+
}
|
|
64
|
+
return renderCard(target, data, level);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TurbolinkPlugin } from "./index.ts";
|
|
2
|
+
export declare const imagePlugin: TurbolinkPlugin;
|
|
3
|
+
export declare const audioPlugin: TurbolinkPlugin;
|
|
4
|
+
export declare const videoPlugin: TurbolinkPlugin;
|
|
5
|
+
export declare const youtubePlugin: TurbolinkPlugin;
|
|
6
|
+
export declare const spotifyPlugin: TurbolinkPlugin;
|
|
7
|
+
/** Matches a Google Maps link that carries coordinates - and renders the
|
|
8
|
+
* same spot as an OpenStreetMap embed. Google retired its keyless embed
|
|
9
|
+
* endpoint, and this is cozier anyway: authors paste the link they have,
|
|
10
|
+
* readers get the map that doesn't track them. Links without coordinates
|
|
11
|
+
* decline to the plain-link floor. */
|
|
12
|
+
export declare const mapsPlugin: TurbolinkPlugin;
|
|
13
|
+
/** The fetchless defaults - safe to hand to any static build. OpenGraph
|
|
14
|
+
* (which fetches) is exported separately and opted into deliberately. */
|
|
15
|
+
export declare const defaultPlugins: TurbolinkPlugin[];
|
package/dist/plugins.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
import { escapeAttr } from "./escape.js";
|
|
6
|
+
function extension(target) {
|
|
7
|
+
const path = target.split(/[?#]/, 1)[0];
|
|
8
|
+
return path.includes(".") ? path.slice(path.lastIndexOf(".") + 1).toLowerCase() : "";
|
|
9
|
+
}
|
|
10
|
+
const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "svg"]);
|
|
11
|
+
const AUDIO_EXT = new Set(["mp3", "ogg", "wav", "flac", "m4a"]);
|
|
12
|
+
const VIDEO_EXT = new Set(["mp4", "webm"]);
|
|
13
|
+
// Shared style chunks: plugins that emit the same classes reference the
|
|
14
|
+
// same constant, and turbolinkStyles() dedupes by content.
|
|
15
|
+
const frameCss = `.mq-turbolink-frame {
|
|
16
|
+
display: block;
|
|
17
|
+
border: 0;
|
|
18
|
+
width: 100%;
|
|
19
|
+
max-width: 32rem;
|
|
20
|
+
border-radius: 0.375rem;
|
|
21
|
+
}
|
|
22
|
+
.mq-frame-video { aspect-ratio: 16 / 9; }`;
|
|
23
|
+
const mediaCss = `.mq-turbolink-image {
|
|
24
|
+
display: block;
|
|
25
|
+
max-width: 20rem;
|
|
26
|
+
max-height: 20rem;
|
|
27
|
+
border-radius: 0.375rem;
|
|
28
|
+
}
|
|
29
|
+
.mq-turbolink-audio,
|
|
30
|
+
.mq-turbolink-video {
|
|
31
|
+
display: block;
|
|
32
|
+
width: 100%;
|
|
33
|
+
max-width: 32rem;
|
|
34
|
+
}
|
|
35
|
+
.mq-turbolink-video { border-radius: 0.375rem; }`;
|
|
36
|
+
export const imagePlugin = {
|
|
37
|
+
name: "image",
|
|
38
|
+
css: mediaCss,
|
|
39
|
+
match: (t) => IMAGE_EXT.has(extension(t)),
|
|
40
|
+
render(target, { level }) {
|
|
41
|
+
if (level !== "full") {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const href = escapeAttr(target);
|
|
45
|
+
return `<a class="mq-turbolink-media" href="${href}"><img class="mq-turbolink-image" src="${href}" alt="" loading="lazy"></a>`;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
export const audioPlugin = {
|
|
49
|
+
name: "audio",
|
|
50
|
+
css: mediaCss,
|
|
51
|
+
match: (t) => AUDIO_EXT.has(extension(t)),
|
|
52
|
+
render(target, { level }) {
|
|
53
|
+
if (level !== "full") {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return `<audio class="mq-turbolink-audio" controls src="${escapeAttr(target)}"></audio>`;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
export const videoPlugin = {
|
|
60
|
+
name: "video",
|
|
61
|
+
css: mediaCss,
|
|
62
|
+
match: (t) => VIDEO_EXT.has(extension(t)),
|
|
63
|
+
render(target, { level }) {
|
|
64
|
+
if (level !== "full") {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return `<video class="mq-turbolink-video" controls src="${escapeAttr(target)}"></video>`;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
const YOUTUBE = /(?:youtube\.com\/watch\?(?:[^#\s]*&)?v=|youtu\.be\/)([A-Za-z0-9_-]{5,20})/;
|
|
71
|
+
export const youtubePlugin = {
|
|
72
|
+
name: "youtube",
|
|
73
|
+
css: frameCss,
|
|
74
|
+
match: (t) => YOUTUBE.test(t),
|
|
75
|
+
render(target, { level }) {
|
|
76
|
+
if (level !== "full") {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const id = YOUTUBE.exec(target)[1];
|
|
80
|
+
// nocookie domain: the least-tracking spelling of a playable embed.
|
|
81
|
+
// referrerpolicy: since late 2025 YouTube refuses embeds (error 153)
|
|
82
|
+
// unless the page identifies itself via Referer - this attribute keeps
|
|
83
|
+
// stricter site-wide policies from stripping it. Pages with no origin
|
|
84
|
+
// at all (file://) can't satisfy YouTube regardless; serve over http.
|
|
85
|
+
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>`;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
const SPOTIFY = /open\.spotify\.com\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/;
|
|
89
|
+
export const spotifyPlugin = {
|
|
90
|
+
name: "spotify",
|
|
91
|
+
css: frameCss,
|
|
92
|
+
match: (t) => SPOTIFY.test(t),
|
|
93
|
+
render(target, { level }) {
|
|
94
|
+
if (level !== "full") {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const m = SPOTIFY.exec(target);
|
|
98
|
+
const height = m[1] === "track" || m[1] === "episode" ? 152 : 352;
|
|
99
|
+
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>`;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const GMAPS_COORDS = /google\.[a-z.]+\/maps\/\S*@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(\d+(?:\.\d+)?)z/;
|
|
103
|
+
/** Matches a Google Maps link that carries coordinates - and renders the
|
|
104
|
+
* same spot as an OpenStreetMap embed. Google retired its keyless embed
|
|
105
|
+
* endpoint, and this is cozier anyway: authors paste the link they have,
|
|
106
|
+
* readers get the map that doesn't track them. Links without coordinates
|
|
107
|
+
* decline to the plain-link floor. */
|
|
108
|
+
export const mapsPlugin = {
|
|
109
|
+
name: "maps",
|
|
110
|
+
css: frameCss,
|
|
111
|
+
match: (t) => GMAPS_COORDS.test(t),
|
|
112
|
+
render(target, { level }) {
|
|
113
|
+
if (level !== "full") {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const m = GMAPS_COORDS.exec(target);
|
|
117
|
+
const lat = Number(m[1]);
|
|
118
|
+
const lon = Number(m[2]);
|
|
119
|
+
const zoom = Math.min(Math.max(Number(m[3]), 1), 19);
|
|
120
|
+
const dLon = 360 / 2 ** zoom;
|
|
121
|
+
const dLat = dLon * 0.4;
|
|
122
|
+
const bbox = [lon - dLon, lat - dLat, lon + dLon, lat + dLat]
|
|
123
|
+
.map((n) => n.toFixed(5))
|
|
124
|
+
.join("%2C");
|
|
125
|
+
const marker = `${lat.toFixed(5)}%2C${lon.toFixed(5)}`;
|
|
126
|
+
return `<iframe class="mq-turbolink-frame mq-frame-map" src="https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${marker}" height="320" title="Map" loading="lazy"></iframe>`;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
/** The fetchless defaults - safe to hand to any static build. OpenGraph
|
|
130
|
+
* (which fetches) is exported separately and opted into deliberately. */
|
|
131
|
+
export const defaultPlugins = [
|
|
132
|
+
youtubePlugin,
|
|
133
|
+
spotifyPlugin,
|
|
134
|
+
mapsPlugin,
|
|
135
|
+
imagePlugin,
|
|
136
|
+
audioPlugin,
|
|
137
|
+
videoPlugin,
|
|
138
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cube-drone/marquee-turbolink",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pluggable turbolink rendering for Marquee: link expanders as plugins, plain link as the floor",
|
|
5
|
+
"license": "MPL-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/cube-drone/marqueemarkup.git",
|
|
9
|
+
"directory": "ts/marquee-turbolink"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22.6.0"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"marquee-src": "./src/index.ts",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node --conditions=marquee-src --test test/*.test.ts",
|
|
32
|
+
"check": "tsc --noEmit",
|
|
33
|
+
"build": "tsc -p tsconfig.build.json",
|
|
34
|
+
"prepack": "npm run build"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@cube-drone/marquee-parser": "^0.1.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^24.0.0",
|
|
41
|
+
"typescript": "^5.9.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/card.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The standard summary card - the safe keyhole. Plugins that just have
|
|
2
|
+
// facts (title, description, image, site) call this and inherit the shared
|
|
3
|
+
// mq-turbolink-* look and its escaping; plugins with richer ideas write
|
|
4
|
+
// their own markup and own their own escaping.
|
|
5
|
+
|
|
6
|
+
import { escapeAttr, escapeText } from "./escape.ts";
|
|
7
|
+
import type { TurbolinkLevel } from "./index.ts";
|
|
8
|
+
|
|
9
|
+
export interface TurbolinkSummary {
|
|
10
|
+
title?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
image?: string;
|
|
13
|
+
site?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function siteOf(target: string): string | undefined {
|
|
17
|
+
const m = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/]+)/.exec(target);
|
|
18
|
+
return m?.[1];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The card's skin. Always included by turbolinkStyles() - the keyhole's
|
|
22
|
+
* look is the library baseline, whichever plugins use it. */
|
|
23
|
+
export const cardCss = `.mq-turbolink-card {
|
|
24
|
+
display: grid;
|
|
25
|
+
grid-template-columns: 1fr auto;
|
|
26
|
+
gap: 0.15rem 0.75rem;
|
|
27
|
+
border: 1px solid rgba(136, 136, 136, 0.33);
|
|
28
|
+
border-left-width: 4px;
|
|
29
|
+
border-radius: 0.375rem;
|
|
30
|
+
padding: 0.5rem 0.75rem;
|
|
31
|
+
}
|
|
32
|
+
.mq-turbolink-site { font-size: 0.8em; opacity: 0.6; }
|
|
33
|
+
.mq-turbolink-title { font-weight: 600; text-decoration: none; }
|
|
34
|
+
.mq-turbolink-desc { font-size: 0.9em; opacity: 0.85; }
|
|
35
|
+
.mq-turbolink-thumb {
|
|
36
|
+
grid-column: 2;
|
|
37
|
+
grid-row: 1 / span 3;
|
|
38
|
+
width: 6rem;
|
|
39
|
+
height: 6rem;
|
|
40
|
+
object-fit: cover;
|
|
41
|
+
border-radius: 0.25rem;
|
|
42
|
+
}
|
|
43
|
+
.mq-turbolink-rich .mq-turbolink-card {
|
|
44
|
+
border: none;
|
|
45
|
+
padding: 0;
|
|
46
|
+
}`;
|
|
47
|
+
|
|
48
|
+
export function renderCard(
|
|
49
|
+
target: string,
|
|
50
|
+
summary: TurbolinkSummary,
|
|
51
|
+
level: TurbolinkLevel,
|
|
52
|
+
): string {
|
|
53
|
+
const href = escapeAttr(target);
|
|
54
|
+
const site = summary.site ?? siteOf(target);
|
|
55
|
+
let card = "";
|
|
56
|
+
if (site !== undefined) {
|
|
57
|
+
card += `<span class="mq-turbolink-site">${escapeText(site)}</span>`;
|
|
58
|
+
}
|
|
59
|
+
card += `<a class="mq-turbolink-title" href="${href}">${escapeText(summary.title ?? target)}</a>`;
|
|
60
|
+
if (level === "full" && summary.description !== undefined) {
|
|
61
|
+
card += `<span class="mq-turbolink-desc">${escapeText(summary.description)}</span>`;
|
|
62
|
+
}
|
|
63
|
+
if (level === "full" && summary.image !== undefined) {
|
|
64
|
+
card += `<img class="mq-turbolink-thumb" src="${escapeAttr(summary.image)}" alt="" loading="lazy">`;
|
|
65
|
+
}
|
|
66
|
+
return `<span class="mq-turbolink-card">${card}</span>`;
|
|
67
|
+
}
|
package/src/escape.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Local escaping (duplicated from the html renderer on purpose: this
|
|
2
|
+
// library stands alone - plugins for other renderers shouldn't drag the
|
|
3
|
+
// HTML renderer in as a dependency).
|
|
4
|
+
|
|
5
|
+
export function escapeText(s: string): string {
|
|
6
|
+
return s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function escapeAttr(s: string): string {
|
|
10
|
+
return escapeText(s).replaceAll('"', """);
|
|
11
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// marquee-turbolink: link expanders as plugins.
|
|
2
|
+
//
|
|
3
|
+
// A plugin owns the *presentation* of the link kinds it recognizes - an
|
|
4
|
+
// image in a medium box, audio controls, a playable YouTube embed, a Spotify
|
|
5
|
+
// widget, a host platform's native content, the Binglebongle plugin of 2035.
|
|
6
|
+
// The
|
|
7
|
+
// Marquee renderer owns only the floor (a plain link, always) and the socket
|
|
8
|
+
// (Profile.turbolink). Embedders compose the chain they trust.
|
|
9
|
+
//
|
|
10
|
+
// Two phases, deliberately split:
|
|
11
|
+
// resolve() - async, MAY touch the network (OpenGraph fetches). Runs in a
|
|
12
|
+
// fetch-ahead pass (static rendering) or on mount (interactive).
|
|
13
|
+
// render() - sync, pure, deterministic given resolved data. This is what
|
|
14
|
+
// the renderer calls; nothing fetches mid-render, ever.
|
|
15
|
+
//
|
|
16
|
+
// Plugins are embedder-trusted code (like Profile.directive): author bytes
|
|
17
|
+
// only ever enter as the `target` string - escape everything you interpolate
|
|
18
|
+
// (escapeText/escapeAttr are exported for exactly that).
|
|
19
|
+
|
|
20
|
+
import type { Node } from "@cube-drone/marquee-parser";
|
|
21
|
+
|
|
22
|
+
export type TurbolinkLevel = "full" | "title" | "bare";
|
|
23
|
+
|
|
24
|
+
export interface TurbolinkContext {
|
|
25
|
+
level: TurbolinkLevel;
|
|
26
|
+
/** Whatever this plugin's resolve() produced for this target, if it ran. */
|
|
27
|
+
data: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TurbolinkPlugin {
|
|
31
|
+
name: string;
|
|
32
|
+
/** Cheap recognition; render()/resolve() run only when this is true. */
|
|
33
|
+
match(target: string): boolean;
|
|
34
|
+
/** Optional async gathering - the ONLY place network is allowed. */
|
|
35
|
+
resolve?(target: string): Promise<unknown>;
|
|
36
|
+
/** Sync and pure. Return HTML, or null to decline (the chain continues;
|
|
37
|
+
* the renderer's plain-link floor catches everything). */
|
|
38
|
+
render(target: string, ctx: TurbolinkContext): string | null;
|
|
39
|
+
/** The stylesheet for the markup render() emits, as a string - collected
|
|
40
|
+
* by turbolinkStyles(), so importing a plugin imports its skin and no
|
|
41
|
+
* per-plugin file scavenger hunt exists. Plugins sharing rules assign the
|
|
42
|
+
* same string constant (aggregation dedupes by content). */
|
|
43
|
+
css?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Everything the composed chain needs styled, once: the standard card's
|
|
47
|
+
* skin (renderCard is the library's keyhole, so its skin is baseline) plus
|
|
48
|
+
* each plugin's declared css, deduplicated. Emit it in a <style> block or
|
|
49
|
+
* write it to a bundle file at build time - either way it's one artifact
|
|
50
|
+
* derived from the same list you composed. */
|
|
51
|
+
export function turbolinkStyles(plugins: TurbolinkPlugin[]): string {
|
|
52
|
+
const chunks = new Set<string>([cardCss]);
|
|
53
|
+
for (const plugin of plugins) {
|
|
54
|
+
if (plugin.css !== undefined) {
|
|
55
|
+
chunks.add(plugin.css);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return [...chunks].join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Compose a plugin chain into the shape Profile.turbolink wants. First
|
|
62
|
+
* plugin that matches AND renders wins. */
|
|
63
|
+
export function composeTurbolinks(
|
|
64
|
+
plugins: TurbolinkPlugin[],
|
|
65
|
+
resolved?: Map<string, unknown>,
|
|
66
|
+
): (target: string, level: TurbolinkLevel) => string | null {
|
|
67
|
+
return (target, level) => {
|
|
68
|
+
for (const plugin of plugins) {
|
|
69
|
+
if (!plugin.match(target)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const data = resolved?.get(`${plugin.name}\n${target}`);
|
|
73
|
+
const html = plugin.render(target, { level, data });
|
|
74
|
+
if (html !== null) {
|
|
75
|
+
return html;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The fetch-ahead pass: run every matching plugin's resolve() over a
|
|
83
|
+
* target list, yielding the map composeTurbolinks() consumes. Targets
|
|
84
|
+
* resolve concurrently (wall-clock is the slowest fetch, not the sum);
|
|
85
|
+
* within one target, plugins still run in order - first resolver wins,
|
|
86
|
+
* like first renderer. */
|
|
87
|
+
export async function resolveTargets(
|
|
88
|
+
targets: string[],
|
|
89
|
+
plugins: TurbolinkPlugin[],
|
|
90
|
+
): Promise<Map<string, unknown>> {
|
|
91
|
+
const resolved = new Map<string, unknown>();
|
|
92
|
+
await Promise.all(
|
|
93
|
+
[...new Set(targets)].map(async (target) => {
|
|
94
|
+
for (const plugin of plugins) {
|
|
95
|
+
if (plugin.resolve === undefined || !plugin.match(target)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const data = await plugin.resolve(target);
|
|
100
|
+
if (data !== null && data !== undefined) {
|
|
101
|
+
resolved.set(`${plugin.name}\n${target}`, data);
|
|
102
|
+
break; // first resolver wins, like first renderer
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// a failed fetch is a plain link, not a failed render
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Every turbolink target in a parsed document (bare-paragraph nodes and
|
|
114
|
+
* explicit :::turbolink directives) - the fetch-ahead pass's shopping list. */
|
|
115
|
+
export function turbolinkTargets(node: Node): string[] {
|
|
116
|
+
const targets: string[] = [];
|
|
117
|
+
(function walk(n: Node): void {
|
|
118
|
+
if (n.type === "turbolink") {
|
|
119
|
+
targets.push(n.target);
|
|
120
|
+
}
|
|
121
|
+
if (n.type === "directive" && n.name === "turbolink" && n.attrs["target"] !== undefined) {
|
|
122
|
+
targets.push(n.attrs["target"]);
|
|
123
|
+
}
|
|
124
|
+
if ("children" in n) {
|
|
125
|
+
for (const child of n.children) {
|
|
126
|
+
walk(child);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
})(node);
|
|
130
|
+
return targets;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
import { cardCss } from "./card.ts";
|
|
134
|
+
|
|
135
|
+
export { escapeAttr, escapeText } from "./escape.ts";
|
|
136
|
+
export { cardCss, renderCard, type TurbolinkSummary } from "./card.ts";
|
|
137
|
+
export {
|
|
138
|
+
audioPlugin,
|
|
139
|
+
defaultPlugins,
|
|
140
|
+
imagePlugin,
|
|
141
|
+
mapsPlugin,
|
|
142
|
+
spotifyPlugin,
|
|
143
|
+
videoPlugin,
|
|
144
|
+
youtubePlugin,
|
|
145
|
+
} from "./plugins.ts";
|
|
146
|
+
export { opengraphPlugin, parseOpenGraph } from "./opengraph.ts";
|
package/src/opengraph.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The OpenGraph fallback: the one default plugin that touches the network,
|
|
2
|
+
// and only ever in its resolve() phase - never during render. Compose it
|
|
3
|
+
// LAST so the shaped plugins (YouTube, media) win their own kinds.
|
|
4
|
+
|
|
5
|
+
import { renderCard, type TurbolinkSummary } from "./card.ts";
|
|
6
|
+
import type { TurbolinkPlugin } from "./index.ts";
|
|
7
|
+
|
|
8
|
+
const UA =
|
|
9
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
|
|
10
|
+
|
|
11
|
+
function decodeEntities(s: string): string {
|
|
12
|
+
return s
|
|
13
|
+
.replaceAll("<", "<")
|
|
14
|
+
.replaceAll(">", ">")
|
|
15
|
+
.replaceAll(""", '"')
|
|
16
|
+
.replaceAll("'", "'")
|
|
17
|
+
.replaceAll("&", "&");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Pure and separately testable: html text in, summary out. */
|
|
21
|
+
export function parseOpenGraph(html: string): TurbolinkSummary | null {
|
|
22
|
+
const head = html.slice(0, 65536); // metadata lives up top; stay bounded
|
|
23
|
+
const meta = (prop: string): string | undefined => {
|
|
24
|
+
const tag = new RegExp(`<meta[^>]+(?:property|name)=["']${prop}["'][^>]*>`, "i").exec(
|
|
25
|
+
head,
|
|
26
|
+
)?.[0];
|
|
27
|
+
if (tag === undefined) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const content = /content=["']([^"']*)["']/i.exec(tag)?.[1];
|
|
31
|
+
return content === undefined || content === "" ? undefined : decodeEntities(content);
|
|
32
|
+
};
|
|
33
|
+
const summary: TurbolinkSummary = {};
|
|
34
|
+
const title = meta("og:title") ?? /<title[^>]*>([^<]*)<\/title>/i.exec(head)?.[1]?.trim();
|
|
35
|
+
if (title !== undefined && title !== "") {
|
|
36
|
+
summary.title = decodeEntities(title);
|
|
37
|
+
}
|
|
38
|
+
const description = meta("og:description") ?? meta("description");
|
|
39
|
+
if (description !== undefined) {
|
|
40
|
+
summary.description = description;
|
|
41
|
+
}
|
|
42
|
+
const image = meta("og:image");
|
|
43
|
+
if (image !== undefined) {
|
|
44
|
+
summary.image = image;
|
|
45
|
+
}
|
|
46
|
+
const site = meta("og:site_name");
|
|
47
|
+
if (site !== undefined) {
|
|
48
|
+
summary.site = site;
|
|
49
|
+
}
|
|
50
|
+
return summary.title === undefined ? null : summary;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const opengraphPlugin: TurbolinkPlugin = {
|
|
54
|
+
name: "opengraph",
|
|
55
|
+
match: (t) => /^https?:\/\//.test(t),
|
|
56
|
+
async resolve(target) {
|
|
57
|
+
// Generous but finite: a hung server costs ten seconds, never a hung
|
|
58
|
+
// build. (A timed-out fetch throws; resolveTargets already treats a
|
|
59
|
+
// failed fetch as a plain link.)
|
|
60
|
+
const res = await fetch(target, {
|
|
61
|
+
headers: { "user-agent": UA },
|
|
62
|
+
signal: AbortSignal.timeout(10_000),
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return parseOpenGraph(await res.text());
|
|
68
|
+
},
|
|
69
|
+
render(target, { level, data }) {
|
|
70
|
+
if (data === undefined || data === null) {
|
|
71
|
+
return null; // nothing resolved: decline to the plain-link floor
|
|
72
|
+
}
|
|
73
|
+
return renderCard(target, data as TurbolinkSummary, level);
|
|
74
|
+
},
|
|
75
|
+
};
|