# Markdown Media Handling

Utilities and React components for handling images, videos, and other media files in markdown content within repository viewers.

**Synced:** 2026-07-18

## Production sources in gittr

- **Relative links / `?path=` / `?file=`:** [`ui/src/lib/utils/markdown-anchor.tsx`](https://gittr.space/npub1n2ph08n4pqz4d3jk6n2p35p2f4ldhc5g5tu7dhftfpueajf4rpxqfjhzmc/gittr?file=ui/src/lib/utils/markdown-anchor.tsx&branch=main) → extract in this folder as **`markdown-anchor.ts`** (`normalizeRepoPath`, `resolveRepoMarkdownHref`).
- **README images:** inlined in the repo page ReactMarkdown `img` component (no standalone `readme-image-handler` in gittr). The teaching extract `readme-image-handler.tsx` mirrors that logic.

## Approaches in this folder

1. **`markdown-anchor.ts`** — pure path helpers from production `markdown-anchor.tsx` (no React, no `@/` imports)

2. **`readme-image-handler.tsx`** - Simple inline approach (teaching extract of gittr repo-page `img` handling)
   - Direct URL transformation for relative image paths
   - Works with GitHub, GitLab, Codeberg raw URLs
   - Minimal dependencies, easy to integrate
   - Best for: Simple use cases where you have `sourceUrl` available

3. **`markdown-media.tsx`** - Full-featured component-based approach
   - API endpoint support with base64 conversion
   - URL caching and retry logic
   - Supports Nostr-native repos via API endpoints
   - Best for: Complex scenarios with multiple data sources

## Features

- ✅ **Relative Image Path Resolution**: Resolves relative image paths to repository assets
- ✅ **Git Provider Raw URLs**: Direct support for GitHub, GitLab, Codeberg raw URL formats
- ✅ **API Endpoint Support**: Handles API endpoints that return base64-encoded binary content (full implementation)
- ✅ **Base64 to Data URL Conversion**: Automatically converts base64 content to data URLs for direct image display
- ✅ **Video Embeds**: Supports YouTube, Vimeo, and direct video file embeds
- ✅ **Relative Link Handling**: Handles relative links within repositories
- ✅ **URL Caching**: Caches fetched URLs to prevent redundant network requests (full implementation)
- ✅ **Multi-Source Support**: Works with GitHub, GitLab, Codeberg, and Nostr-native repos

## Installation

Choose the approach that fits your needs:
- For simple cases: Copy `readme-image-handler.tsx` and use inline in ReactMarkdown components
- For complex cases: Copy `markdown-media.tsx` and import the helper functions

## Usage

### Simple Approach (readme-image-handler.tsx)

This is the approach currently used in gittr. It's simpler and works well when you have a `sourceUrl`:

```tsx
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';

<ReactMarkdown
  remarkPlugins={[remarkGfm]}
  rehypePlugins={[rehypeRaw]}
  components={{
    img: ({ node, ...props }) => {
      let imageSrc = props.src || "";
      
      // Skip absolute URLs
      if (imageSrc.startsWith("http://") || imageSrc.startsWith("https://") || imageSrc.startsWith("data:")) {
        return <img {...props} className="max-w-full h-auto rounded" alt={props.alt || ""} />;
      }
      
      // Transform relative paths using sourceUrl
      if (imageSrc && repoData?.sourceUrl) {
        const branch = selectedBranch || repoData?.defaultBranch || "main";
        const imagePath = imageSrc.startsWith("/") ? imageSrc.slice(1) : imageSrc;
        
        // GitHub
        const githubMatch = repoData.sourceUrl.match(/github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
        if (githubMatch) {
          const [, owner, repo] = githubMatch;
          imageSrc = `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(branch)}/${imagePath}`;
        }
        // GitLab
        else if (repoData.sourceUrl.includes('gitlab.com')) {
          const gitlabMatch = repoData.sourceUrl.match(/gitlab\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
          if (gitlabMatch) {
            const [, owner, repo] = gitlabMatch;
            imageSrc = `https://gitlab.com/${owner}/${repo}/-/raw/${encodeURIComponent(branch)}/${imagePath}`;
          }
        }
        // Codeberg
        else if (repoData.sourceUrl.includes('codeberg.org')) {
          const codebergMatch = repoData.sourceUrl.match(/codeberg\.org\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
          if (codebergMatch) {
            const [, owner, repo] = codebergMatch;
            imageSrc = `https://codeberg.org/${owner}/${repo}/raw/branch/${encodeURIComponent(branch)}/${imagePath}`;
          }
        }
      }
      
      return <img {...props} src={imageSrc} className="max-w-full h-auto rounded" alt={props.alt || ""} />;
    },
  }}
>
  {readmeContent}
</ReactMarkdown>
```

**See `readme-image-handler.tsx` for the complete implementation with error handling.**

### Full-Featured Approach (markdown-media.tsx)

### Basic Usage with ReactMarkdown

```tsx
import ReactMarkdown from 'react-markdown';
import { createMarkdownImageRenderer, createMarkdownLinkRenderer } from './markdown-media';

function MarkdownViewer({ content, readmePath, repoData, selectedBranch, paramsEntity, paramsRepo }) {
  return (
    <ReactMarkdown
      remarkPlugins={[remarkGfm]}
      rehypePlugins={[rehypeRaw]}
      components={{
        img: createMarkdownImageRenderer(
          readmePath,
          repoData,
          selectedBranch,
          paramsEntity,
          paramsRepo,
          resolveEntityToPubkey // optional utility function
        ),
        a: createMarkdownLinkRenderer(readmePath, getRepoLink), // getRepoLink is optional
      }}
    >
      {content}
    </ReactMarkdown>
  );
}
```

### Standalone Helper Functions

```tsx
import {
  normalizeRepoPath,
  resolveRepoRelativePath,
  getRepoAssetUrl,
  getRawUrl,
} from './markdown-media';

// Normalize a repository path (removes ./ and ../ safely)
const normalized = normalizeRepoPath('./images/../logo.png');
// Result: 'logo.png'

// Resolve a relative path relative to a base path
const resolved = resolveRepoRelativePath('../assets/image.png', 'docs/readme.md');
// Result: 'assets/image.png'

// Get the asset URL for an image
const assetUrl = getRepoAssetUrl(
  'public/og-image.png',
  repoData,
  paramsEntity,
  paramsRepo,
  selectedBranch
);
// Returns: '/api/nostr/repo/file-content?...' or 'https://raw.githubusercontent.com/...'

// Get raw URL for Git providers
const rawUrl = getRawUrl('README.md', repoData, selectedBranch, paramsRepo);
// Returns: 'https://raw.githubusercontent.com/owner/repo/branch/README.md'
```

## API Reference

### `createMarkdownImageRenderer(basePath, repoData?, selectedBranch?, paramsEntity?, paramsRepo?, resolveEntityToPubkey?)`

Creates a custom image renderer for ReactMarkdown that handles:
- Relative image paths (resolved relative to the markdown file)
- External URLs (used directly)
- API endpoints (fetched and converted from base64 to data URLs)
- Embedded files in Nostr events

**Parameters:**
- `basePath` (string | null): The path of the markdown file (for resolving relative paths)
- `repoData` (RepoData?): Repository data containing files, sourceUrl, ownerPubkey, etc.
- `selectedBranch` (string?): Currently selected branch (defaults to 'main')
- `paramsEntity` (string?): Entity identifier (npub or pubkey)
- `paramsRepo` (string?): Repository name
- `resolveEntityToPubkey` (function?): Optional utility to resolve entity to pubkey

**Returns:** ReactMarkdown image component renderer

### `createMarkdownLinkRenderer(basePath?, getRepoLink?)`

Creates a custom link renderer for ReactMarkdown that handles:
- YouTube embeds (converts YouTube URLs to iframe embeds)
- Vimeo embeds (converts Vimeo URLs to iframe embeds)
- Direct video files (mp4, webm, ogg, etc.)
- Relative links within repositories
- External links (opens in new tab with proper security attributes)

**Parameters:**
- `basePath` (string | null?): The path of the markdown file (for resolving relative paths)
- `getRepoLink` (function?): Optional function to generate repository links

**Returns:** ReactMarkdown link component renderer

### `getRepoAssetUrl(path, repoData?, paramsEntity?, paramsRepo?, selectedBranch?, resolveEntityToPubkey?)`

Resolves a repository asset URL for images and other media files.

**Returns:** Asset URL string or null

**Priority:**
1. Embedded files in Nostr events (if binary and has content)
2. Raw Git URLs (for GitHub/GitLab/Codeberg repos)
3. API endpoints (for Nostr-native repos)

### `getRawUrl(path, repoData?, selectedBranch?, paramsRepo?)`

Gets the raw URL for a file path from a Git provider.

**Returns:** Raw URL string or null

**Supported Providers:**
- GitHub: Uses `raw.githubusercontent.com` directly
- GitLab: Uses API proxy (`/api/git/file-content`)
- Codeberg: Uses API proxy (`/api/git/file-content`)
- Other providers: Uses API proxy

### `RepoImage` Component

Stateful React component that handles:
- API endpoint fetching
- Base64 to data URL conversion
- URL caching to prevent redundant requests
- Retry logic when sourceUrl becomes available

**Props:**
- `src` (string): Image source path
- `basePath` (string | null): Base path for resolving relative paths
- `repoData` (RepoData?): Repository data
- `selectedBranch` (string?): Currently selected branch
- `paramsEntity` (string?): Entity identifier
- `paramsRepo` (string?): Repository name
- `resolveEntityToPubkey` (function?): Optional utility function
- All standard `<img>` props (alt, className, etc.)

## How It Works

### Image Loading Flow

1. **External URLs**: If the image src starts with `http://`, `https://`, or `data:`, it's used directly
2. **Relative Paths**: Resolved relative to the markdown file's base path
3. **Embedded Files**: Checked first if available in `repoData.files` array
4. **Git Provider URLs**: Constructed for GitHub/GitLab/Codeberg repos using `getRawUrl()`
5. **API Endpoints**: For Nostr-native repos, uses `/api/nostr/repo/file-content`
6. **Base64 Conversion**: API endpoints that return base64 content are converted to data URLs
7. **Caching**: Fetched URLs are cached in `fetchedUrlsRef` to prevent duplicate requests

### Link Handling Flow

1. **Relative Links**: Resolved relative to repository root, converted to query parameter format
2. **YouTube URLs**: Detected and converted to iframe embeds
3. **Vimeo URLs**: Detected and converted to iframe embeds
4. **Video Files**: Direct video files (mp4, webm, etc.) are wrapped in `<video>` tags
5. **External Links**: Opened in new tab with `rel="noopener noreferrer"`

## Example: Complete Integration

```tsx
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import {
  createMarkdownImageRenderer,
  createMarkdownLinkRenderer,
} from './markdown-media';

function RepositoryReadmeViewer({
  content,
  readmePath,
  repoData,
  selectedBranch,
  paramsEntity,
  paramsRepo,
  getRepoLink,
  resolveEntityToPubkey,
}) {
  return (
    <div className="prose prose-invert max-w-none">
      <ReactMarkdown
        remarkPlugins={[remarkGfm]}
        rehypePlugins={[rehypeRaw]}
        components={{
          img: createMarkdownImageRenderer(
            readmePath,
            repoData,
            selectedBranch,
            paramsEntity,
            paramsRepo,
            resolveEntityToPubkey
          ),
          a: createMarkdownLinkRenderer(readmePath, getRepoLink),
        }}
      >
        {content}
      </ReactMarkdown>
    </div>
  );
}
```

## Performance Optimizations

- **URL Caching**: `fetchedUrlsRef` prevents fetching the same API endpoint multiple times
- **Lazy Loading**: Images are only fetched when needed
- **Error Handling**: Failed images are logged once and retried when sourceUrl becomes available
- **Direct URLs**: External URLs and data URLs bypass the fetching logic entirely

## Supported Media Types

### Images
- PNG, JPG, JPEG, GIF, WebP, SVG, BMP, ICO, AVIF, TIFF

### Videos
- YouTube (via URL detection)
- Vimeo (via URL detection)
- Direct video files: MP4, WebM, OGG, MOV, AVI, WMV, FLV, MKV

### Other
- PDFs (via data URL conversion)
- Fonts: WOFF, WOFF2, TTF, OTF
- Audio: MP3, WAV

## Related Snippets

- [File Fetching](../file-fetching/README.md) - File fetching strategies and utilities
- [URL Normalization](../url-normalization/README.md) - Git URL normalization utilities

## Notes

- The `RepoImage` component uses a `key` prop that includes `repoData?.sourceUrl` to force re-renders when the source URL changes
- API endpoints are expected to return JSON with `{ content: string, isBinary: boolean }` format
- Base64 content is automatically converted to data URLs with appropriate MIME types
- The component handles the case where `sourceUrl` becomes available after initial render (retry logic)

## Content Security Policy (CSP) Configuration

**Important**: For YouTube embeds to work, you must configure your Content Security Policy to allow YouTube domains in the `frame-src` directive.

### Next.js Configuration

In your `next.config.js`:

```javascript
async headers() {
  return [
    {
      source: '/:path*',
      headers: [
        {
          key: 'Content-Security-Policy',
          value: [
            "default-src 'self'",
            "script-src 'self' 'unsafe-eval' 'unsafe-inline'",
            "style-src 'self' 'unsafe-inline'",
            "img-src 'self' data: https: blob:",
            "font-src 'self' data:",
            "connect-src 'self' https://*.github.com wss://* https://*",
            "frame-src 'self' https://www.youtube.com https://youtube.com https://youtu.be", // Required for YouTube embeds
            "object-src 'none'",
            "base-uri 'self'",
            "form-action 'self'",
            "frame-ancestors 'self'",
            "upgrade-insecure-requests"
          ].join('; ')
        }
      ]
    }
  ]
}
```

Without this CSP configuration, YouTube embeds will be blocked by the browser's security policy.

