Skip to content

Commit 5d27272

Browse files
author
Shannon Anahata
committed
Fix hydration errors by moving image path resolution to build time
1 parent ad4240c commit 5d27272

File tree

6 files changed

+133
-33
lines changed

6 files changed

+133
-33
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
description:
3+
globs:
4+
alwaysApply: false
5+
---

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
"sidecar": "yarn spotlight-sidecar",
3333
"test": "vitest",
3434
"test:ci": "vitest run",
35-
"enforce-redirects": "node ./scripts/no-vercel-json-redirects.mjs"
35+
"enforce-redirects": "node ./scripts/no-vercel-json-redirects.mjs",
36+
"prebuild": "node scripts/copy-mdx-images.js"
3637
},
3738
"dependencies": {
3839
"@ariakit/react": "^0.4.5",

scripts/copy-mdx-images.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const glob = require('glob');
4+
5+
const DOCS_DIR = path.join(__dirname, '..', 'docs');
6+
const PUBLIC_MDX_IMAGES = path.join(__dirname, '..', 'public', 'mdx-images');
7+
8+
function encodeImagePath(mdxFile, imagePath) {
9+
// Get the absolute path to the image
10+
const mdxDir = path.dirname(mdxFile);
11+
const absImagePath = path.resolve(mdxDir, imagePath);
12+
// Get the path relative to the docs root
13+
let relPath = path.relative(DOCS_DIR, absImagePath);
14+
// Replace path separators with dashes
15+
relPath = relPath.replace(/[\\/]/g, '-');
16+
return relPath;
17+
}
18+
19+
function ensureDirExists(dir) {
20+
if (!fs.existsSync(dir)) {
21+
fs.mkdirSync(dir, {recursive: true});
22+
}
23+
}
24+
25+
function copyImages() {
26+
ensureDirExists(PUBLIC_MDX_IMAGES);
27+
28+
// Find all MDX files in docs/
29+
const mdxFiles = glob.sync(path.join(DOCS_DIR, '**/*.mdx'));
30+
console.log(`Found ${mdxFiles.length} MDX files`);
31+
32+
// Match both ./img/ and ../img/ patterns
33+
const imageRegex = /!\[[^\]]*\]\((\.\.?\/img\/[^")]+)\)/g;
34+
35+
let copied = 0;
36+
mdxFiles.forEach(mdxFile => {
37+
const content = fs.readFileSync(mdxFile, 'utf8');
38+
const matches = [...content.matchAll(imageRegex)];
39+
for (const match of matches) {
40+
const imagePath = match[1];
41+
const encodedName = encodeImagePath(mdxFile, imagePath);
42+
const src = path.resolve(path.dirname(mdxFile), imagePath);
43+
const dest = path.join(PUBLIC_MDX_IMAGES, encodedName);
44+
45+
if (fs.existsSync(src)) {
46+
// Create the destination directory if it doesn't exist
47+
const destDir = path.dirname(dest);
48+
if (!fs.existsSync(destDir)) {
49+
fs.mkdirSync(destDir, {recursive: true});
50+
}
51+
fs.copyFileSync(src, dest);
52+
copied++;
53+
console.log(`Copied: ${src} -> ${dest}`);
54+
} else {
55+
console.warn(`Image not found: ${src} (referenced in ${mdxFile})`);
56+
}
57+
}
58+
});
59+
60+
// Also copy all images from img directories directly
61+
const imgDirs = glob.sync(path.join(DOCS_DIR, '**/img'));
62+
console.log(`\nFound ${imgDirs.length} img directories:`);
63+
imgDirs.forEach(dir => console.log(`- ${path.relative(DOCS_DIR, dir)}`));
64+
65+
imgDirs.forEach(imgDir => {
66+
const files = fs.readdirSync(imgDir);
67+
const imageFiles = files.filter(file => file.match(/\.(png|jpg|jpeg|gif)$/i));
68+
console.log(`\nFound ${imageFiles.length} images in ${path.relative(DOCS_DIR, imgDir)}:`);
69+
imageFiles.forEach(file => console.log(`- ${file}`));
70+
71+
imageFiles.forEach(file => {
72+
const src = path.join(imgDir, file);
73+
// The MDX plugin expects paths like /mdx-images/img-filename.png
74+
const encodedName = `img-${file}`;
75+
const dest = path.join(PUBLIC_MDX_IMAGES, encodedName);
76+
77+
if (!fs.existsSync(dest)) {
78+
fs.copyFileSync(src, dest);
79+
copied++;
80+
console.log(`Copied: ${src} -> ${dest}`);
81+
} else {
82+
console.log(`Skipped (already exists): ${src}`);
83+
}
84+
});
85+
});
86+
87+
console.log(`\nTotal images copied: ${copied}`);
88+
console.log(`Images are in: ${PUBLIC_MDX_IMAGES}`);
89+
}
90+
91+
copyImages();

src/components/docImage.tsx

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,20 @@
1-
import path from 'path';
2-
31
import Image from 'next/image';
42

5-
import {serverContext} from 'sentry-docs/serverContext';
6-
73
export default function DocImage({
84
src,
95
...props
106
}: Omit<React.HTMLProps<HTMLImageElement>, 'ref' | 'placeholder'>) {
11-
const {path: pagePath} = serverContext();
12-
137
if (!src) {
148
return null;
159
}
1610

17-
// Next.js Image component only supports images from the public folder
18-
// or from a remote server with properly configured domain
11+
// Remote images: render as <img>
1912
if (src.startsWith('http')) {
2013
// eslint-disable-next-line @next/next/no-img-element
2114
return <img src={src} {...props} />;
2215
}
2316

24-
// If the image src is not an absolute URL, we assume it's a relative path
25-
// and we prepend /mdx-images/ to it.
26-
if (src.startsWith('./')) {
27-
src = path.join('/mdx-images', src);
28-
}
29-
// account for the old way of doing things where the public folder structure mirrored the docs folder
30-
else if (!src?.startsWith('/') && !src?.includes('://')) {
31-
src = `/${pagePath.join('/')}/${src}`;
32-
}
33-
34-
// parse the size from the URL hash (set by remark-image-size.js)
17+
// Parse width/height from hash (set by remark-image-size.js)
3518
const srcURL = new URL(src, 'https://example.com');
3619
const imgPath = srcURL.pathname;
3720
const [width, height] = srcURL.hash // #wxh

src/mdx.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,11 @@ export async function getFileBySlug(slug: string) {
381381
remarkGfm,
382382
remarkDefList,
383383
remarkFormatCodeBlocks,
384-
[remarkImageSize, {sourceFolder: cwd, publicFolder: path.join(root, 'public')}],
384+
[remarkImageSize, {
385+
sourceFolder: cwd,
386+
publicFolder: path.join(root, 'public'),
387+
mdxFilePath: sourcePath
388+
}],
385389
remarkMdxImages,
386390
remarkCodeTitles,
387391
remarkCodeTabs,

src/remark-image-size.js

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,40 @@ import getImageSize from 'image-size';
44
import {visit} from 'unist-util-visit';
55

66
/**
7-
* appends the image size to the image url as a hash e.g. /img.png -> /img.png#100x100
8-
* the size is consumed by docImage.tsx and passed down to next/image
9-
* **this is a hack!**, there's probably a better way to set image node properties
10-
* but adding a hash to the url seems like a very low risk way to do it 🙈
7+
* Appends the image size to the image url as a hash e.g. /img.png -> /img.png#100x100
8+
* and resolves all local image paths to /mdx-images/... at build time.
9+
* Uses the full relative path from the MDX file to the image, encoded to avoid collisions.
10+
* This ensures deterministic, absolute image paths for hydration safety.
11+
*
12+
* Requires options.mdxFilePath to be set to the absolute path of the current MDX file.
1113
*/
1214
export default function remarkImageSize(options) {
1315
return tree =>
1416
visit(tree, 'image', node => {
15-
// don't process external images
17+
// Remote images: leave as-is
1618
if (node.url.startsWith('http')) {
1719
return;
1820
}
19-
const fullImagePath = path.join(
20-
// if the path starts with / it's a public asset, otherwise it's a relative path
21-
node.url.startsWith('/') ? options.publicFolder : options.sourceFolder,
22-
node.url
23-
);
24-
const imageSize = getImageSize(fullImagePath);
25-
node.url = node.url + `#${imageSize.width}x${imageSize.height}`;
21+
22+
// Public images (start with /): ensure absolute
23+
if (node.url.startsWith('/')) {
24+
const fullImagePath = path.join(options.publicFolder, node.url);
25+
const imageSize = getImageSize(fullImagePath);
26+
// Leave the path as-is, just append the size hash
27+
node.url = node.url + `#${imageSize.width}x${imageSize.height}`;
28+
return;
29+
}
30+
31+
// Local images (relative paths): resolve to /mdx-images/encoded-path-filename.ext
32+
// Compute the absolute path to the image
33+
const mdxDir = path.dirname(options.mdxFilePath);
34+
const absImagePath = path.resolve(mdxDir, node.url);
35+
const imageSize = getImageSize(absImagePath);
36+
37+
// Create a unique, encoded path for the image (e.g., docs-foo-bar-img-foo.png)
38+
// Remove the workspace root and replace path separators with dashes
39+
let relPath = path.relative(options.sourceFolder, absImagePath);
40+
relPath = relPath.replace(/[\\/]/g, '-');
41+
node.url = `/mdx-images/${relPath}#${imageSize.width}x${imageSize.height}`;
2642
});
2743
}

0 commit comments

Comments
 (0)