jamdesk 1.1.218 → 1.1.220
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/__tests__/unit/docs-config.test.js +20 -0
- package/dist/__tests__/unit/docs-config.test.js.map +1 -1
- package/dist/__tests__/unit/logo-svg-text-wiring.test.d.ts +2 -0
- package/dist/__tests__/unit/logo-svg-text-wiring.test.d.ts.map +1 -0
- package/dist/__tests__/unit/logo-svg-text-wiring.test.js +14 -0
- package/dist/__tests__/unit/logo-svg-text-wiring.test.js.map +1 -0
- package/dist/__tests__/unit/logo-svg-text.test.d.ts +2 -0
- package/dist/__tests__/unit/logo-svg-text.test.d.ts.map +1 -0
- package/dist/__tests__/unit/logo-svg-text.test.js +64 -0
- package/dist/__tests__/unit/logo-svg-text.test.js.map +1 -0
- package/dist/__tests__/unit/svg-text-sync.test.d.ts +2 -0
- package/dist/__tests__/unit/svg-text-sync.test.d.ts.map +1 -0
- package/dist/__tests__/unit/svg-text-sync.test.js +16 -0
- package/dist/__tests__/unit/svg-text-sync.test.js.map +1 -0
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +5 -1
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +5 -1
- package/dist/commands/validate.js.map +1 -1
- package/dist/lib/docs-config.d.ts.map +1 -1
- package/dist/lib/docs-config.js +3 -0
- package/dist/lib/docs-config.js.map +1 -1
- package/dist/lib/logo-svg-text.d.ts +8 -0
- package/dist/lib/logo-svg-text.d.ts.map +1 -0
- package/dist/lib/logo-svg-text.js +53 -0
- package/dist/lib/logo-svg-text.js.map +1 -0
- package/dist/lib/svg-text.d.ts +40 -0
- package/dist/lib/svg-text.d.ts.map +1 -0
- package/dist/lib/svg-text.js +189 -0
- package/dist/lib/svg-text.js.map +1 -0
- package/package.json +1 -1
- package/vendored/app/api/og/route.tsx +280 -167
- package/vendored/app/layout.tsx +2 -2
- package/vendored/lib/docs-types.ts +2 -0
- package/vendored/lib/logo-svg-text-warning.ts +55 -0
- package/vendored/lib/og-card.ts +42 -6
- package/vendored/lib/seo.ts +16 -0
- package/vendored/lib/svg-text.ts +185 -0
- package/vendored/lib/validate-config.ts +2 -0
- package/vendored/schema/docs-schema.json +6 -0
- package/vendored/shared/status-reporter.ts +5 -0
- package/vendored/workspace-package-lock.json +121 -121
|
@@ -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;
|
|
@@ -27,10 +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
|
-
// Satori throws on these and the route would answer 200 with an empty body; "no image" is
|
|
32
|
-
|
|
33
|
-
|
|
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 '';
|
|
34
45
|
return `data:${contentType};base64,${Buffer.from(buf).toString('base64')}`;
|
|
35
46
|
} catch {
|
|
36
47
|
return ''; // Skip image on fetch failure
|
|
@@ -39,157 +50,135 @@ async function fetchDataUri(url: string): Promise<string> {
|
|
|
39
50
|
}
|
|
40
51
|
}
|
|
41
52
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
* - section: Section/group name (optional, e.g., "Get Started")
|
|
51
|
-
* - siteName: Site name (optional, defaults to "Documentation")
|
|
52
|
-
* - logo: URL to the project's logo (optional)
|
|
53
|
-
* - bg: URL to a custom background image rendered behind the text (optional)
|
|
54
|
-
* - theme: 'light' (default, warm cream) or 'dark'
|
|
55
|
-
* - accent: hex color (3 or 6 digits, '#' optional) for the badge and bottom bar; low-contrast values fall back to a readable color
|
|
56
|
-
* - hideBar: '1' hides the bottom accent bar
|
|
57
|
-
* - hideName: '1' hides the site name (the logo, if any, still renders)
|
|
58
|
-
*
|
|
59
|
-
* Example:
|
|
60
|
-
* /api/og?title=Introduction§ion=Get%20Started&siteName=Acme%20Docs&logo=https://...
|
|
61
|
-
*/
|
|
62
|
-
export async function GET(request: NextRequest) {
|
|
63
|
-
const searchParams = request.nextUrl.searchParams;
|
|
64
|
-
|
|
65
|
-
const title = searchParams.get('title') || 'Documentation';
|
|
66
|
-
const description = searchParams.get('description') || '';
|
|
67
|
-
const section = searchParams.get('section') || '';
|
|
68
|
-
const siteName = searchParams.get('siteName') || 'Documentation';
|
|
69
|
-
const card = parseCardOptions(searchParams);
|
|
70
|
-
// SSRF guard: HTTPS-only, blocks private/loopback/metadata IPs (incl. encoded forms).
|
|
71
|
-
const logoUrl = sanitizeLogoUrl(searchParams.get('logo') || '');
|
|
72
|
-
const bgUrl = sanitizeLogoUrl(searchParams.get('bg') || '');
|
|
73
|
-
|
|
74
|
-
const [logo, bg] = await Promise.all([
|
|
75
|
-
logoUrl ? fetchDataUri(logoUrl) : Promise.resolve(''),
|
|
76
|
-
bgUrl ? fetchDataUri(bgUrl) : Promise.resolve(''),
|
|
77
|
-
]);
|
|
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
|
+
}
|
|
78
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 !== '';
|
|
79
65
|
const titleFontSize = title.length > LONG_TITLE_THRESHOLD ? '52px' : '64px';
|
|
80
66
|
|
|
81
|
-
return
|
|
82
|
-
|
|
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) */}
|
|
83
96
|
<div
|
|
84
97
|
style={{
|
|
85
|
-
position: 'relative',
|
|
86
|
-
height: '100%',
|
|
87
|
-
width: '100%',
|
|
88
98
|
display: 'flex',
|
|
89
|
-
|
|
90
|
-
|
|
99
|
+
flexDirection: 'column',
|
|
100
|
+
width: '100%',
|
|
101
|
+
height: '100%',
|
|
102
|
+
padding: '60px',
|
|
91
103
|
}}
|
|
92
104
|
>
|
|
93
|
-
{/*
|
|
94
|
-
{
|
|
95
|
-
<
|
|
96
|
-
src={bg}
|
|
97
|
-
alt=""
|
|
98
|
-
width={1200}
|
|
99
|
-
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
|
|
100
108
|
style={{
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
height: '630px',
|
|
106
|
-
objectFit: 'cover',
|
|
109
|
+
display: 'flex',
|
|
110
|
+
flexDirection: 'column',
|
|
111
|
+
alignItems: 'flex-start',
|
|
112
|
+
marginBottom: '40px',
|
|
107
113
|
}}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}}
|
|
129
|
-
>
|
|
130
|
-
{logo && (
|
|
131
|
-
<img
|
|
132
|
-
src={logo}
|
|
133
|
-
alt=""
|
|
134
|
-
width={360}
|
|
135
|
-
height={48}
|
|
136
|
-
style={{
|
|
137
|
-
height: '48px',
|
|
138
|
-
maxWidth: '360px',
|
|
139
|
-
objectFit: 'contain',
|
|
140
|
-
marginBottom: card.showSiteName ? '16px' : 0,
|
|
141
|
-
borderRadius: '8px',
|
|
142
|
-
}}
|
|
143
|
-
/>
|
|
144
|
-
)}
|
|
145
|
-
{card.showSiteName && (
|
|
146
|
-
<span
|
|
147
|
-
style={{
|
|
148
|
-
fontSize: '24px',
|
|
149
|
-
fontWeight: 600,
|
|
150
|
-
color: card.palette.muted,
|
|
151
|
-
textTransform: 'uppercase',
|
|
152
|
-
letterSpacing: '0.05em',
|
|
153
|
-
}}
|
|
154
|
-
>
|
|
155
|
-
{siteName}
|
|
156
|
-
</span>
|
|
157
|
-
)}
|
|
158
|
-
</div>
|
|
159
|
-
)}
|
|
160
|
-
|
|
161
|
-
{/* Section badge */}
|
|
162
|
-
{section && (
|
|
163
|
-
<div
|
|
164
|
-
style={{
|
|
165
|
-
display: 'flex',
|
|
166
|
-
marginBottom: '20px',
|
|
167
|
-
}}
|
|
168
|
-
>
|
|
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 && (
|
|
169
134
|
<span
|
|
170
135
|
style={{
|
|
171
|
-
fontSize: '
|
|
172
|
-
fontWeight:
|
|
173
|
-
color: card.palette.
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
borderRadius: '20px',
|
|
136
|
+
fontSize: '24px',
|
|
137
|
+
fontWeight: 600,
|
|
138
|
+
color: card.palette.muted,
|
|
139
|
+
textTransform: 'uppercase',
|
|
140
|
+
letterSpacing: '0.05em',
|
|
177
141
|
}}
|
|
178
142
|
>
|
|
179
|
-
{
|
|
143
|
+
{siteName}
|
|
180
144
|
</span>
|
|
181
|
-
|
|
182
|
-
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
183
148
|
|
|
184
|
-
|
|
149
|
+
{/* Section badge */}
|
|
150
|
+
{section && (
|
|
185
151
|
<div
|
|
186
152
|
style={{
|
|
187
153
|
display: 'flex',
|
|
188
|
-
|
|
189
|
-
flexDirection: 'column',
|
|
190
|
-
justifyContent: 'center',
|
|
154
|
+
marginBottom: '20px',
|
|
191
155
|
}}
|
|
192
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 && (
|
|
193
182
|
<h1
|
|
194
183
|
style={{
|
|
195
184
|
fontSize: titleFontSize,
|
|
@@ -202,43 +191,167 @@ export async function GET(request: NextRequest) {
|
|
|
202
191
|
>
|
|
203
192
|
{title}
|
|
204
193
|
</h1>
|
|
194
|
+
)}
|
|
205
195
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
style={{
|
|
209
|
-
fontSize: '28px',
|
|
210
|
-
color: card.palette.muted,
|
|
211
|
-
marginTop: '24px',
|
|
212
|
-
lineHeight: 1.4,
|
|
213
|
-
maxWidth: '800px',
|
|
214
|
-
}}
|
|
215
|
-
>
|
|
216
|
-
{truncateText(description, MAX_DESCRIPTION_LENGTH)}
|
|
217
|
-
</p>
|
|
218
|
-
)}
|
|
219
|
-
</div>
|
|
220
|
-
|
|
221
|
-
{/* Bottom accent bar */}
|
|
222
|
-
{card.showAccentBar && (
|
|
223
|
-
<div
|
|
196
|
+
{description && (
|
|
197
|
+
<p
|
|
224
198
|
style={{
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
199
|
+
fontSize: '28px',
|
|
200
|
+
color: card.palette.muted,
|
|
201
|
+
marginTop: '24px',
|
|
202
|
+
lineHeight: 1.4,
|
|
203
|
+
maxWidth: '800px',
|
|
230
204
|
}}
|
|
231
|
-
|
|
205
|
+
>
|
|
206
|
+
{truncateText(description, MAX_DESCRIPTION_LENGTH)}
|
|
207
|
+
</p>
|
|
232
208
|
)}
|
|
233
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
|
+
)}
|
|
234
223
|
</div>
|
|
235
|
-
|
|
236
|
-
{
|
|
237
|
-
width: 1200,
|
|
238
|
-
height: 630,
|
|
239
|
-
headers: {
|
|
240
|
-
'Cache-Control': 'public, s-maxage=86400, stale-while-revalidate=604800',
|
|
241
|
-
},
|
|
242
|
-
}
|
|
224
|
+
</div>
|
|
243
225
|
);
|
|
244
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/app/layout.tsx
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from '@/lib/page-isr-helpers';
|
|
21
21
|
import { isRTLLanguage, resolveLanguageWithFallback, toHreflang } from '@/lib/language-utils';
|
|
22
22
|
import type { DocsConfig } from '@/lib/docs-types';
|
|
23
|
-
import { buildSiteTitle, getFaviconIcons, FALLBACK_METADATA } from '@/lib/seo';
|
|
23
|
+
import { buildSiteTitle, getFaviconIcons, getTitleTemplate, FALLBACK_METADATA } from '@/lib/seo';
|
|
24
24
|
import { withR2OpsContext, emitR2OpsSummary } from '@/lib/r2-content';
|
|
25
25
|
import { DocsChrome, getLocalFileContent, loadCustomAssets } from '@/lib/layout-helpers';
|
|
26
26
|
import { filterNavigationForViewer } from '@/lib/viewer-navigation';
|
|
@@ -69,7 +69,7 @@ async function generateMetadataImpl(): Promise<Metadata> {
|
|
|
69
69
|
const isEmbed = isIsrMode() && (await headers()).get('x-jd-layout') === 'embed';
|
|
70
70
|
return {
|
|
71
71
|
title: {
|
|
72
|
-
template:
|
|
72
|
+
template: getTitleTemplate(config),
|
|
73
73
|
default: buildSiteTitle(config.name),
|
|
74
74
|
},
|
|
75
75
|
description: config.description || `Documentation for ${config.name}`,
|
|
@@ -749,6 +749,8 @@ export interface SeoConfig {
|
|
|
749
749
|
indexHiddenPages?: boolean;
|
|
750
750
|
ai?: { llmsTxt?: boolean }; // default true; false suppresses llms.txt + llms-full.txt
|
|
751
751
|
socialCard?: SocialCardConfig;
|
|
752
|
+
/** Browser tab and search title format; each `%s` becomes the page title (default: '%s — <name>'). Does not change og:title. */
|
|
753
|
+
titleTemplate?: string;
|
|
752
754
|
}
|
|
753
755
|
|
|
754
756
|
/**
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import type { BuildWarning } from '../shared/status-reporter.js'; // vendored copy — NOT '../../shared'
|
|
4
|
+
import { svgHasText, SVG_TEXT_MESSAGE, SVG_TEXT_LINK, SVG_TEXT_MAX_BYTES } from './svg-text.js';
|
|
5
|
+
import { normalizeAssetPath } from './isr-build-executor.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Suggestion for an SVG logo whose <text> the OG card renderer paints blank.
|
|
9
|
+
* The reader is injected so the build (disk) and the CLI can each resolve the
|
|
10
|
+
* ref their own way; any read failure means "no warning", never a failed build.
|
|
11
|
+
* `file` is the real asset path the reader found, like logo_oversized reports.
|
|
12
|
+
*/
|
|
13
|
+
export function logoSvgTextWarning(
|
|
14
|
+
ref: string | null,
|
|
15
|
+
read: (ref: string) => { file: string; svg: string } | null,
|
|
16
|
+
): BuildWarning | null {
|
|
17
|
+
if (!ref || /^(?:https?:)?\/\//i.test(ref)) return null;
|
|
18
|
+
try {
|
|
19
|
+
const found = read(ref);
|
|
20
|
+
if (!found || !svgHasText(found.svg)) return null;
|
|
21
|
+
return { type: 'logo_svg_text', file: found.file, message: SVG_TEXT_MESSAGE, link: SVG_TEXT_LINK };
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Normalize a config image ref to the same key the upload loop uses: strip
|
|
29
|
+
* query/hash, leading ./ or ../, leading /, and a public/ prefix.
|
|
30
|
+
*/
|
|
31
|
+
export function toAssetKey(ref: string): string {
|
|
32
|
+
return normalizeAssetPath(
|
|
33
|
+
ref.split('?')[0].split('#')[0]
|
|
34
|
+
.replace(/^(?:\.\.?\/)+/, '')
|
|
35
|
+
.replace(/^\/+/, ''),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a config logo ref to its collected asset and read the SVG text.
|
|
41
|
+
* Null when the asset is unknown or over the size cap (the scan is skipped,
|
|
42
|
+
* not the build). Extracted from build.ts so a test can exercise the real
|
|
43
|
+
* lookup instead of a fake reader.
|
|
44
|
+
*/
|
|
45
|
+
export function readCardSvgLogo(
|
|
46
|
+
ref: string,
|
|
47
|
+
assetByNormalizedKey: Map<string, string>,
|
|
48
|
+
docsDir: string,
|
|
49
|
+
): { file: string; svg: string } | null {
|
|
50
|
+
const asset = assetByNormalizedKey.get(toAssetKey(ref));
|
|
51
|
+
if (!asset) return null;
|
|
52
|
+
const full = path.join(docsDir, asset);
|
|
53
|
+
if (fs.statSync(full).size > SVG_TEXT_MAX_BYTES) return null;
|
|
54
|
+
return { file: asset, svg: fs.readFileSync(full, 'utf8') };
|
|
55
|
+
}
|
package/vendored/lib/og-card.ts
CHANGED
|
@@ -130,15 +130,51 @@ export function parseCardOptions(params: URLSearchParams): CardOptions {
|
|
|
130
130
|
const UNRENDERABLE_IMAGE_TYPES = new Set([
|
|
131
131
|
'image/webp', 'image/avif',
|
|
132
132
|
'image/tiff', 'image/heic', 'image/heif', 'image/jxl', 'image/bmp',
|
|
133
|
-
// Non-standard spellings of JPEG that real servers send; Satori only knows image/jpeg.
|
|
134
|
-
'image/jpg', 'image/pjpeg',
|
|
135
133
|
]);
|
|
136
134
|
|
|
137
|
-
|
|
138
|
-
|
|
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);
|
|
139
154
|
// A 200 HTML page for a missing image (soft 404) is the realistic non-image case.
|
|
140
155
|
// Only plain-letter subtypes throw (html, plain, xml, css, svg+xml); text/x-generic,
|
|
141
156
|
// text/vnd.foo and text/html5 are byte-sniffed and draw today, so they must stay allowed.
|
|
142
|
-
|
|
143
|
-
|
|
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);
|
|
144
180
|
}
|
package/vendored/lib/seo.ts
CHANGED
|
@@ -34,6 +34,22 @@ export function buildSiteTitle(configName: string): string {
|
|
|
34
34
|
: `${configName} Documentation`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Root `title.template` for the site. Defaults to `%s — <name>`; sites opt in
|
|
39
|
+
* to their own via `seo.titleTemplate`.
|
|
40
|
+
*
|
|
41
|
+
* The schema already rejects a template without `%s`, but the runtime check
|
|
42
|
+
* stays: an older R2 config or a hand edit can slip past it, and a template
|
|
43
|
+
* with no `%s` would give every page the same title.
|
|
44
|
+
*/
|
|
45
|
+
export function getTitleTemplate(config: { name: string; seo?: { titleTemplate?: unknown } }): string {
|
|
46
|
+
const custom = config.seo?.titleTemplate;
|
|
47
|
+
if (typeof custom === 'string' && custom.includes('%s')) {
|
|
48
|
+
return custom;
|
|
49
|
+
}
|
|
50
|
+
return `%s — ${config.name}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
37
53
|
/**
|
|
38
54
|
* Icon descriptors for Next.js Metadata.icons.icon. When both light and
|
|
39
55
|
* dark are configured the dark entry trails the primary with a
|