jamdesk 1.1.221 → 1.1.222

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 (56) hide show
  1. package/dist/__tests__/unit/custom-assets.test.js +157 -6
  2. package/dist/__tests__/unit/custom-assets.test.js.map +1 -1
  3. package/dist/commands/dev.d.ts.map +1 -1
  4. package/dist/commands/dev.js +26 -8
  5. package/dist/commands/dev.js.map +1 -1
  6. package/dist/lib/custom-assets.d.ts +17 -0
  7. package/dist/lib/custom-assets.d.ts.map +1 -1
  8. package/dist/lib/custom-assets.js +109 -1
  9. package/dist/lib/custom-assets.js.map +1 -1
  10. package/package.json +1 -1
  11. package/vendored/app/api/chat/[project]/route.ts +31 -7
  12. package/vendored/components/chat/ChatBrandIcon.tsx +17 -0
  13. package/vendored/components/chat/ChatEmptyState.tsx +5 -5
  14. package/vendored/components/chat/ChatPanel.tsx +10 -7
  15. package/vendored/components/chat/useChatLabel.ts +9 -0
  16. package/vendored/components/layout/LayoutWrapper.tsx +3 -0
  17. package/vendored/components/mdx/Mermaid.tsx +16 -3
  18. package/vendored/components/mdx/MermaidInner.tsx +272 -19
  19. package/vendored/components/mdx/MermaidZoomModal.tsx +87 -0
  20. package/vendored/components/mdx/ZoomableImage.tsx +8 -1
  21. package/vendored/components/navigation/Header.tsx +15 -10
  22. package/vendored/components/navigation/Sidebar.tsx +6 -3
  23. package/vendored/components/navigation/TabsNav.tsx +9 -2
  24. package/vendored/components/search/SearchModal.tsx +6 -3
  25. package/vendored/hooks/useChat.ts +2 -0
  26. package/vendored/hooks/useChatPanel.tsx +9 -2
  27. package/vendored/lib/anthropic-client.ts +7 -0
  28. package/vendored/lib/build/error-parser.ts +17 -0
  29. package/vendored/lib/build/extract-tarball.ts +1 -29
  30. package/vendored/lib/build/validate-no-symlink-escape.ts +100 -0
  31. package/vendored/lib/chat-branding.ts +26 -0
  32. package/vendored/lib/chat-prompt.ts +2 -2
  33. package/vendored/lib/docs-types.ts +20 -0
  34. package/vendored/lib/generate-starter-questions.ts +24 -6
  35. package/vendored/lib/github-git-auth.ts +47 -0
  36. package/vendored/lib/hedge-strip.ts +1 -1
  37. package/vendored/lib/isr-build-executor.ts +63 -12
  38. package/vendored/lib/layout-helpers.tsx +13 -6
  39. package/vendored/lib/link-rewriter.ts +1 -1
  40. package/vendored/lib/mcp-auth.ts +67 -0
  41. package/vendored/lib/openapi/cache.ts +5 -2
  42. package/vendored/lib/openapi/expand-spec-pages.ts +5 -3
  43. package/vendored/lib/openapi/safe-ref-resolvers.ts +455 -0
  44. package/vendored/lib/openapi/validator.ts +46 -9
  45. package/vendored/lib/openapi-isr.ts +24 -11
  46. package/vendored/lib/page-timestamps.ts +29 -6
  47. package/vendored/lib/query-rewriter.ts +7 -3
  48. package/vendored/lib/r2-manifest.ts +2 -0
  49. package/vendored/lib/runtime-flags.ts +54 -0
  50. package/vendored/schema/docs-schema.json +34 -0
  51. package/vendored/scripts/copy-files.cjs +90 -37
  52. package/vendored/scripts/openapi-safe-ref-resolvers.cjs +354 -0
  53. package/vendored/scripts/openapi-spec-loader.cjs +10 -5
  54. package/vendored/themes/base.css +48 -0
  55. package/vendored/workspace-package-lock.json +18 -18
  56. package/vendored/components/chat/ChatFAB.tsx +0 -36
@@ -332,6 +332,8 @@ export function useChat(endpoint = '/_chat'): {
332
332
  let errorMessage = 'Something went wrong. Please try again.';
333
333
  if (response.status === 429) {
334
334
  errorMessage = 'Too many requests. Please wait a moment and try again.';
335
+ } else if (response.status === 401) {
336
+ errorMessage = 'Your session has expired. Reload the page to sign in again.';
335
337
  } else if (response.status === 503) {
336
338
  errorMessage = 'AI chat is temporarily unavailable. Please try again later.';
337
339
  }
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
4
+ import type { ChatIconSpec } from '@/lib/docs-types';
4
5
  import { useDevOnlyNotice, DEV_FEATURE } from '@/components/ui/DevOnlyNotice';
5
6
 
6
7
  const MIN_WIDTH = 350;
@@ -16,6 +17,10 @@ interface ChatPanelContextValue {
16
17
  chatEnabled: boolean;
17
18
  chatEndpoint: string;
18
19
  starterQuestions?: string[];
20
+ /** Raw `chat.label` from docs.json; resolve with a per-site fallback via useChatLabel. */
21
+ chatLabel?: string;
22
+ /** Resolved `chat.icon`; absent means the default sparkles icon. */
23
+ chatIcon?: ChatIconSpec;
19
24
  /** Question queued by an external trigger (e.g. search "Ask AI"). Consumed once by ChatPanel. */
20
25
  pendingQuestion: string | null;
21
26
  /** Open the chat panel and queue `question` to be sent as the next message (append). */
@@ -31,13 +36,15 @@ interface ChatPanelProviderProps {
31
36
  chatEnabled: boolean;
32
37
  chatEndpoint: string;
33
38
  starterQuestions?: string[];
39
+ chatLabel?: string;
40
+ chatIcon?: ChatIconSpec;
34
41
  }
35
42
 
36
43
  function clampWidth(width: number): number {
37
44
  return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, width));
38
45
  }
39
46
 
40
- export function ChatPanelProvider({ children, chatEnabled, chatEndpoint, starterQuestions }: ChatPanelProviderProps) {
47
+ export function ChatPanelProvider({ children, chatEnabled, chatEndpoint, starterQuestions, chatLabel, chatIcon }: ChatPanelProviderProps) {
41
48
  const [isChatOpen, setIsChatOpen] = useState(false);
42
49
  const [chatWidth, setChatWidthRaw] = useState(DEFAULT_WIDTH);
43
50
  const [pendingQuestion, setPendingQuestion] = useState<string | null>(null);
@@ -102,7 +109,7 @@ export function ChatPanelProvider({ children, chatEnabled, chatEndpoint, starter
102
109
  }, [chatEnabled, isChatOpen, isDevMode]);
103
110
 
104
111
  return (
105
- <ChatPanelContext.Provider value={{ isChatOpen, setIsChatOpen, chatWidth, setChatWidth, chatEnabled, chatEndpoint, starterQuestions, pendingQuestion, askChat, consumePendingQuestion }}>
112
+ <ChatPanelContext.Provider value={{ isChatOpen, setIsChatOpen, chatWidth, setChatWidth, chatEnabled, chatEndpoint, starterQuestions, chatLabel, chatIcon, pendingQuestion, askChat, consumePendingQuestion }}>
106
113
  {children}
107
114
  </ChatPanelContext.Provider>
108
115
  );
@@ -7,6 +7,13 @@
7
7
  */
8
8
  import Anthropic from '@anthropic-ai/sdk';
9
9
 
10
+ /**
11
+ * The one Haiku model ID for docs chat, the query rewriter and starter
12
+ * questions. Change it here, then run the live eval
13
+ * (__tests__/lib/chat-prompt.eval.test.ts) before deploying.
14
+ */
15
+ export const HAIKU_MODEL = 'claude-haiku-5-5';
16
+
10
17
  let client: Anthropic | null = null;
11
18
 
12
19
  /** Get or create the shared Anthropic client. Returns null if API key is not configured. */
@@ -1,6 +1,7 @@
1
1
  import type { ErrorDetails } from '../../shared/types.js';
2
2
  import { DEPRECATED_COMPONENTS } from '../deprecated-components.js';
3
3
  import { MIGRATION_DOCS_URL } from '../validate-config.js';
4
+ import { SYMLINK_ESCAPE_ERROR } from './validate-no-symlink-escape.js';
4
5
 
5
6
  /** Format: ERR-XXXXXXXX (first 8 chars of buildId, uppercase) */
6
7
  export function generateErrorRef(buildId: string): string {
@@ -246,6 +247,22 @@ export function parseErrorDetails(
246
247
  };
247
248
  }
248
249
 
250
+ // A CLI-uploaded tree with a symlink pointing outside it
251
+ // (validate-no-symlink-escape.ts, `source` phase). The git clone path
252
+ // removes such links and warns instead of throwing. Kept ahead of the clone
253
+ // fallback below so a throw from either source never reads as "check
254
+ // GitHub App access".
255
+ if (message.includes(SYMLINK_ESCAPE_ERROR)) {
256
+ return {
257
+ type: 'config_error',
258
+ message: 'Repository contains a symlink pointing outside it',
259
+ details: message,
260
+ suggestion:
261
+ 'For security, builds only follow symlinks whose target is inside the repository.\n\n' +
262
+ 'Fix: replace the symlink with a copy of the file, or point it at a file inside the repository, then rebuild.',
263
+ };
264
+ }
265
+
249
266
  // Configuration validation errors (from validate phase)
250
267
  if (message.includes('Missing docs.json') || message.includes('Invalid docs.json')) {
251
268
  const monorepoMatch = message.match(/Missing docs.json at '([^']+)'/);
@@ -16,6 +16,7 @@ import os from 'os';
16
16
  import { GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
17
17
  import { getR2S3Client, getR2Config } from '../r2.js';
18
18
  import { logger } from '../../shared/logger.js';
19
+ import { validateNoSymlinkEscape } from './validate-no-symlink-escape.js';
19
20
 
20
21
  const MAX_DECOMPRESSED_SIZE_BYTES = 500 * 1024 * 1024; // 500MB
21
22
 
@@ -94,35 +95,6 @@ export async function extractTarball(tarballKey: string, repoDir: string): Promi
94
95
  });
95
96
  }
96
97
 
97
- /**
98
- * Walk repoDir recursively and verify no symlinks point outside it.
99
- */
100
- function validateNoSymlinkEscape(repoDir: string): void {
101
- const realRepoDir = fs.realpathSync(repoDir);
102
-
103
- function walk(dir: string): void {
104
- const entries = fs.readdirSync(dir, { withFileTypes: true });
105
- for (const entry of entries) {
106
- const fullPath = path.join(dir, entry.name);
107
-
108
- if (entry.isSymbolicLink()) {
109
- const target = fs.realpathSync(fullPath);
110
- if (!target.startsWith(realRepoDir + path.sep) && target !== realRepoDir) {
111
- // Remove the offending symlink and throw
112
- fs.unlinkSync(fullPath);
113
- throw new Error(
114
- `Symlink escape detected: ${path.relative(repoDir, fullPath)} -> ${target} (outside ${realRepoDir})`
115
- );
116
- }
117
- } else if (entry.isDirectory()) {
118
- walk(fullPath);
119
- }
120
- }
121
- }
122
-
123
- walk(repoDir);
124
- }
125
-
126
98
  /**
127
99
  * Check total decompressed size doesn't exceed limit.
128
100
  */
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Symlink containment for a customer source tree (git clone or CLI tarball).
3
+ *
4
+ * Customer repos are untrusted, git and tar both keep symlinks, and the
5
+ * build's collectors (fast-glob, followSymbolicLinks on) read through them.
6
+ * Without this check `page.mdx -> /proc/self/environ` publishes the build
7
+ * machine's file to R2 as the site's content.
8
+ */
9
+
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+
13
+ /**
14
+ * Stable prefix of the error thrown below. error-parser.ts matches on it so
15
+ * the dashboard says what is wrong instead of a generic build failure.
16
+ */
17
+ export const SYMLINK_ESCAPE_ERROR = 'Symlink escape detected';
18
+
19
+ /**
20
+ * A link is allowed only if it resolves inside the repo and not into its
21
+ * `.git` directory: a clone keeps its credentials in `.git/config`, and an
22
+ * in-repo `page.mdx -> .git/config` would publish them like any outside file.
23
+ */
24
+ function isAllowedTarget(realRepoDir: string, target: string): boolean {
25
+ if (target !== realRepoDir && !target.startsWith(realRepoDir + path.sep)) return false;
26
+ return !path.relative(realRepoDir, target).split(path.sep).some((s) => s.toLowerCase() === '.git');
27
+ }
28
+
29
+ /**
30
+ * Where a symlink points. A dangling link has no realpath, so its target is
31
+ * resolved lexically from the link's real parent directory: nothing can be
32
+ * read through it now, but an outside target could be created later in the
33
+ * build (a temp file, another clone), so it must still be judged.
34
+ */
35
+ function symlinkTarget(fullPath: string): string {
36
+ try {
37
+ return fs.realpathSync(fullPath);
38
+ } catch {
39
+ return path.resolve(fs.realpathSync(path.dirname(fullPath)), fs.readlinkSync(fullPath));
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Walk repoDir recursively and call `onEscape` for every symlink whose target
45
+ * is not allowed (see isAllowedTarget). `onEscape` must remove the link or
46
+ * throw; the walk does not descend through symlinks.
47
+ */
48
+ function forEachEscapingSymlink(
49
+ repoDir: string,
50
+ onEscape: (fullPath: string) => void,
51
+ ): void {
52
+ const realRepoDir = fs.realpathSync(repoDir);
53
+
54
+ function walk(dir: string): void {
55
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
56
+ const fullPath = path.join(dir, entry.name);
57
+ if (entry.isSymbolicLink()) {
58
+ const target = symlinkTarget(fullPath);
59
+ if (!isAllowedTarget(realRepoDir, target)) onEscape(fullPath);
60
+ } else if (entry.isDirectory()) {
61
+ walk(fullPath);
62
+ }
63
+ }
64
+ }
65
+
66
+ walk(repoDir);
67
+ }
68
+
69
+ /**
70
+ * CLI tarball path: on the first escaping link, remove it and throw. The
71
+ * tarball comes straight from the author's machine, so failing the deploy
72
+ * tells them immediately.
73
+ */
74
+ export function validateNoSymlinkEscape(repoDir: string): void {
75
+ forEachEscapingSymlink(repoDir, (fullPath) => {
76
+ fs.unlinkSync(fullPath);
77
+ // Repo-relative link path only: error-parser passes this message to the
78
+ // dashboard as `details`, so neither the target nor the build machine's
79
+ // directory layout may appear in it.
80
+ throw new Error(
81
+ `${SYMLINK_ESCAPE_ERROR}: ${path.relative(repoDir, fullPath)} points outside the repository or into .git`
82
+ );
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Git clone path: remove every escaping link and return their repo-relative
88
+ * paths. Never throws for a bad link, so a stray absolute link committed by
89
+ * accident (`/Users/dev/...`) cannot break a live site's builds or its
90
+ * translation runs. The exfiltration is still closed: the link is gone
91
+ * before anything reads the tree.
92
+ */
93
+ export function removeEscapingSymlinks(repoDir: string): string[] {
94
+ const removed: string[] = [];
95
+ forEachEscapingSymlink(repoDir, (fullPath) => {
96
+ fs.unlinkSync(fullPath);
97
+ removed.push(path.relative(repoDir, fullPath));
98
+ });
99
+ return removed;
100
+ }
@@ -0,0 +1,26 @@
1
+ import { transformConfigImagePath, type ChatIconSpec } from './docs-types';
2
+ import { getIconClass } from './icon-utils';
3
+
4
+ export const DEFAULT_CHAT_ICON = 'sparkles';
5
+
6
+ const IMAGE_PREFIX = /^(https?:\/\/|\/\/|\/|\.\/|data:)/i;
7
+ const IMAGE_EXT = /\.(svg|png|jpe?g|webp|gif)$/i;
8
+
9
+ /**
10
+ * Resolve `chat.icon` to either a Font Awesome class or an image source.
11
+ * Bare relative paths like `images/a.svg` count as images by extension, since
12
+ * a Font Awesome name never contains a dot.
13
+ */
14
+ export function resolveChatIcon(icon: string | undefined, assetVersion?: string): ChatIconSpec {
15
+ const value = icon?.trim() || DEFAULT_CHAT_ICON;
16
+ if (IMAGE_PREFIX.test(value) || IMAGE_EXT.test(value)) {
17
+ // Remote URLs pass through unchanged; local paths get the same asset
18
+ // handling as the logo (/_jd/ prefix for hostAtDocs, cache-busting version).
19
+ return { kind: 'img', src: transformConfigImagePath(value, assetVersion)! };
20
+ }
21
+ return { kind: 'fa', className: getIconClass(value) };
22
+ }
23
+
24
+ export function resolveChatLabel(label: string | undefined, fallback: string): string {
25
+ return label?.trim() || fallback;
26
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Chat System Prompt Builder
3
3
  *
4
- * Builds the system prompt for Claude Haiku 4.5, incorporating RAG context
4
+ * Builds the system prompt for Claude Haiku 5.5, incorporating RAG context
5
5
  * from Upstash Vector search results. The prompt instructs Claude to only
6
6
  * answer from provided documentation and cite sources with links.
7
7
  */
@@ -32,7 +32,7 @@ export function buildCannedIdentityReply(projectName: string): string {
32
32
  /**
33
33
  * Detects whether Claude's markdown output is the canned identity reply.
34
34
  *
35
- * Haiku at temperature 0.3 drifts the exact string in small ways: curly vs
35
+ * Haiku drifts the exact string in small ways: curly vs
36
36
  * straight apostrophes, trailing period variants, surrounding markdown
37
37
  * emphasis (`*...*`), or extra whitespace. An exact === compare against
38
38
  * buildCannedIdentityReply would miss those and let the "top 2 by score"
@@ -187,6 +187,9 @@ export interface GroupConfig {
187
187
  */
188
188
  export type TabsPosition = 'top' | 'left';
189
189
 
190
+ /** Horizontal alignment of the header tab row when tabsPosition is 'top' */
191
+ export type TabsAlignment = 'start' | 'center' | 'end';
192
+
190
193
  /**
191
194
  * External link anchor - appears at top of sidebar on all pages
192
195
  * These are simple external navigation links, NOT content sections
@@ -721,8 +724,21 @@ export interface ChatConfig {
721
724
  enabled?: boolean;
722
725
  /** Starter questions shown in empty state (max 4). Auto-generated by Haiku during builds when omitted. Set to [] to disable. */
723
726
  starterQuestions?: string[];
727
+ /** Name shown on the chat button, panel header and search shortcut in place of "Ask AI" (max 40 characters). Applies to every language. */
728
+ label?: string;
729
+ /** Icon for the chat button and panel: a Font Awesome icon name (optionally "style/name"), an image URL, or a path to an image in the project. */
730
+ icon?: string;
724
731
  }
725
732
 
733
+ /**
734
+ * Resolved chat icon: a Font Awesome class string or an image source.
735
+ * Lives here, not in lib/chat-branding.ts, because hooks/useChatPanel.tsx is
736
+ * vendored into the dashboard and docs-types.ts is already in its vendored lib set.
737
+ */
738
+ export type ChatIconSpec =
739
+ | { kind: 'fa'; className: string }
740
+ | { kind: 'img'; src: string };
741
+
726
742
  /**
727
743
  * Options for the generated social sharing card (og:image).
728
744
  */
@@ -945,6 +961,10 @@ export interface DocsConfig {
945
961
  // 'left': Tabs at top of sidebar (default for every theme)
946
962
  tabsPosition?: TabsPosition;
947
963
 
964
+ // Tab row alignment when tabsPosition is 'top' (default 'start').
965
+ // No effect in Pulsar, which places top tabs on the right of the header.
966
+ tabsAlignment?: TabsAlignment;
967
+
948
968
  // External link anchors - appear at top of sidebar on all pages
949
969
  anchors?: ExternalAnchorConfig[];
950
970
 
@@ -5,10 +5,13 @@
5
5
  * 4 contextually relevant questions new users would likely ask.
6
6
  * Non-fatal: returns empty array on any error so builds aren't blocked.
7
7
  *
8
- * Uses temperature 0 for deterministic output — same docs produce the
9
- * same questions, avoiding unnecessary cache invalidation between builds.
8
+ * Haiku 5.5 rejects temperature, so the same docs can produce different
9
+ * questions on each call. The questions land in docs.json, and any docs.json
10
+ * change revalidates every page, so build.ts reuses the previous build's
11
+ * questions (stored in the R2 manifest) while starterQuestionsKey is unchanged.
10
12
  */
11
- import { getAnthropicClient } from './anthropic-client.js';
13
+ import crypto from 'node:crypto';
14
+ import { getAnthropicClient, HAIKU_MODEL } from './anthropic-client.js';
12
15
 
13
16
  const MAX_QUESTIONS = 4;
14
17
  /** Cap summary lines to avoid excessive prompt size with large doc sets */
@@ -43,6 +46,18 @@ export function buildDocSummary(pages: PageInfo[]): string {
43
46
  }).join('\n');
44
47
  }
45
48
 
49
+ /**
50
+ * Cache key for generated questions: the prompt input plus the model, so a
51
+ * page rename or a model change regenerates and anything else reuses.
52
+ */
53
+ export function starterQuestionsKey(pages: PageInfo[]): string {
54
+ return crypto
55
+ .createHash('sha256')
56
+ .update(`${HAIKU_MODEL}\n${buildDocSummary(pages)}`)
57
+ .digest('hex')
58
+ .slice(0, 16);
59
+ }
60
+
46
61
  /**
47
62
  * Generate starter questions for the chat empty state using Haiku.
48
63
  * Returns up to 4 questions, or empty array if generation fails.
@@ -58,9 +73,11 @@ export async function generateStarterQuestions(pages: PageInfo[]): Promise<strin
58
73
  try {
59
74
  const response = await anthropic.messages.create(
60
75
  {
61
- model: 'claude-haiku-4-5-20251001',
76
+ model: HAIKU_MODEL,
62
77
  max_tokens: 256,
63
- temperature: 0,
78
+ // Haiku 5.5 thinks by default, which would eat the 256-token cap.
79
+ thinking: { type: 'disabled' },
80
+ output_config: { effort: 'low' },
64
81
  messages: [{
65
82
  role: 'user',
66
83
  content: `You are generating starter questions for a documentation chatbot. Given this documentation structure, generate exactly ${MAX_QUESTIONS} questions that a new user would likely ask first.
@@ -79,7 +96,8 @@ ${summary}`,
79
96
  { signal: controller.signal },
80
97
  );
81
98
 
82
- let text = response.content[0]?.type === 'text' ? response.content[0].text : '';
99
+ const textBlock = response.content.find((b) => b.type === 'text');
100
+ let text = textBlock?.type === 'text' ? textBlock.text : '';
83
101
  // Strip markdown code fences — Haiku sometimes wraps JSON in ```json ... ```
84
102
  text = text.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/, '').trim();
85
103
  const parsed = JSON.parse(text);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * One-shot GitHub credentials for git subprocesses.
3
+ *
4
+ * A token in the remote URL (`https://x-access-token:TOKEN@github.com/...`)
5
+ * is written by `git clone` into `.git/config` and the reflog. When a project
6
+ * has no docs subdirectory, `.git/` sits inside the project the build reads
7
+ * and publishes from, so the token could reach a public file.
8
+ *
9
+ * Instead the token goes to git as an `http.extraHeader`, scoped to
10
+ * https://github.com/, through the GIT_CONFIG_COUNT/KEY/VALUE environment
11
+ * variables. Git treats those as command-line config for that one process
12
+ * and never writes them to disk.
13
+ * The environment is used rather than `git -c`, because argv shows up in
14
+ * execFileSync's "Command failed: ..." error message and in process
15
+ * listings. (`git clone -c` would be worse: clone saves those values into the
16
+ * new repo's config.)
17
+ *
18
+ * page-timestamps.ts imports this extension-less (`./github-git-auth`)
19
+ * because it is also pulled into the Next.js/ISR bundle, where Next prod
20
+ * rejects `.js` specifiers in TS source. Keep it free of imports that would
21
+ * break that bundle; `Buffer` is fine on the Node runtime.
22
+ */
23
+
24
+ /** base64("x-access-token:<token>"), the Basic credential GitHub accepts. */
25
+ export function githubBasicAuth(token: string): string {
26
+ return Buffer.from(`x-access-token:${token}`).toString('base64');
27
+ }
28
+
29
+ /**
30
+ * Environment additions that authenticate one git command to GitHub.
31
+ * Spread over `process.env` so git still sees PATH and HOME.
32
+ */
33
+ export function githubGitAuthEnv(token: string): Record<string, string> {
34
+ return {
35
+ GIT_CONFIG_COUNT: '1',
36
+ // URL-scoped so git sends the header only to https://github.com/. A bare
37
+ // http.extraHeader goes with every HTTP request, including ones to hosts a
38
+ // customer repo names (a submodule URL, or a git-lfs endpoint in
39
+ // .lfsconfig if lfs is ever installed in the image).
40
+ GIT_CONFIG_KEY_0: 'http.https://github.com/.extraHeader',
41
+ GIT_CONFIG_VALUE_0: `Authorization: Basic ${githubBasicAuth(token)}`,
42
+ // With no credential in the URL, git may fall back to asking for a
43
+ // username. Fail at once with an auth error (classified as auth_failed)
44
+ // instead of waiting on a prompt nobody can answer.
45
+ GIT_TERMINAL_PROMPT: '0',
46
+ };
47
+ }
@@ -2,7 +2,7 @@
2
2
  * Detects and truncates Claude's canned no-answer sentence
3
3
  * ("I don't have information about that in the documentation.") when followed
4
4
  * by a self-contradicting "However..." — a prompt-adherence failure mode of
5
- * Haiku 4.5. Tolerates curly-vs-straight apostrophes and surrounding markdown
5
+ * Haiku 4.5 (kept on 5.5 as a guard). Tolerates curly-vs-straight apostrophes and surrounding markdown
6
6
  * emphasis.
7
7
  *
8
8
  * ASSUMPTION: the sentence is reserved for the no-answer reply. A docs page
@@ -292,10 +292,10 @@ export async function collectOpenApiFiles(
292
292
  * warned (we can't validate what we won't load). */
293
293
  const MAX_VALIDATED_SPEC_BYTES = 5_000_000;
294
294
 
295
- /** Per-spec wall-clock bound. `SwaggerParser.validate` follows external `$ref`
296
- * HTTP URLs (json-schema-ref-parser enables the HTTP resolver by default), so
297
- * a slow/dead external host could otherwise stall the build. On timeout we
298
- * skip the spec (no warning) — a momentarily-down host must not false-warn. */
295
+ /** Per-spec wall-clock bound. `SwaggerParser.validate` follows external
296
+ * https `$ref`s (via openapi/safe-ref-resolvers.ts, 10s per ref), and a spec
297
+ * with many slow refs could otherwise stall the build. On timeout we skip the
298
+ * spec (no warning) — a momentarily-down host must not false-warn. */
299
299
  const VALIDATE_SPEC_TIMEOUT_MS = 30_000;
300
300
 
301
301
  /** SwaggerParser validation errors can be multi-line schema dumps. Keep the
@@ -370,7 +370,9 @@ export async function validateCollectedOpenApiSpecs(
370
370
  projectDir: string,
371
371
  openApiFiles: readonly OpenApiFileRef[],
372
372
  referencedKeys: ReadonlySet<string>,
373
- opts: { maxBytes?: number; timeoutMs?: number } = {}
373
+ // specBoundaryDir: repo root on hosted builds, so a spec's file $refs may
374
+ // reach a monorepo's shared schemas; see validateOpenApiSpec.
375
+ opts: { maxBytes?: number; timeoutMs?: number; specBoundaryDir?: string } = {}
374
376
  ): Promise<BuildWarning[]> {
375
377
  const maxBytes = opts.maxBytes ?? MAX_VALIDATED_SPEC_BYTES;
376
378
  const timeoutMs = opts.timeoutMs ?? VALIDATE_SPEC_TIMEOUT_MS;
@@ -409,7 +411,7 @@ export async function validateCollectedOpenApiSpecs(
409
411
  try {
410
412
  // Bound external-$ref fetches: a tie returns null → skip (no warning).
411
413
  const result = await Promise.race([
412
- validateOpenApiSpec(relPath, projectDir),
414
+ validateOpenApiSpec(relPath, projectDir, opts.specBoundaryDir),
413
415
  new Promise<null>((resolve) => {
414
416
  timeoutHandle = setTimeout(() => resolve(null), timeoutMs);
415
417
  }),
@@ -579,22 +581,53 @@ export const ISR_PHASES = {
579
581
 
580
582
  export type IsrPhase = keyof typeof ISR_PHASES;
581
583
 
584
+ /**
585
+ * Whether `target` (a real path) is strictly inside `dir` (a real path).
586
+ * Callers must pass realpaths so a symlink cannot hop out of the project.
587
+ */
588
+ function isRealPathInside(dir: string, target: string): boolean {
589
+ const rel = path.relative(dir, target);
590
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
591
+ }
592
+
593
+ /**
594
+ * Whether `target` (a real path inside `dir`) has a `.git` segment. When the
595
+ * project is the repo root, .git is inside it and its files (config, logs,
596
+ * FETCH_HEAD) can hold credentials, so custom JS never reads from there.
597
+ * Case-insensitive because macOS opens `.GIT/config` as `.git/config`.
598
+ */
599
+ function isRealPathInGitDir(dir: string, target: string): boolean {
600
+ return path.relative(dir, target).split(path.sep).some(seg => seg.toLowerCase() === '.git');
601
+ }
602
+
582
603
  /**
583
604
  * Collect custom JavaScript content from project directory.
584
605
  * Priority: styling.js config > auto-detect .js files in project root.
585
606
  * Returns concatenated JS content or null if none found.
607
+ *
608
+ * docs.json and the repo are untrusted, and the result is published as the
609
+ * site's public custom.js. A file is read only when its real path is inside
610
+ * the project's real path, so neither a `/../` styling.js entry nor a
611
+ * committed symlink can pull in files from elsewhere on the build machine,
612
+ * and never from under .git. Symlinks that stay inside the project are
613
+ * allowed. Mirrors copy-files.cjs and the CLI's custom-assets.ts.
614
+ *
615
+ * `boundaryDir` is the directory files must stay in. Hosted builds pass the
616
+ * cloned repo root so a monorepo's shared script (outside docsDir, inside the
617
+ * repo) is not silently dropped, matching the OpenAPI spec boundary. The
618
+ * CLI and copy-files.cjs know no repo root and keep docsDir.
586
619
  */
587
620
  export function collectCustomJs(
588
621
  projectDir: string,
589
- config: DocsConfig
622
+ config: DocsConfig,
623
+ boundaryDir: string = projectDir
590
624
  ): string | null {
591
625
  let jsPaths: string[] = [];
592
626
 
593
627
  if (config.styling?.js) {
594
- const configured = Array.isArray(config.styling.js)
628
+ jsPaths = Array.isArray(config.styling.js)
595
629
  ? config.styling.js
596
630
  : [config.styling.js];
597
- jsPaths = configured.map(p => p.replace(/^\//, ''));
598
631
  } else {
599
632
  try {
600
633
  jsPaths = (fs.readdirSync(projectDir) as string[])
@@ -607,12 +640,30 @@ export function collectCustomJs(
607
640
 
608
641
  if (jsPaths.length === 0) return null;
609
642
 
643
+ let realBoundaryDir: string;
644
+ try {
645
+ realBoundaryDir = fs.realpathSync(boundaryDir);
646
+ } catch {
647
+ return null;
648
+ }
649
+
610
650
  const contents: string[] = [];
611
651
  for (const jsPath of jsPaths) {
612
- const fullPath = path.join(projectDir, jsPath);
613
- if (fs.existsSync(fullPath)) {
614
- contents.push(fs.readFileSync(fullPath, 'utf-8'));
652
+ const fullPath = path.join(projectDir, jsPath.replace(/^\//, ''));
653
+ if (!fs.existsSync(fullPath)) continue;
654
+ const realPath = fs.realpathSync(fullPath);
655
+ if (!isRealPathInside(realBoundaryDir, realPath)) {
656
+ logger.warn(`Skipping custom JS outside the project: ${jsPath}`);
657
+ continue;
658
+ }
659
+ if (isRealPathInGitDir(realBoundaryDir, realPath)) {
660
+ logger.warn(`Skipping custom JS inside .git: ${jsPath}`);
661
+ continue;
615
662
  }
663
+ // A directory named `widgets.js` would throw EISDIR and fail the build;
664
+ // the CLI preview already skips it.
665
+ if (!fs.statSync(realPath).isFile()) continue;
666
+ contents.push(fs.readFileSync(realPath, 'utf-8'));
616
667
  }
617
668
 
618
669
  return contents.length > 0 ? contents.join('\n') : null;
@@ -822,10 +822,13 @@ export async function DocsChrome({
822
822
  <HtmlLangSync defaultLanguage={projectDefaultLang} />
823
823
  </ThemeProvider>
824
824
  {/*
825
- Inline scripts gated to production — avoids React 19's "scripts
826
- inside React components are never executed when rendering on the
827
- client" warning in `jamdesk dev`. Same pattern as the
828
- project-subdomain hydration suppressor in <head>.
825
+ Analytics and Crisp stay production-only: they are no-ops on localhost,
826
+ and the analytics IIFE caused React 19's "scripts inside React
827
+ components are never executed when rendering on the client" warning in
828
+ `jamdesk dev` (1.1.68). Custom JS runs in dev so customers can preview
829
+ it; window.__JAMDESK_DEV__ lets it skip production-only code. With
830
+ consent gating on, custom JS runs only through ConsentGate, which is
831
+ production-only.
829
832
  */}
830
833
  {!consentGating && process.env.NODE_ENV === 'production' && config.integrations?.crisp?.websiteId &&
831
834
  /^[a-f0-9-]{36}$/.test(config.integrations.crisp.websiteId) && (
@@ -835,8 +838,12 @@ export async function DocsChrome({
835
838
  }}
836
839
  />
837
840
  )}
838
- {!consentGating && process.env.NODE_ENV === 'production' && customJs && (
839
- <script dangerouslySetInnerHTML={{ __html: customJs }} />
841
+ {!consentGating && customJs && (
842
+ <script
843
+ dangerouslySetInnerHTML={{
844
+ __html: process.env.NODE_ENV === 'production' ? customJs : `window.__JAMDESK_DEV__=true;${customJs}`,
845
+ }}
846
+ />
840
847
  )}
841
848
  {!consentGating && process.env.NODE_ENV === 'production' && analyticsScript && (
842
849
  <script dangerouslySetInnerHTML={{ __html: analyticsScript }} />
@@ -6,7 +6,7 @@
6
6
  * 1. Bare-slug: `[Anchor](api-reference/foo)` → `[Anchor](https://x/docs/api-reference/foo)`
7
7
  * 2. Absolute URL: `[Anchor](https://anything/.../api-reference/foo)` → canonical absolute URL
8
8
  *
9
- * Both forms are needed because Haiku at temp 0.3 produces a mix:
9
+ * Both forms are needed because Haiku produces a mix:
10
10
  * - Sometimes copies the `URL:` line from chunk context verbatim (form 2, correct host)
11
11
  * - Sometimes emits the pageSlug (form 1)
12
12
  * - Sometimes hallucinates a host (form 2, wrong host) — the slug suffix still resolves