jamdesk 1.1.217 → 1.1.219
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/package.json +1 -1
- package/vendored/app/api/og/route.tsx +281 -166
- package/vendored/lib/og-card.ts +61 -0
- package/vendored/lib/seo.ts +16 -1
- package/vendored/workspace-package-lock.json +13 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jamdesk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.219",
|
|
4
4
|
"description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jamdesk",
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { ImageResponse } from '@vercel/og';
|
|
2
2
|
import { NextRequest } from 'next/server';
|
|
3
3
|
import { sanitizeLogoUrl } from '@/lib/sanitize-url';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
parseCardOptions, dataUriImageType, isDeniedImageType, isLatinText, type CardOptions,
|
|
6
|
+
} from '@/lib/og-card';
|
|
7
|
+
import { log } from '@/lib/logger';
|
|
5
8
|
|
|
6
9
|
export const runtime = 'edge';
|
|
7
10
|
|
|
@@ -10,6 +13,11 @@ const MAX_DESCRIPTION_LENGTH = 120;
|
|
|
10
13
|
const LONG_TITLE_THRESHOLD = 40;
|
|
11
14
|
const FONT_STACK =
|
|
12
15
|
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
16
|
+
const CACHE_CONTROL = 'public, s-maxage=86400, stale-while-revalidate=604800';
|
|
17
|
+
// A degraded card is cached briefly so a later renderer fix replaces it within the hour.
|
|
18
|
+
const FALLBACK_CACHE_CONTROL = 'public, s-maxage=3600';
|
|
19
|
+
// Long enough that a crawler can't force the full render plus fallbacks on every hit.
|
|
20
|
+
const ERROR_CACHE_CONTROL = 'public, s-maxage=60';
|
|
13
21
|
|
|
14
22
|
function truncateText(text: string, maxLength: number): string {
|
|
15
23
|
if (text.length <= maxLength) return text;
|
|
@@ -18,7 +26,7 @@ function truncateText(text: string, maxLength: number): string {
|
|
|
18
26
|
|
|
19
27
|
/**
|
|
20
28
|
* Fetch an image and convert it to a base64 data URI for Satori compatibility
|
|
21
|
-
* (Satori
|
|
29
|
+
* (Satori can fail on remote URLs). 3s timeout; '' on failure or on a type Satori can't decode.
|
|
22
30
|
*/
|
|
23
31
|
async function fetchDataUri(url: string): Promise<string> {
|
|
24
32
|
const controller = new AbortController();
|
|
@@ -27,8 +35,13 @@ async function fetchDataUri(url: string): Promise<string> {
|
|
|
27
35
|
// redirect: 'manual' so a sanitized public host can't 302 to an internal one (SSRF).
|
|
28
36
|
const res = await fetch(url, { signal: controller.signal, redirect: 'manual' });
|
|
29
37
|
if (!res.ok) return '';
|
|
30
|
-
const
|
|
31
|
-
|
|
38
|
+
const declared = res.headers.get('content-type') || 'image/png';
|
|
39
|
+
// Satori throws on these and the route would answer 200 with an empty body; "no image" is
|
|
40
|
+
// the safe outcome. Decided from the label first, so a denied image is never downloaded.
|
|
41
|
+
if (isDeniedImageType(declared)) return '';
|
|
42
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
43
|
+
const contentType = dataUriImageType(declared, buf);
|
|
44
|
+
if (!contentType) return '';
|
|
32
45
|
return `data:${contentType};base64,${Buffer.from(buf).toString('base64')}`;
|
|
33
46
|
} catch {
|
|
34
47
|
return ''; // Skip image on fetch failure
|
|
@@ -37,157 +50,135 @@ async function fetchDataUri(url: string): Promise<string> {
|
|
|
37
50
|
}
|
|
38
51
|
}
|
|
39
52
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
* - section: Section/group name (optional, e.g., "Get Started")
|
|
49
|
-
* - siteName: Site name (optional, defaults to "Documentation")
|
|
50
|
-
* - logo: URL to the project's logo (optional)
|
|
51
|
-
* - bg: URL to a custom background image rendered behind the text (optional)
|
|
52
|
-
* - theme: 'light' (default, warm cream) or 'dark'
|
|
53
|
-
* - accent: hex color (3 or 6 digits, '#' optional) for the badge and bottom bar; low-contrast values fall back to a readable color
|
|
54
|
-
* - hideBar: '1' hides the bottom accent bar
|
|
55
|
-
* - hideName: '1' hides the site name (the logo, if any, still renders)
|
|
56
|
-
*
|
|
57
|
-
* Example:
|
|
58
|
-
* /api/og?title=Introduction§ion=Get%20Started&siteName=Acme%20Docs&logo=https://...
|
|
59
|
-
*/
|
|
60
|
-
export async function GET(request: NextRequest) {
|
|
61
|
-
const searchParams = request.nextUrl.searchParams;
|
|
62
|
-
|
|
63
|
-
const title = searchParams.get('title') || 'Documentation';
|
|
64
|
-
const description = searchParams.get('description') || '';
|
|
65
|
-
const section = searchParams.get('section') || '';
|
|
66
|
-
const siteName = searchParams.get('siteName') || 'Documentation';
|
|
67
|
-
const card = parseCardOptions(searchParams);
|
|
68
|
-
// SSRF guard: HTTPS-only, blocks private/loopback/metadata IPs (incl. encoded forms).
|
|
69
|
-
const logoUrl = sanitizeLogoUrl(searchParams.get('logo') || '');
|
|
70
|
-
const bgUrl = sanitizeLogoUrl(searchParams.get('bg') || '');
|
|
71
|
-
|
|
72
|
-
const [logo, bg] = await Promise.all([
|
|
73
|
-
logoUrl ? fetchDataUri(logoUrl) : Promise.resolve(''),
|
|
74
|
-
bgUrl ? fetchDataUri(bgUrl) : Promise.resolve(''),
|
|
75
|
-
]);
|
|
53
|
+
interface CardContent {
|
|
54
|
+
title: string;
|
|
55
|
+
description: string;
|
|
56
|
+
section: string;
|
|
57
|
+
siteName: string;
|
|
58
|
+
logo: string; // data URI or ''
|
|
59
|
+
bg: string; // data URI or ''
|
|
60
|
+
}
|
|
76
61
|
|
|
62
|
+
function cardElement({ title, description, section, siteName, logo, bg }: CardContent, card: CardOptions) {
|
|
63
|
+
// title and siteName are only ever empty on the fallback card; a normal request always has both.
|
|
64
|
+
const showName = card.showSiteName && siteName !== '';
|
|
77
65
|
const titleFontSize = title.length > LONG_TITLE_THRESHOLD ? '52px' : '64px';
|
|
78
66
|
|
|
79
|
-
return
|
|
80
|
-
|
|
67
|
+
return (
|
|
68
|
+
<div
|
|
69
|
+
style={{
|
|
70
|
+
position: 'relative',
|
|
71
|
+
height: '100%',
|
|
72
|
+
width: '100%',
|
|
73
|
+
display: 'flex',
|
|
74
|
+
background: card.palette.background,
|
|
75
|
+
fontFamily: FONT_STACK,
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{/* Custom background image, full-bleed behind the text */}
|
|
79
|
+
{bg && (
|
|
80
|
+
<img
|
|
81
|
+
src={bg}
|
|
82
|
+
alt=""
|
|
83
|
+
width={1200}
|
|
84
|
+
height={630}
|
|
85
|
+
style={{
|
|
86
|
+
position: 'absolute',
|
|
87
|
+
top: 0,
|
|
88
|
+
left: 0,
|
|
89
|
+
width: '1200px',
|
|
90
|
+
height: '630px',
|
|
91
|
+
objectFit: 'cover',
|
|
92
|
+
}}
|
|
93
|
+
/>
|
|
94
|
+
)}
|
|
95
|
+
{/* Content (painted on top of the background) */}
|
|
81
96
|
<div
|
|
82
97
|
style={{
|
|
83
|
-
position: 'relative',
|
|
84
|
-
height: '100%',
|
|
85
|
-
width: '100%',
|
|
86
98
|
display: 'flex',
|
|
87
|
-
|
|
88
|
-
|
|
99
|
+
flexDirection: 'column',
|
|
100
|
+
width: '100%',
|
|
101
|
+
height: '100%',
|
|
102
|
+
padding: '60px',
|
|
89
103
|
}}
|
|
90
104
|
>
|
|
91
|
-
{/*
|
|
92
|
-
{
|
|
93
|
-
<
|
|
94
|
-
src={bg}
|
|
95
|
-
alt=""
|
|
96
|
-
width={1200}
|
|
97
|
-
height={630}
|
|
105
|
+
{/* Logo and site name. Skipped when both are hidden so its 40px margin doesn't push the title down. */}
|
|
106
|
+
{(logo || showName) && (
|
|
107
|
+
<div
|
|
98
108
|
style={{
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
height: '630px',
|
|
104
|
-
objectFit: 'cover',
|
|
109
|
+
display: 'flex',
|
|
110
|
+
flexDirection: 'column',
|
|
111
|
+
alignItems: 'flex-start',
|
|
112
|
+
marginBottom: '40px',
|
|
105
113
|
}}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}}
|
|
127
|
-
>
|
|
128
|
-
{logo && (
|
|
129
|
-
<img
|
|
130
|
-
src={logo}
|
|
131
|
-
alt=""
|
|
132
|
-
width={360}
|
|
133
|
-
height={48}
|
|
134
|
-
style={{
|
|
135
|
-
height: '48px',
|
|
136
|
-
maxWidth: '360px',
|
|
137
|
-
objectFit: 'contain',
|
|
138
|
-
marginBottom: card.showSiteName ? '16px' : 0,
|
|
139
|
-
borderRadius: '8px',
|
|
140
|
-
}}
|
|
141
|
-
/>
|
|
142
|
-
)}
|
|
143
|
-
{card.showSiteName && (
|
|
144
|
-
<span
|
|
145
|
-
style={{
|
|
146
|
-
fontSize: '24px',
|
|
147
|
-
fontWeight: 600,
|
|
148
|
-
color: card.palette.muted,
|
|
149
|
-
textTransform: 'uppercase',
|
|
150
|
-
letterSpacing: '0.05em',
|
|
151
|
-
}}
|
|
152
|
-
>
|
|
153
|
-
{siteName}
|
|
154
|
-
</span>
|
|
155
|
-
)}
|
|
156
|
-
</div>
|
|
157
|
-
)}
|
|
158
|
-
|
|
159
|
-
{/* Section badge */}
|
|
160
|
-
{section && (
|
|
161
|
-
<div
|
|
162
|
-
style={{
|
|
163
|
-
display: 'flex',
|
|
164
|
-
marginBottom: '20px',
|
|
165
|
-
}}
|
|
166
|
-
>
|
|
114
|
+
>
|
|
115
|
+
{logo && (
|
|
116
|
+
<img
|
|
117
|
+
src={logo}
|
|
118
|
+
alt=""
|
|
119
|
+
width={360}
|
|
120
|
+
height={48}
|
|
121
|
+
style={{
|
|
122
|
+
height: '48px',
|
|
123
|
+
maxWidth: '360px',
|
|
124
|
+
objectFit: 'contain',
|
|
125
|
+
// contain centers an image narrower than the 360x48 box; pin it to the left edge,
|
|
126
|
+
// in line with the site name and title. Logos 7.5:1 or wider fill the box and don't move.
|
|
127
|
+
objectPosition: 'left',
|
|
128
|
+
marginBottom: showName ? '16px' : 0,
|
|
129
|
+
borderRadius: '8px',
|
|
130
|
+
}}
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
{showName && (
|
|
167
134
|
<span
|
|
168
135
|
style={{
|
|
169
|
-
fontSize: '
|
|
170
|
-
fontWeight:
|
|
171
|
-
color: card.palette.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
borderRadius: '20px',
|
|
136
|
+
fontSize: '24px',
|
|
137
|
+
fontWeight: 600,
|
|
138
|
+
color: card.palette.muted,
|
|
139
|
+
textTransform: 'uppercase',
|
|
140
|
+
letterSpacing: '0.05em',
|
|
175
141
|
}}
|
|
176
142
|
>
|
|
177
|
-
{
|
|
143
|
+
{siteName}
|
|
178
144
|
</span>
|
|
179
|
-
|
|
180
|
-
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
181
148
|
|
|
182
|
-
|
|
149
|
+
{/* Section badge */}
|
|
150
|
+
{section && (
|
|
183
151
|
<div
|
|
184
152
|
style={{
|
|
185
153
|
display: 'flex',
|
|
186
|
-
|
|
187
|
-
flexDirection: 'column',
|
|
188
|
-
justifyContent: 'center',
|
|
154
|
+
marginBottom: '20px',
|
|
189
155
|
}}
|
|
190
156
|
>
|
|
157
|
+
<span
|
|
158
|
+
style={{
|
|
159
|
+
fontSize: '18px',
|
|
160
|
+
fontWeight: 500,
|
|
161
|
+
color: card.palette.badgeText,
|
|
162
|
+
padding: '8px 16px',
|
|
163
|
+
background: card.palette.badgeBg,
|
|
164
|
+
borderRadius: '20px',
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
{section}
|
|
168
|
+
</span>
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
{/* Title and description */}
|
|
173
|
+
<div
|
|
174
|
+
style={{
|
|
175
|
+
display: 'flex',
|
|
176
|
+
flex: 1,
|
|
177
|
+
flexDirection: 'column',
|
|
178
|
+
justifyContent: 'center',
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
{title && (
|
|
191
182
|
<h1
|
|
192
183
|
style={{
|
|
193
184
|
fontSize: titleFontSize,
|
|
@@ -200,43 +191,167 @@ export async function GET(request: NextRequest) {
|
|
|
200
191
|
>
|
|
201
192
|
{title}
|
|
202
193
|
</h1>
|
|
194
|
+
)}
|
|
203
195
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
style={{
|
|
207
|
-
fontSize: '28px',
|
|
208
|
-
color: card.palette.muted,
|
|
209
|
-
marginTop: '24px',
|
|
210
|
-
lineHeight: 1.4,
|
|
211
|
-
maxWidth: '800px',
|
|
212
|
-
}}
|
|
213
|
-
>
|
|
214
|
-
{truncateText(description, MAX_DESCRIPTION_LENGTH)}
|
|
215
|
-
</p>
|
|
216
|
-
)}
|
|
217
|
-
</div>
|
|
218
|
-
|
|
219
|
-
{/* Bottom accent bar */}
|
|
220
|
-
{card.showAccentBar && (
|
|
221
|
-
<div
|
|
196
|
+
{description && (
|
|
197
|
+
<p
|
|
222
198
|
style={{
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
199
|
+
fontSize: '28px',
|
|
200
|
+
color: card.palette.muted,
|
|
201
|
+
marginTop: '24px',
|
|
202
|
+
lineHeight: 1.4,
|
|
203
|
+
maxWidth: '800px',
|
|
228
204
|
}}
|
|
229
|
-
|
|
205
|
+
>
|
|
206
|
+
{truncateText(description, MAX_DESCRIPTION_LENGTH)}
|
|
207
|
+
</p>
|
|
230
208
|
)}
|
|
231
209
|
</div>
|
|
210
|
+
|
|
211
|
+
{/* Bottom accent bar */}
|
|
212
|
+
{card.showAccentBar && (
|
|
213
|
+
<div
|
|
214
|
+
style={{
|
|
215
|
+
display: 'flex',
|
|
216
|
+
height: '6px',
|
|
217
|
+
background: card.palette.bar,
|
|
218
|
+
borderRadius: '3px',
|
|
219
|
+
marginTop: '40px',
|
|
220
|
+
}}
|
|
221
|
+
/>
|
|
222
|
+
)}
|
|
232
223
|
</div>
|
|
233
|
-
|
|
234
|
-
{
|
|
235
|
-
width: 1200,
|
|
236
|
-
height: 630,
|
|
237
|
-
headers: {
|
|
238
|
-
'Cache-Control': 'public, s-maxage=86400, stale-while-revalidate=604800',
|
|
239
|
-
},
|
|
240
|
-
}
|
|
224
|
+
</div>
|
|
241
225
|
);
|
|
242
226
|
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* ImageResponse renders lazily inside the body stream, so a Satori throw surfaces
|
|
230
|
+
* after the 200 headers have gone out: an empty card the CDN caches for a week.
|
|
231
|
+
* Reading the body here turns that into an error we can still answer differently.
|
|
232
|
+
*/
|
|
233
|
+
async function renderCard(content: CardContent, card: CardOptions, cacheControl: string): Promise<Response> {
|
|
234
|
+
const res = new ImageResponse(cardElement(content, card), {
|
|
235
|
+
width: 1200,
|
|
236
|
+
height: 630,
|
|
237
|
+
headers: {
|
|
238
|
+
'Cache-Control': cacheControl,
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
const body = await res.arrayBuffer();
|
|
242
|
+
if (body.byteLength === 0) throw new Error('empty image');
|
|
243
|
+
return new Response(body, { status: res.status, headers: res.headers });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The cards to try, in order: the full card; the full text without the logo and bg;
|
|
248
|
+
* the images with only the Latin text; then neither. Images and non-Latin text are the
|
|
249
|
+
* two things that throw. Latin text draws with the bundled font, while any other script
|
|
250
|
+
* makes Satori fetch and shape a font at render time (Arabic: Noto Sans Arabic uses GSUB
|
|
251
|
+
* lookup type 5, which Satori can't apply). Dropping images first keeps a title that
|
|
252
|
+
* draws fine when only the logo is broken. A tier that would draw the same picture as an
|
|
253
|
+
* earlier one is left out, so nothing renders twice.
|
|
254
|
+
*/
|
|
255
|
+
function cardTiers(content: CardContent, card: CardOptions): CardContent[] {
|
|
256
|
+
const keep = (text: string) => (isLatinText(text) ? text : '');
|
|
257
|
+
const noImages: CardContent = { ...content, logo: '', bg: '' };
|
|
258
|
+
const latinOnly: CardContent = {
|
|
259
|
+
...content,
|
|
260
|
+
title: keep(content.title),
|
|
261
|
+
// Judged by what is drawn: a non-Latin tail past the cut never reaches Satori.
|
|
262
|
+
description: isLatinText(truncateText(content.description, MAX_DESCRIPTION_LENGTH)) ? content.description : '',
|
|
263
|
+
section: keep(content.section),
|
|
264
|
+
siteName: keep(content.siteName),
|
|
265
|
+
};
|
|
266
|
+
const neither: CardContent = { ...latinOnly, logo: '', bg: '' };
|
|
267
|
+
|
|
268
|
+
// What cardElement draws: a hidden site name or the cut-off end of a description doesn't count.
|
|
269
|
+
const drawn = (c: CardContent) => JSON.stringify([
|
|
270
|
+
c.title, truncateText(c.description, MAX_DESCRIPTION_LENGTH), c.section,
|
|
271
|
+
card.showSiteName ? c.siteName : '', c.logo, c.bg,
|
|
272
|
+
]);
|
|
273
|
+
const seen = new Set<string>();
|
|
274
|
+
return [content, noImages, latinOnly, neither].filter((tier) => {
|
|
275
|
+
const key = drawn(tier);
|
|
276
|
+
if (seen.has(key)) return false;
|
|
277
|
+
seen.add(key);
|
|
278
|
+
return true;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const errorMessage = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* OG Image Generator
|
|
286
|
+
*
|
|
287
|
+
* Generates branded Open Graph images for documentation sites.
|
|
288
|
+
*
|
|
289
|
+
* Query Parameters:
|
|
290
|
+
* - title: Page title (required)
|
|
291
|
+
* - description: Page description (optional)
|
|
292
|
+
* - section: Section/group name (optional, e.g., "Get Started")
|
|
293
|
+
* - siteName: Site name (optional, defaults to "Documentation")
|
|
294
|
+
* - logo: URL to the project's logo (optional)
|
|
295
|
+
* - bg: URL to a custom background image rendered behind the text (optional)
|
|
296
|
+
* - theme: 'light' (default, warm cream) or 'dark'
|
|
297
|
+
* - accent: hex color (3 or 6 digits, '#' optional) for the badge and bottom bar; low-contrast values fall back to a readable color
|
|
298
|
+
* - hideBar: '1' hides the bottom accent bar
|
|
299
|
+
* - hideName: '1' hides the site name (the logo, if any, still renders)
|
|
300
|
+
*
|
|
301
|
+
* If the card fails to render, fallback cards without the images, without the
|
|
302
|
+
* non-Latin text, then without both are tried and served on a short cache; if
|
|
303
|
+
* every card fails, a 500 cached for a minute.
|
|
304
|
+
*
|
|
305
|
+
* Example:
|
|
306
|
+
* /api/og?title=Introduction§ion=Get%20Started&siteName=Acme%20Docs&logo=https://...
|
|
307
|
+
*/
|
|
308
|
+
export async function GET(request: NextRequest) {
|
|
309
|
+
const searchParams = request.nextUrl.searchParams;
|
|
310
|
+
|
|
311
|
+
const card = parseCardOptions(searchParams);
|
|
312
|
+
// SSRF guard: HTTPS-only, blocks private/loopback/metadata IPs (incl. encoded forms).
|
|
313
|
+
const logoUrl = sanitizeLogoUrl(searchParams.get('logo') || '');
|
|
314
|
+
const bgUrl = sanitizeLogoUrl(searchParams.get('bg') || '');
|
|
315
|
+
|
|
316
|
+
const [logo, bg] = await Promise.all([
|
|
317
|
+
logoUrl ? fetchDataUri(logoUrl) : Promise.resolve(''),
|
|
318
|
+
bgUrl ? fetchDataUri(bgUrl) : Promise.resolve(''),
|
|
319
|
+
]);
|
|
320
|
+
|
|
321
|
+
const content: CardContent = {
|
|
322
|
+
title: searchParams.get('title') || 'Documentation',
|
|
323
|
+
description: searchParams.get('description') || '',
|
|
324
|
+
section: searchParams.get('section') || '',
|
|
325
|
+
siteName: searchParams.get('siteName') || 'Documentation',
|
|
326
|
+
logo,
|
|
327
|
+
bg,
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// The route skips middleware, so these are the only tenant identifiers here. Behind a
|
|
331
|
+
// proxy, host can name the upstream rather than the tenant, so x-forwarded-host wins.
|
|
332
|
+
const tenant = {
|
|
333
|
+
host: request.headers.get('x-forwarded-host') || request.headers.get('host') || request.nextUrl.host,
|
|
334
|
+
siteName: content.siteName,
|
|
335
|
+
};
|
|
336
|
+
const errors: string[] = [];
|
|
337
|
+
for (const [i, tier] of cardTiers(content, card).entries()) {
|
|
338
|
+
try {
|
|
339
|
+
return await renderCard(tier, card, i === 0 ? CACHE_CONTROL : FALLBACK_CACHE_CONTROL);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
errors.push(errorMessage(err));
|
|
342
|
+
if (i === 0) {
|
|
343
|
+
log('warn', 'OG card render failed, trying fallback cards', {
|
|
344
|
+
...tenant,
|
|
345
|
+
titleLength: content.title.length,
|
|
346
|
+
error: errors[0],
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
log('error', 'OG card and every fallback failed', { ...tenant, errors });
|
|
353
|
+
return new Response('OG image render failed', {
|
|
354
|
+
status: 500,
|
|
355
|
+
headers: { 'Cache-Control': ERROR_CACHE_CONTROL, 'Content-Type': 'text/plain; charset=utf-8' },
|
|
356
|
+
});
|
|
357
|
+
}
|
package/vendored/lib/og-card.ts
CHANGED
|
@@ -117,3 +117,64 @@ export function parseCardOptions(params: URLSearchParams): CardOptions {
|
|
|
117
117
|
showSiteName: params.get('hideName') !== '1',
|
|
118
118
|
};
|
|
119
119
|
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Satori throws on these, and the route then answers 200 with an empty body that
|
|
123
|
+
* the CDN caches as a blank card. A deny-list rather than an allow-list on purpose:
|
|
124
|
+
* any type not listed renders (or fails) exactly as it does today, so no root tenant
|
|
125
|
+
* loses a logo that currently shows. ICO is deliberately NOT listed: Satori drops the
|
|
126
|
+
* icon but still renders a full card (with the logo slot reserved), and an existing
|
|
127
|
+
* card may only change when it is broken. Measured 2026-10-02; GIF, SVG, JPEG, PNG and
|
|
128
|
+
* octet-stream all draw.
|
|
129
|
+
*/
|
|
130
|
+
const UNRENDERABLE_IMAGE_TYPES = new Set([
|
|
131
|
+
'image/webp', 'image/avif',
|
|
132
|
+
'image/tiff', 'image/heic', 'image/heif', 'image/jxl', 'image/bmp',
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
// Non-standard spellings of JPEG that real servers send. Satori throws on the label,
|
|
136
|
+
// not the bytes, so real JPEG bytes are relabelled; anything else under these labels
|
|
137
|
+
// is dropped, as it was before relabelling existed.
|
|
138
|
+
const JPEG_ALIASES = new Set(['image/jpg', 'image/pjpeg']);
|
|
139
|
+
|
|
140
|
+
function isJpeg(bytes: Uint8Array): boolean {
|
|
141
|
+
return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function baseType(contentType: string): string {
|
|
145
|
+
return contentType.split(';')[0].trim().toLowerCase();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* True when the declared type alone rules the image out, so the caller can skip
|
|
150
|
+
* downloading it. The JPEG aliases are not ruled out here: they need their bytes.
|
|
151
|
+
*/
|
|
152
|
+
export function isDeniedImageType(contentType: string): boolean {
|
|
153
|
+
const type = baseType(contentType);
|
|
154
|
+
// A 200 HTML page for a missing image (soft 404) is the realistic non-image case.
|
|
155
|
+
// Only plain-letter subtypes throw (html, plain, xml, css, svg+xml); text/x-generic,
|
|
156
|
+
// text/vnd.foo and text/html5 are byte-sniffed and draw today, so they must stay allowed.
|
|
157
|
+
return /^text\/[a-z+]+$/.test(type) || UNRENDERABLE_IMAGE_TYPES.has(type);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The type to declare in an image's data URI, or null when Satori can't draw it and
|
|
162
|
+
* the card should go without the image. Every other type is returned untouched so the
|
|
163
|
+
* data URI, and the card, stay byte-identical.
|
|
164
|
+
*/
|
|
165
|
+
export function dataUriImageType(contentType: string, bytes: Uint8Array): string | null {
|
|
166
|
+
if (JPEG_ALIASES.has(baseType(contentType))) return isJpeg(bytes) ? 'image/jpeg' : null;
|
|
167
|
+
return isDeniedImageType(contentType) ? null : contentType;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Latin letters plus the punctuation, currency and letterlike symbols docs titles use.
|
|
171
|
+
const LATIN_TEXT = /^[\u0000-\u024F\u1E00-\u1EFF\u2000-\u206F\u20A0-\u20CF\u2100-\u214F]*$/;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* True when the text needs no font beyond the one bundled with the renderer. Any
|
|
175
|
+
* other script is shaped with a font fetched at render time, and that is the step
|
|
176
|
+
* that can throw, so the route's fallback card drops text that fails this check.
|
|
177
|
+
*/
|
|
178
|
+
export function isLatinText(text: string): boolean {
|
|
179
|
+
return LATIN_TEXT.test(text);
|
|
180
|
+
}
|
package/vendored/lib/seo.ts
CHANGED
|
@@ -96,6 +96,15 @@ function isUndecodableImage(pathOrUrl: string): boolean {
|
|
|
96
96
|
return /\.(?:webp|avif)$/i.test(pathOrUrl.split(/[?#]/)[0]);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* True for formats the OG card reliably draws as a logo. Deliberately an
|
|
101
|
+
* allow-list: ICO renders as an empty slot and WebP/AVIF are dropped, so none
|
|
102
|
+
* of them gain anything from the subpath /_jd/ rewrite.
|
|
103
|
+
*/
|
|
104
|
+
function isSubpathRewritableImage(pathOrUrl: string): boolean {
|
|
105
|
+
return /\.(?:png|jpe?g|gif|svg)$/i.test(pathOrUrl.split(/[?#]/)[0]);
|
|
106
|
+
}
|
|
107
|
+
|
|
99
108
|
/**
|
|
100
109
|
* Resolve the tenant's effective docs prefix and the domain-root URL that
|
|
101
110
|
* OG assets and metadataBase resolve against.
|
|
@@ -169,7 +178,13 @@ function buildOgImageUrl(
|
|
|
169
178
|
}
|
|
170
179
|
// Make logo URL absolute (use root URL since assets are at domain root)
|
|
171
180
|
if (logoPath && !logoPath.startsWith('http')) {
|
|
172
|
-
|
|
181
|
+
// A subpath tenant's worker only proxies /docs and /_jd/, so a bare
|
|
182
|
+
// root-relative logo 404s on their origin; /_jd/ is where it is served.
|
|
183
|
+
// Gated to formats the card renders, so ICO/WebP/AVIF keep today's URL
|
|
184
|
+
// rather than turning "no logo" into an empty icon slot.
|
|
185
|
+
logoPath = isHostAtDocs && isSubpathRewritableImage(logoPath)
|
|
186
|
+
? toAbsoluteAssetUrl(logoPath, rootUrl)
|
|
187
|
+
: `${rootUrl}${logoPath.startsWith('/') ? '' : '/'}${logoPath}`;
|
|
173
188
|
}
|
|
174
189
|
if (logoPath) {
|
|
175
190
|
params.set('logo', logoPath);
|
|
@@ -2254,12 +2254,12 @@
|
|
|
2254
2254
|
}
|
|
2255
2255
|
},
|
|
2256
2256
|
"node_modules/@vercel/functions": {
|
|
2257
|
-
"version": "3.9.
|
|
2258
|
-
"resolved": "https://registry.npmjs.org/@vercel/functions/-/functions-3.9.
|
|
2259
|
-
"integrity": "sha512-
|
|
2257
|
+
"version": "3.9.11",
|
|
2258
|
+
"resolved": "https://registry.npmjs.org/@vercel/functions/-/functions-3.9.11.tgz",
|
|
2259
|
+
"integrity": "sha512-DbqKqcbUHGBTYgHZieSNDuF1/L2iSPZV0OGm0Xkm/t+s61lFk4VX8yGQ22Og3WYGxypW1l4Elx1NP8BL928u8Q==",
|
|
2260
2260
|
"license": "Apache-2.0",
|
|
2261
2261
|
"dependencies": {
|
|
2262
|
-
"@vercel/oidc": "
|
|
2262
|
+
"@vercel/oidc": "4.0.0"
|
|
2263
2263
|
},
|
|
2264
2264
|
"engines": {
|
|
2265
2265
|
"node": ">= 20"
|
|
@@ -2278,9 +2278,9 @@
|
|
|
2278
2278
|
}
|
|
2279
2279
|
},
|
|
2280
2280
|
"node_modules/@vercel/oidc": {
|
|
2281
|
-
"version": "
|
|
2282
|
-
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-
|
|
2283
|
-
"integrity": "sha512-
|
|
2281
|
+
"version": "4.0.0",
|
|
2282
|
+
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-4.0.0.tgz",
|
|
2283
|
+
"integrity": "sha512-ko6ATeRUlHZMB5hLFdC7+/Q1dI2f3NX3gZHxtpALdkxurwjD5HFHGWYF+4o+4SF0E47jg6KzcHknmRpHcKgJ5A==",
|
|
2284
2284
|
"license": "Apache-2.0",
|
|
2285
2285
|
"dependencies": {
|
|
2286
2286
|
"@vercel/cli-config": "0.3.1",
|
|
@@ -4617,9 +4617,9 @@
|
|
|
4617
4617
|
}
|
|
4618
4618
|
},
|
|
4619
4619
|
"node_modules/mdast-util-from-markdown": {
|
|
4620
|
-
"version": "2.0
|
|
4621
|
-
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.
|
|
4622
|
-
"integrity": "sha512-
|
|
4620
|
+
"version": "2.1.0",
|
|
4621
|
+
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.1.0.tgz",
|
|
4622
|
+
"integrity": "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==",
|
|
4623
4623
|
"license": "MIT",
|
|
4624
4624
|
"dependencies": {
|
|
4625
4625
|
"@types/mdast": "^4.0.0",
|
|
@@ -4873,9 +4873,9 @@
|
|
|
4873
4873
|
}
|
|
4874
4874
|
},
|
|
4875
4875
|
"node_modules/mdast-util-to-markdown": {
|
|
4876
|
-
"version": "2.
|
|
4877
|
-
"resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.
|
|
4878
|
-
"integrity": "sha512-
|
|
4876
|
+
"version": "2.2.0",
|
|
4877
|
+
"resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.2.0.tgz",
|
|
4878
|
+
"integrity": "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==",
|
|
4879
4879
|
"license": "MIT",
|
|
4880
4880
|
"dependencies": {
|
|
4881
4881
|
"@types/mdast": "^4.0.0",
|