Your content here
+${escapeHtml(textContent)}
` + } + return '' + }) + .join('\n ') + + const alertHtml = createAlertHtml(alertInfo.type, alertInfo.title, contentHtml, alertConfig) + + const htmlNode: Html = { + type: 'html', + value: alertHtml, + } + + if ( + parent && + typeof parent === 'object' && + 'children' in parent && + Array.isArray(parent.children) + ) { + parent.children[index] = htmlNode + } + + return true +} + +export const remarkGitHubAlerts: Plugin<[RemarkGitHubAlertsOptions?], Root> = (options = {}) => { + const { alerts = {}, defaultConfig } = options + const baseConfig = mergeConfig(DEFAULT_CONFIG, defaultConfig) + + return tree => { + visit(tree, 'blockquote', (node: Blockquote, index, parent) => { + processBlockquote(node, index, parent, baseConfig, alerts) + }) + + return tree + } +} + +export default remarkGitHubAlerts diff --git a/test/index.test.ts b/test/index.test.ts index 61fc331..5ada6c8 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,40 +1,464 @@ -import { describe, expect, it } from 'vitest' -import { add, greet, multiply } from '../src/index' +import type { Blockquote, Root } from 'mdast' +import rehypeStringify from 'rehype-stringify' +import { remark } from 'remark' +import remarkRehype from 'remark-rehype' +import { describe, expect, it, vi } from 'vitest' +import { processBlockquote, type RemarkGitHubAlertsOptions, remarkGitHubAlerts } from '../src/index' -describe('greet', () => { - it('should return a greeting message', () => { - expect(greet('World')).toBe('Hello, World!') - }) +function processMarkdown(markdown: string, options?: RemarkGitHubAlertsOptions) { + return remark() + .use(remarkGitHubAlerts, options) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeStringify, { allowDangerousHtml: true }) + .process(markdown) + .then(result => result.toString()) +} + +describe('remarkGitHubAlerts', () => { + describe('basic alert types', () => { + it('should convert NOTE alert', async () => { + const markdown = `> [!NOTE] +> This is a note alert.` + const result = await processMarkdown(markdown) + + expect(result).toContain('class="markdown-alert markdown-alert-note"') + expect(result).toContain('class="markdown-alert-title"') + expect(result).toContain('class="markdown-alert-icon"') + expect(result).toContain('class="markdown-alert-content"') + expect(result).toContain('Note') + expect(result).toContain('This is a note alert.') + }) + + it('should convert TIP alert', async () => { + const markdown = `> [!TIP] +> This is a tip alert.` + const result = await processMarkdown(markdown) + + expect(result).toContain('class="markdown-alert markdown-alert-tip"') + expect(result).toContain('Tip') + expect(result).toContain('This is a tip alert.') + }) + + it('should convert IMPORTANT alert', async () => { + const markdown = `> [!IMPORTANT] +> This is an important alert.` + const result = await processMarkdown(markdown) + + expect(result).toContain('class="markdown-alert markdown-alert-important"') + expect(result).toContain('Important') + expect(result).toContain('This is an important alert.') + }) + + it('should convert WARNING alert', async () => { + const markdown = `> [!WARNING] +> This is a warning alert.` + const result = await processMarkdown(markdown) + + expect(result).toContain('class="markdown-alert markdown-alert-warning"') + expect(result).toContain('Warning') + expect(result).toContain('This is a warning alert.') + }) - it('should handle empty string', () => { - expect(greet('')).toBe('Hello, !') + it('should convert CAUTION alert', async () => { + const markdown = `> [!CAUTION] +> This is a caution alert.` + const result = await processMarkdown(markdown) + + expect(result).toContain('class="markdown-alert markdown-alert-caution"') + expect(result).toContain('Caution') + expect(result).toContain('This is a caution alert.') + }) }) -}) -describe('add', () => { - it('should add two positive numbers', () => { - expect(add(2, 3)).toBe(5) + describe('custom titles', () => { + it('should use custom title when provided', async () => { + const markdown = `> [!NOTE] Custom Title +> This note has a custom title.` + const result = await processMarkdown(markdown) + + expect(result).toContain('Custom Title') + expect(result).not.toContain('Note') + expect(result).toContain('This note has a custom title.') + }) + + it('should handle custom title with multiple words', async () => { + const markdown = `> [!TIP] Very Important Tip Here +> This is the content.` + const result = await processMarkdown(markdown) + + expect(result).toContain('Very Important Tip Here') + expect(result).toContain('This is the content.') + }) }) - it('should add negative numbers', () => { - expect(add(-1, -2)).toBe(-3) + describe('multi-line content', () => { + it('should handle multi-line alert content', async () => { + const markdown = `> [!NOTE] +> First line of content. +> Second line of content. +> Third line of content.` + const result = await processMarkdown(markdown) + + expect(result).toContain('First line of content.') + expect(result).toContain('Second line of content.') + expect(result).toContain('Third line of content.') + }) + + it('should handle multi-paragraph content', async () => { + const markdown = `> [!WARNING] +> First paragraph. +> +> Second paragraph with more content.` + const result = await processMarkdown(markdown) + + expect(result).toContain('First paragraph.') + expect(result).toContain('Second paragraph with more content.') + }) + + it('should handle alerts with mixed content types', async () => { + const markdown = `> [!TIP] +> Regular paragraph content. +> +> \`\`\` +> code block content +> \`\`\` +> +> Another paragraph.` + const result = await processMarkdown(markdown) + + expect(result).toContain('markdown-alert') + expect(result).toContain('Regular paragraph content.') + expect(result).toContain('Another paragraph.') + // Code block should be filtered out (returns empty string) + expect(result).not.toContain('code block content') + }) }) - it('should add zero', () => { - expect(add(5, 0)).toBe(5) + describe('configuration options', () => { + it('should apply custom class names', async () => { + const markdown = `> [!NOTE] +> Test content.` + const options: RemarkGitHubAlertsOptions = { + defaultConfig: { + classNames: { + container: 'custom-class another-class', + }, + }, + } + const result = await processMarkdown(markdown, options) + + expect(result).toContain('custom-class') + expect(result).toContain('another-class') + }) + + it('should apply custom container class name', async () => { + const markdown = `> [!TIP] +> Test content.` + const options: RemarkGitHubAlertsOptions = { + defaultConfig: { + classNames: { + container: 'custom-alert', + }, + }, + } + const result = await processMarkdown(markdown, options) + + expect(result).toContain('class="custom-alert custom-alert-tip"') + // Should still use default title/content classes since only container class was customized + expect(result).toContain('class="markdown-alert-title"') + expect(result).toContain('class="markdown-alert-content"') + }) + + it('should apply alert-specific configuration', async () => { + const markdown = `> [!NOTE] +> Test content.` + const options: RemarkGitHubAlertsOptions = { + alerts: { + note: { + classNames: { + container: 'note-container', + title: 'note-title', + content: 'note-content', + icon: 'note-icon', + }, + }, + }, + } + const result = await processMarkdown(markdown, options) + + expect(result).toContain('class="note-container note-container-note"') + expect(result).toContain('class="note-title"') + expect(result).toContain('class="note-content"') + expect(result).toContain('class="note-icon"') + }) + + it('should merge default and alert-specific configurations', async () => { + const markdown = `> [!WARNING] +> Test content.` + const options: RemarkGitHubAlertsOptions = { + defaultConfig: { + classNames: { + container: 'base-class', + }, + }, + alerts: { + warning: { + classNames: { + container: 'warning-specific', + }, + }, + }, + } + const result = await processMarkdown(markdown, options) + + expect(result).toContain('warning-specific') + }) + + it('should apply custom HTML tags', async () => { + const markdown = `> [!NOTE] +> Test content.` + const options: RemarkGitHubAlertsOptions = { + defaultConfig: { + tags: { + container: 'section', + title: 'h3', + icon: 'i', + content: 'article', + }, + }, + } + const result = await processMarkdown(markdown, options) + + expect(result).toContain('/g)).toHaveLength(2) // Two regular blockquotes + }) }) - it('should multiply negative numbers', () => { - expect(multiply(-2, 3)).toBe(-6) + describe('output structure', () => { + it('should generate minimal DOM structure', async () => { + const markdown = `> [!NOTE] +> Simple content.` + const result = await processMarkdown(markdown) + + // Should have exactly one alert container (not counting title/content/icon classes) + expect(result.match(/class="markdown-alert markdown-alert-note"/g)).toHaveLength(1) + + // Should have title and content sections + expect(result.match(/class="markdown-alert-title"/g)).toHaveLength(1) + expect(result.match(/class="markdown-alert-content"/g)).toHaveLength(1) + expect(result.match(/class="markdown-alert-icon"/g)).toHaveLength(1) + }) + + it('should preserve content formatting', async () => { + const markdown = `> [!TIP] +> **Bold text** and *italic text*.` + const result = await processMarkdown(markdown) + + expect(result).toContain('Bold text') + expect(result).toContain('italic text') + }) + + it('should handle edge case with invalid parent or index', () => { + // This tests the defensive code path directly + const blockquoteNode: Blockquote = { + type: 'blockquote', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: '[!NOTE] Test', + }, + ], + }, + ], + } + + const mockConfig = { + iconElementHtml: '', + tags: { + container: 'div' as const, + icon: 'span' as const, + title: 'div' as const, + content: 'div' as const, + }, + classNames: { + container: 'test', + icon: 'test-icon', + title: 'test-title', + content: 'test-content', + }, + } + + // Test with null parent - should return false + expect(processBlockquote(blockquoteNode, 0, null, mockConfig, {})).toBe(false) + + // Test with undefined index - should return false + expect(processBlockquote(blockquoteNode, undefined as any, {}, mockConfig, {})).toBe(false) + + // Test with string index instead of number - should return false + expect(processBlockquote(blockquoteNode, '0' as any, {}, mockConfig, {})).toBe(false) + + // Test the edge case where text.split('\n')[0] is falsy (empty string) + const emptyTextBlockquote: Blockquote = { + type: 'blockquote', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: '', // Empty text triggers the || '' fallback + }, + ], + }, + ], + } + + const mockParent = { children: [emptyTextBlockquote] } + expect(processBlockquote(emptyTextBlockquote, 0, mockParent, mockConfig, {})).toBe(false) + }) }) }) diff --git a/tsconfig.json b/tsconfig.json index fe11a0b..ac405e6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, @@ -26,7 +26,6 @@ "noPropertyAccessFromIndexSignature": true, "useUnknownInCatchVariables": true, "exactOptionalPropertyTypes": true, - "noUncheckedSideEffectImports": true, "noUnusedLocals": true, "noUnusedParameters": true,