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 +1 -1
- package/vendored/app/[[...slug]]/page.tsx +31 -21
- package/vendored/components/layout/LayoutWrapper.tsx +1 -1
- package/vendored/lib/isr-build-executor.ts +93 -9
- package/vendored/lib/openapi/classify-load-error.ts +19 -0
- package/vendored/lib/ui-strings.ts +8 -6
- package/vendored/shared/status-reporter.ts +1 -1
- package/vendored/themes/jam/variables.css +7 -7
- package/vendored/themes/nebula/variables.css +7 -7
- package/vendored/themes/pulsar/variables.css +7 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jamdesk",
|
|
3
|
-
"version": "1.1.
|
|
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
|
|
268
|
-
const slug =
|
|
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
|
|
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
|
|
349
|
-
const slug =
|
|
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
|
|
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
|
-
|
|
529
|
+
lastFailure = null;
|
|
517
530
|
break;
|
|
518
531
|
} catch (err) {
|
|
519
|
-
|
|
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 (
|
|
532
|
-
throw
|
|
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
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
785
|
+
--sidebar-item-spacing: 3px;
|
|
786
786
|
}
|
|
787
787
|
|
|
788
788
|
.sidebar-scroll ul li a {
|
|
789
|
-
padding-top:
|
|
790
|
-
padding-bottom:
|
|
791
|
-
min-height:
|
|
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:
|
|
796
|
-
padding-bottom:
|
|
797
|
-
min-height:
|
|
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:
|
|
144
|
+
--sidebar-item-spacing: 3px;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
.sidebar-scroll ul li a {
|
|
148
|
-
padding-top:
|
|
149
|
-
padding-bottom:
|
|
150
|
-
min-height:
|
|
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:
|
|
155
|
-
padding-bottom:
|
|
156
|
-
min-height:
|
|
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:
|
|
899
|
+
--sidebar-item-spacing: 3px;
|
|
900
900
|
}
|
|
901
901
|
|
|
902
902
|
.sidebar-scroll ul li a {
|
|
903
|
-
padding-top:
|
|
904
|
-
padding-bottom:
|
|
905
|
-
min-height:
|
|
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:
|
|
910
|
-
padding-bottom:
|
|
911
|
-
min-height:
|
|
909
|
+
padding-top: 7px !important;
|
|
910
|
+
padding-bottom: 7px !important;
|
|
911
|
+
min-height: 36px;
|
|
912
912
|
}
|
|
913
913
|
}
|
|
914
914
|
|