jamdesk 1.1.217 → 1.1.218

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.217",
3
+ "version": "1.1.218",
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,7 @@
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 } from '@/lib/og-card';
4
+ import { parseCardOptions, isRenderableImageType } from '@/lib/og-card';
5
5
 
6
6
  export const runtime = 'edge';
7
7
 
@@ -18,7 +18,7 @@ function truncateText(text: string, maxLength: number): string {
18
18
 
19
19
  /**
20
20
  * Fetch an image and convert it to a base64 data URI for Satori compatibility
21
- * (Satori doesn't support webp and can fail on remote URLs). 3s timeout; '' on failure.
21
+ * (Satori can fail on remote URLs). 3s timeout; '' on failure or on a type Satori can't decode.
22
22
  */
23
23
  async function fetchDataUri(url: string): Promise<string> {
24
24
  const controller = new AbortController();
@@ -28,6 +28,8 @@ async function fetchDataUri(url: string): Promise<string> {
28
28
  const res = await fetch(url, { signal: controller.signal, redirect: 'manual' });
29
29
  if (!res.ok) return '';
30
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 '';
31
33
  const buf = await res.arrayBuffer();
32
34
  return `data:${contentType};base64,${Buffer.from(buf).toString('base64')}`;
33
35
  } catch {
@@ -117,3 +117,28 @@ 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
+ // Non-standard spellings of JPEG that real servers send; Satori only knows image/jpeg.
134
+ 'image/jpg', 'image/pjpeg',
135
+ ]);
136
+
137
+ export function isRenderableImageType(contentType: string): boolean {
138
+ const type = contentType.split(';')[0].trim().toLowerCase();
139
+ // A 200 HTML page for a missing image (soft 404) is the realistic non-image case.
140
+ // Only plain-letter subtypes throw (html, plain, xml, css, svg+xml); text/x-generic,
141
+ // 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);
144
+ }
@@ -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
- logoPath = `${rootUrl}${logoPath.startsWith('/') ? '' : '/'}${logoPath}`;
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);