jamdesk 1.1.20 → 1.1.21

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.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=openapi-server-variable-description.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openapi-server-variable-description.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/unit/openapi-server-variable-description.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Regression guard for the @apidevtools/openapi-schemas@2.1.0 typo in
3
+ * the OpenAPI 3.1 server-variable schema (`descriptions` instead of
4
+ * `description`). The patcher at scripts/patch-openapi-schemas.js must
5
+ * have fixed the installed schema by the time this test runs.
6
+ *
7
+ * This test uses the REAL SwaggerParser (not a mock) so it actually
8
+ * exercises the bundled schema. If the patcher didn't run, or a future
9
+ * upstream release reintroduces the typo, this test fails.
10
+ */
11
+ import { describe, it } from 'vitest';
12
+ import SwaggerParser from '@apidevtools/swagger-parser';
13
+ describe('OpenAPI 3.1 server variable description (regression)', () => {
14
+ it('accepts `description` on a server variable (spec §4.7.10.1)', async () => {
15
+ const spec = {
16
+ openapi: '3.1.0',
17
+ info: { title: 'Regression Test', version: '1.0.0' },
18
+ servers: [
19
+ {
20
+ url: 'https://{project}.jamdesk.app',
21
+ description: 'Production (subdomain)',
22
+ variables: {
23
+ project: {
24
+ default: 'your-project',
25
+ description: 'Your Jamdesk project slug',
26
+ },
27
+ },
28
+ },
29
+ ],
30
+ paths: {},
31
+ };
32
+ // Should not throw. If the schema still has `descriptions` (typo), SwaggerParser
33
+ // rejects `description` as an unevaluated property.
34
+ await SwaggerParser.validate(spec);
35
+ });
36
+ it('accepts a server variable without description (sanity check)', async () => {
37
+ const spec = {
38
+ openapi: '3.1.0',
39
+ info: { title: 'Regression Test', version: '1.0.0' },
40
+ servers: [
41
+ {
42
+ url: 'https://{project}.jamdesk.app',
43
+ variables: {
44
+ project: {
45
+ default: 'your-project',
46
+ },
47
+ },
48
+ },
49
+ ],
50
+ paths: {},
51
+ };
52
+ await SwaggerParser.validate(spec);
53
+ });
54
+ });
55
+ //# sourceMappingURL=openapi-server-variable-description.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openapi-server-variable-description.test.js","sourceRoot":"","sources":["../../../src/__tests__/unit/openapi-server-variable-description.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,aAAa,MAAM,6BAA6B,CAAC;AAExD,QAAQ,CAAC,sDAAsD,EAAE,GAAG,EAAE;IACpE,EAAE,CAAC,6DAA6D,EAAE,KAAK,IAAI,EAAE;QAC3E,MAAM,IAAI,GAAG;YACX,OAAO,EAAE,OAAO;YAChB,IAAI,EAAE,EAAE,KAAK,EAAE,iBAAiB,EAAE,OAAO,EAAE,OAAO,EAAE;YACpD,OAAO,EAAE;gBACP;oBACE,GAAG,EAAE,+BAA+B;oBACpC,WAAW,EAAE,wBAAwB;oBACrC,SAAS,EAAE;wBACT,OAAO,EAAE;4BACP,OAAO,EAAE,cAAc;4BACvB,WAAW,EAAE,2BAA2B;yBACzC;qBACF;iBACF;aACF;YACD,KAAK,EAAE,EAAE;SACV,CAAC;QACF,iFAAiF;QACjF,oDAAoD;QACpD,MAAM,aAAa,CAAC,QAAQ,CAAC,IAAa,CAAC,CAAC;IAC9C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,8DAA8D,EAAE,KAAK,IAAI,EAAE;QAC5E,MAAM,IAAI,GAAG;YACX,OAAO,EAAE,OAAO;YAChB,IAAI,EAAE,EAAE,KAAK,EAAE,iBAAiB,EAAE,OAAO,EAAE,OAAO,EAAE;YACpD,OAAO,EAAE;gBACP;oBACE,GAAG,EAAE,+BAA+B;oBACpC,SAAS,EAAE;wBACT,OAAO,EAAE;4BACP,OAAO,EAAE,cAAc;yBACxB;qBACF;iBACF;aACF;YACD,KAAK,EAAE,EAAE;SACV,CAAC;QACF,MAAM,aAAa,CAAC,QAAQ,CAAC,IAAa,CAAC,CAAC;IAC9C,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.20",
3
+ "version": "1.1.21",
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",
@@ -59,6 +59,7 @@
59
59
  "files": [
60
60
  "bin/",
61
61
  "dist/",
62
+ "scripts/patch-openapi-schemas.js",
62
63
  "templates/",
63
64
  "vendored/components/",
64
65
  "vendored/contexts/",
@@ -91,7 +92,8 @@
91
92
  "test": "vitest",
92
93
  "test:local": "node scripts/test-local.js",
93
94
  "lint": "eslint src/",
94
- "dev": "tsc --watch"
95
+ "dev": "tsc --watch",
96
+ "postinstall": "node scripts/patch-openapi-schemas.js"
95
97
  },
96
98
  "dependencies": {
97
99
  "@apidevtools/swagger-parser": "^12.1.0",
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Patch a typo in @apidevtools/openapi-schemas@2.1.0.
4
+ *
5
+ * The bundled OpenAPI 3.1 meta-schema defines `server-variable.properties`
6
+ * as `{ enum, default, descriptions }` — but the OpenAPI 3.1 spec says the
7
+ * third property is `description` (singular). Combined with
8
+ * `unevaluatedProperties: false` on the same definition, any spec-valid
9
+ * `description` field on a server variable is rejected with:
10
+ *
11
+ * #/servers/0/variables/<name> must NOT have unevaluated properties
12
+ *
13
+ * We swap the typo at install time so swagger-parser accepts the spec.
14
+ * Idempotent — safe to run multiple times. Deletes itself cleanly once
15
+ * upstream ships a fix:
16
+ *
17
+ * https://github.com/APIDevTools/openapi-schemas
18
+ *
19
+ * This file runs as an npm `postinstall` script in the package root, so
20
+ * `__dirname` is always `<package>/scripts/` and `node_modules` resolves
21
+ * one level up.
22
+ */
23
+
24
+ import fs from 'fs';
25
+ import path from 'path';
26
+ import { fileURLToPath } from 'url';
27
+
28
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
+
30
+ const schemaPath = path.join(
31
+ __dirname,
32
+ '..',
33
+ 'node_modules',
34
+ '@apidevtools',
35
+ 'openapi-schemas',
36
+ 'schemas',
37
+ 'v3.1',
38
+ 'schema.json'
39
+ );
40
+
41
+ // If the dep isn't installed (e.g., production install with --omit=dev and
42
+ // swagger-parser moved to devDependencies), silently no-op.
43
+ if (!fs.existsSync(schemaPath)) {
44
+ process.exit(0);
45
+ }
46
+
47
+ let schema;
48
+ try {
49
+ schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
50
+ } catch (err) {
51
+ console.warn(
52
+ '[jamdesk] patch-openapi-schemas: failed to parse schema, skipping:',
53
+ err.message
54
+ );
55
+ process.exit(0);
56
+ }
57
+
58
+ const defs = schema && schema.$defs;
59
+ const serverVariable = defs && defs['server-variable'];
60
+ const props = serverVariable && serverVariable.properties;
61
+
62
+ if (!props) {
63
+ // Schema shape changed upstream — bail out quietly.
64
+ process.exit(0);
65
+ }
66
+
67
+ if (props.description) {
68
+ // Already patched (or upstream fixed it). Either way, no work to do.
69
+ process.exit(0);
70
+ }
71
+
72
+ if (!props.descriptions) {
73
+ // No typo to fix. Quietly no-op.
74
+ process.exit(0);
75
+ }
76
+
77
+ props.description = props.descriptions;
78
+ delete props.descriptions;
79
+
80
+ try {
81
+ fs.writeFileSync(schemaPath, JSON.stringify(schema, null, 2));
82
+ } catch (err) {
83
+ console.warn(
84
+ '[jamdesk] patch-openapi-schemas: failed to write patch, skipping:',
85
+ err.message
86
+ );
87
+ process.exit(0);
88
+ }
89
+ console.log(
90
+ '[jamdesk] patched openapi-schemas 3.1 server-variable typo (descriptions → description)'
91
+ );
@@ -47,7 +47,8 @@ import { mdxSecurityOptions } from '@/lib/mdx-security-options';
47
47
  import fs from 'fs';
48
48
  import path from 'path';
49
49
  import matter from 'gray-matter';
50
- import { getDocsConfig, getContentDir } from '@/lib/docs';
50
+ import { getContentDir } from '@/lib/docs';
51
+ import type { DocsConfig } from '@/lib/docs-types';
51
52
  import { buildSeoMetadata, generateAutoDescription, buildSiteTitle } from '@/lib/seo';
52
53
  import { buildJsonLd } from '@/lib/json-ld';
53
54
  import {
@@ -182,7 +183,7 @@ function getAllDocPaths(): string[] {
182
183
  /**
183
184
  * Find the first page in navigation (used to resolve empty root slug in place).
184
185
  */
185
- function findFirstPage(config: ReturnType<typeof getDocsConfig>): string {
186
+ function findFirstPage(config: DocsConfig): string {
186
187
  const navigation = config.navigation;
187
188
 
188
189
  // Helper to extract page path from a page entry
@@ -339,9 +340,10 @@ export async function generateMetadata({ params }: PageProps) {
339
340
  // Normalize slug: strip /docs prefix when hostAtDocs=true.
340
341
  // Empty root → resolve to first page (see DocPage for the full rationale).
341
342
  const normalizedSlug = normalizeSlugForContent(resolvedParams.slug || [], hostAtDocs);
342
- const slug = normalizedSlug.length > 0
343
- ? normalizedSlug
344
- : pathToSlug(findFirstPage(await loader.getConfig()));
343
+ const isRoot = normalizedSlug.length === 0;
344
+ const slug = isRoot
345
+ ? pathToSlug(findFirstPage(await loader.getConfig()))
346
+ : normalizedSlug;
345
347
  const pagePath = slug.join('/');
346
348
 
347
349
  // Fetch content and config in parallel
@@ -381,7 +383,7 @@ export async function generateMetadata({ params }: PageProps) {
381
383
  ...seoMetadata,
382
384
  // Root serves first-page content but canonical points at /{firstPage};
383
385
  // noindex as a second dedup signal alongside the canonical tag.
384
- ...(normalizedSlug.length === 0 && { robots: { index: false, follow: true } }),
386
+ ...(isRoot && { robots: { index: false, follow: true } }),
385
387
  ...(data.rss ? {
386
388
  alternates: {
387
389
  ...seoMetadata.alternates,
@@ -420,15 +422,13 @@ export default async function DocPage({ params }: PageProps) {
420
422
  const loader = getContentLoader(projectSlug ?? undefined);
421
423
 
422
424
  // Normalize slug: strip /docs prefix when hostAtDocs=true.
423
- // Empty root request → render the first page in place instead of 307'ing
424
- // to /{firstPage} (Lighthouse flagged ~1.5s mobile; Next's redirect() emits
425
- // cache-control: private so CDNs can't absorb it). Canonical URL still
426
- // points at /{firstPage} via buildSeoMetadata, and generateMetadata
427
- // noindexes the root to prevent duplicate indexing.
425
+ // Empty root renders the first page in place rather than 307'ing — Next's
426
+ // redirect() emits cache-control: private, blocking CDN caching. Canonical
427
+ // + noindex in generateMetadata prevent duplicate indexing.
428
428
  const normalizedSlug = normalizeSlugForContent(resolvedParams.slug || [], hostAtDocs);
429
- const slug = normalizedSlug.length > 0
430
- ? normalizedSlug
431
- : pathToSlug(findFirstPage(await loader.getConfig()));
429
+ const slug = normalizedSlug.length === 0
430
+ ? pathToSlug(findFirstPage(await loader.getConfig()))
431
+ : normalizedSlug;
432
432
  const pagePath = slug.join('/');
433
433
  const [fileContents, config] = await Promise.all([
434
434
  loader.getContent(pagePath).catch(() => null),
@@ -10,9 +10,6 @@ import { trackSearch } from '@/lib/analytics-client';
10
10
  import { useLinkPrefix } from '@/lib/link-prefix-context';
11
11
  import { useProjectSlug } from '@/lib/project-slug-context';
12
12
 
13
- // Build-time env var for client-side configuration
14
- const projectSlug = process.env.NEXT_PUBLIC_PROJECT_SLUG || 'default';
15
-
16
13
  interface SearchResult {
17
14
  id: string;
18
15
  title: string;
@@ -104,7 +101,7 @@ function NoResultsState({ query, onSuggestionClick }: { query: string; onSuggest
104
101
 
105
102
  export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: SearchModalProps) {
106
103
  const linkPrefix = useLinkPrefix();
107
- const analyticsProjectSlug = useProjectSlug();
104
+ const projectSlug = useProjectSlug();
108
105
  const [query, setQuery] = useState('');
109
106
  const [results, setResults] = useState<SearchResult[]>([]);
110
107
  const [selectedIndex, setSelectedIndex] = useState(0);
@@ -144,7 +141,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
144
141
  useEffect(() => {
145
142
  if (!isOpen && lastSearchRef.current && !hasTrackedRef.current) {
146
143
  // Modal closed with an untracked search - track it now
147
- trackSearch(analyticsProjectSlug, {
144
+ trackSearch(projectSlug, {
148
145
  type: 'search_query',
149
146
  query: lastSearchRef.current.query,
150
147
  resultsCount: lastSearchRef.current.resultsCount,
@@ -155,15 +152,19 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
155
152
  hasTrackedRef.current = false;
156
153
  lastSearchRef.current = null;
157
154
  }
158
- }, [isOpen, analyticsProjectSlug]);
155
+ }, [isOpen, projectSlug]);
159
156
 
160
- // Load search data and recent searches on mount
157
+ // Load recent searches (depends on projectSlug from context, kept separate
158
+ // so search-data init doesn't re-fetch when the slug would hypothetically change)
161
159
  useEffect(() => {
162
160
  if (isOpen) {
163
- // Load recent searches
164
161
  setRecentSearches(getRecentSearches(projectSlug));
162
+ }
163
+ }, [isOpen, projectSlug]);
165
164
 
166
- // Initialize search
165
+ // Load search data on mount
166
+ useEffect(() => {
167
+ if (isOpen) {
167
168
  setIsInitializing(true);
168
169
  setInitError(null);
169
170
 
@@ -265,7 +266,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
265
266
  else if (query.trim() && results.length === 0 && !isSearching && e.key === 'Enter') {
266
267
  e.preventDefault();
267
268
  // Track this as a committed search with zero results
268
- trackSearch(analyticsProjectSlug, {
269
+ trackSearch(projectSlug, {
269
270
  type: 'search_query',
270
271
  query: query.trim(),
271
272
  resultsCount: 0,
@@ -303,19 +304,19 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
303
304
 
304
305
  document.addEventListener('keydown', handleKeyDown);
305
306
  return () => document.removeEventListener('keydown', handleKeyDown);
306
- }, [isOpen, query, recentSearches, results, selectedIndex, effectivePopularPages, popularIndex, router, onClose, onNavigate, analyticsProjectSlug]);
307
+ }, [isOpen, query, recentSearches, results, selectedIndex, effectivePopularPages, popularIndex, router, onClose, onNavigate, projectSlug]);
307
308
 
308
309
  const handleResultClick = (result: SearchResult, index: number) => {
309
310
  if (query.trim()) {
310
311
  // Track search_query event (the search itself)
311
- trackSearch(analyticsProjectSlug, {
312
+ trackSearch(projectSlug, {
312
313
  type: 'search_query',
313
314
  query: query.trim(),
314
315
  resultsCount: results.length,
315
316
  });
316
317
 
317
318
  // Track search_click event (the result they clicked)
318
- trackSearch(analyticsProjectSlug, {
319
+ trackSearch(projectSlug, {
319
320
  type: 'search_click',
320
321
  query: query.trim(),
321
322
  resultsCount: results.length,
@@ -1,14 +1,9 @@
1
- // Search Analytics Client
2
- // Tracks search queries and clicks for analytics dashboard.
3
- //
4
- // Endpoint is /_jd/search-ev (not /api/search-ev) so that sites fronted by
5
- // the jamdesk.com Cloudflare Worker — which only proxies /_jd/* — route the
6
- // request to the ISR app. Middleware rewrites /_jd/search-ev to /api/search-ev.
1
+ // Endpoint is /_jd/search-ev (not /api/search-ev) so sites fronted by the
2
+ // jamdesk.com Cloudflare Worker which only proxies /_jd/* — reach the ISR
3
+ // app. Middleware rewrites /_jd/search-ev to /api/search-ev.
7
4
 
8
5
  const ANALYTICS_URL = '/_jd/search-ev';
9
6
 
10
- // Reuse the same session ID as the pageview tracking script (localStorage with 30-min inactivity timeout).
11
- // Every call refreshes the timestamp, so active users keep the same session.
12
7
  const SESSION_TIMEOUT_MS = 1800000; // 30 minutes
13
8
 
14
9
  function getSessionId(): string {
@@ -29,7 +24,6 @@ function getSessionId(): string {
29
24
  return sessionId;
30
25
  }
31
26
 
32
- // Use separate event types to avoid double-counting
33
27
  interface SearchQueryEvent {
34
28
  type: 'search_query';
35
29
  query: string;
@@ -50,20 +44,11 @@ interface SearchClickEvent {
50
44
  type SearchEvent = SearchQueryEvent | SearchClickEvent;
51
45
 
52
46
  /**
53
- * Track a search analytics event.
54
- *
55
- * The project slug MUST be supplied at runtime (typically via `useProjectSlug()`
56
- * from `lib/project-slug-context`). Build-time env vars are not usable because
57
- * the ISR deployment is multi-tenant and NEXT_PUBLIC_* vars are baked per-build.
58
- *
59
- * Fire-and-forget: returns immediately. The fetch runs in the background with
60
- * `keepalive: true` so it survives page navigation. Errors are silently ignored.
47
+ * Fire-and-forget. The slug must be supplied at runtime (via `useProjectSlug()`)
48
+ * because `NEXT_PUBLIC_*` vars are baked per-build and the ISR app is multi-tenant.
61
49
  */
62
50
  export function trackSearch(projectSlug: string, event: SearchEvent): void {
63
- // Don't track in development
64
51
  if (process.env.NODE_ENV === 'development') return;
65
-
66
- // Empty slug means we couldn't resolve the project (e.g., non-ISR fallback) — no-op
67
52
  if (!projectSlug) return;
68
53
 
69
54
  try {
@@ -79,10 +64,6 @@ export function trackSearch(projectSlug: string, event: SearchEvent): void {
79
64
  headers: { 'Content-Type': 'application/json' },
80
65
  body: payload,
81
66
  keepalive: true,
82
- }).catch(() => {
83
- // Silent fail - analytics should never break the app
84
- });
85
- } catch {
86
- // Silent fail - analytics should never break the app
87
- }
67
+ }).catch(() => {});
68
+ } catch {}
88
69
  }
@@ -2,16 +2,10 @@
2
2
 
3
3
  import { createContext, useContext, type ReactNode } from 'react';
4
4
 
5
- /**
6
- * Context for exposing the resolved project slug to client components.
7
- *
8
- * The value is populated in `app/layout.tsx` from the `x-project-slug`
9
- * request header (set by middleware). Empty string means "unknown" — clients
10
- * should treat this as a signal to no-op (e.g. skip analytics).
11
- *
12
- * Why this exists: `NEXT_PUBLIC_PROJECT_SLUG` is inlined at build time and is
13
- * unset in the multi-tenant ISR deployment, so client code cannot read it.
14
- */
5
+ // `NEXT_PUBLIC_PROJECT_SLUG` is inlined at build time and unset in the
6
+ // multi-tenant ISR deployment, so the slug has to flow through context.
7
+ // `layout.tsx` seeds the value from the `x-project-slug` header set by
8
+ // middleware. Empty string means "unknown" consumers should no-op.
15
9
  const ProjectSlugContext = createContext<string>('');
16
10
 
17
11
  export function ProjectSlugProvider({
@@ -28,9 +22,6 @@ export function ProjectSlugProvider({
28
22
  );
29
23
  }
30
24
 
31
- /**
32
- * Hook returning the resolved project slug, or '' when unavailable.
33
- */
34
25
  export function useProjectSlug(): string {
35
26
  return useContext(ProjectSlugContext);
36
27
  }