diff --git a/app/globals.css b/app/globals.css index de7c493bb4107e..f338ee8f47e6d6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -177,6 +177,6 @@ body { .onboarding-step .step-heading::before, .onboarding-step h2::before { - content: "Step " counter(onboarding-step) ": "; + content: 'Step ' counter(onboarding-step) ': '; font-weight: inherit; } diff --git a/next.config.ts b/next.config.ts index 3ab0fb515b6808..104aa5d27992dc 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,7 @@ import {codecovNextJSWebpackPlugin} from '@codecov/nextjs-webpack-plugin'; import {withSentryConfig} from '@sentry/nextjs'; import {redirects} from './redirects.js'; +import {REMOTE_IMAGE_PATTERNS} from './src/config/images'; const outputFileTracingExcludes = process.env.NEXT_PUBLIC_DEVELOPER_DOCS ? { @@ -54,6 +55,10 @@ const nextConfig = { trailingSlash: true, serverExternalPackages: ['rehype-preset-minify'], outputFileTracingExcludes, + images: { + contentDispositionType: 'inline', // "open image in new tab" instead of downloading + remotePatterns: REMOTE_IMAGE_PATTERNS, + }, webpack: (config, options) => { config.plugins.push( codecovNextJSWebpackPlugin({ @@ -71,7 +76,7 @@ const nextConfig = { DEVELOPER_DOCS_: process.env.NEXT_PUBLIC_DEVELOPER_DOCS, }, redirects, - rewrites: async () => [ + rewrites: () => [ { source: '/:path*.md', destination: '/md-exports/:path*.md', diff --git a/package.json b/package.json index e5063ea4b06a6a..a48b7410e46d1f 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@prettier/plugin-xml": "^3.3.1", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-collapsible": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-tabs": "^1.1.1", diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index cef75042244cee..dd207908314117 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -1,11 +1,68 @@ import path from 'path'; -import Image from 'next/image'; - +import {isExternalImage} from 'sentry-docs/config/images'; import {serverContext} from 'sentry-docs/serverContext'; +import {ImageLightbox} from './imageLightbox'; + +// Helper function to safely parse dimension values +const parseDimension = (value: string | number | undefined): number | undefined => { + if (typeof value === 'number' && value > 0 && value <= 10000) return value; + if (typeof value === 'string') { + const parsed = parseInt(value, 10); + return parsed > 0 && parsed <= 10000 ? parsed : undefined; + } + return undefined; +}; + +// Dimension pattern regex - used to identify dimension hashes like "800x600" +const DIMENSION_PATTERN = /^(\d+)x(\d+)$/; + +// Helper function to extract hash from URL string (works with both relative and absolute URLs) +const extractHash = (url: string): string => { + const hashIndex = url.indexOf('#'); + return hashIndex !== -1 ? url.slice(hashIndex + 1) : ''; +}; + +// Helper function to check if a hash contains dimension information +const isDimensionHash = (hash: string): boolean => { + return DIMENSION_PATTERN.test(hash); +}; + +// Helper function to parse dimensions from URL hash +const parseDimensionsFromHash = (url: string): number[] => { + const hash = extractHash(url); + const match = hash.match(DIMENSION_PATTERN); + + if (match) { + const width = parseInt(match[1], 10); + const height = parseInt(match[2], 10); + return width > 0 && width <= 10000 && height > 0 && height <= 10000 + ? [width, height] + : []; + } + + return []; +}; + +// Helper function to remove dimension hash from URL while preserving fragment identifiers +const cleanUrl = (url: string): string => { + const hash = extractHash(url); + + // If no hash or hash is not a dimension pattern, return original URL + if (!hash || !isDimensionHash(hash)) { + return url; + } + + // Remove dimension hash + const hashIndex = url.indexOf('#'); + return url.slice(0, hashIndex); +}; + export default function DocImage({ src, + width: propsWidth, + height: propsHeight, ...props }: Omit, 'ref' | 'placeholder'>) { const {path: pagePath} = serverContext(); @@ -14,44 +71,46 @@ export default function DocImage({ return null; } - // Next.js Image component only supports images from the public folder - // or from a remote server with properly configured domain - if (src.startsWith('http')) { - // eslint-disable-next-line @next/next/no-img-element - return ; - } + const isExternal = isExternalImage(src); + let finalSrc = src; + let imgPath = src; - // 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}`; + // For internal images, process the path + if (!isExternal) { + if (src.startsWith('./')) { + finalSrc = path.join('/mdx-images', src); + } else if (!src?.startsWith('/') && !src?.includes('://')) { + finalSrc = `/${pagePath.join('/')}/${src}`; + } + + // For internal images, imgPath should be the pathname only + try { + const srcURL = new URL(finalSrc, 'https://example.com'); + imgPath = srcURL.pathname; + } catch (_error) { + imgPath = finalSrc; + } + } else { + // For external images, clean URL by removing only dimension hashes, preserving fragment identifiers + finalSrc = cleanUrl(src); + imgPath = finalSrc; } - // parse the size from the URL 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 - .slice(1) - .split('x') - .map(s => parseInt(s, 10)); + // Parse dimensions from URL hash (works for both internal and external) + const hashDimensions = parseDimensionsFromHash(src); + + // Use hash dimensions first, fallback to props + const width = hashDimensions[0] > 0 ? hashDimensions[0] : parseDimension(propsWidth); + const height = hashDimensions[1] > 0 ? hashDimensions[1] : parseDimension(propsHeight); return ( - - {props.alt - + ); } diff --git a/src/components/imageLightbox/index.tsx b/src/components/imageLightbox/index.tsx new file mode 100644 index 00000000000000..63173f41cdb37f --- /dev/null +++ b/src/components/imageLightbox/index.tsx @@ -0,0 +1,142 @@ +'use client'; + +import {useState} from 'react'; +import Image from 'next/image'; + +import {Lightbox} from 'sentry-docs/components/lightbox'; +import {isAllowedRemoteImage, isExternalImage} from 'sentry-docs/config/images'; + +interface ImageLightboxProps + extends Omit< + React.HTMLProps, + 'ref' | 'src' | 'width' | 'height' | 'alt' + > { + alt: string; + imgPath: string; + src: string; + height?: number; + width?: number; +} + +const getImageUrl = (src: string, imgPath: string): string => { + if (isExternalImage(src)) { + // Normalize protocol-relative URLs to use https: + return src.startsWith('//') ? `https:${src}` : src; + } + return imgPath; +}; + +type ValidDimensions = { + height: number; + width: number; +}; + +const getValidDimensions = (width?: number, height?: number): ValidDimensions | null => { + if ( + width != null && + height != null && + !isNaN(width) && + !isNaN(height) && + width > 0 && + height > 0 + ) { + return {width, height}; + } + return null; +}; + +export function ImageLightbox({ + src, + alt, + width, + height, + imgPath, + style, + className, + ...props +}: ImageLightboxProps) { + const [open, setOpen] = useState(false); + + const dimensions = getValidDimensions(width, height); + const shouldUseNextImage = + !!dimensions && (!isExternalImage(src) || isAllowedRemoteImage(src)); + + const openInNewTab = () => { + window.open(getImageUrl(src, imgPath), '_blank', 'noopener,noreferrer'); + }; + + const handleClick = (e: React.MouseEvent) => { + // Middle-click or Ctrl/Cmd+click opens in new tab + if (e.button === 1 || e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + openInNewTab(); + } + // Regular click is handled by Dialog.Trigger + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Ctrl/Cmd+Enter/Space opens in new tab + // Regular Enter/Space is handled by Dialog.Trigger + if ((e.key === 'Enter' || e.key === ' ') && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + openInNewTab(); + } + }; + + // Filter out props that are incompatible with Next.js Image component + // Next.js Image has stricter typing for certain props like 'placeholder' + const {placeholder: _placeholder, ...imageCompatibleProps} = props; + + const renderImage = (isInline: boolean = true) => { + const renderedSrc = getImageUrl(src, imgPath); + const imageClassName = isInline + ? className + : 'max-h-[90vh] max-w-[90vw] object-contain'; + const imageStyle = isInline + ? {width: '100%', height: 'auto', ...style} + : {width: 'auto', height: 'auto'}; + + if (shouldUseNextImage && dimensions) { + return ( + {alt} + ); + } + return ( + /* eslint-disable-next-line @next/next/no-img-element */ + {alt} + ); + }; + + return ( + + + {renderImage()} + + + ); +} diff --git a/src/components/lightbox/index.tsx b/src/components/lightbox/index.tsx new file mode 100644 index 00000000000000..f3ed95a4fb275b --- /dev/null +++ b/src/components/lightbox/index.tsx @@ -0,0 +1,109 @@ +/** + * Reusable Lightbox component built on top of Radix UI Dialog. + * Provides a modal overlay for displaying images or other content. + * + * @example + * // Basic usage - you must provide Lightbox.Trigger as children + * }> + * + * Click to expand + * + * + * + * @example + * // Controlled state with custom trigger + * const [open, setOpen] = useState(false); + * + * }> + * + * + * + * + */ + +'use client'; + +import {Fragment, useState} from 'react'; +import {X} from 'react-feather'; +import * as Dialog from '@radix-ui/react-dialog'; + +import styles from './lightbox.module.scss'; + +interface LightboxProps { + content: React.ReactNode; + children?: React.ReactNode; + closeButton?: boolean; + onOpenChange?: (open: boolean) => void; + open?: boolean; +} + +interface LightboxTriggerProps extends React.ComponentProps { + children: React.ReactNode; +} + +interface LightboxCloseProps { + children?: React.ReactNode; + className?: string; +} + +// Root component +function LightboxRoot({ + children, + content, + onOpenChange, + open: controlledOpen, + closeButton = true, +}: LightboxProps) { + const [internalOpen, setInternalOpen] = useState(false); + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen; + + return ( + + {children} + + + + + {closeButton && ( + + + Close + + )} +
{content}
+
+
+
+ ); +} + +// Trigger component +function LightboxTrigger({children, ...props}: LightboxTriggerProps) { + return {children}; +} + +// Close button component +function LightboxClose({children, className = ''}: LightboxCloseProps) { + return ( + + {children || ( + + + Close + + )} + + ); +} + +export const Lightbox = { + Root: LightboxRoot, + Trigger: LightboxTrigger, + Close: LightboxClose, +}; + +export default LightboxRoot; diff --git a/src/components/lightbox/lightbox.module.scss b/src/components/lightbox/lightbox.module.scss new file mode 100644 index 00000000000000..aecf60e0f8fb71 --- /dev/null +++ b/src/components/lightbox/lightbox.module.scss @@ -0,0 +1,76 @@ +/* Lightbox animations */ +@keyframes dialogContentShow { + from { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +@keyframes dialogContentHide { + from { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + to { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } +} + +@keyframes dialogOverlayShow { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes dialogOverlayHide { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +/* Lightbox content styles */ +.lightboxContent { + position: fixed; + left: 50%; + top: 50%; + z-index: 50; + max-height: 90vh; + max-width: 90vw; + transform: translate(-50%, -50%); +} + +.lightboxContent[data-state='open'] { + animation: dialogContentShow 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.lightboxContent[data-state='closed'] { + animation: dialogContentHide 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +/* Lightbox overlay styles */ +.lightboxOverlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); + z-index: 50; +} + +.lightboxOverlay[data-state='open'] { + animation: dialogOverlayShow 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.lightboxOverlay[data-state='closed'] { + animation: dialogOverlayHide 200ms cubic-bezier(0.16, 1, 0.3, 1); +} \ No newline at end of file diff --git a/src/config/images.ts b/src/config/images.ts new file mode 100644 index 00000000000000..3177674d448d84 --- /dev/null +++ b/src/config/images.ts @@ -0,0 +1,27 @@ +export const REMOTE_IMAGE_HOSTNAMES = [ + 'user-images.githubusercontent.com', + 'sentry-brand.storage.googleapis.com', +] as const; + +export const REMOTE_IMAGE_PATTERNS = REMOTE_IMAGE_HOSTNAMES.map(hostname => ({ + protocol: 'https' as const, + hostname, +})); + +export function isExternalImage(src: string): boolean { + return src.startsWith('http') || src.startsWith('//'); +} + +export function isAllowedRemoteImage(src: string): boolean { + try { + // Handle protocol-relative URLs by adding https: protocol + const normalizedSrc = src.startsWith('//') ? `https:${src}` : src; + const url = new URL(normalizedSrc); + return ( + url.protocol === 'https:' && + (REMOTE_IMAGE_HOSTNAMES as readonly string[]).includes(url.hostname) + ); + } catch (_error) { + return false; + } +} diff --git a/src/remark-image-size.js b/src/remark-image-size.js index b934168a09c6f9..197ae3c5b7ec93 100644 --- a/src/remark-image-size.js +++ b/src/remark-image-size.js @@ -13,7 +13,7 @@ export default function remarkImageSize(options) { return tree => visit(tree, 'image', node => { // don't process external images - if (node.url.startsWith('http')) { + if (node.url.startsWith('http') || node.url.startsWith('//')) { return; } const fullImagePath = path.join(