Skip to content

Commit 776f87a

Browse files
authored
feat: Versioned Docs (#11290)
1 parent 1295964 commit 776f87a

File tree

23 files changed

+1341
-75
lines changed

23 files changed

+1341
-75
lines changed

app/[[...path]]/page.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,15 @@ import {
1616
nodeForPath,
1717
} from 'sentry-docs/docTree';
1818
import {isDeveloperDocs} from 'sentry-docs/isDeveloperDocs';
19-
import {getDevDocsFrontMatter, getDocsFrontMatter, getFileBySlug} from 'sentry-docs/mdx';
19+
import {
20+
getDevDocsFrontMatter,
21+
getDocsFrontMatter,
22+
getFileBySlug,
23+
getVersionsFromDoc,
24+
} from 'sentry-docs/mdx';
2025
import {mdxComponents} from 'sentry-docs/mdxComponents';
2126
import {setServerContext} from 'sentry-docs/serverContext';
27+
import {stripVersion} from 'sentry-docs/versioning';
2228

2329
export async function generateStaticParams() {
2430
const docs = await (isDeveloperDocs ? getDevDocsFrontMatter() : getDocsFrontMatter());
@@ -47,6 +53,7 @@ function MDXLayoutRenderer({mdxSource, ...rest}) {
4753
export default async function Page({params}: {params: {path?: string[]}}) {
4854
// get frontmatter of all docs in tree
4955
const rootNode = await getDocsRootNode();
56+
5057
setServerContext({
5158
rootNode,
5259
path: params.path ?? [],
@@ -88,6 +95,7 @@ export default async function Page({params}: {params: {path?: string[]}}) {
8895
}
8996

9097
const pageNode = nodeForPath(rootNode, params.path);
98+
9199
if (!pageNode) {
92100
// eslint-disable-next-line no-console
93101
console.warn('no page node', params.path);
@@ -108,8 +116,14 @@ export default async function Page({params}: {params: {path?: string[]}}) {
108116
}
109117
const {mdxSource, frontMatter} = doc;
110118

119+
// collect versioned files
120+
const allFm = await getDocsFrontMatter();
121+
const versions = getVersionsFromDoc(allFm, pageNode.path);
122+
111123
// pass frontmatter tree into sidebar, rendered page + fm into middle, headers into toc.
112-
return <MDXLayoutRenderer mdxSource={mdxSource} frontMatter={frontMatter} />;
124+
return (
125+
<MDXLayoutRenderer mdxSource={mdxSource} frontMatter={{...frontMatter, versions}} />
126+
);
113127
}
114128

115129
type MetadataProps = {
@@ -135,9 +149,13 @@ export async function generateMetadata({params}: MetadataProps): Promise<Metadat
135149
const rootNode = await getDocsRootNode();
136150

137151
if (params.path) {
138-
const pageNode = nodeForPath(rootNode, params.path);
152+
const pageNode = nodeForPath(
153+
rootNode,
154+
stripVersion(params.path.join('/')).split('/')
155+
);
139156
if (pageNode) {
140157
const guideOrPlatform = getCurrentPlatformOrGuide(rootNode, params.path);
158+
141159
title =
142160
pageNode.frontmatter.title +
143161
(guideOrPlatform ? ` | Sentry for ${guideOrPlatform.title}` : '');
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
title: Versioning
3+
sidebar_order: 2
4+
---
5+
6+
7+
Versioning allows for the maintenance of multiple versions of the same documentation, which is crucial for supporting different SDK releases. This guide explains how to create versioned documentation in the docs platform.
8+
9+
## Creating a Versioned Page
10+
11+
To create a versioned page:
12+
13+
1. Ensure the original page already exists.
14+
2. Create a new file with the same name as the original, but append `__v{VERSION}` (two underscores) to the filename before the extension.
15+
16+
**The version should follow one of these two formats:**
17+
- Major version only (for general version ranges): `__v{MAJOR}.x`
18+
- Full semantic version (for specific versions): `__v{MAJOR}.{MINOR}.{PATCH}`
19+
20+
For example:
21+
22+
```
23+
index.mdx (original)
24+
index__v7.x.mdx (version 7.x.x)
25+
index__v7.1.0.mdx (specific version 7.1.0)
26+
```
27+
28+
29+
## Versioning Platform Includes
30+
31+
Pages in the `platform-includes` folder can be versioned using the same method. However, the root page referencing these includes must also be versioned.
32+
33+
## File Structure Example
34+
35+
```
36+
docs/
37+
├── getting-started/
38+
│ ├── index.mdx
39+
│ └── index__v7.x.mdx
40+
└── platform-includes/
41+
└── configuration/
42+
├── example.mdx
43+
└── example__v7.x.mdx
44+
```
45+
46+
## Version Selection and User Experience
47+
48+
When multiple versions of a page exist:
49+
50+
1. A dropdown menu appears below the platform selector, displaying all available versions for the current file.
51+
52+
2. When a user selects a version:
53+
54+
- They are redirected to the chosen version of the page.
55+
- A preference is stored in localStorage, specific to the current guide or platform.
56+
57+
3. An alert is displayed when viewing "non-latest" pages to inform users they're not on the most recent version.
58+
59+
4. If a user has a stored version preference:
60+
61+
- The app will perform a client-side redirect to this version whenever it's available for other pages.
62+
63+
This approach ensures users can easily navigate between versions and keep their version preference across the documentation.
64+
65+
## Best Practices
66+
67+
1. Only add versioning to pages when necessary, to avoid content duplication and save build times.
68+
2. Ensure all related content (including `platform-includes`) is versioned consistently.
69+
70+
71+
## Limitations and Considerations
72+
73+
- Versioning is only available for existing pages.
74+
- The versioning system relies on the file naming convention, so it's important to follow the `__v{VERSION}` format precisely.
75+
76+
By following these guidelines, it's possible to effectively manage multiple versions of documentation, ensuring users can access the appropriate information for their specific version of the software or API.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"lint:prettier:fix": "prettier --write \"./{src,app,scripts}/**/*.{md,mdx,ts,tsx,js,jsx,mjs}\"",
3232
"lint:fix": "yarn run lint:prettier:fix && yarn run lint:eslint:fix",
3333
"sidecar": "yarn spotlight-sidecar",
34-
"test": "jest",
34+
"test": "vitest",
3535
"enforce-redirects": "node ./scripts/no-vercel-json-redirects.mjs"
3636
},
3737
"prisma": {
@@ -128,6 +128,8 @@
128128
"tailwindcss": "^3.4.1",
129129
"ts-node": "^10.9.1",
130130
"typescript": "^5",
131+
"vite-tsconfig-paths": "^5.0.1",
132+
"vitest": "^2.1.1",
131133
"ws": "^8.17.1"
132134
},
133135
"volta": {

src/__tests__/utils.test.js

Lines changed: 0 additions & 24 deletions
This file was deleted.

src/components/docPage/index.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {getCurrentGuide, getCurrentPlatform, nodeForPath} from 'sentry-docs/docT
44
import {serverContext} from 'sentry-docs/serverContext';
55
import {FrontMatter} from 'sentry-docs/types';
66
import {isTruthy} from 'sentry-docs/utils';
7+
import {getUnversionedPath} from 'sentry-docs/versioning';
78

89
import './type.scss';
910

@@ -43,14 +44,18 @@ export function DocPage({
4344

4445
const searchPlatforms = [currentPlatform?.name, currentGuide?.name].filter(isTruthy);
4546

46-
const leafNode = nodeForPath(rootNode, path);
47+
const unversionedPath = getUnversionedPath(path, false);
48+
49+
const leafNode = nodeForPath(rootNode, unversionedPath);
4750

4851
return (
4952
<div className="tw-app">
5053
<Header pathname={pathname} searchPlatforms={searchPlatforms} />
5154

5255
<section className="px-0 flex relative">
53-
{sidebar ?? <Sidebar path={path} />}
56+
{sidebar ?? (
57+
<Sidebar path={unversionedPath.split('/')} versions={frontMatter.versions} />
58+
)}
5459
<main className="main-content flex w-full mt-[var(--header-height)] flex-1 mx-auto">
5560
<div
5661
className={[

src/components/dynamicNav.tsx

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {Fragment} from 'react';
22

33
import {serverContext} from 'sentry-docs/serverContext';
44
import {sortPages} from 'sentry-docs/utils';
5+
import {getUnversionedPath, VERSION_INDICATOR} from 'sentry-docs/versioning';
56

67
import {NavChevron} from './sidebar/navChevron';
78
import {SidebarLink} from './sidebarLink';
@@ -30,28 +31,30 @@ export interface EntityTree extends Entity<EntityTree> {}
3031
export const toTree = (nodeList: Node[]): EntityTree[] => {
3132
const result: EntityTree[] = [];
3233
const level = {result};
33-
3434
nodeList
3535
.sort((a, b) => a.path.localeCompare(b.path))
3636
.forEach(node => {
3737
let curPath = '';
38-
node.path.split('/').reduce((r, name: string) => {
39-
curPath += `${name}/`;
40-
if (!r[name]) {
41-
r[name] = {result: []};
42-
r.result.push({
43-
name,
44-
children: r[name].result,
45-
node: curPath === node.path ? node : null,
46-
});
47-
}
48-
49-
return r[name];
50-
}, level);
38+
39+
// hide versioned pages in sidebar
40+
if (!node.path.includes(VERSION_INDICATOR)) {
41+
node.path.split('/').reduce((r, name: string) => {
42+
curPath += `${name}/`;
43+
if (!r[name]) {
44+
r[name] = {result: []};
45+
r.result.push({
46+
name,
47+
children: r[name].result,
48+
node: curPath === node.path ? node : null,
49+
});
50+
}
51+
52+
return r[name];
53+
}, level);
54+
}
5155
});
5256

53-
result.length; // result[0] is undefined without this. wat
54-
return result[0].children;
57+
return result.length > 0 ? result[0].children : [];
5558
};
5659

5760
export const renderChildren = (
@@ -164,7 +167,7 @@ export function DynamicNav({
164167
parentNode && !noHeadingLink ? (
165168
<SmartLink
166169
to={`/${root}/`}
167-
className={`${headerClassName} ${path.join('/') === root ? 'active' : ''} justify-between`}
170+
className={`${headerClassName} ${getUnversionedPath(path, false) === root ? 'active' : ''} justify-between`}
168171
activeClassName="active"
169172
data-sidebar-link
170173
>

src/components/platformCategorySection.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ const isSupported = (
1919
return false;
2020
}
2121

22-
// @ts-ignore
23-
const categories = Object.values(platformOrGuide.categories) as string[];
22+
const categories = (platformOrGuide.categories || []) as string[];
2423

2524
if (supported.length && !supported.some(v => categories.includes(v))) {
2625
return false;

src/components/platformContent.tsx

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import fs from 'fs';
2+
13
import {useMemo} from 'react';
24
import {getMDXComponent} from 'mdx-bundler/client';
35

46
import {getCurrentGuide, getDocsRootNode, getPlatform} from 'sentry-docs/docTree';
57
import {getFileBySlug} from 'sentry-docs/mdx';
68
import {mdxComponents} from 'sentry-docs/mdxComponents';
79
import {serverContext} from 'sentry-docs/serverContext';
10+
import {isVersioned, stripVersion} from 'sentry-docs/versioning';
811

912
import {Include} from './include';
1013

@@ -16,6 +19,21 @@ type Props = {
1619
platform?: string;
1720
};
1821

22+
const udpatePathIfVersionedFileDoesNotExist = (path: string): string => {
23+
if (!isVersioned(path)) {
24+
return path;
25+
}
26+
// Add .mdx extension if not present
27+
const pathWithExtension =
28+
path.endsWith('.mdx') || path.endsWith('.md') ? path : `${path}.mdx`;
29+
30+
if (isVersioned(pathWithExtension) && !fs.existsSync(pathWithExtension)) {
31+
return stripVersion(path);
32+
}
33+
34+
return path;
35+
};
36+
1937
export async function PlatformContent({includePath, platform, noGuides}: Props) {
2038
const {path} = serverContext();
2139

@@ -32,9 +50,13 @@ export async function PlatformContent({includePath, platform, noGuides}: Props)
3250
}
3351

3452
let doc: Awaited<ReturnType<typeof getFileBySlug>> | null = null;
53+
3554
if (guide) {
55+
const guidePath = udpatePathIfVersionedFileDoesNotExist(
56+
`platform-includes/${includePath}/${guide}`
57+
);
3658
try {
37-
doc = await getFileBySlug(`platform-includes/${includePath}/${guide}`);
59+
doc = await getFileBySlug(guidePath);
3860
} catch (e) {
3961
// It's fine - keep looking.
4062
}
@@ -44,11 +66,13 @@ export async function PlatformContent({includePath, platform, noGuides}: Props)
4466
const rootNode = await getDocsRootNode();
4567
const guideObject = getCurrentGuide(rootNode, path);
4668

69+
const fallbackGuidePath = udpatePathIfVersionedFileDoesNotExist(
70+
`platform-includes/${includePath}/${guideObject?.fallbackGuide}`
71+
);
72+
4773
if (guideObject?.fallbackGuide) {
4874
try {
49-
doc = await getFileBySlug(
50-
`platform-includes/${includePath}/${guideObject.fallbackGuide}`
51-
);
75+
doc = await getFileBySlug(fallbackGuidePath);
5276
} catch (e) {
5377
// It's fine - keep looking.
5478
}
@@ -57,7 +81,11 @@ export async function PlatformContent({includePath, platform, noGuides}: Props)
5781

5882
if (!doc) {
5983
try {
60-
doc = await getFileBySlug(`platform-includes/${includePath}/${platform}`);
84+
const platformPath = udpatePathIfVersionedFileDoesNotExist(
85+
`platform-includes/${includePath}/${platform}`
86+
);
87+
88+
doc = await getFileBySlug(platformPath);
6189
} catch (e) {
6290
// It's fine - keep looking.
6391
}
@@ -66,11 +94,14 @@ export async function PlatformContent({includePath, platform, noGuides}: Props)
6694
if (!doc) {
6795
const rootNode = await getDocsRootNode();
6896
const platformObject = getPlatform(rootNode, platform);
97+
98+
const fallbackPlatformPath = udpatePathIfVersionedFileDoesNotExist(
99+
`platform-includes/${includePath}/${platformObject?.fallbackPlatform}`
100+
);
101+
69102
if (platformObject?.fallbackPlatform) {
70103
try {
71-
doc = await getFileBySlug(
72-
`platform-includes/${includePath}/${platformObject.fallbackPlatform}`
73-
);
104+
doc = await getFileBySlug(fallbackPlatformPath);
74105
} catch (e) {
75106
// It's fine - keep looking.
76107
}

src/components/platformSection.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export function PlatformSection({
4545
}: Props) {
4646
const {rootNode, path} = serverContext();
4747
const currentPlatformOrGuide = getCurrentPlatformOrGuide(rootNode, path);
48+
4849
if (!currentPlatformOrGuide) {
4950
return null;
5051
}

0 commit comments

Comments
 (0)