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.
Files changed (43) hide show
  1. package/dist/__tests__/unit/docs-config.test.js +20 -0
  2. package/dist/__tests__/unit/docs-config.test.js.map +1 -1
  3. package/dist/__tests__/unit/logo-svg-text-wiring.test.d.ts +2 -0
  4. package/dist/__tests__/unit/logo-svg-text-wiring.test.d.ts.map +1 -0
  5. package/dist/__tests__/unit/logo-svg-text-wiring.test.js +14 -0
  6. package/dist/__tests__/unit/logo-svg-text-wiring.test.js.map +1 -0
  7. package/dist/__tests__/unit/logo-svg-text.test.d.ts +2 -0
  8. package/dist/__tests__/unit/logo-svg-text.test.d.ts.map +1 -0
  9. package/dist/__tests__/unit/logo-svg-text.test.js +64 -0
  10. package/dist/__tests__/unit/logo-svg-text.test.js.map +1 -0
  11. package/dist/__tests__/unit/svg-text-sync.test.d.ts +2 -0
  12. package/dist/__tests__/unit/svg-text-sync.test.d.ts.map +1 -0
  13. package/dist/__tests__/unit/svg-text-sync.test.js +16 -0
  14. package/dist/__tests__/unit/svg-text-sync.test.js.map +1 -0
  15. package/dist/commands/dev.d.ts.map +1 -1
  16. package/dist/commands/dev.js +5 -1
  17. package/dist/commands/dev.js.map +1 -1
  18. package/dist/commands/validate.d.ts.map +1 -1
  19. package/dist/commands/validate.js +5 -1
  20. package/dist/commands/validate.js.map +1 -1
  21. package/dist/lib/docs-config.d.ts.map +1 -1
  22. package/dist/lib/docs-config.js +3 -0
  23. package/dist/lib/docs-config.js.map +1 -1
  24. package/dist/lib/logo-svg-text.d.ts +8 -0
  25. package/dist/lib/logo-svg-text.d.ts.map +1 -0
  26. package/dist/lib/logo-svg-text.js +53 -0
  27. package/dist/lib/logo-svg-text.js.map +1 -0
  28. package/dist/lib/svg-text.d.ts +40 -0
  29. package/dist/lib/svg-text.d.ts.map +1 -0
  30. package/dist/lib/svg-text.js +189 -0
  31. package/dist/lib/svg-text.js.map +1 -0
  32. package/package.json +1 -1
  33. package/vendored/app/api/og/route.tsx +280 -167
  34. package/vendored/app/layout.tsx +2 -2
  35. package/vendored/lib/docs-types.ts +2 -0
  36. package/vendored/lib/logo-svg-text-warning.ts +55 -0
  37. package/vendored/lib/og-card.ts +42 -6
  38. package/vendored/lib/seo.ts +16 -0
  39. package/vendored/lib/svg-text.ts +185 -0
  40. package/vendored/lib/validate-config.ts +2 -0
  41. package/vendored/schema/docs-schema.json +6 -0
  42. package/vendored/shared/status-reporter.ts +5 -0
  43. 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 { parseCardOptions, isRenderableImageType } from '@/lib/og-card';
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 contentType = res.headers.get('content-type') || 'image/png';
31
- // Satori throws on these and the route would answer 200 with an empty body; "no image" is the safe outcome.
32
- if (!isRenderableImageType(contentType)) return '';
33
- const buf = await res.arrayBuffer();
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
- * OG Image Generator
44
- *
45
- * Generates branded Open Graph images for documentation sites.
46
- *
47
- * Query Parameters:
48
- * - title: Page title (required)
49
- * - description: Page description (optional)
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&section=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 new ImageResponse(
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
- background: card.palette.background,
90
- fontFamily: FONT_STACK,
99
+ flexDirection: 'column',
100
+ width: '100%',
101
+ height: '100%',
102
+ padding: '60px',
91
103
  }}
92
104
  >
93
- {/* Custom background image, full-bleed behind the text */}
94
- {bg && (
95
- <img
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
- position: 'absolute',
102
- top: 0,
103
- left: 0,
104
- width: '1200px',
105
- height: '630px',
106
- objectFit: 'cover',
109
+ display: 'flex',
110
+ flexDirection: 'column',
111
+ alignItems: 'flex-start',
112
+ marginBottom: '40px',
107
113
  }}
108
- />
109
- )}
110
- {/* Content (painted on top of the background) */}
111
- <div
112
- style={{
113
- display: 'flex',
114
- flexDirection: 'column',
115
- width: '100%',
116
- height: '100%',
117
- padding: '60px',
118
- }}
119
- >
120
- {/* Logo and site name. Skipped when both are hidden so its 40px margin doesn't push the title down. */}
121
- {(logo || card.showSiteName) && (
122
- <div
123
- style={{
124
- display: 'flex',
125
- flexDirection: 'column',
126
- alignItems: 'flex-start',
127
- marginBottom: '40px',
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: '18px',
172
- fontWeight: 500,
173
- color: card.palette.badgeText,
174
- padding: '8px 16px',
175
- background: card.palette.badgeBg,
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
- {section}
143
+ {siteName}
180
144
  </span>
181
- </div>
182
- )}
145
+ )}
146
+ </div>
147
+ )}
183
148
 
184
- {/* Title and description */}
149
+ {/* Section badge */}
150
+ {section && (
185
151
  <div
186
152
  style={{
187
153
  display: 'flex',
188
- flex: 1,
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
- {description && (
207
- <p
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
- display: 'flex',
226
- height: '6px',
227
- background: card.palette.bar,
228
- borderRadius: '3px',
229
- marginTop: '40px',
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&section=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
+ }
@@ -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: `%s — ${config.name}`,
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
+ }
@@ -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
- export function isRenderableImageType(contentType: string): boolean {
138
- const type = contentType.split(';')[0].trim().toLowerCase();
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
- if (/^text\/[a-z+]+$/.test(type)) return false;
143
- return !UNRENDERABLE_IMAGE_TYPES.has(type);
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
  }
@@ -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