jamdesk 1.1.30 → 1.1.32

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.30",
3
+ "version": "1.1.32",
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",
@@ -70,10 +70,10 @@ import {
70
70
  generateCodeExamples,
71
71
  formatOpenApiWarning,
72
72
  type OpenApiEndpointData,
73
- type OpenApiValidationError,
74
73
  type CodeExample,
75
74
  type AuthMethod,
76
75
  } from '@/lib/openapi';
76
+ import { classifyOpenApiLoadError } from '@/lib/openapi/classify-load-error';
77
77
  import { extractLanguageFromPath, isValidLanguageCode } from '@/lib/language-utils';
78
78
  import { findFirstNavPage } from '@/lib/find-first-nav-page';
79
79
  import { candidateSpecPaths } from '@/lib/openapi/lang-spec-path';
@@ -192,6 +192,10 @@ function findFirstPage(config: DocsConfig, lang?: string): string {
192
192
  return result ? result.replace(/^\//, '') : 'introduction';
193
193
  }
194
194
 
195
+ function needsSlugRewrite(slug: string[]): boolean {
196
+ return slug.length === 0 || (slug.length === 1 && isValidLanguageCode(slug[0]));
197
+ }
198
+
195
199
  function resolveSlug(normalizedSlug: string[], config: DocsConfig): string[] {
196
200
  if (normalizedSlug.length === 0) return pathToSlug(findFirstPage(config));
197
201
  if (normalizedSlug.length === 1 && isValidLanguageCode(normalizedSlug[0])) {
@@ -264,12 +268,17 @@ export async function generateMetadata({ params }: PageProps) {
264
268
  // Normalize slug: strip /docs prefix when hostAtDocs=true.
265
269
  // Empty root → resolve to first page (see DocPage for the full rationale).
266
270
  const normalizedSlug = normalizeSlugForContent(resolvedParams.slug || [], hostAtDocs);
267
- const config = await loader.getConfig();
268
- const slug = resolveSlug(normalizedSlug, config);
271
+ const configP = loader.getConfig();
272
+ const slug = needsSlugRewrite(normalizedSlug)
273
+ ? resolveSlug(normalizedSlug, await configP)
274
+ : normalizedSlug;
269
275
  const isRoot = normalizedSlug.length === 0;
270
276
  const pagePath = slug.join('/');
271
277
 
272
- const fileContents = await loader.getContent(pagePath).catch(() => null);
278
+ const [fileContents, config] = await Promise.all([
279
+ loader.getContent(pagePath).catch(() => null),
280
+ configP,
281
+ ]);
273
282
 
274
283
  if (!fileContents) {
275
284
  return {
@@ -345,11 +354,16 @@ export default async function DocPage({ params }: PageProps) {
345
354
  // redirect() emits cache-control: private, blocking CDN caching. Canonical
346
355
  // + noindex in generateMetadata prevent duplicate indexing.
347
356
  const normalizedSlug = normalizeSlugForContent(resolvedParams.slug || [], hostAtDocs);
348
- const config = await loader.getConfig();
349
- const slug = resolveSlug(normalizedSlug, config);
357
+ const configP = loader.getConfig();
358
+ const slug = needsSlugRewrite(normalizedSlug)
359
+ ? resolveSlug(normalizedSlug, await configP)
360
+ : normalizedSlug;
350
361
  const pagePath = slug.join('/');
351
362
  const currentLang = extractLanguageFromPath(`/${pagePath}`);
352
- const fileContents = await loader.getContent(pagePath).catch(() => null);
363
+ const [fileContents, config] = await Promise.all([
364
+ loader.getContent(pagePath).catch(() => null),
365
+ configP,
366
+ ]);
353
367
 
354
368
  // Check if content exists (getContent returns null via catch if not found)
355
369
  if (!fileContents) {
@@ -466,6 +480,7 @@ export default async function DocPage({ params }: PageProps) {
466
480
  let openApiError: string | null = null;
467
481
 
468
482
  // OpenAPI spec parsing - supports both static and ISR modes
483
+ let lastFailure: { err: unknown; specPath: string } | null = null;
469
484
  if (data.openapi && typeof data.openapi === 'string') {
470
485
  try {
471
486
  // Normalize config to array (handles string, array, or undefined)
@@ -496,8 +511,6 @@ export default async function DocPage({ params }: PageProps) {
496
511
  : null;
497
512
  const contentDir = useIsr ? null : getContentDir();
498
513
 
499
- let lastError: unknown = null;
500
-
501
514
  for (let i = 0; i < specsToTry.length; i++) {
502
515
  const specPath = specsToTry[i];
503
516
  try {
@@ -513,10 +526,10 @@ export default async function DocPage({ params }: PageProps) {
513
526
  const { api } = await getCachedSpec(specPath, contentDir!);
514
527
  openApiEndpointData = parseEndpoint(api, parsed.method, parsed.path, specPath);
515
528
  }
516
- lastError = null;
529
+ lastFailure = null;
517
530
  break;
518
531
  } catch (err) {
519
- lastError = err;
532
+ lastFailure = { err, specPath };
520
533
  const isLast = i === specsToTry.length - 1;
521
534
  if (!isLast) {
522
535
  // Lang variant (or intermediate candidate) failed — log so we
@@ -528,8 +541,8 @@ export default async function DocPage({ params }: PageProps) {
528
541
  }
529
542
  }
530
543
 
531
- if (lastError) {
532
- throw lastError;
544
+ if (lastFailure) {
545
+ throw lastFailure.err;
533
546
  }
534
547
 
535
548
  // Generate code examples
@@ -539,14 +552,11 @@ export default async function DocPage({ params }: PageProps) {
539
552
  openApiCodeExamples = generateCodeExamples(openApiEndpointData, { authMethod, languages });
540
553
  }
541
554
  } catch (err) {
542
- const error = err as Partial<OpenApiValidationError>;
543
- if (error.type && error.specPath && error.message) {
544
- console.warn(formatOpenApiWarning(error as OpenApiValidationError));
545
- openApiError = error.message;
546
- } else {
547
- console.error(`Failed to parse OpenAPI for ${slug.join('/')}:`, err);
548
- openApiError = 'Unexpected error loading OpenAPI specification';
549
- }
555
+ const typed = classifyOpenApiLoadError(err, lastFailure?.specPath ?? null);
556
+ console.warn(formatOpenApiWarning(typed));
557
+ openApiError = typed.suggestion
558
+ ? `${typed.message} — ${typed.suggestion}`
559
+ : typed.message;
550
560
  }
551
561
  }
552
562
 
@@ -107,7 +107,7 @@ export function LayoutWrapper({ config, children }: LayoutWrapperProps) {
107
107
  onClose={closeSidebar}
108
108
  />
109
109
 
110
- <div className="flex-1 lg:ml-[295px] flex flex-col lg:h-screen bg-[var(--color-bg-content,var(--color-bg-primary))]">
110
+ <div className="flex-1 min-w-0 lg:ml-[295px] flex flex-col lg:h-screen bg-[var(--color-bg-content,var(--color-bg-primary))]">
111
111
  <Header
112
112
  config={config}
113
113
  layout={layout}
@@ -10,6 +10,7 @@
10
10
  import fs from 'fs';
11
11
  import path from 'path';
12
12
  import glob from 'fast-glob';
13
+ import { isPathWithinProject } from '../shared/path-security.js';
13
14
  import type { DocsConfig } from './docs-types.js';
14
15
 
15
16
  /**
@@ -95,17 +96,100 @@ export async function collectSnippetFiles(projectDir: string): Promise<string[]>
95
96
  return files;
96
97
  }
97
98
 
98
- /** Collect OpenAPI spec files from project directory. Returns paths relative to openapi/. */
99
- export async function collectOpenApiFiles(projectDir: string): Promise<string[]> {
100
- const openapiDir = `${projectDir}/openapi`;
101
- if (!fs.existsSync(openapiDir)) return [];
99
+ /**
100
+ * An OpenAPI spec file resolved for upload.
101
+ *
102
+ * `key` is the R2 key suffix (after `{slug}/`) it must match what the ISR
103
+ * fetcher computes from the docs.json path so upload and fetch stay in sync.
104
+ * `localPath` is the absolute file path on disk.
105
+ */
106
+ export interface OpenApiFileRef {
107
+ key: string;
108
+ localPath: string;
109
+ }
102
110
 
103
- const files = await glob('**/*.{yaml,yml,json}', {
104
- cwd: openapiDir,
105
- ignore: ['node_modules/**'],
106
- });
111
+ /**
112
+ * Collect OpenAPI spec files to upload for a project.
113
+ *
114
+ * Resolution strategy (symmetric with the ISR fetcher in `r2-content.ts`):
115
+ * 1. For each path in `docsConfig.api.openapi`, normalize it (strip leading
116
+ * `/`), then look for the file at `projectDir/<normalized>`. If not
117
+ * found, fall back to `projectDir/openapi/<normalized>` so short-name
118
+ * refs like `"foo.yaml"` keep working. Files are uploaded to R2 at
119
+ * `{slug}/<normalized>` regardless of disk location so the fetcher's
120
+ * `{slug}/<normalized>` lookup succeeds.
121
+ * 2. Additionally, scan `projectDir/openapi/**` and upload anything there
122
+ * at `{slug}/openapi/<relpath>`. Preserves the legacy convention where
123
+ * full-form MDX frontmatter (`/openapi/foo.yaml GET /pet`) works even
124
+ * when docs.json omits the ref.
125
+ *
126
+ * Missing referenced files are logged via `onMissing` (caller decides how
127
+ * to surface — build log, warning email, etc.) but never fail the build.
128
+ * HTTP(S) refs are skipped; they are fetched by the ISR renderer directly.
129
+ */
130
+ export interface CollectOpenApiFilesOptions {
131
+ openapiRefs?: readonly string[];
132
+ onMissing?: (ref: string, searchedPaths: readonly string[]) => void;
133
+ }
107
134
 
108
- return files;
135
+ export async function collectOpenApiFiles(
136
+ projectDir: string,
137
+ options: CollectOpenApiFilesOptions = {}
138
+ ): Promise<OpenApiFileRef[]> {
139
+ const { openapiRefs = [], onMissing } = options;
140
+ const results: OpenApiFileRef[] = [];
141
+ const seenKeys = new Set<string>();
142
+
143
+ // docs.json is user-authored; `isPathWithinProject` rejects `..` traversal,
144
+ // null bytes, and URL-encoded separators. Does NOT follow symlinks — any
145
+ // leak still lands in the customer's own R2 prefix, so blast radius is
146
+ // self-contained.
147
+
148
+ // Resolve refs from docs.json.
149
+ for (const ref of openapiRefs) {
150
+ if (!ref) continue;
151
+ if (/^https?:\/\//i.test(ref)) continue; // URLs are fetched by the renderer.
152
+
153
+ const normalized = ref.replace(/^\//, '');
154
+ if (!normalized) continue;
155
+
156
+ const primary = path.join(projectDir, normalized);
157
+ const legacy = path.join(projectDir, 'openapi', normalized);
158
+ let localPath: string | null = null;
159
+ if (isPathWithinProject(primary, projectDir) && fs.existsSync(primary)) {
160
+ localPath = primary;
161
+ } else if (isPathWithinProject(legacy, projectDir) && fs.existsSync(legacy)) {
162
+ localPath = legacy;
163
+ }
164
+
165
+ if (!localPath) {
166
+ onMissing?.(ref, [primary, legacy]);
167
+ continue;
168
+ }
169
+
170
+ if (!seenKeys.has(normalized)) {
171
+ seenKeys.add(normalized);
172
+ results.push({ key: normalized, localPath });
173
+ }
174
+ }
175
+
176
+ // Legacy scan: anything under `openapi/` gets uploaded at its relative
177
+ // path. Preserves full-form MDX refs that don't list the spec in docs.json.
178
+ const openapiDir = path.join(projectDir, 'openapi');
179
+ if (fs.existsSync(openapiDir)) {
180
+ const scanned = await glob('**/*.{yaml,yml,json}', {
181
+ cwd: openapiDir,
182
+ ignore: ['node_modules/**'],
183
+ });
184
+ for (const rel of scanned) {
185
+ const key = `openapi/${rel}`;
186
+ if (seenKeys.has(key)) continue;
187
+ seenKeys.add(key);
188
+ results.push({ key, localPath: path.join(openapiDir, rel) });
189
+ }
190
+ }
191
+
192
+ return results;
109
193
  }
110
194
 
111
195
  /**
@@ -0,0 +1,19 @@
1
+ import { isR2NotFound } from '../r2-content';
2
+ import { createFileNotFoundError, formatOpenApiError } from './errors';
3
+ import type { OpenApiValidationError } from './types';
4
+
5
+ /** Maps a raw load error to a typed OpenApiValidationError for the renderer. */
6
+ export function classifyOpenApiLoadError(
7
+ err: unknown,
8
+ specPath: string | null
9
+ ): OpenApiValidationError {
10
+ // `'specPath' in err` not truthiness — createFrontmatterError uses `specPath: ''`.
11
+ if (err && typeof err === 'object' && 'type' in err && 'message' in err && 'specPath' in err) {
12
+ return err as OpenApiValidationError;
13
+ }
14
+ const fallbackPath = specPath || 'unknown';
15
+ if (isR2NotFound(err)) {
16
+ return createFileNotFoundError(fallbackPath);
17
+ }
18
+ return formatOpenApiError(err, fallbackPath);
19
+ }
@@ -24,8 +24,7 @@ const ZH: Partial<UiStrings> = {
24
24
  selectLanguage: '选择语言',
25
25
  };
26
26
 
27
- const STRINGS: Partial<Record<LanguageCode, Partial<UiStrings>>> = {
28
- en: EN,
27
+ const OVERRIDES: Partial<Record<LanguageCode, Partial<UiStrings>>> = {
29
28
  fr: {
30
29
  search: 'Rechercher',
31
30
  askAi: "Demander à l'IA",
@@ -44,9 +43,12 @@ const STRINGS: Partial<Record<LanguageCode, Partial<UiStrings>>> = {
44
43
  cn: ZH,
45
44
  };
46
45
 
46
+ // Pre-merge so repeated calls return a stable object reference.
47
+ const MERGED: Partial<Record<LanguageCode, UiStrings>> = {};
48
+ for (const [code, overrides] of Object.entries(OVERRIDES) as [LanguageCode, Partial<UiStrings>][]) {
49
+ MERGED[code] = { ...EN, ...overrides };
50
+ }
51
+
47
52
  export function getUiStrings(lang: LanguageCode | undefined): UiStrings {
48
- if (!lang) return EN;
49
- const overrides = STRINGS[lang];
50
- if (!overrides) return EN;
51
- return { ...EN, ...overrides };
53
+ return (lang && MERGED[lang]) || EN;
52
54
  }
@@ -22,7 +22,7 @@ export interface ProgressUpdate {
22
22
  }
23
23
 
24
24
  /** Warning types that can occur during builds (non-blocking) */
25
- export type BuildWarningType = 'broken_link' | 'auto_migrate' | 'missing_asset' | 'missing_page';
25
+ export type BuildWarningType = 'broken_link' | 'auto_migrate' | 'missing_asset' | 'missing_page' | 'missing_openapi_ref';
26
26
 
27
27
  /** Build warning structure */
28
28
  export interface BuildWarning {
@@ -782,19 +782,19 @@ body[data-theme="jam"] .prose video {
782
782
  /* Phase 2: Touch Targets for Sidebar */
783
783
  @media (max-width: 1023px) {
784
784
  :root {
785
- --sidebar-item-spacing: 6px;
785
+ --sidebar-item-spacing: 3px;
786
786
  }
787
787
 
788
788
  .sidebar-scroll ul li a {
789
- padding-top: 10px !important;
790
- padding-bottom: 10px !important;
791
- min-height: 44px;
789
+ padding-top: 7px !important;
790
+ padding-bottom: 7px !important;
791
+ min-height: 36px;
792
792
  }
793
793
 
794
794
  .sidebar-scroll .nav-group-l1 {
795
- padding-top: 10px !important;
796
- padding-bottom: 10px !important;
797
- min-height: 44px;
795
+ padding-top: 7px !important;
796
+ padding-bottom: 7px !important;
797
+ min-height: 36px;
798
798
  }
799
799
  }
800
800
 
@@ -141,19 +141,19 @@
141
141
  /* Phase 2: Touch Targets for Sidebar */
142
142
  @media (max-width: 1023px) {
143
143
  :root {
144
- --sidebar-item-spacing: 6px;
144
+ --sidebar-item-spacing: 3px;
145
145
  }
146
146
 
147
147
  .sidebar-scroll ul li a {
148
- padding-top: 10px !important;
149
- padding-bottom: 10px !important;
150
- min-height: 44px;
148
+ padding-top: 7px !important;
149
+ padding-bottom: 7px !important;
150
+ min-height: 36px;
151
151
  }
152
152
 
153
153
  .sidebar-scroll .nav-group-l1 {
154
- padding-top: 10px !important;
155
- padding-bottom: 10px !important;
156
- min-height: 44px;
154
+ padding-top: 7px !important;
155
+ padding-bottom: 7px !important;
156
+ min-height: 36px;
157
157
  }
158
158
  }
159
159
 
@@ -896,19 +896,19 @@ body[data-theme="pulsar"] header [data-theme-toggle] {
896
896
  /* Phase 3: Touch Targets for Sidebar */
897
897
  @media (max-width: 1023px) {
898
898
  :root {
899
- --sidebar-item-spacing: 6px;
899
+ --sidebar-item-spacing: 3px;
900
900
  }
901
901
 
902
902
  .sidebar-scroll ul li a {
903
- padding-top: 10px !important;
904
- padding-bottom: 10px !important;
905
- min-height: 44px;
903
+ padding-top: 7px !important;
904
+ padding-bottom: 7px !important;
905
+ min-height: 36px;
906
906
  }
907
907
 
908
908
  .sidebar-scroll .nav-group-l1 {
909
- padding-top: 10px !important;
910
- padding-bottom: 10px !important;
911
- min-height: 44px;
909
+ padding-top: 7px !important;
910
+ padding-bottom: 7px !important;
911
+ min-height: 36px;
912
912
  }
913
913
  }
914
914