@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 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 (wall-clock is the slowest fetch, not the sum);
35
- * within one target, plugins still run in order - first resolver wins,
36
- * like first renderer. */
37
- export declare function resolveTargets(targets: string[], plugins: TurbolinkPlugin[]): Promise<Map<string, unknown>>;
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 (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) {
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
- 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
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
- return parseOpenGraph(await res.text());
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.0",
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.0"
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 (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. */
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
- await Promise.all(
93
- [...new Set(targets)].map(async (target) => {
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
- return parseOpenGraph(await res.text());
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) {