From ba87b1469f4c9717dc91ee4e9edd9237ceccdd11 Mon Sep 17 00:00:00 2001
From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com>
Date: Wed, 16 Sep 2026 18:12:35 -0600
Subject: [PATCH 01/11] fix: prefix basePath on root-relative src
Production serves under /docs. next/link adds the prefix, and
next/image do not, so /sourcegraph-mark.svg in the mobile nav and the
LinkCards/ProductCards icons, plus a couple of root-relative markdown
images, 404 on sourcegraph.com.
---
src/components/MobileNavigation.tsx | 3 ++-
src/components/mdx/LinkCards.tsx | 7 ++++++-
src/components/mdx/ProductCards.tsx | 7 ++++++-
src/components/mdx/ZoomableImage.tsx | 12 +++++++++++-
src/lib/utils.ts | 9 +++++++++
5 files changed, 34 insertions(+), 4 deletions(-)
diff --git a/src/components/MobileNavigation.tsx b/src/components/MobileNavigation.tsx
index a59ac44db..aff20d9f1 100644
--- a/src/components/MobileNavigation.tsx
+++ b/src/components/MobileNavigation.tsx
@@ -6,6 +6,7 @@ import Link from 'next/link';
import {usePathname, useSearchParams} from 'next/navigation';
import {Dialog} from '@headlessui/react';
import {Navigation} from '@/components/Navigation';
+import {withBasePath} from '@/lib/utils';
function MenuIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
return (
@@ -100,7 +101,7 @@ export function MobileNavigation() {
{/* eslint-disable-next-line @next/next/no-img-element -- Small MDX icons do not need image optimization. */}
-
+
{title}
diff --git a/src/components/mdx/ProductCards.tsx b/src/components/mdx/ProductCards.tsx
index 38f0f7d03..9e58f5646 100644
--- a/src/components/mdx/ProductCards.tsx
+++ b/src/components/mdx/ProductCards.tsx
@@ -1,5 +1,6 @@
import Link from 'next/link';
import {CustomLink} from './CustomLink';
+import {withBasePath} from '@/lib/utils';
export function ProductCards({children}: {children: React.ReactNode}) {
return (
@@ -29,7 +30,11 @@ export function ProductCard({
{/* eslint-disable-next-line @next/next/no-img-element -- Small MDX icons do not need image optimization. */}
-
+
diff --git a/src/components/mdx/ZoomableImage.tsx b/src/components/mdx/ZoomableImage.tsx
index 1ad752aea..883e4f5ee 100644
--- a/src/components/mdx/ZoomableImage.tsx
+++ b/src/components/mdx/ZoomableImage.tsx
@@ -1,10 +1,20 @@
'use client';
import {useCallback, useEffect, useState} from 'react';
+import {withBasePath} from '@/lib/utils';
interface ZoomableImageProps extends React.ImgHTMLAttributes {}
-export function ZoomableImage({className, alt, ...props}: ZoomableImageProps) {
+export function ZoomableImage({
+ className,
+ alt,
+ src,
+ ...rest
+}: ZoomableImageProps) {
+ const props = {
+ ...rest,
+ src: typeof src === 'string' ? withBasePath(src) : src
+ };
const [isOpen, setIsOpen] = useState(false);
const openModal = useCallback(() => setIsOpen(true), []);
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 065c19661..4018927aa 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -4,3 +4,12 @@ import {twMerge} from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+
+// Production serves the site under /docs (basePath in next.config.js). next/link
+// adds it to hrefs, but and next/image do not, so a root-relative path
+// to a file in public/ 404s on sourcegraph.com unless prefixed here.
+export function withBasePath(url: string) {
+ const basePath = process.env.NEXT_PUBLIC_DOCS_BASE_PATH || '';
+ const isRootRelative = url.startsWith('/') && !url.startsWith('//');
+ return isRootRelative ? `${basePath}${url}` : url;
+}
From 76da6a502fdd9c94d4b1dc800ae9505476017e90 Mon Sep 17 00:00:00 2001
From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com>
Date: Thu, 17 Sep 2026 02:15:24 -0600
Subject: [PATCH 02/11] cspell: allow hrefs
Amp-Thread-ID: https://ampcode.com/threads/T-01a0ae6a-1645-702a-a5ed-92f8b9bce33b
Co-authored-by: Amp
---
cspell-allow-list.txt | 1 +
1 file changed, 1 insertion(+)
diff --git a/cspell-allow-list.txt b/cspell-allow-list.txt
index 00ef52265..f3fdb47a7 100644
--- a/cspell-allow-list.txt
+++ b/cspell-allow-list.txt
@@ -241,6 +241,7 @@ horsegraph
horsten
hostmatcher
hostpath
+hrefs
HSTS
httptest
huggingface
From 68901e11f3374911bbc52bdc8b07aa499b05fe86 Mon Sep 17 00:00:00 2001
From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com>
Date: Thu, 17 Sep 2026 02:45:42 -0600
Subject: [PATCH 03/11] site: serve every deployment under /docs
Previews and local dev served at / while production served at /docs, so
a root-relative only 404d after deploy. Use basePath /docs
everywhere; the / -> /docs redirect from #1955 now applies to every
deployment. Update the tooling that assumed previews had no basePath.
Amp-Thread-ID: https://ampcode.com/threads/T-01a0ae6a-1645-702a-a5ed-92f8b9bce33b
Co-authored-by: Amp
---
.amp/services.yaml | 4 ++--
.github/workflows/preview-links.yml | 4 ++--
AGENTS.md | 12 +++++++++++-
README.md | 4 ++--
dev/verify-links-live.mjs | 7 +++----
next.config.js | 18 +++++++-----------
src/lib/utils.ts | 6 +++---
7 files changed, 30 insertions(+), 25 deletions(-)
diff --git a/.amp/services.yaml b/.amp/services.yaml
index 87e947eb5..73d0ae63a 100644
--- a/.amp/services.yaml
+++ b/.amp/services.yaml
@@ -4,7 +4,7 @@ services:
port: 31420
env:
NODE_OPTIONS: --max-old-space-size=3072
- health: /agentic-batch-changes
+ health: /docs/agentic-batch-changes
portal:
- url: /agentic-batch-changes
+ url: /docs/agentic-batch-changes
title: Sourcegraph Docs
diff --git a/.github/workflows/preview-links.yml b/.github/workflows/preview-links.yml
index 70c688e0d..c4447b8f9 100644
--- a/.github/workflows/preview-links.yml
+++ b/.github/workflows/preview-links.yml
@@ -60,11 +60,11 @@ jobs:
});
// Mirror contentlayer's flattenedPath: docs/.mdx -> /,
- // with a trailing /index dropped. Preview deployments have no basePath.
+ // with a trailing /index dropped, under the /docs basePath.
const pages = files
.filter(f => f.filename.startsWith('docs/') && f.filename.endsWith('.mdx') && f.status !== 'removed')
.map(f => f.filename.slice('docs/'.length, -'.mdx'.length).replace(/^index$/, '').replace(/\/index$/, ''))
- .map(path => ({path, url: `${previewUrl}/${path}`}));
+ .map(path => ({path, url: `${previewUrl}/docs/${path}`}));
if (pages.length === 0) {
core.info(`PR #${pr.number} changes no docs pages; not commenting.`);
diff --git a/AGENTS.md b/AGENTS.md
index 8ae767663..b3a6ddfc2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -32,7 +32,7 @@
linking the job log, instead of failing the PR: an empty report says nothing
about the PR
- **Prove changed links resolve on a deploy**:
- `node dev/verify-links-live.mjs --site ` prints a
+ `node dev/verify-links-live.mjs --site /docs` prints a
Markdown table for the PR description
- **Spell check**: `.github/workflows/spellcheck.yml` runs
`dev/check-spelling.mjs` on the lines a PR adds plus its title and
@@ -92,3 +92,13 @@ next run.
(From a manifest), install it, copy its
Bot User OAuth Token into the secret, and `/invite @Vercel build log` to the
channel.
+
+
+
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
+
+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
+
+
diff --git a/README.md b/README.md
index 398d259ba..dd6064ea3 100644
--- a/README.md
+++ b/README.md
@@ -54,7 +54,7 @@ Next, run the development server:
pnpm run dev
```
-Finally, open [`http://localhost:3000`](http://localhost:3000) in your browser
+Finally, open [`http://localhost:3000/docs`](http://localhost:3000/docs) in your browser
to view the website.
## Writing and contributing to Sourcegraph Docs
@@ -225,7 +225,7 @@ instructions visit
As you make changes to the documentation, the development server will
automatically update. Review your changes by navigating to
-`http://localhost:3000` in your browser.
+`http://localhost:3000/docs` in your browser.
### Previewing Vercel Deployments
diff --git a/dev/verify-links-live.mjs b/dev/verify-links-live.mjs
index 7f4434a3e..b39d32f4b 100644
--- a/dev/verify-links-live.mjs
+++ b/dev/verify-links-live.mjs
@@ -13,11 +13,10 @@
// Prints a Markdown table to paste into a PR. Old links point at --old-site so
// reviewers can see the current breakage.
//
-// node dev/verify-links-live.mjs --site https://.vercel.app [--old-site https://sourcegraph.com/docs] [--base origin/main]
+// node dev/verify-links-live.mjs --site https://.vercel.app/docs [--old-site https://sourcegraph.com/docs] [--base origin/main]
//
-// Production serves under https://sourcegraph.com/docs (basePath in
-// next.config.js); Vercel previews serve at the root, so pass the full prefix
-// in --site.
+// Every deployment serves under /docs (basePath in next.config.js), so --site
+// includes that prefix.
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
diff --git a/next.config.js b/next.config.js
index 941a822af..84aca2adc 100644
--- a/next.config.js
+++ b/next.config.js
@@ -2,12 +2,10 @@ const {PHASE_DEVELOPMENT_SERVER} = require('next/constants');
const {withContentlayer} = require('next-contentlayer2');
/** @type {import('next').NextConfig} */
-// in prod, we serve the docs from sourcegraph.com/docs, and this requires special config on the GFE side
-// in preview/development, this is not necessary.
-//
-// VERCEL_ENV is a system env var set by Vercel
-// https://vercel.com/docs/projects/environment-variables/system-environment-variables
-const basePath = process.env.VERCEL_ENV === 'production' ? '/docs' : '';
+// sourcegraph.com proxies /docs/* to this site. Previews and local dev use the
+// same basePath so that a root-relative URL which 404s in production also 404s
+// there, instead of only breaking after deploy.
+const basePath = '/docs';
const nextConfig = {
reactStrictMode: true,
@@ -19,12 +17,10 @@ const nextConfig = {
env: {
NEXT_PUBLIC_DOCS_BASE_PATH: basePath
},
- // With basePath set, nothing serves `/`, so the *.vercel.app deployment URL
- // that Vercel links from Slack / GitHub 404s. sourcegraph.com never proxies
- // `/` to us, so this only affects visitors opening that raw URL. Stay on the
- // same host so they see this exact deployment, not whatever is live.
+ // Nothing serves `/`, so the deployment URLs Vercel links from Slack / GitHub
+ // and http://localhost:3000 would 404. sourcegraph.com never proxies `/` to
+ // us. Stay on the same host so visitors see this exact deployment.
async redirects() {
- if (!basePath) return [];
return [
{
source: '/',
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 4018927aa..fb935217b 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -5,9 +5,9 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
-// Production serves the site under /docs (basePath in next.config.js). next/link
-// adds it to hrefs, but and next/image do not, so a root-relative path
-// to a file in public/ 404s on sourcegraph.com unless prefixed here.
+// The site is served under /docs (basePath in next.config.js). next/link adds it
+// to hrefs, but and next/image do not, so a root-relative path to a
+// file in public/ 404s unless prefixed here.
export function withBasePath(url: string) {
const basePath = process.env.NEXT_PUBLIC_DOCS_BASE_PATH || '';
const isRootRelative = url.startsWith('/') && !url.startsWith('//');
From ca00492efeafdfd1305735613860e0b66e03e581 Mon Sep 17 00:00:00 2001
From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com>
Date: Thu, 17 Sep 2026 03:07:29 -0600
Subject: [PATCH 04/11] site: one DOCS_BASE_PATH constant; drop
NEXT_PUBLIC_DOCS_BASE_PATH
basePath is declared once, in docs.config.js. next.config.js reads it for
routing; everything that builds a URL Next does not prefix (img src, a href,
fetch, metadata, proxy redirects) goes through withBasePath or reads the
constant, instead of nine copies of process.env.NEXT_PUBLIC_DOCS_BASE_PATH
or a hardcoded '/docs'.
proxy.ts: request.nextUrl.pathname already excludes the basePath, so the
path.replace('/docs', '') was a no-op that would have mangled any real
path containing '/docs'. Removed.
Amp-Thread-ID: https://ampcode.com/threads/T-01a0ae6a-1645-702a-a5ed-92f8b9bce33b
Co-authored-by: Amp
---
docs.config.js | 5 ++-
next.config.js | 13 ++-----
src/app/[...slug]/page.tsx | 3 +-
src/app/layout.tsx | 5 +--
src/app/page.tsx | 3 +-
src/components/Logo.tsx | 8 ++---
src/components/NotFoundLinks.tsx | 8 ++---
src/components/ReleasesTable.tsx | 4 +--
src/components/Toc.tsx | 4 +--
src/components/search/Search.tsx | 7 ++--
.../search/docsearch/NoResultsScreen.tsx | 5 ++-
.../search/docsearch/StartScreen.tsx | 5 ++-
src/lib/utils.ts | 9 +++--
src/proxy.ts | 36 +++++++++----------
14 files changed, 54 insertions(+), 61 deletions(-)
diff --git a/docs.config.js b/docs.config.js
index f5ca0dd01..f5b04f4b9 100644
--- a/docs.config.js
+++ b/docs.config.js
@@ -1,5 +1,8 @@
const config = {
- DOCS_LATEST_VERSION: '8.0'
+ DOCS_LATEST_VERSION: '8.0',
+ // sourcegraph.com proxies /docs/* to this site. Previews and local dev use the
+ // same basePath so a root-relative URL that 404s in production 404s there too.
+ DOCS_BASE_PATH: '/docs'
};
module.exports = config;
diff --git a/next.config.js b/next.config.js
index 84aca2adc..eea492352 100644
--- a/next.config.js
+++ b/next.config.js
@@ -1,22 +1,15 @@
const {PHASE_DEVELOPMENT_SERVER} = require('next/constants');
const {withContentlayer} = require('next-contentlayer2');
+const {DOCS_BASE_PATH} = require('./docs.config.js');
/** @type {import('next').NextConfig} */
-// sourcegraph.com proxies /docs/* to this site. Previews and local dev use the
-// same basePath so that a root-relative URL which 404s in production also 404s
-// there, instead of only breaking after deploy.
-const basePath = '/docs';
-
const nextConfig = {
reactStrictMode: true,
- basePath,
+ basePath: DOCS_BASE_PATH,
// Orb portals proxy the dev server through a different hostname.
allowedDevOrigins: process.env.PUBLIC_URL
? [new URL(process.env.PUBLIC_URL).hostname]
: [],
- env: {
- NEXT_PUBLIC_DOCS_BASE_PATH: basePath
- },
// Nothing serves `/`, so the deployment URLs Vercel links from Slack / GitHub
// and http://localhost:3000 would 404. sourcegraph.com never proxies `/` to
// us. Stay on the same host so visitors see this exact deployment.
@@ -24,7 +17,7 @@ const nextConfig = {
return [
{
source: '/',
- destination: basePath,
+ destination: DOCS_BASE_PATH,
basePath: false,
permanent: false
}
diff --git a/src/app/[...slug]/page.tsx b/src/app/[...slug]/page.tsx
index 3bc78c815..095d5871c 100644
--- a/src/app/[...slug]/page.tsx
+++ b/src/app/[...slug]/page.tsx
@@ -4,6 +4,7 @@ import {PreviewGuard} from '@/components/PreviewGuard';
import {PrevNextLinks} from '@/components/PrevNextLinks';
import {Prose} from '@/components/Prose';
import {TableOfContents} from '@/components/Toc';
+import {withBasePath} from '@/lib/utils';
import {allPosts} from 'contentlayer/generated';
import {getMDXComponent} from 'next-contentlayer2/hooks';
import {notFound} from 'next/navigation';
@@ -28,7 +29,7 @@ export const generateMetadata = async ({params}: Props) => {
const post = allPosts.find(post => post._raw.flattenedPath === path);
if (post && post.headings && post.headings.length > 0) {
const title = post.headings[0].title;
- const ogImageUrl = `${process.env.NEXT_PUBLIC_DOCS_BASE_PATH || ''}/api/og/${path}`;
+ const ogImageUrl = withBasePath(`/api/og/${path}`);
return {
title,
// The root layout's canonical is the landing page; without this
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 3408ec9c0..97a20de64 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,6 +1,7 @@
import {Providers} from '@/app/providers';
import {Layout} from '@/components/Layout';
import {TECHNICAL_CHANGELOG_RSS_URL} from '@/data/constants';
+import {withBasePath} from '@/lib/utils';
import clsx from 'clsx';
import config from 'docs.config';
import {type Metadata} from 'next';
@@ -26,11 +27,11 @@ export const metadata: Metadata = {
canonical: '/docs'
},
openGraph: {
- images: [{url: `${process.env.NEXT_PUBLIC_DOCS_BASE_PATH || ''}/api/og/index`, width: 1200, height: 630}]
+ images: [{url: withBasePath('/api/og/index'), width: 1200, height: 630}]
},
twitter: {
card: 'summary_large_image',
- images: [{url: `${process.env.NEXT_PUBLIC_DOCS_BASE_PATH || ''}/api/og/index`, width: 1200, height: 630}]
+ images: [{url: withBasePath('/api/og/index'), width: 1200, height: 630}]
}
};
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 01a78b0fe..11cd0c712 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,11 +1,12 @@
import MdxComponents from '@/components/MdxComponents';
import {Prose} from '@/components/Prose';
+import {withBasePath} from '@/lib/utils';
import {allPosts} from 'contentlayer/generated';
import {getMDXComponent} from 'next-contentlayer2/hooks';
import {notFound} from 'next/navigation';
export const generateMetadata = () => {
- const ogImageUrl = `${process.env.NEXT_PUBLIC_DOCS_BASE_PATH || ''}/api/og/index`;
+ const ogImageUrl = withBasePath('/api/og/index');
return {
openGraph: {
images: [{url: ogImageUrl, width: 1200, height: 630}]
diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx
index 2acb056e7..dc6119f74 100644
--- a/src/components/Logo.tsx
+++ b/src/components/Logo.tsx
@@ -1,18 +1,18 @@
-export function Logo(props: React.ComponentPropsWithoutRef<'svg'>) {
- const basePath = process.env.NEXT_PUBLIC_DOCS_BASE_PATH || '';
+import {withBasePath} from '@/lib/utils';
+export function Logo(props: React.ComponentPropsWithoutRef<'svg'>) {
return (
<>
{/* eslint-disable-next-line @next/next/no-img-element -- SVG logos do not need image optimization. */}
{/* eslint-disable-next-line @next/next/no-img-element -- SVG logos do not need image optimization. */}
>
diff --git a/src/components/NotFoundLinks.tsx b/src/components/NotFoundLinks.tsx
index 9d532cb7b..0d0230562 100644
--- a/src/components/NotFoundLinks.tsx
+++ b/src/components/NotFoundLinks.tsx
@@ -4,6 +4,7 @@ import {usePreviousPathname} from '@/components/PreviousPathname';
import Link from 'next/link';
import {usePathname} from 'next/navigation';
import {useEffect, useMemo, useState} from 'react';
+import config from 'docs.config';
const linkClassName =
'text-sm font-medium text-slate-900 hover:underline dark:text-white';
@@ -22,9 +23,6 @@ function nearestExistingAncestor(
return null;
}
-// Production serves the docs under /docs (basePath in next.config.js).
-const basePath = process.env.NEXT_PUBLIC_DOCS_BASE_PATH || '';
-
interface PageLink {
href: string;
pathname: string;
@@ -39,8 +37,8 @@ function docsReferrer(): PageLink | null {
if (!document.referrer) return null;
const referrer = new URL(document.referrer);
if (referrer.origin !== window.location.origin) return null;
- if (!referrer.pathname.startsWith(`${basePath}/`)) return null;
- const pathname = referrer.pathname.slice(basePath.length);
+ if (!referrer.pathname.startsWith(`${config.DOCS_BASE_PATH}/`)) return null;
+ const pathname = referrer.pathname.slice(config.DOCS_BASE_PATH.length);
// The home link already covers the root.
if (pathname === '/') return null;
return {href: pathname + referrer.search + referrer.hash, pathname};
diff --git a/src/components/ReleasesTable.tsx b/src/components/ReleasesTable.tsx
index 383830a37..bb81bc5f3 100644
--- a/src/components/ReleasesTable.tsx
+++ b/src/components/ReleasesTable.tsx
@@ -2,6 +2,7 @@
import {useEffect, useState} from 'react';
import Link from 'next/link';
+import {withBasePath} from '@/lib/utils';
type Release = {
id: number;
@@ -31,8 +32,7 @@ export function SupportedReleasesTable() {
const [error, setError] = useState(null);
useEffect(() => {
- const basePath = process.env.NEXT_PUBLIC_DOCS_BASE_PATH || '';
- fetch(`${basePath}/api/releases`)
+ fetch(withBasePath('/api/releases'))
.then(res => {
if (!res.ok) throw new Error('Failed to fetch releases');
return res.json();
diff --git a/src/components/Toc.tsx b/src/components/Toc.tsx
index 9d037c197..c62cae983 100644
--- a/src/components/Toc.tsx
+++ b/src/components/Toc.tsx
@@ -4,6 +4,7 @@ import clsx from 'clsx';
import Link from 'next/link';
import {useParams} from 'next/navigation';
import {useCallback, useEffect, useState} from 'react';
+import {withBasePath} from '@/lib/utils';
import {ClipboardIcon} from './icons/ClipboardIcon';
import {PencilIcon} from './icons/PencilIcon';
import {DocumentIcon} from './icons/DocumentIcon';
@@ -29,7 +30,6 @@ export function TableOfContents({headings, rawMarkdown, editPath}: Props) {
let [currentSection, setCurrentSection] = useState(headings[0]?.id);
const [copied, setCopied] = useState(false);
const params: ParamsType = useParams();
- const basePath = process.env.NEXT_PUBLIC_DOCS_BASE_PATH || '';
const handleCopyPage = useCallback(async () => {
if (!rawMarkdown) return;
@@ -168,7 +168,7 @@ export function TableOfContents({headings, rawMarkdown, editPath}: Props) {