diff --git a/.cursor/hydration-error-refactor-guide.mdc b/.cursor/hydration-error-refactor-guide.mdc new file mode 100644 index 0000000000000..b93c988bf44d9 --- /dev/null +++ b/.cursor/hydration-error-refactor-guide.mdc @@ -0,0 +1,5 @@ +--- +description: +globs: +alwaysApply: false +--- diff --git a/package.json b/package.json index fbe70c5297a24..f28345c66f5b0 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "sidecar": "yarn spotlight-sidecar", "test": "vitest", "test:ci": "vitest run", - "enforce-redirects": "node ./scripts/no-vercel-json-redirects.mjs" + "enforce-redirects": "node ./scripts/no-vercel-json-redirects.mjs", + "prebuild": "node scripts/copy-mdx-images.js" }, "dependencies": { "@ariakit/react": "^0.4.5", diff --git a/scripts/copy-mdx-images.js b/scripts/copy-mdx-images.js new file mode 100644 index 0000000000000..8cdf0d2f3801d --- /dev/null +++ b/scripts/copy-mdx-images.js @@ -0,0 +1,93 @@ +const fs = require('fs'); +const path = require('path'); +const glob = require('glob'); + +const DOCS_DIR = path.join(__dirname, '..', 'docs'); +const PUBLIC_MDX_IMAGES = path.join(__dirname, '..', 'public', 'mdx-images'); + +function encodeImagePath(mdxFile, imagePath) { + // Get the absolute path to the image + const mdxDir = path.dirname(mdxFile); + const absImagePath = path.resolve(mdxDir, imagePath); + // Get the path relative to the docs root + let relPath = path.relative(DOCS_DIR, absImagePath); + // Replace path separators with dashes + relPath = relPath.replace(/[\\/]/g, '-'); + return relPath; +} + +function ensureDirExists(dir) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, {recursive: true}); + } +} + +function copyImages() { + ensureDirExists(PUBLIC_MDX_IMAGES); + + // Find all MDX files in docs/ + const mdxFiles = glob.sync(path.join(DOCS_DIR, '**/*.mdx')); + console.log(`Found ${mdxFiles.length} MDX files`); + + // Match both ./img/ and ../img/ patterns + const imageRegex = /!\[[^\]]*\]\((\.\.?\/img\/[^")]+)\)/g; + + let copied = 0; + mdxFiles.forEach(mdxFile => { + const content = fs.readFileSync(mdxFile, 'utf8'); + const matches = [...content.matchAll(imageRegex)]; + for (const match of matches) { + const imagePath = match[1]; + const encodedName = encodeImagePath(mdxFile, imagePath); + const src = path.resolve(path.dirname(mdxFile), imagePath); + const dest = path.join(PUBLIC_MDX_IMAGES, encodedName); + + if (fs.existsSync(src)) { + // Create the destination directory if it doesn't exist + const destDir = path.dirname(dest); + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, {recursive: true}); + } + fs.copyFileSync(src, dest); + copied++; + console.log(`Copied: ${src} -> ${dest}`); + } else { + console.warn(`Image not found: ${src} (referenced in ${mdxFile})`); + } + } + }); + + // Also copy all images from img directories directly + const imgDirs = glob.sync(path.join(DOCS_DIR, '**/img')); + console.log(`\nFound ${imgDirs.length} img directories:`); + imgDirs.forEach(dir => console.log(`- ${path.relative(DOCS_DIR, dir)}`)); + + imgDirs.forEach(imgDir => { + const files = fs.readdirSync(imgDir); + const imageFiles = files.filter(file => file.match(/\.(png|jpg|jpeg|gif)$/i)); + console.log( + `\nFound ${imageFiles.length} images in ${path.relative(DOCS_DIR, imgDir)}:` + ); + imageFiles.forEach(file => console.log(`- ${file}`)); + + imageFiles.forEach(file => { + const src = path.join(imgDir, file); + // The MDX plugin expects paths like /mdx-images/img-filename.png + const encodedName = `img-${file}`; + const dest = path.join(PUBLIC_MDX_IMAGES, encodedName); + + if (!fs.existsSync(dest)) { + fs.copyFileSync(src, dest); + copied++; + console.log(`Copied: ${src} -> ${dest}`); + } else { + console.log(`Skipped (already exists): ${src}`); + } + }); + }); + + console.log(`\nTotal images copied: ${copied}`); + console.log(`Images are in: ${PUBLIC_MDX_IMAGES}`); +} + +copyImages(); diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index cef75042244ce..338061896a82c 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -1,37 +1,20 @@ -import path from 'path'; - import Image from 'next/image'; -import {serverContext} from 'sentry-docs/serverContext'; - export default function DocImage({ src, ...props }: Omit, 'ref' | 'placeholder'>) { - const {path: pagePath} = serverContext(); - if (!src) { return null; } - // Next.js Image component only supports images from the public folder - // or from a remote server with properly configured domain + // Remote images: render as if (src.startsWith('http')) { // eslint-disable-next-line @next/next/no-img-element return ; } - // If the image src is not an absolute URL, we assume it's a relative path - // and we prepend /mdx-images/ to it. - if (src.startsWith('./')) { - src = path.join('/mdx-images', src); - } - // account for the old way of doing things where the public folder structure mirrored the docs folder - else if (!src?.startsWith('/') && !src?.includes('://')) { - src = `/${pagePath.join('/')}/${src}`; - } - - // parse the size from the URL hash (set by remark-image-size.js) + // Parse width/height from hash (set by remark-image-size.js) const srcURL = new URL(src, 'https://example.com'); const imgPath = srcURL.pathname; const [width, height] = srcURL.hash // #wxh diff --git a/src/mdx.ts b/src/mdx.ts index 3eb125b0f344b..daef6088f8cf0 100644 --- a/src/mdx.ts +++ b/src/mdx.ts @@ -381,7 +381,14 @@ export async function getFileBySlug(slug: string) { remarkGfm, remarkDefList, remarkFormatCodeBlocks, - [remarkImageSize, {sourceFolder: cwd, publicFolder: path.join(root, 'public')}], + [ + remarkImageSize, + { + sourceFolder: cwd, + publicFolder: path.join(root, 'public'), + mdxFilePath: sourcePath, + }, + ], remarkMdxImages, remarkCodeTitles, remarkCodeTabs, diff --git a/src/remark-image-size.js b/src/remark-image-size.js index b934168a09c6f..5d0eb3662a635 100644 --- a/src/remark-image-size.js +++ b/src/remark-image-size.js @@ -4,24 +4,40 @@ import getImageSize from 'image-size'; import {visit} from 'unist-util-visit'; /** - * appends the image size to the image url as a hash e.g. /img.png -> /img.png#100x100 - * the size is consumed by docImage.tsx and passed down to next/image - * **this is a hack!**, there's probably a better way to set image node properties - * but adding a hash to the url seems like a very low risk way to do it 🙈 + * Appends the image size to the image url as a hash e.g. /img.png -> /img.png#100x100 + * and resolves all local image paths to /mdx-images/... at build time. + * Uses the full relative path from the MDX file to the image, encoded to avoid collisions. + * This ensures deterministic, absolute image paths for hydration safety. + * + * Requires options.mdxFilePath to be set to the absolute path of the current MDX file. */ export default function remarkImageSize(options) { return tree => visit(tree, 'image', node => { - // don't process external images + // Remote images: leave as-is if (node.url.startsWith('http')) { return; } - const fullImagePath = path.join( - // if the path starts with / it's a public asset, otherwise it's a relative path - node.url.startsWith('/') ? options.publicFolder : options.sourceFolder, - node.url - ); - const imageSize = getImageSize(fullImagePath); - node.url = node.url + `#${imageSize.width}x${imageSize.height}`; + + // Public images (start with /): ensure absolute + if (node.url.startsWith('/')) { + const fullImagePath = path.join(options.publicFolder, node.url); + const imageSize = getImageSize(fullImagePath); + // Leave the path as-is, just append the size hash + node.url = node.url + `#${imageSize.width}x${imageSize.height}`; + return; + } + + // Local images (relative paths): resolve to /mdx-images/encoded-path-filename.ext + // Compute the absolute path to the image + const mdxDir = path.dirname(options.mdxFilePath); + const absImagePath = path.resolve(mdxDir, node.url); + const imageSize = getImageSize(absImagePath); + + // Create a unique, encoded path for the image (e.g., docs-foo-bar-img-foo.png) + // Remove the workspace root and replace path separators with dashes + let relPath = path.relative(options.sourceFolder, absImagePath); + relPath = relPath.replace(/[\\/]/g, '-'); + node.url = `/mdx-images/${relPath}#${imageSize.width}x${imageSize.height}`; }); }