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.
- package/dist/__tests__/unit/custom-assets.test.js +157 -6
- package/dist/__tests__/unit/custom-assets.test.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +26 -8
- package/dist/commands/dev.js.map +1 -1
- package/dist/lib/custom-assets.d.ts +17 -0
- package/dist/lib/custom-assets.d.ts.map +1 -1
- package/dist/lib/custom-assets.js +109 -1
- package/dist/lib/custom-assets.js.map +1 -1
- package/package.json +1 -1
- package/vendored/app/api/chat/[project]/route.ts +31 -7
- package/vendored/components/chat/ChatBrandIcon.tsx +17 -0
- package/vendored/components/chat/ChatEmptyState.tsx +5 -5
- package/vendored/components/chat/ChatPanel.tsx +10 -7
- package/vendored/components/chat/useChatLabel.ts +9 -0
- package/vendored/components/layout/LayoutWrapper.tsx +3 -0
- package/vendored/components/mdx/Mermaid.tsx +16 -3
- package/vendored/components/mdx/MermaidInner.tsx +272 -19
- package/vendored/components/mdx/MermaidZoomModal.tsx +87 -0
- package/vendored/components/mdx/ZoomableImage.tsx +8 -1
- package/vendored/components/navigation/Header.tsx +15 -10
- package/vendored/components/navigation/Sidebar.tsx +6 -3
- package/vendored/components/navigation/TabsNav.tsx +9 -2
- package/vendored/components/search/SearchModal.tsx +6 -3
- package/vendored/hooks/useChat.ts +2 -0
- package/vendored/hooks/useChatPanel.tsx +9 -2
- package/vendored/lib/anthropic-client.ts +7 -0
- package/vendored/lib/build/error-parser.ts +17 -0
- package/vendored/lib/build/extract-tarball.ts +1 -29
- package/vendored/lib/build/validate-no-symlink-escape.ts +100 -0
- package/vendored/lib/chat-branding.ts +26 -0
- package/vendored/lib/chat-prompt.ts +2 -2
- package/vendored/lib/docs-types.ts +20 -0
- package/vendored/lib/generate-starter-questions.ts +24 -6
- package/vendored/lib/github-git-auth.ts +47 -0
- package/vendored/lib/hedge-strip.ts +1 -1
- package/vendored/lib/isr-build-executor.ts +63 -12
- package/vendored/lib/layout-helpers.tsx +13 -6
- package/vendored/lib/link-rewriter.ts +1 -1
- package/vendored/lib/mcp-auth.ts +67 -0
- package/vendored/lib/openapi/cache.ts +5 -2
- package/vendored/lib/openapi/expand-spec-pages.ts +5 -3
- package/vendored/lib/openapi/safe-ref-resolvers.ts +455 -0
- package/vendored/lib/openapi/validator.ts +46 -9
- package/vendored/lib/openapi-isr.ts +24 -11
- package/vendored/lib/page-timestamps.ts +29 -6
- package/vendored/lib/query-rewriter.ts +7 -3
- package/vendored/lib/r2-manifest.ts +2 -0
- package/vendored/lib/runtime-flags.ts +54 -0
- package/vendored/schema/docs-schema.json +34 -0
- package/vendored/scripts/copy-files.cjs +90 -37
- package/vendored/scripts/openapi-safe-ref-resolvers.cjs +354 -0
- package/vendored/scripts/openapi-spec-loader.cjs +10 -5
- package/vendored/themes/base.css +48 -0
- package/vendored/workspace-package-lock.json +18 -18
- 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
|
+
* 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
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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:
|
|
76
|
+
model: HAIKU_MODEL,
|
|
62
77
|
max_tokens: 256,
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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 &&
|
|
839
|
-
<script
|
|
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
|
|
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
|