@cube-drone/marquee-turbolink 0.6.0 → 0.6.1
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/dist/card.js +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.js +28 -18
- package/dist/opengraph.js +59 -1
- package/package.json +2 -2
- package/src/card.ts +1 -1
- package/src/index.ts +16 -7
- package/src/opengraph.ts +59 -1
package/dist/card.js
CHANGED
|
@@ -45,7 +45,7 @@ export function renderCard(target, summary, level) {
|
|
|
45
45
|
card += `<span class="mq-turbolink-desc">${escapeText(summary.description)}</span>`;
|
|
46
46
|
}
|
|
47
47
|
if (level === "full" && summary.image !== undefined) {
|
|
48
|
-
card += `<img class="mq-turbolink-thumb" src="${escapeAttr(summary.image)}" alt="" loading="lazy">`;
|
|
48
|
+
card += `<img class="mq-turbolink-thumb" src="${escapeAttr(summary.image)}" alt="" loading="lazy" referrerpolicy="no-referrer">`;
|
|
49
49
|
}
|
|
50
50
|
return `<span class="mq-turbolink-card">${card}</span>`;
|
|
51
51
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -31,10 +31,13 @@ export declare function turbolinkStyles(plugins: TurbolinkPlugin[]): string;
|
|
|
31
31
|
export declare function composeTurbolinks(plugins: TurbolinkPlugin[], resolved?: Map<string, unknown>): (target: string, level: TurbolinkLevel) => string | null;
|
|
32
32
|
/** The fetch-ahead pass: run every matching plugin's resolve() over a
|
|
33
33
|
* target list, yielding the map composeTurbolinks() consumes. Targets
|
|
34
|
-
* resolve concurrently
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
|
|
34
|
+
* resolve concurrently but BOUNDED (`concurrency`, default 8) - a link-heavy
|
|
35
|
+
* document shouldn't open hundreds of simultaneous sockets, exhaust file
|
|
36
|
+
* descriptors, or trip a host's rate limiter. Within one target, plugins
|
|
37
|
+
* still run in order - first resolver wins, like first renderer. */
|
|
38
|
+
export declare function resolveTargets(targets: string[], plugins: TurbolinkPlugin[], opts?: {
|
|
39
|
+
concurrency?: number;
|
|
40
|
+
}): Promise<Map<string, unknown>>;
|
|
38
41
|
/** Every turbolink target in a parsed document (bare-paragraph nodes and
|
|
39
42
|
* explicit :::turbolink directives) - the fetch-ahead pass's shopping list. */
|
|
40
43
|
export declare function turbolinkTargets(node: Node): string[];
|
package/dist/index.js
CHANGED
|
@@ -49,28 +49,38 @@ export function composeTurbolinks(plugins, resolved) {
|
|
|
49
49
|
}
|
|
50
50
|
/** The fetch-ahead pass: run every matching plugin's resolve() over a
|
|
51
51
|
* target list, yielding the map composeTurbolinks() consumes. Targets
|
|
52
|
-
* resolve concurrently
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
|
|
52
|
+
* resolve concurrently but BOUNDED (`concurrency`, default 8) - a link-heavy
|
|
53
|
+
* document shouldn't open hundreds of simultaneous sockets, exhaust file
|
|
54
|
+
* descriptors, or trip a host's rate limiter. Within one target, plugins
|
|
55
|
+
* still run in order - first resolver wins, like first renderer. */
|
|
56
|
+
export async function resolveTargets(targets, plugins, opts = {}) {
|
|
56
57
|
const resolved = new Map();
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
58
|
+
const unique = [...new Set(targets)];
|
|
59
|
+
const limit = Math.max(1, opts.concurrency ?? 8);
|
|
60
|
+
let next = 0;
|
|
61
|
+
// A fixed pool of workers pulling from a shared cursor: JS is single-
|
|
62
|
+
// threaded, so `next++` between awaits can't race.
|
|
63
|
+
const worker = async () => {
|
|
64
|
+
while (next < unique.length) {
|
|
65
|
+
const target = unique[next++];
|
|
66
|
+
for (const plugin of plugins) {
|
|
67
|
+
if (plugin.resolve === undefined || !plugin.match(target)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const data = await plugin.resolve(target);
|
|
72
|
+
if (data !== null && data !== undefined) {
|
|
73
|
+
resolved.set(`${plugin.name}\n${target}`, data);
|
|
74
|
+
break; // first resolver wins, like first renderer
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// a failed fetch is a plain link, not a failed render
|
|
67
79
|
}
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
// a failed fetch is a plain link, not a failed render
|
|
71
80
|
}
|
|
72
81
|
}
|
|
73
|
-
}
|
|
82
|
+
};
|
|
83
|
+
await Promise.all(Array.from({ length: Math.min(limit, unique.length) }, worker));
|
|
74
84
|
return resolved;
|
|
75
85
|
}
|
|
76
86
|
/** Every turbolink target in a parsed document (bare-paragraph nodes and
|
package/dist/opengraph.js
CHANGED
|
@@ -41,6 +41,46 @@ export function parseOpenGraph(html) {
|
|
|
41
41
|
}
|
|
42
42
|
return summary.title === undefined ? null : summary;
|
|
43
43
|
}
|
|
44
|
+
const MAX_OG_BYTES = 128 * 1024;
|
|
45
|
+
/** Read at most maxBytes of the body, then cancel the stream. The metadata we
|
|
46
|
+
* want lives in the first N KiB (parseOpenGraph caps its parse there too);
|
|
47
|
+
* this bounds the *download*, so a hostile or merely huge page can't blow up a
|
|
48
|
+
* build's memory before the parse cap ever applies. */
|
|
49
|
+
async function readCapped(res, maxBytes) {
|
|
50
|
+
if (res.body === null) {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
const reader = res.body.getReader();
|
|
54
|
+
const buf = new Uint8Array(maxBytes);
|
|
55
|
+
let len = 0;
|
|
56
|
+
try {
|
|
57
|
+
while (len < maxBytes) {
|
|
58
|
+
const { done, value } = await reader.read();
|
|
59
|
+
if (done) {
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
const take = Math.min(value.length, maxBytes - len);
|
|
63
|
+
buf.set(value.subarray(0, take), len);
|
|
64
|
+
len += take;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
await reader.cancel().catch(() => undefined); // stop the transfer early
|
|
69
|
+
}
|
|
70
|
+
return new TextDecoder().decode(buf.subarray(0, len));
|
|
71
|
+
}
|
|
72
|
+
/** Resolve a possibly-relative URL against a base, keeping only http(s). An
|
|
73
|
+
* og:image is relative to the page it came from, not our output; and a
|
|
74
|
+
* `javascript:`/`data:` image src should never reach a reader's card. */
|
|
75
|
+
function absoluteHttp(url, base) {
|
|
76
|
+
try {
|
|
77
|
+
const u = new URL(url, base);
|
|
78
|
+
return u.protocol === "http:" || u.protocol === "https:" ? u.href : undefined;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
44
84
|
export const opengraphPlugin = {
|
|
45
85
|
name: "opengraph",
|
|
46
86
|
match: (t) => /^https?:\/\//.test(t),
|
|
@@ -55,7 +95,25 @@ export const opengraphPlugin = {
|
|
|
55
95
|
if (!res.ok) {
|
|
56
96
|
return null;
|
|
57
97
|
}
|
|
58
|
-
|
|
98
|
+
// A linked image/PDF/zip has no OpenGraph to find - don't stream a binary
|
|
99
|
+
// body just to parse nothing. (Empty/absent type: allow, and let the
|
|
100
|
+
// parse decide.)
|
|
101
|
+
const type = res.headers.get("content-type") ?? "";
|
|
102
|
+
if (type !== "" && !/text\/html|application\/xhtml|text\/plain/i.test(type)) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const summary = parseOpenGraph(await readCapped(res, MAX_OG_BYTES));
|
|
106
|
+
if (summary !== null && summary.image !== undefined) {
|
|
107
|
+
// res.url is the post-redirect URL: the right base for a relative image.
|
|
108
|
+
const abs = absoluteHttp(summary.image, res.url || target);
|
|
109
|
+
if (abs === undefined) {
|
|
110
|
+
delete summary.image;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
summary.image = abs;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return summary;
|
|
59
117
|
},
|
|
60
118
|
render(target, { level, data }) {
|
|
61
119
|
if (data === undefined || data === null) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cube-drone/marquee-turbolink",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Pluggable turbolink rendering for Marquee: link expanders as plugins, plain link as the floor",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"prepack": "npm run build"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@cube-drone/marquee-parser": "^0.6.
|
|
37
|
+
"@cube-drone/marquee-parser": "^0.6.1"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/node": "^24.0.0",
|
package/src/card.ts
CHANGED
|
@@ -61,7 +61,7 @@ export function renderCard(
|
|
|
61
61
|
card += `<span class="mq-turbolink-desc">${escapeText(summary.description)}</span>`;
|
|
62
62
|
}
|
|
63
63
|
if (level === "full" && summary.image !== undefined) {
|
|
64
|
-
card += `<img class="mq-turbolink-thumb" src="${escapeAttr(summary.image)}" alt="" loading="lazy">`;
|
|
64
|
+
card += `<img class="mq-turbolink-thumb" src="${escapeAttr(summary.image)}" alt="" loading="lazy" referrerpolicy="no-referrer">`;
|
|
65
65
|
}
|
|
66
66
|
return `<span class="mq-turbolink-card">${card}</span>`;
|
|
67
67
|
}
|
package/src/index.ts
CHANGED
|
@@ -81,16 +81,24 @@ export function composeTurbolinks(
|
|
|
81
81
|
|
|
82
82
|
/** The fetch-ahead pass: run every matching plugin's resolve() over a
|
|
83
83
|
* target list, yielding the map composeTurbolinks() consumes. Targets
|
|
84
|
-
* resolve concurrently
|
|
85
|
-
*
|
|
86
|
-
*
|
|
84
|
+
* resolve concurrently but BOUNDED (`concurrency`, default 8) - a link-heavy
|
|
85
|
+
* document shouldn't open hundreds of simultaneous sockets, exhaust file
|
|
86
|
+
* descriptors, or trip a host's rate limiter. Within one target, plugins
|
|
87
|
+
* still run in order - first resolver wins, like first renderer. */
|
|
87
88
|
export async function resolveTargets(
|
|
88
89
|
targets: string[],
|
|
89
90
|
plugins: TurbolinkPlugin[],
|
|
91
|
+
opts: { concurrency?: number } = {},
|
|
90
92
|
): Promise<Map<string, unknown>> {
|
|
91
93
|
const resolved = new Map<string, unknown>();
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
const unique = [...new Set(targets)];
|
|
95
|
+
const limit = Math.max(1, opts.concurrency ?? 8);
|
|
96
|
+
let next = 0;
|
|
97
|
+
// A fixed pool of workers pulling from a shared cursor: JS is single-
|
|
98
|
+
// threaded, so `next++` between awaits can't race.
|
|
99
|
+
const worker = async (): Promise<void> => {
|
|
100
|
+
while (next < unique.length) {
|
|
101
|
+
const target = unique[next++]!;
|
|
94
102
|
for (const plugin of plugins) {
|
|
95
103
|
if (plugin.resolve === undefined || !plugin.match(target)) {
|
|
96
104
|
continue;
|
|
@@ -105,8 +113,9 @@ export async function resolveTargets(
|
|
|
105
113
|
// a failed fetch is a plain link, not a failed render
|
|
106
114
|
}
|
|
107
115
|
}
|
|
108
|
-
}
|
|
109
|
-
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
await Promise.all(Array.from({ length: Math.min(limit, unique.length) }, worker));
|
|
110
119
|
return resolved;
|
|
111
120
|
}
|
|
112
121
|
|
package/src/opengraph.ts
CHANGED
|
@@ -50,6 +50,47 @@ export function parseOpenGraph(html: string): TurbolinkSummary | null {
|
|
|
50
50
|
return summary.title === undefined ? null : summary;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
const MAX_OG_BYTES = 128 * 1024;
|
|
54
|
+
|
|
55
|
+
/** Read at most maxBytes of the body, then cancel the stream. The metadata we
|
|
56
|
+
* want lives in the first N KiB (parseOpenGraph caps its parse there too);
|
|
57
|
+
* this bounds the *download*, so a hostile or merely huge page can't blow up a
|
|
58
|
+
* build's memory before the parse cap ever applies. */
|
|
59
|
+
async function readCapped(res: Response, maxBytes: number): Promise<string> {
|
|
60
|
+
if (res.body === null) {
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
const reader = res.body.getReader();
|
|
64
|
+
const buf = new Uint8Array(maxBytes);
|
|
65
|
+
let len = 0;
|
|
66
|
+
try {
|
|
67
|
+
while (len < maxBytes) {
|
|
68
|
+
const { done, value } = await reader.read();
|
|
69
|
+
if (done) {
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
const take = Math.min(value.length, maxBytes - len);
|
|
73
|
+
buf.set(value.subarray(0, take), len);
|
|
74
|
+
len += take;
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
await reader.cancel().catch(() => undefined); // stop the transfer early
|
|
78
|
+
}
|
|
79
|
+
return new TextDecoder().decode(buf.subarray(0, len));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Resolve a possibly-relative URL against a base, keeping only http(s). An
|
|
83
|
+
* og:image is relative to the page it came from, not our output; and a
|
|
84
|
+
* `javascript:`/`data:` image src should never reach a reader's card. */
|
|
85
|
+
function absoluteHttp(url: string, base: string): string | undefined {
|
|
86
|
+
try {
|
|
87
|
+
const u = new URL(url, base);
|
|
88
|
+
return u.protocol === "http:" || u.protocol === "https:" ? u.href : undefined;
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
53
94
|
export const opengraphPlugin: TurbolinkPlugin = {
|
|
54
95
|
name: "opengraph",
|
|
55
96
|
match: (t) => /^https?:\/\//.test(t),
|
|
@@ -64,7 +105,24 @@ export const opengraphPlugin: TurbolinkPlugin = {
|
|
|
64
105
|
if (!res.ok) {
|
|
65
106
|
return null;
|
|
66
107
|
}
|
|
67
|
-
|
|
108
|
+
// A linked image/PDF/zip has no OpenGraph to find - don't stream a binary
|
|
109
|
+
// body just to parse nothing. (Empty/absent type: allow, and let the
|
|
110
|
+
// parse decide.)
|
|
111
|
+
const type = res.headers.get("content-type") ?? "";
|
|
112
|
+
if (type !== "" && !/text\/html|application\/xhtml|text\/plain/i.test(type)) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const summary = parseOpenGraph(await readCapped(res, MAX_OG_BYTES));
|
|
116
|
+
if (summary !== null && summary.image !== undefined) {
|
|
117
|
+
// res.url is the post-redirect URL: the right base for a relative image.
|
|
118
|
+
const abs = absoluteHttp(summary.image, res.url || target);
|
|
119
|
+
if (abs === undefined) {
|
|
120
|
+
delete summary.image;
|
|
121
|
+
} else {
|
|
122
|
+
summary.image = abs;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return summary;
|
|
68
126
|
},
|
|
69
127
|
render(target, { level, data }) {
|
|
70
128
|
if (data === undefined || data === null) {
|