Skip to content
Merged
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
90 changes: 85 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ Transform GitHub-style markdown alerts into HTML using the [unified][unified] ec
## Features

- 🎯 **GitHub compatibility** - Renders `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, and `[!CAUTION]` alerts
- 🔄 **Dual rendering modes** - Smart auto-detection for HTML and component-based pipelines (react-markdown, MDX)
- 🛡️ **100% test coverage** - Comprehensive test suite
- 🔧 **Maximum extensibility** - Configure HTML elements, class names, and custom icons per alert type
- 🎨 **Unstyled by default** - No opinionated CSS, works with any design system
- 📦 **TypeScript support** - Batteries included with typed HTML tags and more
- 🔧 **[Unified][unified] ecosystem** - Works with remark, rehype and can be easily used with [react-markdown][react-markdown]
- 🔧 **[Unified][unified] ecosystem** - Works with remark, rehype and seamlessly integrates with [react-markdown][react-markdown] and MDX

## Installation

Expand All @@ -28,6 +29,54 @@ pnpm add remark-github-markdown-alerts
bun add remark-github-markdown-alerts
```

## Rendering Modes

This plugin automatically detects your rendering environment and optimizes output accordingly:

### 🔄 Auto-Detection (Recommended)

The plugin automatically chooses the optimal rendering mode:

- **HTML Mode**: Traditional remark → rehype → HTML pipelines
- **Component Mode**: react-markdown, MDX, and component-based systems

```ts
import { remarkGitHubAlerts } from 'remark-github-markdown-alerts'

// Auto-detects the right mode for your setup
remark().use(remarkGitHubAlerts)
```

### 🎯 Manual Mode Selection

Override auto-detection when needed:

```ts
import { remarkGitHubAlerts } from 'remark-github-markdown-alerts'

// Force HTML mode (traditional pipelines)
remark().use(remarkGitHubAlerts, { mode: 'html' })

// Force component mode (react-markdown, MDX)
remark().use(remarkGitHubAlerts, { mode: 'component' })

// Auto-detection (default)
remark().use(remarkGitHubAlerts, { mode: 'auto' })
```

### 📋 Mode Detection Logic

**Component mode** is automatically triggered when:
- Using with `react-markdown`
- Processing `.mdx` files
- File data contains `{ mdx: true }`
- File data contains `{ allowDangerousHtml: false }`

**HTML mode** is used for:
- Traditional remark → rehype → HTML pipelines
- Static site generators
- Server-side rendering without components

## Usage

### With remark
Expand All @@ -53,7 +102,6 @@ const result = await processor.process(markdown)
console.log(result.toString())
```


### With React Server Components and custom icons

Using [`react-markdown`][react-markdown] and [`common-tags`][common-tags]'s `html` helper, example code in Next.js application:
Expand Down Expand Up @@ -141,7 +189,7 @@ const result = await processor.process('> [!IMPORTANT]\\n> Critical information

## Configuration

The plugin accepts an options object with two main sections:
The plugin accepts an options object with the following sections:

### 🎛️ Global Configuration (`defaultConfig`)

Expand All @@ -152,9 +200,12 @@ import { remarkGitHubAlerts } from 'remark-github-markdown-alerts'

const options = {
defaultConfig: {
// General options
mode: "auto" // Rendering mode selection

// 🎨 CSS Class Names
classNames: {
container: 'alert', // Main wrapper class
container: 'alert', // Main wrapper class
icon: 'alert-icon', // Icon container class
title: 'alert-title', // Title/header class
content: 'alert-content' // Content body class
Expand Down Expand Up @@ -203,6 +254,15 @@ const processor = remark().use(remarkGitHubAlerts, options)

### 📋 Configuration Reference

#### Plugin Options

| Property | Type | Default | Description |
|----------|------|---------|-------------|
| **Rendering** | | | |
| `mode` | `'auto' \| 'html' \| 'component'` | `'auto'` | Rendering mode selection |
| `defaultConfig` | `PartialDeep<AlertConfig>` | See below | Global configuration for all alerts |
| `alerts` | `AlertsConfig` | `{}` | Alert-specific configuration overrides |

#### Default Configuration Options

| Property | Type | Default | Description |
Expand Down Expand Up @@ -235,7 +295,11 @@ const processor = remark().use(remarkGitHubAlerts, options)
> [!TIP]
> Alert-specific configurations merge with the default config, so you only need to specify the properties you want to override.

## Example output
## Output Differences by Mode

### HTML Mode Output

Traditional HTML structure optimized for static sites and server-side rendering:

```html
<div class="markdown-alert markdown-alert-note">
Expand All @@ -249,6 +313,22 @@ const processor = remark().use(remarkGitHubAlerts, options)
</div>
```

### Component Mode Output

Enhanced structure optimized for react-markdown and MDX with additional metadata:

```html
<div class="markdown-alert markdown-alert-note" data-alert-type="note">
<div class="markdown-alert-title">
<span class="markdown-alert-icon"></span>
Note
</div>
<div class="markdown-alert-content">
<p>Your content here</p>
</div>
</div>
```

# License

The MIT License
Expand Down
181 changes: 163 additions & 18 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Blockquote, Html, Paragraph, PhrasingContent, Root, Text } from 'm
import type { PartialDeep } from 'type-fest'
import type { Plugin } from 'unified'
import { visit } from 'unist-util-visit'
import type { VFile } from 'vfile'

type HtmlElement =
| 'html'
Expand Down Expand Up @@ -143,9 +144,12 @@ export type AlertsConfig = {
caution?: PartialDeep<AlertConfig>
}

export type RenderMode = 'html' | 'component' | 'auto'

export type RemarkGitHubAlertsOptions = {
alerts?: AlertsConfig
defaultConfig?: PartialDeep<AlertConfig>
mode?: RenderMode
}

const ALERT_TYPES = ['NOTE', 'TIP', 'IMPORTANT', 'WARNING', 'CAUTION'] as const
Expand Down Expand Up @@ -228,6 +232,26 @@ function escapeHtml(text: string): string {
return result
}

function detectRenderMode(file?: VFile): RenderMode {
if (!file) return 'html'

const data = file.data || {}

if (data['reactMarkdown'] === true) {
return 'component'
}

if (data['mdx'] === true || file.extname === '.mdx') {
return 'component'
}

if (data['allowDangerousHtml'] === false) {
return 'component'
}

return 'html'
}

function extractTextContent(node: PhrasingContent): string {
if (node.type === 'text') {
return node.value
Expand All @@ -238,6 +262,70 @@ function extractTextContent(node: PhrasingContent): string {
return ''
}

function createAlertComponent(
type: AlertType,
title: string,
children: Blockquote['children'],
config: AlertConfig
): Record<string, unknown> {
const alertTypeKey = type.toLowerCase() as Lowercase<AlertType>
const containerClasses = [
config.classNames.container,
`${config.classNames.container}-${alertTypeKey}`,
]
.filter(Boolean)
.join(' ')

const iconChildren = config.iconElementHtml
? [{ type: 'text', value: config.iconElementHtml }]
: []

return {
type: config.tags.container,
data: {
hName: config.tags.container,
hProperties: {
className: containerClasses,
'data-alert-type': alertTypeKey,
},
},
children: [
{
type: config.tags.title,
data: {
hName: config.tags.title,
hProperties: {
className: config.classNames.title,
},
},
children: [
{
type: config.tags.icon,
data: {
hName: config.tags.icon,
hProperties: {
className: config.classNames.icon,
},
},
children: iconChildren,
},
{ type: 'text', value: title },
],
},
{
type: config.tags.content,
data: {
hName: config.tags.content,
hProperties: {
className: config.classNames.content,
},
},
children: children,
},
],
}
}

function createAlertHtml(
type: AlertType,
title: string,
Expand Down Expand Up @@ -288,21 +376,7 @@ function mergeConfig(
}
}

export function processBlockquote(
node: Blockquote,
index: number | undefined,
parent: unknown,
baseConfig: AlertConfig,
alerts: AlertsConfig
): boolean {
if (!parent || typeof index !== 'number') return false

const alertInfo = isAlertBlockquote(node)
if (!alertInfo.isAlert || !alertInfo.type || !alertInfo.title) return false

const alertTypeKey = alertInfo.type.toLowerCase() as keyof AlertsConfig
const alertConfig = mergeConfig(baseConfig, alerts[alertTypeKey])

function processBlockquoteContent(node: Blockquote): Blockquote['children'] {
const firstParagraph = node.children[0] as Paragraph
const firstTextNode = firstParagraph.children[0] as Text
const alertDeclarationMatch = firstTextNode.value.match(
Expand All @@ -324,6 +398,61 @@ export function processBlockquote(
}
}

return node.children
}

export function processBlockquoteAsComponent(
node: Blockquote,
index: number | undefined,
parent: unknown,
baseConfig: AlertConfig,
alerts: AlertsConfig
): boolean {
if (!parent || typeof index !== 'number') return false

const alertInfo = isAlertBlockquote(node)
if (!alertInfo.isAlert || !alertInfo.type || !alertInfo.title) return false

const alertTypeKey = alertInfo.type.toLowerCase() as keyof AlertsConfig
const alertConfig = mergeConfig(baseConfig, alerts[alertTypeKey])

const processedChildren = processBlockquoteContent(node)
const componentNode = createAlertComponent(
alertInfo.type,
alertInfo.title,
processedChildren,
alertConfig
)

if (
parent &&
typeof parent === 'object' &&
'children' in parent &&
Array.isArray(parent.children)
) {
parent.children[index] = componentNode as unknown as Blockquote
}

return true
}

export function processBlockquoteAsHtml(
node: Blockquote,
index: number | undefined,
parent: unknown,
baseConfig: AlertConfig,
alerts: AlertsConfig
): boolean {
if (!parent || typeof index !== 'number') return false

const alertInfo = isAlertBlockquote(node)
if (!alertInfo.isAlert || !alertInfo.type || !alertInfo.title) return false

const alertTypeKey = alertInfo.type.toLowerCase() as keyof AlertsConfig
const alertConfig = mergeConfig(baseConfig, alerts[alertTypeKey])

processBlockquoteContent(node)

const contentHtml = node.children
.map(child => {
if (child.type === 'paragraph') {
Expand Down Expand Up @@ -353,13 +482,29 @@ export function processBlockquote(
return true
}

export function processBlockquote(
node: Blockquote,
index: number | undefined,
parent: unknown,
baseConfig: AlertConfig,
alerts: AlertsConfig,
mode: RenderMode = 'html'
): boolean {
if (mode === 'component') {
return processBlockquoteAsComponent(node, index, parent, baseConfig, alerts)
}
return processBlockquoteAsHtml(node, index, parent, baseConfig, alerts)
}

export const remarkGitHubAlerts: Plugin<[RemarkGitHubAlertsOptions?], Root> = (options = {}) => {
const { alerts = {}, defaultConfig } = options
const { alerts = {}, defaultConfig, mode = 'auto' } = options
const baseConfig = mergeConfig(DEFAULT_CONFIG, defaultConfig)

return tree => {
return (tree, file) => {
const renderMode = mode === 'auto' ? detectRenderMode(file) : mode

visit(tree, 'blockquote', (node: Blockquote, index, parent) => {
processBlockquote(node, index, parent, baseConfig, alerts)
processBlockquote(node, index, parent, baseConfig, alerts, renderMode)
})

return tree
Expand Down
Loading