diff --git a/docs-site/README.md b/docs-site/README.md index ad6272c3379..126e0aff210 100644 --- a/docs-site/README.md +++ b/docs-site/README.md @@ -17,13 +17,15 @@ npm install ### Setup Content -Link the documentation content from the parent `docs` directory: +Prepare the public documentation content from the parent `docs` directory: ```bash npm run link ``` -This creates a symbolic link from `../docs` to `content` in the project. +This creates a `content` directory with copies of the public docs sections. +Internal planning, design, and E2E notes remain outside the docs site content +tree. ### Development diff --git a/docs-site/package.json b/docs-site/package.json index 1b5af5ae55f..532699e110d 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -7,10 +7,10 @@ "type": "module", "main": "index.js", "scripts": { - "link": "ln -s ../docs content", + "link": "node scripts/link-public-docs.mjs", "clean": "rm -rf .next", "dev": "npm run clean && next --turbopack", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "vitest run --config vitest.config.js" }, "dependencies": { "next": "^16.0.8", @@ -18,5 +18,8 @@ "nextra-theme-docs": "^4.6.1", "react": "^19.2.1", "react-dom": "^19.2.1" + }, + "devDependencies": { + "vitest": "^3.2.4" } } diff --git a/docs-site/scripts/link-public-docs.mjs b/docs-site/scripts/link-public-docs.mjs new file mode 100644 index 00000000000..a57ec04f11f --- /dev/null +++ b/docs-site/scripts/link-public-docs.mjs @@ -0,0 +1,26 @@ +import { cp, mkdir, rm, symlink } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { PUBLIC_DOC_ROOTS } from '../src/app/public-docs.js'; + +const contentDir = 'content'; + +async function linkPublicDocs() { + try { + await rm(contentDir, { force: true, recursive: true }); + await mkdir(contentDir); + await cp('../docs/index.md', join(contentDir, 'index.md')); + await cp('../docs/_meta.ts', join(contentDir, '_meta.ts')); + + for (const root of PUBLIC_DOC_ROOTS) { + await symlink(join('..', '..', 'docs', root), join(contentDir, root)); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to link public docs into ${contentDir}: ${message}`, + ); + } +} + +await linkPublicDocs(); diff --git a/docs-site/src/app/[[...mdxPath]]/page.jsx b/docs-site/src/app/[[...mdxPath]]/page.jsx index c980e9f6075..85f6a3377b5 100644 --- a/docs-site/src/app/[[...mdxPath]]/page.jsx +++ b/docs-site/src/app/[[...mdxPath]]/page.jsx @@ -1,10 +1,23 @@ import { generateStaticParamsFor, importPage } from 'nextra/pages'; +import { notFound } from 'next/navigation'; import { useMDXComponents as getMDXComponents } from '../../../mdx-components'; +import { filterPublicStaticParams, isPublicDocsPath } from '../public-docs'; -export const generateStaticParams = generateStaticParamsFor('mdxPath'); +const generateAllStaticParams = generateStaticParamsFor('mdxPath'); + +export const dynamicParams = false; + +export async function generateStaticParams(...args) { + const staticParams = await generateAllStaticParams(...args); + return filterPublicStaticParams(staticParams); +} export async function generateMetadata(props) { const params = await props.params; + if (!isPublicDocsPath(params.mdxPath)) { + notFound(); + } + const { metadata } = await importPage(params.mdxPath); return metadata; } @@ -13,6 +26,10 @@ const Wrapper = getMDXComponents().wrapper; export default async function Page(props) { const params = await props.params; + if (!isPublicDocsPath(params.mdxPath)) { + notFound(); + } + const { default: MDXContent, toc, diff --git a/docs-site/src/app/[[...mdxPath]]/page.test.jsx b/docs-site/src/app/[[...mdxPath]]/page.test.jsx new file mode 100644 index 00000000000..af1caf2fce2 --- /dev/null +++ b/docs-site/src/app/[[...mdxPath]]/page.test.jsx @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const generateAllStaticParams = vi.fn(); + + return { + generateAllStaticParams, + generateStaticParamsFor: vi.fn(() => generateAllStaticParams), + }; +}); + +vi.mock('nextra/pages', () => ({ + generateStaticParamsFor: mocks.generateStaticParamsFor, + importPage: vi.fn(), +})); + +vi.mock('next/navigation', () => ({ + notFound: vi.fn(), +})); + +vi.mock('../../../mdx-components', () => ({ + useMDXComponents: () => ({ + wrapper: ({ children }) => children, + }), +})); + +describe('generateStaticParams', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('filters internal docs from Nextra static params', async () => { + mocks.generateAllStaticParams.mockResolvedValue([ + { mdxPath: [] }, + { mdxPath: ['users', 'foo'] }, + { mdxPath: ['en', 'users'] }, + { mdxPath: ['design', 'bar'] }, + { mdxPath: ['plans'] }, + ]); + + const { generateStaticParams } = await import('./page.jsx'); + + await expect(generateStaticParams()).resolves.toEqual([ + { mdxPath: [] }, + { mdxPath: ['users', 'foo'] }, + { mdxPath: ['en', 'users'] }, + ]); + }); + + it('fails closed if Nextra changes the static params shape', async () => { + mocks.generateAllStaticParams.mockResolvedValue([{ slug: ['users'] }]); + + const { generateStaticParams } = await import('./page.jsx'); + + await expect(generateStaticParams()).rejects.toThrow( + 'Expected generateStaticParamsFor("mdxPath") to return objects with an mdxPath array.', + ); + }); +}); diff --git a/docs-site/src/app/public-docs.js b/docs-site/src/app/public-docs.js new file mode 100644 index 00000000000..6e1d7c2a4f2 --- /dev/null +++ b/docs-site/src/app/public-docs.js @@ -0,0 +1,33 @@ +const LOCALE_SEGMENTS = new Set(['en', 'zh', 'de', 'fr', 'ja', 'ru', 'pt-BR']); + +// Keep this in sync with the public top-level page entries in docs/_meta.ts. +// docs-site/scripts/link-public-docs.mjs consumes the same allowlist. +export const PUBLIC_DOC_ROOTS = ['users', 'developers']; + +const PUBLIC_DOC_ROOT_SET = new Set(PUBLIC_DOC_ROOTS); + +function publicRootFromSegments(segments = []) { + if (segments.length === 0 || (segments.length === 1 && segments[0] === '')) { + return undefined; + } + + const rootIndex = LOCALE_SEGMENTS.has(segments[0]) ? 1 : 0; + return segments[rootIndex]; +} + +export function isPublicDocsPath(mdxPath = []) { + const root = publicRootFromSegments(mdxPath); + return root === undefined || PUBLIC_DOC_ROOT_SET.has(root); +} + +export function filterPublicStaticParams(staticParams = []) { + return staticParams.filter((staticParam) => { + if (!Array.isArray(staticParam?.mdxPath)) { + throw new TypeError( + 'Expected generateStaticParamsFor("mdxPath") to return objects with an mdxPath array.', + ); + } + + return isPublicDocsPath(staticParam.mdxPath); + }); +} diff --git a/docs-site/src/app/public-docs.test.js b/docs-site/src/app/public-docs.test.js new file mode 100644 index 00000000000..6583e6d443f --- /dev/null +++ b/docs-site/src/app/public-docs.test.js @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { filterPublicStaticParams, isPublicDocsPath } from './public-docs.js'; + +describe('isPublicDocsPath', () => { + it.each([ + [[], true], + [[''], true], + [['users', 'foo'], true], + [['design', 'bar'], false], + [['en', 'users'], true], + [['plans'], false], + [['en'], true], + ])('returns %s for %j', (mdxPath, expected) => { + expect(isPublicDocsPath(mdxPath)).toBe(expected); + }); +}); + +describe('filterPublicStaticParams', () => { + it('keeps public paths and rejects internal docs paths', () => { + expect( + filterPublicStaticParams([ + { mdxPath: [] }, + { mdxPath: [''] }, + { mdxPath: ['users', 'foo'] }, + { mdxPath: ['en', 'developers'] }, + { mdxPath: ['design', 'bar'] }, + { mdxPath: ['plans'] }, + ]), + ).toEqual([ + { mdxPath: [] }, + { mdxPath: [''] }, + { mdxPath: ['users', 'foo'] }, + { mdxPath: ['en', 'developers'] }, + ]); + }); + + it('fails closed if Nextra changes the static params shape', () => { + expect(() => filterPublicStaticParams([{ slug: ['users'] }])).toThrow( + 'Expected generateStaticParamsFor("mdxPath") to return objects with an mdxPath array.', + ); + }); +}); diff --git a/docs-site/vitest.config.js b/docs-site/vitest.config.js new file mode 100644 index 00000000000..aa08810cba2 --- /dev/null +++ b/docs-site/vitest.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.{js,jsx}'], + }, +}); diff --git a/docs/users/configuration/_meta.ts b/docs/users/configuration/_meta.ts index 8899eb91f88..af332d49620 100644 --- a/docs/users/configuration/_meta.ts +++ b/docs/users/configuration/_meta.ts @@ -1,9 +1,6 @@ export default { settings: 'Settings', auth: 'Authentication', - memory: { - display: 'hidden', - }, 'qwen-ignore': 'Ignoring Files', 'trusted-folders': 'Trusted Folders', themes: 'Themes',