Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/[[...path]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {Fragment, useMemo} from 'react';
import * as Sentry from '@sentry/nextjs';
import {getMDXComponent} from 'mdx-bundler/client';
import {Metadata} from 'next';
import {notFound} from 'next/navigation';

Expand All @@ -20,6 +19,7 @@ import {
getPreviousNode,
nodeForPath,
} from 'sentry-docs/docTree';
import {getMDXComponent} from 'sentry-docs/getMDXComponent';
import {isDeveloperDocs} from 'sentry-docs/isDeveloperDocs';
import {
getDevDocsFrontMatter,
Expand Down
21 changes: 18 additions & 3 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,29 @@
return slugs;
}

/**
* Generate sitemap from pre-computed doctree.
* If doctree isn't available (serverless edge case), return minimal sitemap.
* Fixes: DOCS-A3F
*/
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const rootNode = await getDocsRootNode();
const baseUrl = isDeveloperDocs
? 'https://develop.sentry.dev'
: 'https://docs.sentry.io';

const paths = extractSlugsFromDocTree(rootNode);
return docsToSitemap(paths, baseUrl);
try {
const rootNode = await getDocsRootNode();
const paths = extractSlugsFromDocTree(rootNode);
return docsToSitemap(paths, baseUrl);
} catch (error) {
// If doctree.json is not available in serverless function,
// return minimal sitemap with just the homepage.
// This prevents 500 errors on /sitemap.xml while the static
// sitemap is still served from CDN cache for most requests.
// Fixes: DOCS-A3F
console.warn('Sitemap: doctree not available, returning minimal sitemap', error);

Check warning on line 47 in app/sitemap.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
return [{url: baseUrl + '/'}];
}
}

function docsToSitemap(paths: string[], baseUrl: string): MetadataRoute.Sitemap {
Expand Down
30 changes: 17 additions & 13 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import {redirects} from './redirects.js';
// output is used at runtime, so bundling these ~150-200MB of dependencies would bloat
// functions unnecessarily and cause deployment failures.
//
// Note: mdx-bundler/client (getMDXComponent) is a tiny runtime module needed by
// app/[[...path]]/page.tsx, so we exclude only the build parts (dist/!(client)*).
// Note: We use a local getMDXComponent implementation (src/getMDXComponent.ts)
// instead of mdx-bundler/client to avoid CJS/ESM compatibility issues at runtime.
// This allows us to fully exclude mdx-bundler from serverless bundles.
// Fixes: DOCS-A0W
const outputFileTracingExcludes = process.env.NEXT_PUBLIC_DEVELOPER_DOCS
? {
'/**/*': [
Expand All @@ -30,9 +32,11 @@ const outputFileTracingExcludes = process.env.NEXT_PUBLIC_DEVELOPER_DOCS
'node_modules/@prettier/**/*',
'node_modules/sharp/**/*',
'node_modules/mermaid/**/*',
// Exclude MDX processing dependencies (but keep mdx-bundler/client for runtime)
'node_modules/mdx-bundler/dist/!(client*)',
'node_modules/mdx-bundler/node_modules/**/*',
// Exclude MDX processing dependencies
// Note: We use a local getMDXComponent implementation (src/getMDXComponent.ts)
// instead of mdx-bundler/client to avoid CJS/ESM compatibility issues at runtime.
// Fixes: DOCS-A0W
'node_modules/mdx-bundler/**/*',
'node_modules/rehype-preset-minify/**/*',
'node_modules/rehype-prism-plus/**/*',
'node_modules/rehype-prism-diff/**/*',
Expand All @@ -59,9 +63,11 @@ const outputFileTracingExcludes = process.env.NEXT_PUBLIC_DEVELOPER_DOCS
'node_modules/@prettier/**/*',
'node_modules/sharp/**/*',
'node_modules/mermaid/**/*',
// Exclude MDX processing dependencies (but keep mdx-bundler/client for runtime)
'node_modules/mdx-bundler/dist/!(client*)',
'node_modules/mdx-bundler/node_modules/**/*',
// Exclude MDX processing dependencies
// Note: We use a local getMDXComponent implementation (src/getMDXComponent.ts)
// instead of mdx-bundler/client to avoid CJS/ESM compatibility issues at runtime.
// Fixes: DOCS-A0W
'node_modules/mdx-bundler/**/*',
'node_modules/rehype-preset-minify/**/*',
'node_modules/rehype-prism-plus/**/*',
'node_modules/rehype-prism-diff/**/*',
Expand Down Expand Up @@ -139,11 +145,9 @@ const nextConfig = {
'@esbuild/linux-x64',
'@esbuild/win32-x64',
// Note: mdx-bundler is intentionally NOT in serverExternalPackages.
// The package is ESM-only ("type": "module") and cannot be require()'d at runtime.
// Keeping it out allows webpack to bundle mdx-bundler/client properly while
// outputFileTracingExcludes still prevents the heavy build-time parts from
// being included in the serverless function bundle.
// Fixes: DOCS-A0W
// We use a local getMDXComponent implementation (src/getMDXComponent.ts) and
// webpack bundles the mdx-bundler code that's imported for API pages.
// This avoids CJS/ESM compatibility issues at runtime. Fixes: DOCS-A0W
'sharp',
'@aws-sdk/client-s3',
'@google-cloud/storage',
Expand Down
46 changes: 44 additions & 2 deletions src/build/resolveOpenAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,29 @@

import {promises as fs} from 'fs';

import rehypeStringify from 'rehype-stringify';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import {unified} from 'unified';

import {DeRefedOpenAPI} from './open-api/types';

/**
* Compile markdown to HTML at build time.
* This avoids needing esbuild/mdx-bundler at runtime.
* Fixes: DOCS-A3H
*/
async function compileMarkdownToHtml(markdown: string): Promise<string> {
const result = await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeStringify)
.process(markdown);
return String(result);
}

// SENTRY_API_SCHEMA_SHA is used in the sentry-docs GHA workflow in getsentry/sentry-api-schema.
// DO NOT change variable name unless you change it in the sentry-docs GHA workflow in getsentry/sentry-api-schema.
const SENTRY_API_SCHEMA_SHA = 'b7cec710553c59d251cc6e50172c26455b53a640';
Expand Down Expand Up @@ -73,6 +94,9 @@ export type API = {
server: string;
slug: string;
bodyContentType?: string;
/** Pre-compiled HTML from descriptionMarkdown (built at build time to avoid runtime esbuild) */
descriptionHtml?: string;
/** Raw markdown description from OpenAPI spec */
descriptionMarkdown?: string;
requestBodyContent?: any;
security?: {[key: string]: string[]};
Expand Down Expand Up @@ -119,14 +143,18 @@ async function apiCategoriesUncached(): Promise<APICategory[]> {
};
});

// Collect all APIs first, then compile markdown in parallel
const apiPromises: Promise<void>[] = [];

Object.entries(data.paths).forEach(([apiPath, methods]) => {
Object.entries(methods).forEach(([method, apiData]) => {
let server = 'https://sentry.io';
if (apiData.servers && apiData.servers[0]) {
server = apiData.servers[0].url;
}

apiData.tags.forEach(tag => {
categoryMap[tag].apis.push({
const api: API = {
apiPath,
method,
name: apiData.operationId,
Expand Down Expand Up @@ -165,11 +193,25 @@ async function apiCategoriesUncached(): Promise<APICategory[]> {
...rest,
};
}),
});
};

categoryMap[tag].apis.push(api);

// Pre-compile markdown to HTML at build time (fixes DOCS-A3H)
if (api.descriptionMarkdown) {
apiPromises.push(
compileMarkdownToHtml(api.descriptionMarkdown).then(html => {
api.descriptionHtml = html;
})
);
}
});
});
});

// Wait for all markdown compilation to complete
await Promise.all(apiPromises);

const categories = Object.values(categoryMap);
categories.sort((a, b) => a.name.localeCompare(b.name));
categories.forEach(c => {
Expand Down
66 changes: 15 additions & 51 deletions src/components/apiPage/index.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import {Fragment, ReactElement, useMemo} from 'react';
import {bundleMDX} from 'mdx-bundler';
import {getMDXComponent} from 'mdx-bundler/client';
import {Fragment} from 'react';

import {type API} from 'sentry-docs/build/resolveOpenAPI';
import {mdxComponents} from 'sentry-docs/mdxComponents';
import remarkCodeTabs from 'sentry-docs/remark-code-tabs';
import remarkCodeTitles from 'sentry-docs/remark-code-title';

import './styles.scss';

import {ApiExamples} from '../apiExamples/apiExamples';
import {DocPage} from '../docPage';
import {SmartLink} from '../smartLink';

/**
* Renders pre-compiled HTML from markdown.
* The HTML is compiled at build time in resolveOpenAPI.ts to avoid
* needing esbuild/mdx-bundler at runtime.
* Fixes: DOCS-A3H
*/
function MarkdownHtml({html}: {html: string}) {
return <div dangerouslySetInnerHTML={{__html: html}} />;
}

function Params({params}) {
return (
<dl className="api-params">
Expand Down Expand Up @@ -59,7 +64,8 @@ function Params({params}) {
</ul>
</Fragment>
)}
{param.description && parseMarkdown(param.description)}
{/* Parameter descriptions are simple text, render directly */}
{param.description}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parameter descriptions render as plain text, not markdown

Medium Severity

API parameter descriptions are now rendered as plain text instead of being processed as markdown. The main API descriptionMarkdown field is pre-compiled to HTML at build time in resolveOpenAPI.ts, but parameter descriptions (path, query, body) receive no markdown processing. OpenAPI specs support CommonMark in description fields, so any markdown formatting like backticks, links, or emphasis in parameter descriptions will display as raw syntax instead of rendered HTML. This is inconsistent with how apiCategoryPage.tsx processes category descriptions with markdown2Html.

Fix in Cursor Fix in Web

</dd>
)}
</Fragment>
Expand All @@ -74,49 +80,6 @@ const getScopes = (data, securityScheme) => {
return obj[securityScheme];
};

// https://stackoverflow.com/a/38137700
function cssToObj(css) {
const obj = {},
s = css
.toLowerCase()
.replace(/-(.)/g, function (_, g) {
return g.toUpperCase();
})
.replace(/;\s?$/g, '')
.split(/:|;/g);
for (let i = 0; i < s.length; i += 2) {
obj[s[i].replace(/\s/g, '')] = s[i + 1].replace(/^\s+|\s+$/g, '');
}
return obj;
}

async function parseMarkdown(source: string): Promise<ReactElement> {
// Source uses string styles, but MDX requires object styles.
source = source.replace(/style="([^"]+)"/g, (_, style) => {
return `style={${JSON.stringify(cssToObj(style))}}`;
});
const {code} = await bundleMDX({
source,
cwd: process.cwd(),
mdxOptions(options) {
options.remarkPlugins = [remarkCodeTitles, remarkCodeTabs];
return options;
},
esbuildOptions: options => {
options.loader = {
...options.loader,
'.js': 'jsx',
};
return options;
},
});
function MDXLayoutRenderer({mdxSource, ...rest}) {
const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource]);
return <MDXLayout components={mdxComponents()} {...rest} />;
}
return <MDXLayoutRenderer mdxSource={code} />;
}

type Props = {
api: API;
};
Expand All @@ -141,7 +104,8 @@ export function ApiPage({api}: Props) {
<div className="w-full md:w-1/2">
{api.summary && <p>{api.summary}</p>}

{api.descriptionMarkdown && parseMarkdown(api.descriptionMarkdown)}
{/* Use pre-compiled HTML instead of runtime bundleMDX (fixes DOCS-A3H) */}
{api.descriptionHtml && <MarkdownHtml html={api.descriptionHtml} />}

{!!api.pathParameters.length && (
<div className="api-info-row">
Expand Down
2 changes: 1 addition & 1 deletion src/components/include.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {useMemo} from 'react';
import {getMDXComponent} from 'mdx-bundler/client';

import {getMDXComponent} from 'sentry-docs/getMDXComponent';
import {getFileBySlugWithCache} from 'sentry-docs/mdx';
import {mdxComponents} from 'sentry-docs/mdxComponents';

Expand Down
2 changes: 1 addition & 1 deletion src/components/platformContent.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import fs from 'fs';

import {cache, useMemo} from 'react';
import {getMDXComponent} from 'mdx-bundler/client';

import {getCurrentGuide, getDocsRootNode, getPlatform} from 'sentry-docs/docTree';
import {getMDXComponent} from 'sentry-docs/getMDXComponent';
import {getFileBySlugWithCache} from 'sentry-docs/mdx';
import {mdxComponents} from 'sentry-docs/mdxComponents';
import {serverContext} from 'sentry-docs/serverContext';
Expand Down
65 changes: 65 additions & 0 deletions src/getMDXComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Local implementation of getMDXComponent from mdx-bundler/client.
*
* This eliminates the runtime dependency on mdx-bundler/client which has
* CJS/ESM compatibility issues when loaded in Vercel serverless functions.
* The mdx-bundler package uses "type": "module" but the client/ subdirectory
* uses CommonJS require() to load ../dist/client.js, causing:
* "require() of ES Module not supported"
*
* Since getMDXComponent only needs React at runtime (and the compiled MDX code
* is just a string), we can safely inline this implementation.
*
* Fixes: DOCS-A0W
* @see https://github.com/kentcdodds/mdx-bundler/blob/main/src/client.js
*/
import type {ComponentType, FunctionComponent} from 'react';
// These namespace imports are required - the MDX runtime expects React, ReactDOM,
// and jsx_runtime objects in scope to call methods like React.createElement()
// eslint-disable-next-line no-restricted-imports
import * as React from 'react';
import * as jsxRuntime from 'react/jsx-runtime';
// eslint-disable-next-line no-restricted-imports
import * as ReactDOM from 'react-dom';

export interface MDXContentProps {
[key: string]: unknown;
components?: Record<string, ComponentType>;
}

/**
* Takes the compiled MDX code string from bundleMDX and returns a React component.
*
* @param code - The string of code you got from bundleMDX
* @param globals - Any variables your MDX needs to have accessible when it runs
* @returns A React component that renders the MDX content
*/
export function getMDXComponent(
code: string,
globals?: Record<string, unknown>
): FunctionComponent<MDXContentProps> {
const mdxExport = getMDXExport(code, globals);
return mdxExport.default;
}

/**
* Takes the compiled MDX code string from bundleMDX and returns all exports.
*
* @param code - The string of code you got from bundleMDX
* @param globals - Any variables your MDX needs to have accessible when it runs
* @returns All exports from the MDX module, including the default component
*/
export function getMDXExport<
ExportedObject = {default: FunctionComponent<MDXContentProps>},
>(code: string, globals?: Record<string, unknown>): ExportedObject {
const scope = {
React,
ReactDOM,
_jsx_runtime: jsxRuntime,
...globals,
};
// The MDX runtime requires dynamic function construction to evaluate compiled code
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
const fn = new Function(...Object.keys(scope), code);
return fn(...Object.values(scope));
}
Loading