diff --git a/generate-symbols.ts b/generate-symbols.ts index aac52b8..2ec88b1 100644 --- a/generate-symbols.ts +++ b/generate-symbols.ts @@ -5,6 +5,7 @@ import { createSymbolMapGenerator } from './src/lib/utils/symbol-generation.js'; import { createLogger } from './src/lib/utils/logger.js'; +import { getVersion } from './src/lib/utils/version.js'; const logger = createLogger('generate-symbols'); @@ -12,7 +13,7 @@ const generator = createSymbolMapGenerator({ sourcePatterns: ['src/lib/**/*.ts'], excludePatterns: ['**/*.test.ts', '**/*.spec.ts', '**/node_modules/**', '**/dist/**'], cacheDir: '.dev/tmp', - cacheVersion: '1.0.0', + cacheVersion: getVersion(), outputPath: 'docs/.generated/symbol-map.json', baseDir: process.cwd(), }); diff --git a/packages/docs-engine-cli/src/index.ts b/packages/docs-engine-cli/src/index.ts index f5dae51..416379a 100644 --- a/packages/docs-engine-cli/src/index.ts +++ b/packages/docs-engine-cli/src/index.ts @@ -300,7 +300,7 @@ program .option('-o, --output ', 'Output path for symbol map', 'docs/symbol-map.json') .option('-e, --exclude ', 'Patterns to exclude') .option('--cache-dir ', 'Cache directory', '.cache') - .option('--cache-version ', 'Cache version', '1.0') + .option('--cache-version ', 'Cache version', getVersion()) .option('-w, --watch', 'Watch files and regenerate on changes') .option('-b, --benchmark', 'Run performance benchmark') .option('--debounce ', 'Debounce delay for watch mode in ms', '500') diff --git a/src/lib/generators/generic-generator.ts b/src/lib/generators/generic-generator.ts index 1f45f66..089f240 100644 --- a/src/lib/generators/generic-generator.ts +++ b/src/lib/generators/generic-generator.ts @@ -138,26 +138,21 @@ const readFileAsync = promisify(readFile); * Generic documentation generator */ export class GenericGenerator { - constructor(private config: GeneratorConfig) {} + readonly #config: GeneratorConfig; + + constructor(config: GeneratorConfig) { + this.#config = config; + } /** * Generate documentation */ async generate(): Promise { - // 1. Parse source file - const items = await this.parse(); - - // 2. Categorize items - const categorized = this.categorize(items); - - // 3. Enrich with metadata - const enriched = this.enrich(categorized); - - // 4. Generate markdown - const markdown = this.generateMarkdown(enriched); - - // 5. Calculate stats - const stats = this.calculateStats(enriched); + const items = await this.#parse(); + const categorized = this.#categorize(items); + const enriched = this.#enrich(categorized); + const markdown = this.#generateMarkdown(enriched); + const stats = this.#calculateStats(enriched); return { markdown, @@ -166,55 +161,42 @@ export class GenericGenerator { }; } - /** - * Parse source file based on parser configuration - */ - private async parse(): Promise { - const content = await readFileAsync(this.config.input, 'utf-8'); - const parser = this.config.parser; + async #parse(): Promise { + const content = await readFileAsync(this.#config.input, 'utf-8'); + const parser = this.#config.parser; switch (parser.type) { case 'json': return parseJSON(content, parser.path); - case 'env': return parseEnv(content, parser.categoryPrefix); - case 'sql': return parseSQL(content, parser.tablePattern); - case 'grep': return parseGrep(parser.command, parser.extractPattern); - case 'custom': - return parser.parse(content, this.config); - + return parser.parse(content, this.#config); default: throw new Error(`Unknown parser type: ${(parser as { type?: string }).type}`); } } - /** - * Categorize items based on rules - */ - private categorize(items: ParsedItem[]): Map { + #categorize(items: ParsedItem[]): Map { const categorized = new Map(); - // Initialize categories - for (const rule of this.config.categories) { + for (const rule of this.#config.categories) { categorized.set(rule.name, []); } categorized.set('__uncategorized__', []); - // Apply rules for (const item of items) { let assigned = false; - for (const rule of this.config.categories) { - if (this.matchesRule(item, rule)) { + for (const rule of this.#config.categories) { + if (this.#matchesRule(item, rule)) { categorized.get(rule.name)?.push({ ...item, category: rule.name }); assigned = true; - break; // First match wins + break; } } @@ -223,9 +205,8 @@ export class GenericGenerator { } } - // Remove empty categories - for (const [name, items] of categorized) { - if (items.length === 0) { + for (const [name, categoryItems] of categorized) { + if (categoryItems.length === 0) { categorized.delete(name); } } @@ -233,49 +214,35 @@ export class GenericGenerator { return categorized; } - /** - * Check if item matches category rule - */ - private matchesRule(item: ParsedItem, rule: CategoryRule): boolean { + #matchesRule(item: ParsedItem, rule: CategoryRule): boolean { if (typeof rule.match === 'function') { return rule.match(item); } - - // Regex pattern const pattern = new RegExp(rule.match); const testValue = String(item.name ?? item.value ?? item.key ?? item); return pattern.test(testValue); } - /** - * Enrich items with metadata - */ - private enrich(categorized: Map): Map { - if (!this.config.enrichments && !this.config.descriptions) { + #enrich(categorized: Map): Map { + if (!this.#config.enrichments && !this.#config.descriptions) { return categorized; } const enriched = new Map(); - - for (const [category, items] of categorized) { + for (const [category, categoryItems] of categorized) { enriched.set( category, - items.map((item) => this.enrichItem(item)) + categoryItems.map((item) => this.#enrichItem(item)) ); } - return enriched; } - /** - * Enrich single item - */ - private enrichItem(item: ParsedItem): ParsedItem { + #enrichItem(item: ParsedItem): ParsedItem { const enriched = { ...item }; - // Apply enrichment rules - if (this.config.enrichments) { - for (const rule of this.config.enrichments) { + if (this.#config.enrichments) { + for (const rule of this.#config.enrichments) { if (typeof rule.value === 'function') { enriched[rule.field] = rule.value(item); } else { @@ -285,72 +252,61 @@ export class GenericGenerator { } } - // Apply descriptions - if (this.config.descriptions) { + if (this.#config.descriptions) { const key = String(item.name ?? item.key ?? item.value ?? ''); - if (key && this.config.descriptions[key]) { - enriched.description = this.config.descriptions[key]; + if (key && this.#config.descriptions[key]) { + enriched.description = this.#config.descriptions[key]; } } return enriched; } - /** - * Generate markdown from categorized items - */ - private generateMarkdown(categorized: Map): string { + #generateMarkdown(categorized: Map): string { const sections: string[] = []; - const template = this.config.template; + const template = this.#config.template; - // Title sections.push(`# ${template.title}\n`); - // Source header if (template.source) { sections.push(`> **Source**: ${template.source}`); sections.push(`> **Generated**: ${new Date().toISOString()}\n`); } - // Overview if (template.overview) { sections.push('## Overview\n'); if (typeof template.overview === 'function') { - const stats = this.calculateStats(categorized); + const stats = this.#calculateStats(categorized); sections.push(template.overview(stats) + '\n'); } else { sections.push(template.overview + '\n'); } } - // Statistics if (template.showStats) { - const stats = this.calculateStats(categorized); + const stats = this.#calculateStats(categorized); sections.push('### Statistics\n'); sections.push(`- **Total items**: ${stats.totalItems}`); sections.push(`- **Categories**: ${stats.categoryCount}\n`); } - // Table of contents if (template.showTOC) { sections.push('## Categories\n'); - for (const [category, items] of categorized) { + for (const [category, categoryItems] of categorized) { if (category === '__uncategorized__') continue; const anchor = category.toLowerCase().replace(/[^a-z0-9]+/g, '-'); - sections.push(`- [${category}](#${anchor}) (${items.length} items)`); + sections.push(`- [${category}](#${anchor}) (${categoryItems.length} items)`); } sections.push(''); } - // Category sections - for (const [category, items] of categorized) { + for (const [category, categoryItems] of categorized) { if (category === '__uncategorized__') continue; sections.push(`## ${category}\n`); - // Generate table const headers = template.columns.map((col) => col.header); - const rows = items.map((item) => + const rows = categoryItems.map((item) => template.columns.map((col) => { let value: unknown; if (typeof col.field === 'function') { @@ -358,20 +314,17 @@ export class GenericGenerator { } else { value = item[col.field]; } - if (col.format) { value = col.format(value); } - return String(value); }) ); - sections.push(this.generateTable(headers, rows)); + sections.push(this.#generateTable(headers, rows)); sections.push(''); } - // Footer if (template.footer) { sections.push('---\n'); if (Array.isArray(template.footer)) { @@ -384,28 +337,17 @@ export class GenericGenerator { return sections.join('\n'); } - /** - * Generate markdown table - */ - private generateTable(headers: string[], rows: string[][]): string { + #generateTable(headers: string[], rows: string[][]): string { const lines: string[] = []; - - // Header lines.push('| ' + headers.join(' | ') + ' |'); lines.push('| ' + headers.map(() => '---').join(' | ') + ' |'); - - // Rows for (const row of rows) { lines.push('| ' + row.join(' | ') + ' |'); } - return lines.join('\n'); } - /** - * Calculate statistics - */ - private calculateStats(categorized: Map): GeneratorStats { + #calculateStats(categorized: Map): GeneratorStats { const stats: GeneratorStats = { totalItems: 0, categoryCount: 0, @@ -413,14 +355,14 @@ export class GenericGenerator { uncategorized: 0, }; - for (const [category, items] of categorized) { + for (const [category, categoryItems] of categorized) { if (category === '__uncategorized__') { - stats.uncategorized = items.length; + stats.uncategorized = categoryItems.length; } else { stats.categoryCount++; - stats.itemsByCategory[category] = items.length; + stats.itemsByCategory[category] = categoryItems.length; } - stats.totalItems += items.length; + stats.totalItems += categoryItems.length; } return stats; diff --git a/src/lib/mdast.d.ts b/src/lib/mdast.d.ts new file mode 100644 index 0000000..b6858fe --- /dev/null +++ b/src/lib/mdast.d.ts @@ -0,0 +1,105 @@ +/** + * Type augmentation for mdast to support remark-math and remark-directive nodes + * + * This extends the standard mdast types with custom node types from: + * - remark-math: InlineMath, Math (display math) + * - remark-directive: ContainerDirective, LeafDirective, TextDirective + * + * @see https://github.com/remarkjs/remark-math + * @see https://github.com/remarkjs/remark-directive + */ +import type { Data, Literal, Parent } from 'mdast'; + +// ============================================================================ +// remark-math nodes +// ============================================================================ + +/** + * Inline math node ($...$) + * Extends Literal with math-specific properties + */ +export interface InlineMath extends Literal { + type: 'inlineMath'; + data?: Data; +} + +/** + * Display/block math node ($$...$$) + * Extends Literal with math-specific properties + */ +export interface Math extends Literal { + type: 'math'; + data?: Data; + /** + * Custom data properties (e.g., hName, hProperties for rehype) + */ + meta?: string | null; +} + +// ============================================================================ +// remark-directive nodes +// ============================================================================ + +/** + * Directive attributes + */ +export interface DirectiveAttributes { + [key: string]: string | undefined; +} + +/** + * Container directive (:::name) + * Block-level directive that can contain other block content + */ +export interface ContainerDirective extends Parent { + type: 'containerDirective'; + name: string; + attributes?: DirectiveAttributes; + data?: Data; +} + +/** + * Leaf directive (::name) + * Block-level directive without block children + */ +export interface LeafDirective extends Parent { + type: 'leafDirective'; + name: string; + attributes?: DirectiveAttributes; + data?: Data; +} + +/** + * Text directive (:name) + * Inline directive + */ +export interface TextDirective extends Parent { + type: 'textDirective'; + name: string; + attributes?: DirectiveAttributes; + data?: Data; +} + +// ============================================================================ +// Module augmentation +// ============================================================================ + +declare module 'mdast' { + interface RootContentMap { + inlineMath: InlineMath; + math: Math; + containerDirective: ContainerDirective; + leafDirective: LeafDirective; + } + + interface PhrasingContentMap { + inlineMath: InlineMath; + textDirective: TextDirective; + } + + interface BlockContentMap { + math: Math; + containerDirective: ContainerDirective; + leafDirective: LeafDirective; + } +} diff --git a/src/lib/plugins/collapse.ts b/src/lib/plugins/collapse.ts index 4e54dc1..fb98692 100644 --- a/src/lib/plugins/collapse.ts +++ b/src/lib/plugins/collapse.ts @@ -8,23 +8,12 @@ import type { Code, Blockquote, Heading, + PhrasingContent, } from 'mdast'; -import type { PhrasingContent } from 'mdast'; +import type { ContainerDirective } from '../mdast.d.ts'; import { escapeHtml } from '../utils/html.js'; import { sanitizeTree } from '../utils/ast.js'; -/** - * Interface for container directive nodes (from remark-directive) - * Used for collapse directives that get transformed to HTML - */ -interface ContainerDirectiveNode { - type: string; - name: string; - attributes?: Record; - children?: BlockContent[]; - value?: string; -} - /** * Remark plugin to transform :::collapse directives to HTML
elements * @@ -52,22 +41,21 @@ export function collapsePlugin(): (tree: Root) => void { sanitizeTree(tree); visit(tree, 'containerDirective', (node: unknown) => { - // Extra defensive checks if (!node) return; - const n = node as ContainerDirectiveNode; - if (typeof n.name !== 'string') return; - if (n.name !== 'collapse') return; + const directive = node as ContainerDirective; + if (directive.name !== 'collapse') return; // Extract attributes - const title = n.attributes?.title || 'Details'; - const open = n.attributes?.open !== 'false'; // "false" string means closed + const title = directive.attributes?.title || 'Details'; + const open = directive.attributes?.open !== 'false'; // "false" string means closed // Render nested markdown to HTML - const contentHtml = renderChildren(n.children || []); + const contentHtml = renderChildren(directive.children as BlockContent[]); - // Transform to HTML - n.type = 'html'; - n.value = `
+ // Mutable reference for in-place transformation to HTML node + const mutable = node as { type: string; value: string; children?: unknown }; + mutable.type = 'html'; + mutable.value = `
@@ -78,7 +66,7 @@ export function collapsePlugin(): (tree: Root) => void { ${contentHtml}
`; - delete n.children; + delete mutable.children; // Return SKIP to prevent visiting children of the transformed node return SKIP; diff --git a/src/lib/plugins/image-optimization.test.ts b/src/lib/plugins/image-optimization.test.ts index b996c6c..3f758df 100644 --- a/src/lib/plugins/image-optimization.test.ts +++ b/src/lib/plugins/image-optimization.test.ts @@ -156,8 +156,7 @@ describe('imageOptimizationPlugin', () => { // Config should be base64 encoded, so we can't easily inspect it // But we can verify it exists and has the right structure const configMatch = transformed.value.match(/data-config="([^"]+)"/); - expect(configMatch).toBeTruthy(); - expect(configMatch[1].length).toBeGreaterThan(0); + expect(configMatch?.[1]).toBeTruthy(); }); it('should preserve alt text', async () => { diff --git a/src/lib/plugins/katex.test.ts b/src/lib/plugins/katex.test.ts index ff51779..2e65a26 100644 --- a/src/lib/plugins/katex.test.ts +++ b/src/lib/plugins/katex.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { katexPlugin, type KaTeXOptions } from './katex'; import type { Root, Paragraph, Html, Text } from 'mdast'; +import type { InlineMath, Math } from '../mdast.d.ts'; /** * Tests for KaTeX math rendering plugin @@ -11,40 +12,20 @@ import type { Root, Paragraph, Html, Text } from 'mdast'; * - Tests actual user-facing functionality */ -/** - * Inline math node type (custom mdast node from remark-math) - */ -interface InlineMath { - type: 'inlineMath'; - value: string; - data?: Record; -} - -/** - * Display math node type (custom mdast node from remark-math) - */ -interface DisplayMath { - type: 'math'; - value: string; - data?: Record; -} - /** * Helper to create a test tree with inline math */ function createInlineMathTree(latex: string): Root { + const mathNode: InlineMath = { + type: 'inlineMath', + value: latex, + }; return { type: 'root', children: [ { type: 'paragraph', - children: [ - { - type: 'inlineMath', - value: latex, - data: {}, - } as unknown as InlineMath, - ], + children: [mathNode], }, ], }; @@ -54,15 +35,13 @@ function createInlineMathTree(latex: string): Root { * Helper to create a test tree with display math */ function createDisplayMathTree(latex: string): Root { + const mathNode: Math = { + type: 'math', + value: latex, + }; return { type: 'root', - children: [ - { - type: 'math', - value: latex, - data: {}, - } as unknown as DisplayMath, - ], + children: [mathNode], }; } @@ -223,25 +202,12 @@ describe('katex plugin', () => { describe('multiple math nodes', () => { it('should handle multiple math nodes in a tree', () => { + const inline1: InlineMath = { type: 'inlineMath', value: 'x^2' }; + const display: Math = { type: 'math', value: 'y = mx + b' }; + const inline2: InlineMath = { type: 'inlineMath', value: 'z^3' }; const tree: Root = { type: 'root', - children: [ - { - type: 'inlineMath', - value: 'x^2', - data: {}, - } as unknown as InlineMath, - { - type: 'math', - value: 'y = mx + b', - data: {}, - } as unknown as DisplayMath, - { - type: 'inlineMath', - value: 'z^3', - data: {}, - } as unknown as InlineMath, - ], + children: [inline1, display, inline2], }; const plugin = katexPlugin(); @@ -256,25 +222,16 @@ describe('katex plugin', () => { }); it('should handle mixed content (text and math)', () => { + const inlineMath: InlineMath = { type: 'inlineMath', value: 'x^2' }; const tree: Root = { type: 'root', children: [ { type: 'paragraph', children: [ - { - type: 'text', - value: 'Some text ', - }, - { - type: 'inlineMath', - value: 'x^2', - data: {}, - } as unknown as InlineMath, - { - type: 'text', - value: ' more text', - }, + { type: 'text', value: 'Some text ' }, + inlineMath, + { type: 'text', value: ' more text' }, ], }, ], @@ -284,11 +241,11 @@ describe('katex plugin', () => { plugin(tree); const paragraph = tree.children[0] as Paragraph; - const mathNode = paragraph.children[1] as Html; + const transformed = paragraph.children[1] as Html; - expect(mathNode.type).toBe('html'); - expect(mathNode.value).toContain('katex'); - expect(mathNode.value).toContain('x'); + expect(transformed.type).toBe('html'); + expect(transformed.value).toContain('katex'); + expect(transformed.value).toContain('x'); }); }); @@ -315,23 +272,15 @@ describe('katex plugin', () => { }); it('should not modify non-math nodes', () => { + const inlineMath: InlineMath = { type: 'inlineMath', value: 'x^2' }; const tree: Root = { type: 'root', children: [ { type: 'paragraph', - children: [ - { - type: 'text', - value: 'Plain text', - }, - ], + children: [{ type: 'text', value: 'Plain text' }], }, - { - type: 'inlineMath', - value: 'x^2', - data: {}, - } as unknown as InlineMath, + inlineMath, ], }; @@ -345,8 +294,8 @@ describe('katex plugin', () => { expect((paragraph.children[0] as Text).value).toBe('Plain text'); // Math node should be transformed - const mathNode = tree.children[1] as Html; - expect(mathNode.type).toBe('html'); + const transformed = tree.children[1] as Html; + expect(transformed.type).toBe('html'); }); }); }); diff --git a/src/lib/plugins/katex.ts b/src/lib/plugins/katex.ts index ebaa4e4..6688512 100644 --- a/src/lib/plugins/katex.ts +++ b/src/lib/plugins/katex.ts @@ -1,17 +1,13 @@ import { visit } from 'unist-util-visit'; import type { Root } from 'mdast'; +import type { InlineMath, Math } from '../mdast.d.ts'; import katex from 'katex'; import { escapeHtml } from '../utils/html.js'; /** - * Interface for math nodes (from remark-math) - * Represents both inline math and display math nodes + * Union type for math nodes (from remark-math) */ -interface MathNode { - type: string; - value: string; - data?: unknown; -} +type MathNode = InlineMath | Math; /** * Configuration options for math rendering with KaTeX @@ -177,7 +173,6 @@ export function katexPlugin(options: KaTeXOptions = {}): (tree: Root) => void { // Transform each math node to HTML mathNodes.forEach(({ node }) => { - // Type assertion needed for accessing node properties const n = node as MathNode; const latex = n.value; const displayMode = n.type === 'math'; // 'math' = display (block), 'inlineMath' = inline @@ -185,10 +180,11 @@ export function katexPlugin(options: KaTeXOptions = {}): (tree: Root) => void { // Render with KaTeX const html = renderMath(latex, displayMode, options); - // Transform node to HTML - n.type = 'html'; - n.value = html; - delete n.data; + // Transform node to HTML (mutate in place) + const mutable = node as { type: string; value: string; data?: unknown }; + mutable.type = 'html'; + mutable.value = html; + delete mutable.data; }); }; } diff --git a/src/lib/plugins/reference.test.ts b/src/lib/plugins/reference.test.ts index d02b912..9c1a73b 100644 --- a/src/lib/plugins/reference.test.ts +++ b/src/lib/plugins/reference.test.ts @@ -1,19 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { referencePlugin } from './reference'; -import type { Root, Paragraph, Html, Link, Text, RootContent } from 'mdast'; +import type { Root, Paragraph, Html, Link, Text } from 'mdast'; +import type { ContainerDirective } from '../mdast.d.ts'; import * as symbolResolver from '../utils/symbol-resolver.js'; import * as symbolRenderer from '../utils/symbol-renderer.js'; -/** - * Container directive node type (from remark-directive) - */ -interface ContainerDirective { - type: 'containerDirective'; - name: string; - attributes?: Record; - children: RootContent[]; -} - describe('reference plugin', () => { // Mock symbol map and related functions beforeEach(() => { @@ -59,22 +50,23 @@ describe('reference plugin', () => { }); // Helper to create a reference block directive - const createReferenceBlock = (symbolName: string, attributes?: Record): Root => ({ - type: 'root', - children: [ - { - type: 'containerDirective', - name: 'reference', - attributes, - children: [ - { - type: 'paragraph', - children: [{ type: 'text', value: symbolName }], - } as Paragraph, - ], - } as unknown as ContainerDirective, - ], - }); + const createReferenceBlock = (symbolName: string, attributes?: Record): Root => { + const directive: ContainerDirective = { + type: 'containerDirective', + name: 'reference', + attributes, + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: symbolName }], + }, + ], + }; + return { + type: 'root', + children: [directive], + }; + }; describe('inline references', () => { it('should transform {@SymbolName} to a link node', () => { @@ -325,15 +317,14 @@ describe('reference plugin', () => { it('should handle empty reference block', () => { vi.spyOn(symbolResolver, 'loadSymbolMap').mockReturnValue(mockSymbolMap); + const emptyDirective: ContainerDirective = { + type: 'containerDirective', + name: 'reference', + children: [], + }; const tree: Root = { type: 'root', - children: [ - { - type: 'containerDirective', - name: 'reference', - children: [], - } as unknown as ContainerDirective, - ], + children: [emptyDirective], }; const plugin = referencePlugin(); @@ -345,21 +336,20 @@ describe('reference plugin', () => { it('should handle reference block with non-text content', () => { vi.spyOn(symbolResolver, 'loadSymbolMap').mockReturnValue(mockSymbolMap); - const tree: Root = { - type: 'root', + const directiveWithEmphasis: ContainerDirective = { + type: 'containerDirective', + name: 'reference', children: [ { - type: 'containerDirective', - name: 'reference', - children: [ - { - type: 'paragraph', - children: [{ type: 'emphasis', children: [{ type: 'text', value: 'MyFunction' }] }], - } as Paragraph, - ], - } as unknown as ContainerDirective, + type: 'paragraph', + children: [{ type: 'emphasis', children: [{ type: 'text', value: 'MyFunction' }] }], + }, ], }; + const tree: Root = { + type: 'root', + children: [directiveWithEmphasis], + }; const plugin = referencePlugin(); diff --git a/src/lib/plugins/reference.ts b/src/lib/plugins/reference.ts index a11b539..9b579bd 100644 --- a/src/lib/plugins/reference.ts +++ b/src/lib/plugins/reference.ts @@ -1,6 +1,7 @@ import { visit } from 'unist-util-visit'; import type { Root, Text, Paragraph, PhrasingContent, Html } from 'mdast'; import type { Parent } from 'unist'; +import type { ContainerDirective } from '../mdast.d.ts'; import { resolveSymbol, loadSymbolMap, @@ -12,19 +13,6 @@ import type { RenderOptions } from '../utils/symbol-renderer.js'; import { escapeHtml } from '../utils/html.js'; import { sanitizeTree } from '../utils/ast.js'; -/** - * Interface for container directive nodes (from remark-directive) - * Used for reference directives that get transformed to HTML - */ -interface ContainerDirectiveNode { - type: string; - name?: string; - attributes?: Record; - children?: unknown[]; - value?: string; - data?: unknown; -} - // Use [^}]+ to match reference content - simpler and avoids overlapping character classes const INLINE_REFERENCE_REGEX = /{@([^}]+)}/g; @@ -129,37 +117,43 @@ export function referencePlugin() { }); referenceBlocks.forEach((node) => { - // Type assertion needed for node transformation - const n = node as ContainerDirectiveNode; - - const symbolRef = extractSymbolReference(node); + const directive = node as ContainerDirective; + const symbolRef = extractSymbolReference(directive); if (!symbolRef) { throw new Error(':::reference directive requires a symbol name'); } - const options = extractRenderOptions(node); + const options = extractRenderOptions(directive); + + // Mutable reference for in-place transformation to HTML node + const mutable = node as { + type: string; + value?: string; + children?: unknown; + name?: string; + attributes?: unknown; + data?: unknown; + }; try { const symbol = resolveSymbol(symbolRef, symbolMap); - - n.type = 'html'; - n.value = renderBlock(symbol, options); - delete n.children; - delete n.name; - delete n.attributes; - delete n.data; + mutable.type = 'html'; + mutable.value = renderBlock(symbol, options); + delete mutable.children; + delete mutable.name; + delete mutable.attributes; + delete mutable.data; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); console.warn( `[ReferencePlugin] Failed to resolve symbol in :::reference ${symbolRef}: ${message}` ); - // Instead of throwing, render a warning block - n.type = 'html'; - n.value = createWarningBlockHtml(`:::reference ${symbolRef}`, message); - delete n.children; - delete n.name; - delete n.attributes; - delete n.data; + mutable.type = 'html'; + mutable.value = createWarningBlockHtml(`:::reference ${symbolRef}`, message); + delete mutable.children; + delete mutable.name; + delete mutable.attributes; + delete mutable.data; } }); }; @@ -242,16 +236,9 @@ function createInlineReferenceNode(symbol: SymbolDefinition): PhrasingContent { }; } -function extractSymbolReference(node: unknown): string | undefined { - if (!node || typeof node !== 'object' || !('children' in node)) return undefined; - const nodeObj = node as Record; - const firstChild = Array.isArray(nodeObj.children) ? nodeObj.children[0] : undefined; - if ( - firstChild && - typeof firstChild === 'object' && - 'type' in firstChild && - firstChild.type === 'paragraph' - ) { +function extractSymbolReference(directive: ContainerDirective): string | undefined { + const firstChild = directive.children[0]; + if (firstChild?.type === 'paragraph') { const textNode = (firstChild as Paragraph).children?.[0]; if (textNode?.type === 'text') { return textNode.value.trim(); @@ -260,14 +247,12 @@ function extractSymbolReference(node: unknown): string | undefined { return undefined; } -function extractRenderOptions(node: unknown): RenderOptions { - if (!node || typeof node !== 'object' || !('attributes' in node)) return {}; - const nodeObj = node as Record; - const showAttr = (nodeObj.attributes as Record | undefined)?.show; +function extractRenderOptions(directive: ContainerDirective): RenderOptions { + const showAttr = directive.attributes?.show; if (!showAttr) return {}; return { - show: String(showAttr) + show: showAttr .split(',') .map((value) => value.trim()) .filter(Boolean) as RenderOptions['show'], diff --git a/src/lib/server/circuit-breaker.ts b/src/lib/server/circuit-breaker.ts index c5da21b..d201ce1 100644 --- a/src/lib/server/circuit-breaker.ts +++ b/src/lib/server/circuit-breaker.ts @@ -80,14 +80,14 @@ export class CircuitBreakerError extends Error { * @public */ export class CircuitBreaker { - private state: CircuitState = CircuitState.CLOSED; - private failureCount = 0; - private successCount = 0; - private nextAttempt = Date.now(); - private readonly config: CircuitBreakerConfig; + #state: CircuitState = CircuitState.CLOSED; + #failureCount = 0; + #successCount = 0; + #nextAttempt = Date.now(); + readonly #config: CircuitBreakerConfig; constructor(config: Partial & { name: string }) { - this.config = { + this.#config = { failureThreshold: config.failureThreshold ?? CIRCUIT_BREAKER.FAILURE_THRESHOLD, recoveryTimeout: config.recoveryTimeout ?? CIRCUIT_BREAKER.RECOVERY_TIMEOUT, successThreshold: config.successThreshold ?? CIRCUIT_BREAKER.SUCCESS_THRESHOLD, @@ -104,33 +104,33 @@ export class CircuitBreaker { * @throws CircuitBreakerError if circuit is open */ async execute(fn: () => Promise): Promise { - if (this.state === CircuitState.OPEN) { - if (Date.now() < this.nextAttempt) { + if (this.#state === CircuitState.OPEN) { + if (Date.now() < this.#nextAttempt) { logger.warn( { - breaker: this.config.name, - state: this.state, - nextAttempt: new Date(this.nextAttempt).toISOString(), + breaker: this.#config.name, + state: this.#state, + nextAttempt: new Date(this.#nextAttempt).toISOString(), }, 'Circuit breaker is OPEN' ); - throw new CircuitBreakerError(this.config.name); + throw new CircuitBreakerError(this.#config.name); } // Try recovery - this.state = CircuitState.HALF_OPEN; - this.successCount = 0; - logger.info({ breaker: this.config.name }, 'Circuit breaker entering HALF_OPEN state'); + this.#state = CircuitState.HALF_OPEN; + this.#successCount = 0; + logger.info({ breaker: this.#config.name }, 'Circuit breaker entering HALF_OPEN state'); } try { // Add timeout to prevent hanging - const result = await this.withTimeout(fn()); + const result = await this.#withTimeout(fn()); - this.onSuccess(); + this.#onSuccess(); return result; } catch (error) { - this.onFailure(error); + this.#onFailure(error); throw error; } } @@ -139,13 +139,13 @@ export class CircuitBreaker { * Wrap promise with timeout * Properly cleans up timer to prevent memory leaks */ - private withTimeout(promise: Promise): Promise { + #withTimeout(promise: Promise): Promise { let timeoutId: ReturnType; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { - reject(new Error(`Request timeout after ${this.config.requestTimeout}ms`)); - }, this.config.requestTimeout); + reject(new Error(`Request timeout after ${this.#config.requestTimeout}ms`)); + }, this.#config.requestTimeout); }); return Promise.race([promise, timeoutPromise]).finally(() => { @@ -156,15 +156,15 @@ export class CircuitBreaker { /** * Handle successful request */ - private onSuccess(): void { - this.failureCount = 0; + #onSuccess(): void { + this.#failureCount = 0; - if (this.state === CircuitState.HALF_OPEN) { - this.successCount++; + if (this.#state === CircuitState.HALF_OPEN) { + this.#successCount++; - if (this.successCount >= this.config.successThreshold) { - this.state = CircuitState.CLOSED; - logger.info({ breaker: this.config.name }, 'Circuit breaker CLOSED - service recovered'); + if (this.#successCount >= this.#config.successThreshold) { + this.#state = CircuitState.CLOSED; + logger.info({ breaker: this.#config.name }, 'Circuit breaker CLOSED - service recovered'); } } } @@ -172,31 +172,31 @@ export class CircuitBreaker { /** * Handle failed request */ - private onFailure(error: unknown): void { - this.failureCount++; - this.successCount = 0; + #onFailure(error: unknown): void { + this.#failureCount++; + this.#successCount = 0; logger.warn( { - breaker: this.config.name, - failureCount: this.failureCount, - threshold: this.config.failureThreshold, + breaker: this.#config.name, + failureCount: this.#failureCount, + threshold: this.#config.failureThreshold, error: error instanceof Error ? error.message : String(error), }, 'Circuit breaker request failed' ); if ( - this.failureCount >= this.config.failureThreshold || - this.state === CircuitState.HALF_OPEN + this.#failureCount >= this.#config.failureThreshold || + this.#state === CircuitState.HALF_OPEN ) { - this.state = CircuitState.OPEN; - this.nextAttempt = Date.now() + this.config.recoveryTimeout; + this.#state = CircuitState.OPEN; + this.#nextAttempt = Date.now() + this.#config.recoveryTimeout; logger.error( { - breaker: this.config.name, - nextAttempt: new Date(this.nextAttempt).toISOString(), + breaker: this.#config.name, + nextAttempt: new Date(this.#nextAttempt).toISOString(), }, 'Circuit breaker OPEN - too many failures' ); @@ -207,18 +207,18 @@ export class CircuitBreaker { * Get current circuit state */ getState(): CircuitState { - return this.state; + return this.#state; } /** * Manually reset circuit breaker to CLOSED state */ reset(): void { - this.state = CircuitState.CLOSED; - this.failureCount = 0; - this.successCount = 0; - this.nextAttempt = Date.now(); + this.#state = CircuitState.CLOSED; + this.#failureCount = 0; + this.#successCount = 0; + this.#nextAttempt = Date.now(); - logger.info({ breaker: this.config.name }, 'Circuit breaker manually reset to CLOSED'); + logger.info({ breaker: this.#config.name }, 'Circuit breaker manually reset to CLOSED'); } } diff --git a/src/lib/server/cli-executor.ts b/src/lib/server/cli-executor.ts index e7a6882..6a2882a 100644 --- a/src/lib/server/cli-executor.ts +++ b/src/lib/server/cli-executor.ts @@ -58,10 +58,13 @@ export interface CliExecutorConfig { * @public */ export class CliExecutor { - private config: Required; + readonly #config: Required; + + /** Pattern to detect shell metacharacters that could enable command injection */ + static readonly #DANGEROUS_CHARS = /[;&|`$(){}[\]<>\\]/; constructor(config: CliExecutorConfig) { - this.config = { + this.#config = { timeout: TIMEOUT.VERY_LONG, maxOutputLength: FILE_SIZE.MAX_CLI_OUTPUT, workingDirectory: process.cwd(), @@ -69,17 +72,7 @@ export class CliExecutor { }; } - /** - * Pattern to detect shell metacharacters that could enable command injection - * Note: Quotes are allowed for argument grouping but will be handled by parseCommand - */ - private static readonly DANGEROUS_CHARS = /[;&|`$(){}[\]<>\\]/; - - /** - * Parse command string into executable and arguments - * Handles quoted strings (single and double quotes) for proper argument grouping - */ - private parseCommand(command: string): { executable: string; args: string[] } { + #parseCommand(command: string): { executable: string; args: string[] } { const tokens: string[] = []; let current = ''; let inQuote: string | null = null; @@ -88,20 +81,15 @@ export class CliExecutor { const char = command[i]; if (inQuote) { - // Inside a quoted string if (char === inQuote) { - // End of quote inQuote = null; } else { current += char; } } else { - // Outside quotes if (char === '"' || char === "'") { - // Start of quote inQuote = char; } else if (char === ' ' || char === '\t') { - // Whitespace separator if (current) { tokens.push(current); current = ''; @@ -112,7 +100,6 @@ export class CliExecutor { } } - // Push any remaining token if (current) { tokens.push(current); } @@ -123,15 +110,10 @@ export class CliExecutor { }; } - /** - * Validate command against allowlist and check for injection attempts - * Only allows commands that start with an allowed prefix and contain no shell metacharacters - */ - private validateCommand(command: string): boolean { + #validateCommand(command: string): boolean { const baseCommand = command.trim().split(' ')[0]; - // Check allowlist first - const isAllowed = this.config.allowedCommands.some( + const isAllowed = this.#config.allowedCommands.some( (allowed) => baseCommand === allowed || baseCommand.startsWith(allowed + '/') ); @@ -139,8 +121,7 @@ export class CliExecutor { return false; } - // Block shell metacharacters to prevent command injection - if (CliExecutor.DANGEROUS_CHARS.test(command)) { + if (CliExecutor.#DANGEROUS_CHARS.test(command)) { return false; } @@ -160,36 +141,33 @@ export class CliExecutor { * @throws Error if command is not allowed */ async execute(command: string): Promise { - if (!this.validateCommand(command)) { + if (!this.#validateCommand(command)) { throw new Error(`Command not allowed: ${command.split(' ')[0]}`); } - const { executable, args } = this.parseCommand(command); + const { executable, args } = this.#parseCommand(command); const startTime = Date.now(); try { const { stdout, stderr } = await execFileAsync(executable, args, { - timeout: this.config.timeout, - maxBuffer: this.config.maxOutputLength, - cwd: this.config.workingDirectory, + timeout: this.#config.timeout, + maxBuffer: this.#config.maxOutputLength, + cwd: this.#config.workingDirectory, env: { ...process.env, - // Force color output for commands that support it FORCE_COLOR: '1', CLICOLOR_FORCE: '1', }, }); return { - stdout: stdout.slice(0, this.config.maxOutputLength), - stderr: stderr.slice(0, this.config.maxOutputLength), + stdout: stdout.slice(0, this.#config.maxOutputLength), + stderr: stderr.slice(0, this.#config.maxOutputLength), exitCode: 0, duration: Date.now() - startTime, }; } catch (error: unknown) { const err = error as Record; - // Node.js error codes can be strings (e.g., 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') - // Process exit codes are numbers. Use 1 as default for Node.js errors. const exitCode = typeof err.code === 'number' ? err.code : 1; return { stdout: (err.stdout as string) || '', diff --git a/src/lib/utils/openapi-formatter.ts b/src/lib/utils/openapi-formatter.ts index 42ace5c..ee26fb3 100644 --- a/src/lib/utils/openapi-formatter.ts +++ b/src/lib/utils/openapi-formatter.ts @@ -371,8 +371,7 @@ function generateExampleBody(schema: unknown): unknown { } if (schemaObj.type === 'object' || schemaObj.properties) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const example: any = {}; + const example: Record = {}; if (schemaObj.properties) { for (const [key, prop] of Object.entries(schemaObj.properties)) { diff --git a/src/lib/utils/symbol-generation.ts b/src/lib/utils/symbol-generation.ts index 43fab52..b95b78b 100644 --- a/src/lib/utils/symbol-generation.ts +++ b/src/lib/utils/symbol-generation.ts @@ -925,13 +925,13 @@ export class SymbolMapGenerator { * * @example * ```typescript - * import { createSymbolMapGenerator } from '@goobits/docs-engine/utils'; + * import { createSymbolMapGenerator, getVersion } from '@goobits/docs-engine/utils'; * * const generator = createSymbolMapGenerator({ * sourcePatterns: ['src/**\/*.ts'], * excludePatterns: ['**\/*.test.ts'], * cacheDir: '.dev/tmp', - * cacheVersion: '1.0', + * cacheVersion: getVersion(), // Use package.json version for cache invalidation * outputPath: 'docs/.generated/symbol-map.json' * }); *