diff --git a/src/lib/generators/api-docs.test.ts b/src/lib/generators/api-docs.test.ts new file mode 100644 index 0000000..b0a1fb7 --- /dev/null +++ b/src/lib/generators/api-docs.test.ts @@ -0,0 +1,1374 @@ +/** + * Tests for API documentation markdown generator + */ + +import { describe, it, expect } from 'vitest'; +import type { + ApiFunction, + ApiClass, + ApiInterface, + ApiTypeAlias, + ApiEnum, + ApiMetadata, + ParsedApiFile, + ApiItem, +} from './api-parser.js'; +import { + generateMarkdown, + groupByCategory, + generateApiDocFile, + generateIndexFile, + type MarkdownGeneratorConfig, +} from './api-docs.js'; + +// Test helpers +const defaultMetadata: ApiMetadata = { + isPublic: true, + tags: {}, +}; + +const defaultSource = { + file: 'src/example.ts', + line: 10, +}; + +describe('generateMarkdown - Functions', () => { + it('should generate basic function documentation', () => { + const func: ApiFunction = { + kind: 'function', + name: 'testFunc', + description: 'A test function', + parameters: [], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).toContain('## testFunc'); + expect(result).toContain('A test function'); + expect(result).toContain('function testFunc(): void'); + expect(result).toContain('---'); + }); + + it('should generate function with parameters', () => { + const func: ApiFunction = { + kind: 'function', + name: 'add', + description: 'Adds two numbers', + parameters: [ + { name: 'a', type: 'number', optional: false }, + { name: 'b', type: 'number', optional: false }, + ], + returnType: 'number', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).toContain('**Parameters:**'); + expect(result).toContain('**`a`**: `number`'); + expect(result).toContain('**`b`**: `number`'); + expect(result).toContain('**Returns:** `number`'); + }); + + it('should generate function with optional parameters', () => { + const func: ApiFunction = { + kind: 'function', + name: 'greet', + description: 'Greets a person', + parameters: [ + { name: 'name', type: 'string', optional: false }, + { name: 'title', type: 'string', optional: true, description: 'Optional title' }, + ], + returnType: 'string', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).toContain('**`name`**: `string`'); + expect(result).toContain('**`title`** (optional): `string` - Optional title'); + expect(result).toContain('name: string, title?: string'); + }); + + it('should generate function with default parameters', () => { + const func: ApiFunction = { + kind: 'function', + name: 'multiply', + description: 'Multiplies a number', + parameters: [ + { name: 'value', type: 'number', optional: false }, + { name: 'factor', type: 'number', optional: true, defaultValue: '2' }, + ], + returnType: 'number', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).toContain('**`factor`** (optional): `number` = `2`'); + expect(result).toContain('factor?: number = 2'); + }); + + it('should generate function with type parameters', () => { + const func: ApiFunction = { + kind: 'function', + name: 'identity', + description: 'Returns input value', + typeParameters: ['T'], + parameters: [{ name: 'value', type: 'T', optional: false }], + returnType: 'T', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).toContain('function identity(value: T): T'); + }); + + it('should generate function with examples', () => { + const func: ApiFunction = { + kind: 'function', + name: 'sum', + description: 'Calculates sum', + parameters: [], + returnType: 'number', + metadata: defaultMetadata, + source: defaultSource, + examples: [ + { code: 'const result = sum(1, 2);', caption: 'Basic usage' }, + { code: 'const total = sum(...numbers);' }, + ], + }; + + const result = generateMarkdown(func); + + expect(result).toContain('**Examples:**'); + expect(result).toContain('*Basic usage*'); + expect(result).toContain('```typescript\nconst result = sum(1, 2);\n```'); + expect(result).toContain('const total = sum(...numbers);'); + }); + + it('should generate function with return description', () => { + const func: ApiFunction = { + kind: 'function', + name: 'calculate', + description: 'Performs calculation', + parameters: [], + returnType: 'number', + returnDescription: 'The calculated result', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).toContain('**Returns:** `number` - The calculated result'); + }); + + it('should not show returns section for void functions', () => { + const func: ApiFunction = { + kind: 'function', + name: 'logMessage', + description: 'Logs a message', + parameters: [{ name: 'msg', type: 'string', optional: false }], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).not.toContain('**Returns:**'); + }); +}); + +describe('generateMarkdown - Classes', () => { + it('should generate basic class documentation', () => { + const cls: ApiClass = { + kind: 'class', + name: 'TestClass', + description: 'A test class', + properties: [], + methods: [], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(cls); + + expect(result).toContain('## TestClass'); + expect(result).toContain('A test class'); + expect(result).toContain('class TestClass'); + expect(result).toContain('---'); + }); + + it('should generate class with properties', () => { + const cls: ApiClass = { + kind: 'class', + name: 'Person', + description: 'Represents a person', + properties: [ + { name: 'name', type: 'string', optional: false, readonly: false }, + { + name: 'age', + type: 'number', + optional: false, + readonly: false, + description: 'Person age', + }, + ], + methods: [], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(cls); + + expect(result).toContain('### Properties'); + expect(result).toContain('#### `name`'); + expect(result).toContain('**Type:** `string`'); + expect(result).toContain('#### `age`'); + expect(result).toContain('Person age'); + }); + + it('should generate class with readonly and optional properties', () => { + const cls: ApiClass = { + kind: 'class', + name: 'Config', + description: 'Configuration class', + properties: [ + { name: 'id', type: 'string', optional: false, readonly: true }, + { name: 'debug', type: 'boolean', optional: true, readonly: false }, + ], + methods: [], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(cls); + + expect(result).toContain('#### `id` (readonly)'); + expect(result).toContain('#### `debug` (optional)'); + }); + + it('should generate class with methods', () => { + const cls: ApiClass = { + kind: 'class', + name: 'Calculator', + description: 'A calculator', + properties: [], + methods: [ + { + name: 'add', + description: 'Adds numbers', + parameters: [ + { name: 'a', type: 'number', optional: false }, + { name: 'b', type: 'number', optional: false }, + ], + returnType: 'number', + metadata: defaultMetadata, + }, + ], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(cls); + + expect(result).toContain('### Methods'); + expect(result).toContain('#### add()'); + expect(result).toContain('Adds numbers'); + expect(result).toContain('**Parameters:**'); + }); + + it('should generate class with constructor', () => { + const cls: ApiClass = { + kind: 'class', + name: 'Database', + description: 'Database connection', + properties: [], + methods: [], + constructorDoc: { + description: 'Creates a new database connection', + parameters: [ + { name: 'url', type: 'string', optional: false, description: 'Connection URL' }, + ], + }, + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(cls); + + expect(result).toContain('### Constructor'); + expect(result).toContain('Creates a new database connection'); + expect(result).toContain('**`url`**: `string` - Connection URL'); + }); + + it('should generate class with extends and implements', () => { + const cls: ApiClass = { + kind: 'class', + name: 'SpecialList', + description: 'A special list', + extends: 'Array', + implements: ['Iterable', 'Serializable'], + properties: [], + methods: [], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(cls); + + expect(result).toContain('class SpecialList extends Array implements Iterable, Serializable'); + }); + + it('should generate class with type parameters', () => { + const cls: ApiClass = { + kind: 'class', + name: 'Container', + description: 'Generic container', + typeParameters: ['T'], + properties: [], + methods: [], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(cls); + + expect(result).toContain('class Container'); + }); +}); + +describe('generateMarkdown - Interfaces', () => { + it('should generate basic interface documentation', () => { + const iface: ApiInterface = { + kind: 'interface', + name: 'User', + description: 'User interface', + properties: [ + { name: 'id', type: 'string', optional: false, readonly: false }, + { name: 'name', type: 'string', optional: false, readonly: false }, + ], + methods: [], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(iface); + + expect(result).toContain('## User'); + expect(result).toContain('User interface'); + expect(result).toContain('interface User'); + expect(result).toContain('### Properties'); + expect(result).toContain('#### `id`'); + expect(result).toContain('#### `name`'); + }); + + it('should generate interface with extends', () => { + const iface: ApiInterface = { + kind: 'interface', + name: 'Employee', + description: 'Employee interface', + extends: ['Person', 'Identifiable'], + properties: [], + methods: [], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(iface); + + expect(result).toContain('interface Employee extends Person, Identifiable'); + }); + + it('should generate interface with type parameters', () => { + const iface: ApiInterface = { + kind: 'interface', + name: 'Result', + description: 'Result wrapper', + typeParameters: ['T', 'E'], + properties: [], + methods: [], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(iface); + + expect(result).toContain('interface Result'); + }); + + it('should generate interface with methods', () => { + const iface: ApiInterface = { + kind: 'interface', + name: 'Repository', + description: 'Data repository', + properties: [], + methods: [ + { + name: 'save', + description: 'Saves an item', + parameters: [{ name: 'item', type: 'T', optional: false }], + returnType: 'Promise', + metadata: defaultMetadata, + }, + ], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(iface); + + expect(result).toContain('### Methods'); + expect(result).toContain('#### save()'); + expect(result).toContain('Saves an item'); + }); +}); + +describe('generateMarkdown - Type Aliases', () => { + it('should generate basic type alias documentation', () => { + const type: ApiTypeAlias = { + kind: 'type', + name: 'ID', + description: 'Unique identifier', + definition: 'string | number', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(type); + + expect(result).toContain('## ID'); + expect(result).toContain('Unique identifier'); + expect(result).toContain('type ID = string | number'); + }); + + it('should generate type alias with type parameters', () => { + const type: ApiTypeAlias = { + kind: 'type', + name: 'Callback', + description: 'Callback function type', + typeParameters: ['T'], + definition: '(value: T) => void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(type); + + expect(result).toContain('type Callback = (value: T) => void'); + }); + + it('should generate type alias with complex definition', () => { + const type: ApiTypeAlias = { + kind: 'type', + name: 'ComplexType', + description: 'A complex type', + definition: '{ foo: string; bar: number } & { baz: boolean }', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(type); + + expect(result).toContain('type ComplexType = { foo: string; bar: number } & { baz: boolean }'); + }); +}); + +describe('generateMarkdown - Enums', () => { + it('should generate basic enum documentation', () => { + const enumItem: ApiEnum = { + kind: 'enum', + name: 'Status', + description: 'Status codes', + members: [ + { name: 'Active', value: 'active', description: 'Active status' }, + { name: 'Inactive', value: 'inactive', description: 'Inactive status' }, + ], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(enumItem); + + expect(result).toContain('## Status'); + expect(result).toContain('Status codes'); + expect(result).toContain('### Members'); + expect(result).toContain('**`Active`** = "active" - Active status'); + expect(result).toContain('**`Inactive`** = "inactive" - Inactive status'); + }); + + it('should generate enum with numeric values', () => { + const enumItem: ApiEnum = { + kind: 'enum', + name: 'Priority', + description: 'Priority levels', + members: [ + { name: 'Low', value: 1 }, + { name: 'Medium', value: 2 }, + { name: 'High', value: 3 }, + ], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(enumItem); + + expect(result).toContain('**`Low`** = 1'); + expect(result).toContain('**`Medium`** = 2'); + expect(result).toContain('**`High`** = 3'); + }); + + it('should generate enum members without values', () => { + const enumItem: ApiEnum = { + kind: 'enum', + name: 'Direction', + description: 'Directions', + members: [{ name: 'North' }, { name: 'South' }], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(enumItem); + + expect(result).toContain('**`North`**'); + expect(result).toContain('**`South`**'); + expect(result).not.toContain('='); + }); +}); + +describe('Metadata - Badges and Tags', () => { + it('should generate deprecated badge and notice', () => { + const func: ApiFunction = { + kind: 'function', + name: 'oldFunc', + description: 'An old function', + parameters: [], + returnType: 'void', + metadata: { + ...defaultMetadata, + deprecated: 'Use newFunc instead', + }, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).toContain('**⚠️ DEPRECATED**'); + expect(result).toContain('**Deprecation Notice:** Use newFunc instead'); + }); + + it('should generate experimental badge', () => { + const func: ApiFunction = { + kind: 'function', + name: 'betaFunc', + description: 'A beta function', + parameters: [], + returnType: 'void', + metadata: { + ...defaultMetadata, + experimental: true, + }, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).toContain('**🧪 EXPERIMENTAL**'); + }); + + it('should generate since badge', () => { + const func: ApiFunction = { + kind: 'function', + name: 'newFunc', + description: 'A new function', + parameters: [], + returnType: 'void', + metadata: { + ...defaultMetadata, + since: 'v2.0.0', + }, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).toContain('**Added in:** v2.0.0'); + }); + + it('should generate multiple badges', () => { + const func: ApiFunction = { + kind: 'function', + name: 'testFunc', + description: 'Test function', + parameters: [], + returnType: 'void', + metadata: { + ...defaultMetadata, + deprecated: 'Will be removed', + experimental: true, + since: 'v1.5.0', + }, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).toContain('**⚠️ DEPRECATED**'); + expect(result).toContain('**🧪 EXPERIMENTAL**'); + expect(result).toContain('**Added in:** v1.5.0'); + expect(result).toContain('·'); + }); +}); + +describe('Type Linking', () => { + it('should not link built-in types', () => { + const func: ApiFunction = { + kind: 'function', + name: 'test', + description: 'Test', + parameters: [ + { name: 'str', type: 'string', optional: false }, + { name: 'num', type: 'number', optional: false }, + { name: 'bool', type: 'boolean', optional: false }, + { name: 'any', type: 'any', optional: false }, + { name: 'unknown', type: 'unknown', optional: false }, + ], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).toContain('**`str`**: `string`'); + expect(result).toContain('**`num`**: `number`'); + expect(result).toContain('**`bool`**: `boolean`'); + expect(result).toContain('**`any`**: `any`'); + expect(result).toContain('**`unknown`**: `unknown`'); + }); + + it('should link custom types with baseUrl', () => { + const func: ApiFunction = { + kind: 'function', + name: 'test', + description: 'Test', + parameters: [{ name: 'user', type: 'User', optional: false }], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const config: MarkdownGeneratorConfig = { + baseUrl: '/api', + }; + + const result = generateMarkdown(func, config); + + expect(result).toContain('[`User`](/api/user)'); + }); + + it('should use custom type links', () => { + const func: ApiFunction = { + kind: 'function', + name: 'test', + description: 'Test', + parameters: [{ name: 'config', type: 'Config', optional: false }], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const config: MarkdownGeneratorConfig = { + typeLinks: { + Config: '/docs/config', + }, + }; + + const result = generateMarkdown(func, config); + + expect(result).toContain('[`Config`](/docs/config)'); + }); + + it('should escape but not link complex types', () => { + const func: ApiFunction = { + kind: 'function', + name: 'test', + description: 'Test', + parameters: [ + { name: 'generic', type: 'Array', optional: false }, + { name: 'union', type: 'string | number', optional: false }, + { name: 'intersection', type: 'A & B', optional: false }, + { name: 'array', type: 'string[]', optional: false }, + ], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).toContain('`Array<string>`'); + expect(result).toContain('`string | number`'); + expect(result).toContain('`A & B`'); + expect(result).toContain('`string[]`'); + }); + + it('should escape angle brackets in types', () => { + const type: ApiTypeAlias = { + kind: 'type', + name: 'Handler', + description: 'Event handler', + definition: 'Map>', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(type); + + // The definition in code blocks doesn't need escaping + expect(result).toContain('type Handler = Map>'); + }); +}); + +describe('Source Links', () => { + it('should not generate source links by default', () => { + const func: ApiFunction = { + kind: 'function', + name: 'test', + description: 'Test', + parameters: [], + returnType: 'void', + metadata: defaultMetadata, + source: { file: 'src/test.ts', line: 42 }, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).not.toContain('**Source:**'); + }); + + it('should generate source links when configured', () => { + const func: ApiFunction = { + kind: 'function', + name: 'test', + description: 'Test', + parameters: [], + returnType: 'void', + metadata: defaultMetadata, + source: { file: 'src/test.ts', line: 42 }, + examples: [], + }; + + const config: MarkdownGeneratorConfig = { + includeSourceLinks: true, + repoUrl: 'https://github.com/user/repo', + }; + + const result = generateMarkdown(func, config); + + expect(result).toContain('**Source:**'); + expect(result).toContain( + '[src/test.ts:42](https://github.com/user/repo/blob/main/src/test.ts#L42)' + ); + }); + + it('should use custom branch for source links', () => { + const func: ApiFunction = { + kind: 'function', + name: 'test', + description: 'Test', + parameters: [], + returnType: 'void', + metadata: defaultMetadata, + source: { file: 'src/test.ts', line: 10 }, + examples: [], + }; + + const config: MarkdownGeneratorConfig = { + includeSourceLinks: true, + repoUrl: 'https://github.com/user/repo', + repoBranch: 'develop', + }; + + const result = generateMarkdown(func, config); + + expect(result).toContain('https://github.com/user/repo/blob/develop/src/test.ts#L10'); + }); + + it('should normalize Windows paths in source links', () => { + const func: ApiFunction = { + kind: 'function', + name: 'test', + description: 'Test', + parameters: [], + returnType: 'void', + metadata: defaultMetadata, + source: { file: 'src\\utils\\test.ts', line: 5 }, + examples: [], + }; + + const config: MarkdownGeneratorConfig = { + includeSourceLinks: true, + repoUrl: 'https://github.com/user/repo', + }; + + const result = generateMarkdown(func, config); + + expect(result).toContain('src/utils/test.ts'); + expect(result).not.toContain('\\'); + }); +}); + +describe('Edge Cases', () => { + it('should handle empty descriptions', () => { + const func: ApiFunction = { + kind: 'function', + name: 'test', + description: '', + parameters: [], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).toContain('## test'); + expect(result).toContain('function test(): void'); + }); + + it('should handle missing descriptions', () => { + const func: ApiFunction = { + kind: 'function', + name: 'test', + parameters: [], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).toContain('## test'); + }); + + it('should handle special characters in names', () => { + const func: ApiFunction = { + kind: 'function', + name: '$special_name', + description: 'Special function', + parameters: [], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).toContain('## $special_name'); + expect(result).toContain('function $special_name(): void'); + }); + + it('should handle parameters without descriptions', () => { + const func: ApiFunction = { + kind: 'function', + name: 'test', + description: 'Test', + parameters: [{ name: 'param', type: 'string', optional: false }], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).toContain('**`param`**: `string`'); + }); + + it('should handle long descriptions', () => { + const longDesc = 'A'.repeat(500); + const func: ApiFunction = { + kind: 'function', + name: 'test', + description: longDesc, + parameters: [], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).toContain(longDesc); + }); + + it('should handle empty parameter list', () => { + const func: ApiFunction = { + kind: 'function', + name: 'test', + description: 'Test', + parameters: [], + returnType: 'string', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).not.toContain('**Parameters:**'); + expect(result).toContain('function test(): string'); + }); + + it('should handle empty examples list', () => { + const func: ApiFunction = { + kind: 'function', + name: 'test', + description: 'Test', + parameters: [], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(func); + + expect(result).not.toContain('**Examples:**'); + }); + + it('should handle class with no properties or methods', () => { + const cls: ApiClass = { + kind: 'class', + name: 'Empty', + description: 'Empty class', + properties: [], + methods: [], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(cls); + + expect(result).toContain('## Empty'); + expect(result).not.toContain('### Properties'); + expect(result).not.toContain('### Methods'); + }); + + it('should handle interface with no properties or methods', () => { + const iface: ApiInterface = { + kind: 'interface', + name: 'Empty', + description: 'Empty interface', + properties: [], + methods: [], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }; + + const result = generateMarkdown(iface); + + expect(result).toContain('## Empty'); + expect(result).not.toContain('### Properties'); + expect(result).not.toContain('### Methods'); + }); +}); + +describe('groupByCategory', () => { + it('should group items by category', () => { + const items: ApiItem[] = [ + { + kind: 'function', + name: 'funcA', + parameters: [], + returnType: 'void', + metadata: { ...defaultMetadata, category: 'Utils' }, + source: defaultSource, + examples: [], + }, + { + kind: 'function', + name: 'funcB', + parameters: [], + returnType: 'void', + metadata: { ...defaultMetadata, category: 'Utils' }, + source: defaultSource, + examples: [], + }, + { + kind: 'function', + name: 'funcC', + parameters: [], + returnType: 'void', + metadata: { ...defaultMetadata, category: 'Core' }, + source: defaultSource, + examples: [], + }, + ]; + + const groups = groupByCategory(items); + + expect(groups.size).toBe(2); + expect(groups.get('Utils')).toHaveLength(2); + expect(groups.get('Core')).toHaveLength(1); + }); + + it('should use "Uncategorized" for items without category', () => { + const items: ApiItem[] = [ + { + kind: 'function', + name: 'func', + parameters: [], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }, + ]; + + const groups = groupByCategory(items); + + expect(groups.has('Uncategorized')).toBe(true); + expect(groups.get('Uncategorized')).toHaveLength(1); + }); + + it('should sort items alphabetically within each category', () => { + const items: ApiItem[] = [ + { + kind: 'function', + name: 'zebra', + parameters: [], + returnType: 'void', + metadata: { ...defaultMetadata, category: 'Utils' }, + source: defaultSource, + examples: [], + }, + { + kind: 'function', + name: 'apple', + parameters: [], + returnType: 'void', + metadata: { ...defaultMetadata, category: 'Utils' }, + source: defaultSource, + examples: [], + }, + { + kind: 'function', + name: 'mango', + parameters: [], + returnType: 'void', + metadata: { ...defaultMetadata, category: 'Utils' }, + source: defaultSource, + examples: [], + }, + ]; + + const groups = groupByCategory(items); + const utilsGroup = groups.get('Utils')!; + + expect(utilsGroup[0].name).toBe('apple'); + expect(utilsGroup[1].name).toBe('mango'); + expect(utilsGroup[2].name).toBe('zebra'); + }); +}); + +describe('generateApiDocFile', () => { + it('should generate complete API doc file', () => { + const parsedFile: ParsedApiFile = { + file: 'src/lib/example.ts', + items: [ + { + kind: 'function', + name: 'testFunc', + description: 'Test function', + parameters: [], + returnType: 'void', + metadata: { ...defaultMetadata, category: 'Core' }, + source: defaultSource, + examples: [], + }, + ], + }; + + const result = generateApiDocFile(parsedFile); + + expect(result.fileName).toBe('example.md'); + expect(result.content).toContain('# example'); + expect(result.content).toContain( + '> Auto-generated API documentation from `src/lib/example.ts`' + ); + expect(result.content).toContain('## Table of Contents'); + expect(result.content).toContain('### Core'); + expect(result.content).toContain('- [testFunc](#testfunc)'); + expect(result.content).toContain('## testFunc'); + }); + + it('should handle files without category grouping', () => { + const parsedFile: ParsedApiFile = { + file: 'src/utils.ts', + items: [ + { + kind: 'function', + name: 'utilA', + parameters: [], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }, + ], + }; + + const result = generateApiDocFile(parsedFile); + + expect(result.content).toContain('### Uncategorized'); + }); + + it('should generate file name from path', () => { + const parsedFile: ParsedApiFile = { + file: 'src/deep/nested/module.ts', + items: [], + }; + + const result = generateApiDocFile(parsedFile); + + expect(result.fileName).toBe('module.md'); + }); + + it('should handle path without extension', () => { + const parsedFile: ParsedApiFile = { + file: 'src/module', + items: [], + }; + + const result = generateApiDocFile(parsedFile); + + expect(result.fileName).toBe('module.md'); + }); +}); + +describe('generateIndexFile', () => { + it('should generate index file with module list', () => { + const files = [ + { + fileName: 'utils.md', + items: [ + { + kind: 'function' as const, + name: 'helper', + parameters: [], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }, + ], + }, + ]; + + const result = generateIndexFile(files); + + expect(result).toContain('# API Reference'); + expect(result).toContain('Auto-generated API documentation.'); + expect(result).toContain('## Modules'); + expect(result).toContain('### [utils](./utils.md)'); + expect(result).toContain('**Functions:** helper'); + }); + + it('should list all API item types', () => { + const files = [ + { + fileName: 'complete.md', + items: [ + { + kind: 'function' as const, + name: 'func1', + parameters: [], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }, + { + kind: 'class' as const, + name: 'Class1', + properties: [], + methods: [], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }, + { + kind: 'interface' as const, + name: 'Interface1', + properties: [], + methods: [], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }, + { + kind: 'type' as const, + name: 'Type1', + definition: 'string', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }, + { + kind: 'enum' as const, + name: 'Enum1', + members: [], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }, + ], + }, + ]; + + const result = generateIndexFile(files); + + expect(result).toContain('**Functions:** func1'); + expect(result).toContain('**Classes:** Class1'); + expect(result).toContain('**Interfaces:** Interface1'); + expect(result).toContain('**Types:** Type1'); + expect(result).toContain('**Enums:** Enum1'); + }); + + it('should handle multiple items of same type', () => { + const files = [ + { + fileName: 'multi.md', + items: [ + { + kind: 'function' as const, + name: 'funcA', + parameters: [], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }, + { + kind: 'function' as const, + name: 'funcB', + parameters: [], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }, + ], + }, + ]; + + const result = generateIndexFile(files); + + expect(result).toContain('**Functions:** funcA, funcB'); + }); + + it('should handle multiple modules', () => { + const files = [ + { + fileName: 'module1.md', + items: [ + { + kind: 'function' as const, + name: 'func1', + parameters: [], + returnType: 'void', + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }, + ], + }, + { + fileName: 'module2.md', + items: [ + { + kind: 'class' as const, + name: 'Class2', + properties: [], + methods: [], + metadata: defaultMetadata, + source: defaultSource, + examples: [], + }, + ], + }, + ]; + + const result = generateIndexFile(files); + + expect(result).toContain('### [module1](./module1.md)'); + expect(result).toContain('### [module2](./module2.md)'); + }); + + it('should handle empty module', () => { + const files = [ + { + fileName: 'empty.md', + items: [], + }, + ]; + + const result = generateIndexFile(files); + + expect(result).toContain('### [empty](./empty.md)'); + expect(result).not.toContain('**Functions:**'); + expect(result).not.toContain('**Classes:**'); + }); +}); diff --git a/src/lib/generators/api-parser.test.ts b/src/lib/generators/api-parser.test.ts index 8a07b51..80c5315 100644 --- a/src/lib/generators/api-parser.test.ts +++ b/src/lib/generators/api-parser.test.ts @@ -2,7 +2,7 @@ * Tests for API parser */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { parseApi } from './api-parser'; import type { ApiFunction, ApiClass, ApiInterface, ApiTypeAlias, ApiEnum } from './api-parser'; import { mkdtempSync, writeFileSync, rmSync } from 'fs'; @@ -10,20 +10,46 @@ import { join } from 'path'; import { tmpdir } from 'os'; describe('API Parser', () => { - // Helper to create a temporary test file - function createTempFile(content: string): { file: string; cleanup: () => void } { - const dir = mkdtempSync(join(tmpdir(), 'api-parser-test-')); - const file = join(dir, 'test.ts'); - writeFileSync(file, content, 'utf-8'); - - return { - file, - cleanup: () => rmSync(dir, { recursive: true, force: true }), + // Shared temp directory and fixture files + let tempDir: string; + let fixtures: { + simpleFunctionFile: string; + optionalParamsFile: string; + classFile: string; + interfaceFile: string; + typeAliasFile: string; + enumFile: string; + exampleTagsFile: string; + metadataTagsFile: string; + categoryTagFile: string; + genericsFile: string; + excludeFile: string; + exportedOnlyFile: string; + }; + + // Create all fixture files once before all tests + beforeAll(() => { + tempDir = mkdtempSync(join(tmpdir(), 'api-parser-test-')); + + fixtures = { + simpleFunctionFile: join(tempDir, 'simple-function.ts'), + optionalParamsFile: join(tempDir, 'optional-params.ts'), + classFile: join(tempDir, 'class.ts'), + interfaceFile: join(tempDir, 'interface.ts'), + typeAliasFile: join(tempDir, 'type-alias.ts'), + enumFile: join(tempDir, 'enum.ts'), + exampleTagsFile: join(tempDir, 'example-tags.ts'), + metadataTagsFile: join(tempDir, 'metadata-tags.ts'), + categoryTagFile: join(tempDir, 'category-tag.ts'), + genericsFile: join(tempDir, 'generics.ts'), + excludeFile: join(tempDir, 'test.ts'), // Must be named 'test.ts' for exclude test + exportedOnlyFile: join(tempDir, 'exported-only.ts'), }; - } - it('should parse a simple function', () => { - const { file, cleanup } = createTempFile(` + // Write all fixture files + writeFileSync( + fixtures.simpleFunctionFile, + ` /** * Parse YAML frontmatter from markdown files * @param markdown - Markdown content with frontmatter @@ -32,31 +58,13 @@ describe('API Parser', () => { export function parseFrontmatter(markdown: string): string { return markdown; } - `); - - try { - const result = parseApi({ entryPoints: [file] }); - - expect(result).toHaveLength(1); - expect(result[0].items).toHaveLength(1); - - const item = result[0].items[0] as ApiFunction; - expect(item.kind).toBe('function'); - expect(item.name).toBe('parseFrontmatter'); - expect(item.description).toBe('Parse YAML frontmatter from markdown files'); - expect(item.parameters).toHaveLength(1); - expect(item.parameters[0].name).toBe('markdown'); - expect(item.parameters[0].type).toBe('string'); - expect(item.parameters[0].description).toBe('Markdown content with frontmatter'); - expect(item.returnType).toBe('string'); - expect(item.returnDescription).toBe('Parsed frontmatter and content'); - } finally { - cleanup(); - } - }); + `, + 'utf-8' + ); - it('should parse function with optional parameters', () => { - const { file, cleanup } = createTempFile(` + writeFileSync( + fixtures.optionalParamsFile, + ` /** * Create a logger * @param name - Logger name @@ -64,22 +72,13 @@ export function parseFrontmatter(markdown: string): string { */ export function createLogger(name: string, options?: { level: string }): void { } - `); - - try { - const result = parseApi({ entryPoints: [file] }); - const item = result[0].items[0] as ApiFunction; - - expect(item.parameters).toHaveLength(2); - expect(item.parameters[0].optional).toBe(false); - expect(item.parameters[1].optional).toBe(true); - } finally { - cleanup(); - } - }); + `, + 'utf-8' + ); - it('should parse a class with properties and methods', () => { - const { file, cleanup } = createTempFile(` + writeFileSync( + fixtures.classFile, + ` /** * A simple counter class */ @@ -97,27 +96,13 @@ export class Counter { this.count += amount; } } - `); - - try { - const result = parseApi({ entryPoints: [file] }); - const item = result[0].items[0] as ApiClass; - - expect(item.kind).toBe('class'); - expect(item.name).toBe('Counter'); - expect(item.description).toBe('A simple counter class'); - expect(item.properties).toHaveLength(1); - expect(item.properties[0].name).toBe('count'); - expect(item.properties[0].type).toBe('number'); - expect(item.methods).toHaveLength(1); - expect(item.methods[0].name).toBe('increment'); - } finally { - cleanup(); - } - }); + `, + 'utf-8' + ); - it('should parse an interface', () => { - const { file, cleanup } = createTempFile(` + writeFileSync( + fixtures.interfaceFile, + ` /** * User configuration interface */ @@ -132,44 +117,24 @@ export interface UserConfig { */ age?: number; } - `); - - try { - const result = parseApi({ entryPoints: [file] }); - const item = result[0].items[0] as ApiInterface; - - expect(item.kind).toBe('interface'); - expect(item.name).toBe('UserConfig'); - expect(item.properties).toHaveLength(2); - expect(item.properties[0].optional).toBe(false); - expect(item.properties[1].optional).toBe(true); - } finally { - cleanup(); - } - }); + `, + 'utf-8' + ); - it('should parse a type alias', () => { - const { file, cleanup } = createTempFile(` + writeFileSync( + fixtures.typeAliasFile, + ` /** * Result type for API calls */ export type ApiResult = { success: true; data: string } | { success: false; error: string }; - `); + `, + 'utf-8' + ); - try { - const result = parseApi({ entryPoints: [file] }); - const item = result[0].items[0] as ApiTypeAlias; - - expect(item.kind).toBe('type'); - expect(item.name).toBe('ApiResult'); - expect(item.description).toBe('Result type for API calls'); - } finally { - cleanup(); - } - }); - - it('should parse an enum', () => { - const { file, cleanup } = createTempFile(` + writeFileSync( + fixtures.enumFile, + ` /** * Log levels */ @@ -187,25 +152,13 @@ export enum LogLevel { */ ERROR = 'error' } - `); - - try { - const result = parseApi({ entryPoints: [file] }); - const item = result[0].items[0] as ApiEnum; - - expect(item.kind).toBe('enum'); - expect(item.name).toBe('LogLevel'); - expect(item.members).toHaveLength(3); - expect(item.members[0].name).toBe('DEBUG'); - expect(item.members[0].value).toBe('debug'); - expect(item.members[0].description).toBe('Debug messages'); - } finally { - cleanup(); - } - }); + `, + 'utf-8' + ); - it('should parse JSDoc @example tags', () => { - const { file, cleanup } = createTempFile(` + writeFileSync( + fixtures.exampleTagsFile, + ` /** * Add two numbers * @param a - First number @@ -219,22 +172,13 @@ export enum LogLevel { export function add(a: number, b: number): number { return a + b; } - `); + `, + 'utf-8' + ); - try { - const result = parseApi({ entryPoints: [file] }); - const item = result[0].items[0] as ApiFunction; - - expect(item.examples).toHaveLength(2); - expect(item.examples[0].code).toContain('add(1, 2)'); - expect(item.examples[1].code).toContain('add(10, 20)'); - } finally { - cleanup(); - } - }); - - it('should parse metadata tags (@deprecated, @since, @experimental)', () => { - const { file, cleanup } = createTempFile(` + writeFileSync( + fixtures.metadataTagsFile, + ` /** * Old function * @deprecated Use newFunction() instead @@ -247,24 +191,13 @@ export function oldFunction(): void {} * @experimental */ export function experimentalFunction(): void {} - `); - - try { - const result = parseApi({ entryPoints: [file] }); + `, + 'utf-8' + ); - const oldFunc = result[0].items[0] as ApiFunction; - expect(oldFunc.metadata.deprecated).toBe('Use newFunction() instead'); - expect(oldFunc.metadata.since).toBe('1.0.0'); - - const expFunc = result[0].items[1] as ApiFunction; - expect(expFunc.metadata.experimental).toBe(true); - } finally { - cleanup(); - } - }); - - it('should parse @category tag for grouping', () => { - const { file, cleanup } = createTempFile(` + writeFileSync( + fixtures.categoryTagFile, + ` /** * String utility * @category Utilities @@ -286,21 +219,13 @@ export function round(num: number): number { * @category Parsers */ export function parse(input: string): void {} - `); - - try { - const result = parseApi({ entryPoints: [file] }); + `, + 'utf-8' + ); - expect(result[0].items[0].metadata.category).toBe('Utilities'); - expect(result[0].items[1].metadata.category).toBe('Utilities'); - expect(result[0].items[2].metadata.category).toBe('Parsers'); - } finally { - cleanup(); - } - }); - - it('should handle generic type parameters', () => { - const { file, cleanup } = createTempFile(` + writeFileSync( + fixtures.genericsFile, + ` /** * Generic map function */ @@ -317,54 +242,162 @@ export class Container { this.value = value; } } - `); + `, + 'utf-8' + ); - try { - const result = parseApi({ entryPoints: [file] }); + writeFileSync( + fixtures.excludeFile, + ` +export function shouldBeExcluded(): void {} + `, + 'utf-8' + ); - const mapFunc = result[0].items[0] as ApiFunction; - expect(mapFunc.typeParameters).toEqual(['T', 'U']); + writeFileSync( + fixtures.exportedOnlyFile, + ` +// Not exported - should be ignored +function privateFunction(): void {} + +// Exported - should be parsed +export function publicFunction(): void {} + `, + 'utf-8' + ); + }); - const container = result[0].items[1] as ApiClass; - expect(container.typeParameters).toEqual(['T']); - } finally { - cleanup(); + // Clean up all fixtures once after all tests + afterAll(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); } }); - it('should exclude files based on exclude patterns', () => { - const { file, cleanup } = createTempFile(` -export function shouldBeExcluded(): void {} - `); + it('should parse a simple function', () => { + const result = parseApi({ entryPoints: [fixtures.simpleFunctionFile] }); + + expect(result).toHaveLength(1); + expect(result[0].items).toHaveLength(1); + + const item = result[0].items[0] as ApiFunction; + expect(item.kind).toBe('function'); + expect(item.name).toBe('parseFrontmatter'); + expect(item.description).toBe('Parse YAML frontmatter from markdown files'); + expect(item.parameters).toHaveLength(1); + expect(item.parameters[0].name).toBe('markdown'); + expect(item.parameters[0].type).toBe('string'); + expect(item.parameters[0].description).toBe('Markdown content with frontmatter'); + expect(item.returnType).toBe('string'); + expect(item.returnDescription).toBe('Parsed frontmatter and content'); + }); - try { - const result = parseApi({ - entryPoints: [file], - exclude: ['test.ts'], - }); + it('should parse function with optional parameters', () => { + const result = parseApi({ entryPoints: [fixtures.optionalParamsFile] }); + const item = result[0].items[0] as ApiFunction; - expect(result).toHaveLength(0); - } finally { - cleanup(); - } + expect(item.parameters).toHaveLength(2); + expect(item.parameters[0].optional).toBe(false); + expect(item.parameters[1].optional).toBe(true); }); - it('should only parse exported declarations', () => { - const { file, cleanup } = createTempFile(` -// Not exported - should be ignored -function privateFunction(): void {} + it('should parse a class with properties and methods', () => { + const result = parseApi({ entryPoints: [fixtures.classFile] }); + const item = result[0].items[0] as ApiClass; + + expect(item.kind).toBe('class'); + expect(item.name).toBe('Counter'); + expect(item.description).toBe('A simple counter class'); + expect(item.properties).toHaveLength(1); + expect(item.properties[0].name).toBe('count'); + expect(item.properties[0].type).toBe('number'); + expect(item.methods).toHaveLength(1); + expect(item.methods[0].name).toBe('increment'); + }); -// Exported - should be parsed -export function publicFunction(): void {} - `); + it('should parse an interface', () => { + const result = parseApi({ entryPoints: [fixtures.interfaceFile] }); + const item = result[0].items[0] as ApiInterface; + + expect(item.kind).toBe('interface'); + expect(item.name).toBe('UserConfig'); + expect(item.properties).toHaveLength(2); + expect(item.properties[0].optional).toBe(false); + expect(item.properties[1].optional).toBe(true); + }); - try { - const result = parseApi({ entryPoints: [file] }); + it('should parse a type alias', () => { + const result = parseApi({ entryPoints: [fixtures.typeAliasFile] }); + const item = result[0].items[0] as ApiTypeAlias; - expect(result[0].items).toHaveLength(1); - expect(result[0].items[0].name).toBe('publicFunction'); - } finally { - cleanup(); - } + expect(item.kind).toBe('type'); + expect(item.name).toBe('ApiResult'); + expect(item.description).toBe('Result type for API calls'); + }); + + it('should parse an enum', () => { + const result = parseApi({ entryPoints: [fixtures.enumFile] }); + const item = result[0].items[0] as ApiEnum; + + expect(item.kind).toBe('enum'); + expect(item.name).toBe('LogLevel'); + expect(item.members).toHaveLength(3); + expect(item.members[0].name).toBe('DEBUG'); + expect(item.members[0].value).toBe('debug'); + expect(item.members[0].description).toBe('Debug messages'); + }); + + it('should parse JSDoc @example tags', () => { + const result = parseApi({ entryPoints: [fixtures.exampleTagsFile] }); + const item = result[0].items[0] as ApiFunction; + + expect(item.examples).toHaveLength(2); + expect(item.examples[0].code).toContain('add(1, 2)'); + expect(item.examples[1].code).toContain('add(10, 20)'); + }); + + it('should parse metadata tags (@deprecated, @since, @experimental)', () => { + const result = parseApi({ entryPoints: [fixtures.metadataTagsFile] }); + + const oldFunc = result[0].items[0] as ApiFunction; + expect(oldFunc.metadata.deprecated).toBe('Use newFunction() instead'); + expect(oldFunc.metadata.since).toBe('1.0.0'); + + const expFunc = result[0].items[1] as ApiFunction; + expect(expFunc.metadata.experimental).toBe(true); + }); + + it('should parse @category tag for grouping', () => { + const result = parseApi({ entryPoints: [fixtures.categoryTagFile] }); + + expect(result[0].items[0].metadata.category).toBe('Utilities'); + expect(result[0].items[1].metadata.category).toBe('Utilities'); + expect(result[0].items[2].metadata.category).toBe('Parsers'); + }); + + it('should handle generic type parameters', () => { + const result = parseApi({ entryPoints: [fixtures.genericsFile] }); + + const mapFunc = result[0].items[0] as ApiFunction; + expect(mapFunc.typeParameters).toEqual(['T', 'U']); + + const container = result[0].items[1] as ApiClass; + expect(container.typeParameters).toEqual(['T']); + }); + + it('should exclude files based on exclude patterns', () => { + const result = parseApi({ + entryPoints: [fixtures.excludeFile], + exclude: ['test.ts'], + }); + + expect(result).toHaveLength(0); + }); + + it('should only parse exported declarations', () => { + const result = parseApi({ entryPoints: [fixtures.exportedOnlyFile] }); + + expect(result[0].items).toHaveLength(1); + expect(result[0].items[0].name).toBe('publicFunction'); }); }); diff --git a/src/lib/plugins/collapse.test.ts b/src/lib/plugins/collapse.test.ts index 7bc518b..65fda10 100644 --- a/src/lib/plugins/collapse.test.ts +++ b/src/lib/plugins/collapse.test.ts @@ -141,130 +141,72 @@ describe('collapse plugin', () => { expect(htmlNode.value).toContain('

Second paragraph

'); }); - it('should render code blocks', () => { - const tree = createCollapseDirective([ - { - type: 'code', - lang: 'javascript', - value: 'console.log("test");', - }, - ]); - - const plugin = collapsePlugin(); - plugin(tree); - - const htmlNode = tree.children[0] as any; - expect(htmlNode.value).toContain('
');
-      expect(htmlNode.value).toContain('console.log("test");');
-    });
-
-    it('should render code blocks without language', () => {
-      const tree = createCollapseDirective([
-        {
-          type: 'code',
-          value: 'plain code',
-        },
-      ]);
-
-      const plugin = collapsePlugin();
-      plugin(tree);
-
-      const htmlNode = tree.children[0] as any;
-      expect(htmlNode.value).toContain('
plain code
'); - }); - - it('should render blockquotes', () => { - const tree = createCollapseDirective([ - { + it.each([ + { + name: 'code block with language', + content: { type: 'code', lang: 'javascript', value: 'console.log("test");' }, + expected: ['
', 'console.log("test");'],
+      },
+      {
+        name: 'code block without language',
+        content: { type: 'code', value: 'plain code' },
+        expected: ['
plain code
'], + }, + { + name: 'blockquote', + content: { type: 'blockquote', - children: [ - { - type: 'paragraph', - children: [{ type: 'text', value: 'Quote text' }], - }, - ], + children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Quote text' }] }], }, - ]); - - const plugin = collapsePlugin(); - plugin(tree); - - const htmlNode = tree.children[0] as any; - expect(htmlNode.value).toContain('
'); - expect(htmlNode.value).toContain('

Quote text

'); - expect(htmlNode.value).toContain('
'); - }); - - it('should render headings', () => { - const tree = createCollapseDirective([ - { + expected: ['
', '

Quote text

', '
'], + }, + { + name: 'heading', + content: { type: 'heading', depth: 2, children: [{ type: 'text', value: 'Section Title' }], }, - ]); - - const plugin = collapsePlugin(); - plugin(tree); - - const htmlNode = tree.children[0] as any; - expect(htmlNode.value).toContain('

Section Title

'); - }); - - it('should render unordered lists', () => { - const tree = createCollapseDirective([ - { - type: 'list', - ordered: false, - children: [ - { - type: 'listItem', - children: [ - { - type: 'paragraph', - children: [{ type: 'text', value: 'Item 1' }], - }, - ], - }, - { - type: 'listItem', - children: [ - { - type: 'paragraph', - children: [{ type: 'text', value: 'Item 2' }], - }, - ], - }, - ], - }, - ]); - + expected: ['

Section Title

'], + }, + ])('should render $name', ({ content, expected }) => { + const tree = createCollapseDirective([content as any]); const plugin = collapsePlugin(); plugin(tree); const htmlNode = tree.children[0] as any; - expect(htmlNode.value).toContain('
    '); - expect(htmlNode.value).toContain('
  • Item 1
  • '); - expect(htmlNode.value).toContain('
  • Item 2
  • '); - expect(htmlNode.value).toContain('
'); + expected.forEach((exp) => { + expect(htmlNode.value).toContain(exp); + }); }); - it('should render ordered lists', () => { + it.each([ + { + name: 'unordered list', + ordered: false, + items: ['Item 1', 'Item 2'], + expectedTag: 'ul', + }, + { + name: 'ordered list', + ordered: true, + items: ['First', 'Second'], + expectedTag: 'ol', + }, + ])('should render $name', ({ ordered, items, expectedTag }) => { const tree = createCollapseDirective([ { type: 'list', - ordered: true, - children: [ - { - type: 'listItem', - children: [ - { - type: 'paragraph', - children: [{ type: 'text', value: 'First' }], - }, - ], - }, - ], + ordered, + children: items.map((item) => ({ + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: item }], + }, + ], + })), }, ]); @@ -272,9 +214,11 @@ describe('collapse plugin', () => { plugin(tree); const htmlNode = tree.children[0] as any; - expect(htmlNode.value).toContain('
    '); - expect(htmlNode.value).toContain('
  1. First
  2. '); - expect(htmlNode.value).toContain('
'); + expect(htmlNode.value).toContain(`<${expectedTag}>`); + items.forEach((item) => { + expect(htmlNode.value).toContain(`
  • ${item}
  • `); + }); + expect(htmlNode.value).toContain(``); }); it('should render nested lists', () => { @@ -322,55 +266,38 @@ describe('collapse plugin', () => { }); describe('inline content rendering', () => { - it('should render emphasis', () => { - const tree = createCollapseDirective([ - { - type: 'paragraph', - children: [ - { type: 'text', value: 'Normal ' }, - { - type: 'emphasis', - children: [{ type: 'text', value: 'italic' }], - }, - ], - }, - ]); - - const plugin = collapsePlugin(); - plugin(tree); - - const htmlNode = tree.children[0] as any; - expect(htmlNode.value).toContain('Normal italic'); - }); - - it('should render strong', () => { - const tree = createCollapseDirective([ - { - type: 'paragraph', - children: [ - { - type: 'strong', - children: [{ type: 'text', value: 'bold' }], - }, - ], - }, - ]); - - const plugin = collapsePlugin(); - plugin(tree); - - const htmlNode = tree.children[0] as any; - expect(htmlNode.value).toContain('bold'); - }); - - it('should render inline code', () => { + it.each([ + { + name: 'emphasis', + children: [ + { type: 'text', value: 'Normal ' }, + { type: 'emphasis', children: [{ type: 'text', value: 'italic' }] }, + ], + expected: 'Normal italic', + }, + { + name: 'strong', + children: [{ type: 'strong', children: [{ type: 'text', value: 'bold' }] }], + expected: 'bold', + }, + { + name: 'inline code', + children: [ + { type: 'text', value: 'Use ' }, + { type: 'inlineCode', value: 'console.log()' }, + ], + expected: 'Use console.log()', + }, + { + name: 'strikethrough', + children: [{ type: 'delete', children: [{ type: 'text', value: 'deleted' }] }], + expected: 'deleted', + }, + ])('should render $name', ({ children, expected }) => { const tree = createCollapseDirective([ { type: 'paragraph', - children: [ - { type: 'text', value: 'Use ' }, - { type: 'inlineCode', value: 'console.log()' }, - ], + children: children as any, }, ]); @@ -378,7 +305,7 @@ describe('collapse plugin', () => { plugin(tree); const htmlNode = tree.children[0] as any; - expect(htmlNode.value).toContain('Use console.log()'); + expect(htmlNode.value).toContain(expected); }); it('should render links', () => { @@ -424,26 +351,6 @@ describe('collapse plugin', () => { expect(htmlNode.value).toContain('title="Example Site"'); }); - it('should render strikethrough', () => { - const tree = createCollapseDirective([ - { - type: 'paragraph', - children: [ - { - type: 'delete', - children: [{ type: 'text', value: 'deleted' }], - }, - ], - }, - ]); - - const plugin = collapsePlugin(); - plugin(tree); - - const htmlNode = tree.children[0] as any; - expect(htmlNode.value).toContain('deleted'); - }); - it('should render images', () => { const tree = createCollapseDirective([ { @@ -507,58 +414,6 @@ describe('collapse plugin', () => { }); }); - describe('HTML escaping', () => { - it('should escape HTML in title', () => { - const tree = createCollapseDirective( - [ - { - type: 'paragraph', - children: [{ type: 'text', value: 'Content' }], - }, - ], - { title: '' } - ); - - const plugin = collapsePlugin(); - plugin(tree); - - const htmlNode = tree.children[0] as any; - expect(htmlNode.value).not.toContain('', - }, - ]); - - const plugin = collapsePlugin(); - plugin(tree); - - const htmlNode = tree.children[0] as any; - expect(htmlNode.value).toContain('<script>'); - }); - }); - describe('edge cases', () => { it('should handle empty collapse directive', () => { const tree = createCollapseDirective([]); diff --git a/src/lib/plugins/katex.test.ts b/src/lib/plugins/katex.test.ts index cd6fd84..436d1dd 100644 --- a/src/lib/plugins/katex.test.ts +++ b/src/lib/plugins/katex.test.ts @@ -73,51 +73,28 @@ describe('katex plugin', () => { expect(output).not.toContain('katex-display'); // Should be inline, not display }); - it('should handle simple fractions', () => { - const tree = createInlineMathTree('\\frac{1}{2}'); - const plugin = katexPlugin(); - plugin(tree); - - const output = getHtmlOutput(tree); - expect(output).toContain('katex'); - expect(output).toContain('frac'); - }); - - it('should handle square roots', () => { - const tree = createInlineMathTree('\\sqrt{x}'); - const plugin = katexPlugin(); - plugin(tree); - - const output = getHtmlOutput(tree); - expect(output).toContain('katex'); - expect(output).toContain('sqrt'); - }); - - it('should handle Greek letters', () => { - const tree = createInlineMathTree('\\alpha + \\beta = \\gamma'); - const plugin = katexPlugin(); - plugin(tree); - - const output = getHtmlOutput(tree); - expect(output).toContain('katex'); - }); - - it('should handle subscripts and superscripts', () => { - const tree = createInlineMathTree('x^2 + y_1'); - const plugin = katexPlugin(); - plugin(tree); - - const output = getHtmlOutput(tree); - expect(output).toContain('katex'); - }); - - it('should handle basic arithmetic', () => { - const tree = createInlineMathTree('2 + 2 = 4'); + it.each([ + { formula: '\\frac{1}{2}', description: 'fractions', expectedContent: 'frac' }, + { formula: '\\sqrt{x}', description: 'square roots', expectedContent: 'sqrt' }, + { + formula: '\\alpha + \\beta = \\gamma', + description: 'Greek letters', + expectedContent: 'katex', + }, + { + formula: 'x^2 + y_1', + description: 'subscripts and superscripts', + expectedContent: 'katex', + }, + { formula: '2 + 2 = 4', description: 'basic arithmetic', expectedContent: 'katex' }, + ])('should handle $description', ({ formula, expectedContent }) => { + const tree = createInlineMathTree(formula); const plugin = katexPlugin(); plugin(tree); const output = getHtmlOutput(tree); expect(output).toContain('katex'); + expect(output).toContain(expectedContent); }); }); @@ -132,76 +109,23 @@ describe('katex plugin', () => { expect(output).toContain('katex-display'); }); - it('should handle integrals', () => { - const tree = createDisplayMathTree('\\int_{-\\infty}^{\\infty} e^{-x^2} dx'); - const plugin = katexPlugin(); - plugin(tree); - - const output = getHtmlOutput(tree); - expect(output).toContain('katex'); - }); - - it('should handle summations', () => { - const tree = createDisplayMathTree('\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}'); - const plugin = katexPlugin(); - plugin(tree); - - const output = getHtmlOutput(tree); - expect(output).toContain('katex'); - }); - - it('should handle matrices', () => { - const tree = createDisplayMathTree('\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}'); - const plugin = katexPlugin(); - plugin(tree); - - const output = getHtmlOutput(tree); - expect(output).toContain('katex'); - }); - - it('should handle complex fractions', () => { - const tree = createDisplayMathTree('\\frac{\\frac{a}{b}}{\\frac{c}{d}}'); - const plugin = katexPlugin(); - plugin(tree); - - const output = getHtmlOutput(tree); - expect(output).toContain('katex'); - }); - - it('should handle limits', () => { - const tree = createDisplayMathTree('\\lim_{x \\to \\infty} f(x) = L'); - const plugin = katexPlugin(); - plugin(tree); - - const output = getHtmlOutput(tree); - expect(output).toContain('katex'); - }); - - it('should handle derivatives', () => { - const tree = createDisplayMathTree('\\frac{d}{dx} x^2 = 2x'); - const plugin = katexPlugin(); - plugin(tree); - - const output = getHtmlOutput(tree); - expect(output).toContain('katex'); - }); - - it('should handle products', () => { - const tree = createDisplayMathTree('\\prod_{i=1}^{n} x_i'); - const plugin = katexPlugin(); - plugin(tree); - - const output = getHtmlOutput(tree); - expect(output).toContain('katex'); - }); - - it('should handle binomial coefficients', () => { - const tree = createDisplayMathTree('\\binom{n}{k} = \\frac{n!}{k!(n-k)!}'); + it.each([ + { formula: '\\int_{-\\infty}^{\\infty} e^{-x^2} dx', description: 'integrals' }, + { formula: '\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}', description: 'summations' }, + { formula: '\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}', description: 'matrices' }, + { formula: '\\frac{\\frac{a}{b}}{\\frac{c}{d}}', description: 'complex fractions' }, + { formula: '\\lim_{x \\to \\infty} f(x) = L', description: 'limits' }, + { formula: '\\frac{d}{dx} x^2 = 2x', description: 'derivatives' }, + { formula: '\\prod_{i=1}^{n} x_i', description: 'products' }, + { formula: '\\binom{n}{k} = \\frac{n!}{k!(n-k)!}', description: 'binomial coefficients' }, + ])('should handle $description', ({ formula }) => { + const tree = createDisplayMathTree(formula); const plugin = katexPlugin(); plugin(tree); const output = getHtmlOutput(tree); expect(output).toContain('katex'); + expect(output).toContain('katex-display'); }); }); diff --git a/src/lib/plugins/tabs.test.ts b/src/lib/plugins/tabs.test.ts new file mode 100644 index 0000000..18043dc --- /dev/null +++ b/src/lib/plugins/tabs.test.ts @@ -0,0 +1,603 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect } from 'vitest'; +import { tabsPlugin } from './tabs'; +import type { Root, Code } from 'mdast'; +import { decodeJsonBase64 } from '../utils/base64'; + +describe('tabs plugin', () => { + const createCodeBlock = (lang: string, value: string): Root => ({ + type: 'root', + children: [ + { + type: 'code', + lang, + value, + } as Code, + ], + }); + + describe('plugin basic functionality', () => { + it('should export tabsPlugin function', () => { + expect(tabsPlugin).toBeDefined(); + expect(typeof tabsPlugin).toBe('function'); + }); + + it('should return transformer function', () => { + const plugin = tabsPlugin(); + expect(typeof plugin).toBe('function'); + }); + + it('should transform tabs code blocks', () => { + const tree = createCodeBlock( + 'tabs:example', + `tab: JavaScript +--- +\`\`\`js +console.log('test'); +\`\`\`` + ); + + const plugin = tabsPlugin(); + plugin(tree); + + const htmlNode = tree.children[0] as any; + expect(htmlNode.type).toBe('html'); + expect(htmlNode.value).toContain('md-code-tabs'); + }); + + it('should skip non-tabs code blocks', () => { + const tree = createCodeBlock('javascript', 'console.log("test");'); + + const plugin = tabsPlugin(); + plugin(tree); + + const node = tree.children[0] as any; + expect(node.type).toBe('code'); + expect(node.lang).toBe('javascript'); + }); + + it('should preserve non-tabs nodes', () => { + const tree: Root = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'Normal text' }], + }, + ], + }; + + const plugin = tabsPlugin(); + plugin(tree); + + expect(tree.children[0].type).toBe('paragraph'); + }); + }); + + describe('tab parsing', () => { + it('should parse single tab', () => { + const tree = createCodeBlock( + 'tabs:single', + `tab: JavaScript +--- +\`\`\`js +const x = 1; +\`\`\`` + ); + + const plugin = tabsPlugin(); + plugin(tree); + + const htmlNode = tree.children[0] as any; + const dataTabs = htmlNode.value.match(/data-tabs="([^"]+)"/)?.[1]; + const tabs = decodeJsonBase64(dataTabs); + + expect(tabs).toHaveLength(1); + expect(tabs[0].label).toBe('JavaScript'); + expect(tabs[0].content).toBe('const x = 1;'); + expect(tabs[0].language).toBe('js'); + }); + + it('should parse multiple tabs', () => { + const tree = createCodeBlock( + 'tabs:multi', + `tab: JavaScript +--- +\`\`\`js +const x = 1; +\`\`\` +--- +tab: TypeScript +--- +\`\`\`ts +const x: number = 1; +\`\`\`` + ); + + const plugin = tabsPlugin(); + plugin(tree); + + const htmlNode = tree.children[0] as any; + const dataTabs = htmlNode.value.match(/data-tabs="([^"]+)"/)?.[1]; + const tabs = decodeJsonBase64(dataTabs); + + expect(tabs).toHaveLength(2); + expect(tabs[0].label).toBe('JavaScript'); + expect(tabs[1].label).toBe('TypeScript'); + }); + + it('should extract tab labels', () => { + const tree = createCodeBlock( + 'tabs:labels', + `tab: My Custom Label +--- +\`\`\`js +code; +\`\`\` +--- +tab: Another Label +--- +\`\`\`js +more code; +\`\`\`` + ); + + const plugin = tabsPlugin(); + plugin(tree); + + const htmlNode = tree.children[0] as any; + const dataTabs = htmlNode.value.match(/data-tabs="([^"]+)"/)?.[1]; + const tabs = decodeJsonBase64(dataTabs); + + expect(tabs[0].label).toBe('My Custom Label'); + expect(tabs[1].label).toBe('Another Label'); + }); + + it('should extract code content', () => { + const tree = createCodeBlock( + 'tabs:content', + `tab: Example +--- +\`\`\`js +const data = await fetch('/api'); +const json = await data.json(); +console.log(json); +\`\`\`` + ); + + const plugin = tabsPlugin(); + plugin(tree); + + const htmlNode = tree.children[0] as any; + const dataTabs = htmlNode.value.match(/data-tabs="([^"]+)"/)?.[1]; + const tabs = decodeJsonBase64(dataTabs); + + expect(tabs[0].content).toContain('const data = await fetch'); + expect(tabs[0].content).toContain('const json = await data.json()'); + expect(tabs[0].content).toContain('console.log(json)'); + }); + + it('should detect language from code fences', () => { + const tree = createCodeBlock( + 'tabs:lang', + `tab: Python +--- +\`\`\`python +print("Hello") +\`\`\` +--- +tab: Ruby +--- +\`\`\`ruby +puts "Hello" +\`\`\`` + ); + + const plugin = tabsPlugin(); + plugin(tree); + + const htmlNode = tree.children[0] as any; + const dataTabs = htmlNode.value.match(/data-tabs="([^"]+)"/)?.[1]; + const tabs = decodeJsonBase64(dataTabs); + + expect(tabs[0].language).toBe('python'); + expect(tabs[1].language).toBe('ruby'); + }); + + it('should handle tabs without language', () => { + const tree = createCodeBlock( + 'tabs:nolang', + `tab: Plain +--- +\`\`\` +plain text +\`\`\`` + ); + + const plugin = tabsPlugin(); + plugin(tree); + + const htmlNode = tree.children[0] as any; + const dataTabs = htmlNode.value.match(/data-tabs="([^"]+)"/)?.[1]; + const tabs = decodeJsonBase64(dataTabs); + + expect(tabs[0].language).toBeUndefined(); + expect(tabs[0].content).toBe('plain text'); + }); + + it('should handle separators (---)', () => { + const tree = createCodeBlock( + 'tabs:sep', + `tab: First +--- +\`\`\`js +first; +\`\`\` +--- +tab: Second +--- +\`\`\`js +second; +\`\`\`` + ); + + const plugin = tabsPlugin(); + plugin(tree); + + const htmlNode = tree.children[0] as any; + const dataTabs = htmlNode.value.match(/data-tabs="([^"]+)"/)?.[1]; + const tabs = decodeJsonBase64(dataTabs); + + expect(tabs).toHaveLength(2); + expect(tabs[0].content).toBe('first;'); + expect(tabs[1].content).toBe('second;'); + }); + + it('should trim whitespace', () => { + const tree = createCodeBlock( + 'tabs:trim', + `tab: Spaced Label +--- +\`\`\`js + const x = 1; + const y = 2; +\`\`\`` + ); + + const plugin = tabsPlugin(); + plugin(tree); + + const htmlNode = tree.children[0] as any; + const dataTabs = htmlNode.value.match(/data-tabs="([^"]+)"/)?.[1]; + const tabs = decodeJsonBase64(dataTabs); + + expect(tabs[0].label).toBe('Spaced Label'); + // Content should preserve internal spacing but trim overall + expect(tabs[0].content).toContain('const x = 1;'); + }); + + it('should handle empty tabs (skip them)', () => { + const tree = createCodeBlock( + 'tabs:empty', + `tab: Empty +--- +\`\`\`js +\`\`\` +--- +tab: Valid +--- +\`\`\`js +code; +\`\`\`` + ); + + const plugin = tabsPlugin(); + plugin(tree); + + const htmlNode = tree.children[0] as any; + const dataTabs = htmlNode.value.match(/data-tabs="([^"]+)"/)?.[1]; + const tabs = decodeJsonBase64(dataTabs); + + // Empty tab should be skipped + expect(tabs).toHaveLength(1); + expect(tabs[0].label).toBe('Valid'); + }); + + it('should handle missing labels (skip them)', () => { + const tree = createCodeBlock( + 'tabs:nolabel', + `\`\`\`js +orphan code; +\`\`\` +--- +tab: Valid +--- +\`\`\`js +valid code; +\`\`\`` + ); + + const plugin = tabsPlugin(); + plugin(tree); + + const htmlNode = tree.children[0] as any; + const dataTabs = htmlNode.value.match(/data-tabs="([^"]+)"/)?.[1]; + const tabs = decodeJsonBase64(dataTabs); + + // Only the tab with a label should be included + expect(tabs).toHaveLength(1); + expect(tabs[0].label).toBe('Valid'); + }); + }); + + describe('HTML generation', () => { + it('should generate div with correct class', () => { + const tree = createCodeBlock( + 'tabs:class', + `tab: Test +--- +\`\`\`js +test; +\`\`\`` + ); + + const plugin = tabsPlugin(); + plugin(tree); + + const htmlNode = tree.children[0] as any; + expect(htmlNode.value).toContain('class="md-code-tabs"'); + }); + + it('should include tabs-id attribute', () => { + const tree = createCodeBlock( + 'tabs:my-custom-id', + `tab: Test +--- +\`\`\`js +test; +\`\`\`` + ); + + const plugin = tabsPlugin(); + plugin(tree); + + const htmlNode = tree.children[0] as any; + expect(htmlNode.value).toContain('data-tabs-id="my-custom-id"'); + }); + + it('should include base64-encoded data-tabs attribute', () => { + const tree = createCodeBlock( + 'tabs:encoded', + `tab: JavaScript +--- +\`\`\`js +const x = 1; +\`\`\`` + ); + + const plugin = tabsPlugin(); + plugin(tree); + + const htmlNode = tree.children[0] as any; + expect(htmlNode.value).toMatch(/data-tabs="[A-Za-z0-9+/=]+"/); + + // Verify it can be decoded + const dataTabs = htmlNode.value.match(/data-tabs="([^"]+)"/)?.[1]; + const tabs = decodeJsonBase64(dataTabs); + expect(Array.isArray(tabs)).toBe(true); + expect(tabs[0].label).toBe('JavaScript'); + }); + + it('should escape HTML in tabs-id', () => { + const tree = createCodeBlock( + 'tabs:', + `tab: Test +--- +\`\`\`js +test; +\`\`\`` + ); + + const plugin = tabsPlugin(); + plugin(tree); + + const htmlNode = tree.children[0] as any; + expect(htmlNode.value).toContain('<script>'); + expect(htmlNode.value).toContain('</script>'); + expect(htmlNode.value).not.toContain('