diff --git a/package-lock.json b/package-lock.json index da59cef..cdb565c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,11 @@ "license": "MIT", "dependencies": { "cac": "^6.7.14", + "diff": "^8.0.3", "type-fest": "^5.4.1" }, "bin": { - "envase": "dist/cli/main.ts" + "envase": "dist/cli/main.js" }, "devDependencies": { "@biomejs/biome": "^2.1.3", @@ -1326,6 +1327,15 @@ "node": ">=18" } }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", diff --git a/package.json b/package.json index 3ac8e8c..6ec4db7 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ }, "dependencies": { "cac": "^6.7.14", + "diff": "^8.0.3", "type-fest": "^5.4.1" }, "devDependencies": { diff --git a/src/cli/generate-markdown.ts b/src/cli/generate-markdown.ts index d96baa3..275e53e 100644 --- a/src/cli/generate-markdown.ts +++ b/src/cli/generate-markdown.ts @@ -12,10 +12,14 @@ export const generateMarkdown = ( for (const envvar of extractedEnvvars) { const key = envvar.path.length > 0 ? envvar.path.join('.') : ''; - if (!envvarsByPath.has(key)) { - envvarsByPath.set(key, []); + + const envvars = envvarsByPath.get(key); + + if (envvars) { + envvars.push(envvar); + } else { + envvarsByPath.set(key, [envvar]); } - envvarsByPath.get(key)?.push(envvar); } for (const [path, envvars] of envvarsByPath.entries()) { diff --git a/src/cli/main.ts b/src/cli/main.ts index f8a6a6a..5093d40 100755 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -75,9 +75,10 @@ cli } console.error('Validation failed! Found differences:\n'); - for (const diff of result.differences) { - console.error(diff); - } + console.error(result.differences); + + console.error('\nTo regenerate documentation:'); + console.error(` envase generate ${schemaPath} -o ${markdownPath}`); process.exit(1); } catch (error) { console.error( diff --git a/src/cli/validate-markdown.test.ts b/src/cli/validate-markdown.test.ts index e5796e2..3795bd1 100644 --- a/src/cli/validate-markdown.test.ts +++ b/src/cli/validate-markdown.test.ts @@ -1,3 +1,4 @@ +import { stripVTControlCharacters } from 'node:util'; import { describe, expect, it } from 'vitest'; import { validateMarkdown } from './validate-markdown.ts'; @@ -16,7 +17,6 @@ describe('validateMarkdown', () => { const result = validateMarkdown(markdown, markdown); expect(result.isValid).toBe(true); - expect(result.differences).toEqual([]); }); it('normalizes line endings and multiple newlines', () => { @@ -26,29 +26,9 @@ describe('validateMarkdown', () => { const result = validateMarkdown(actual, expected); expect(result.isValid).toBe(true); - expect(result.differences).toEqual([]); }); - it('detects missing lines in actual file', () => { - const actual = `# Environment variables - -## App`; - const expected = `# Environment variables - -## App - -- \`PORT\` (required)`; - - const result = validateMarkdown(actual, expected); - - expect(result.isValid).toBe(false); - expect(result.differences.length).toBeGreaterThan(0); - expect( - result.differences.some((d) => d.includes('Missing in actual file')), - ).toBe(true); - }); - - it('detects extra lines in actual file', () => { + it('shows mixed changes with all prefix types', () => { const actual = `# Environment variables ## App @@ -59,54 +39,23 @@ describe('validateMarkdown', () => { ## App -- \`PORT\` (required)`; - - const result = validateMarkdown(actual, expected); - - expect(result.isValid).toBe(false); - expect(result.differences).toContain('Line 6: Extra line in actual file'); - }); - - it('detects content mismatches', () => { - const actual = `# Environment variables - -- \`PORT\` (optional)`; - const expected = `# Environment variables - -- \`PORT\` (required)`; - - const result = validateMarkdown(actual, expected); - - expect(result.isValid).toBe(false); - expect(result.differences).toContain('Line 3: Content mismatch'); - expect(result.differences).toContain(' Expected: - `PORT` (required)'); - expect(result.differences).toContain(' Actual: - `PORT` (optional)'); - }); - - it('handles empty strings', () => { - const result = validateMarkdown('', ''); - - expect(result.isValid).toBe(true); - expect(result.differences).toEqual([]); - }); - - it('reports all differences in a complex mismatch', () => { - const actual = `# Environment variables - -## Database - -- \`DB_URL\` (optional)`; - const expected = `# Environment variables - -## App - -- \`PORT\` (required)`; +- \`PORT\` (required) +- \`URL\` (required)`; const result = validateMarkdown(actual, expected); expect(result.isValid).toBe(false); - expect(result.differences.length).toBeGreaterThan(0); - expect(result.differences).toContain('Line 3: Content mismatch'); - expect(result.differences).toContain('Line 5: Content mismatch'); + if (!result.isValid) { + const plain = stripVTControlCharacters(result.differences); + + // Should contain unchanged lines + expect(plain).toContain(' # Environment variables'); + expect(plain).toContain(' ## App'); + expect(plain).toContain(' - `PORT` (required)'); + // Should contain removed line + expect(plain).toContain('- - `HOST` (optional)'); + // Should contain added line + expect(plain).toContain('+ - `URL` (required)'); + } }); }); diff --git a/src/cli/validate-markdown.ts b/src/cli/validate-markdown.ts index b803f6a..6cf45d3 100644 --- a/src/cli/validate-markdown.ts +++ b/src/cli/validate-markdown.ts @@ -1,3 +1,6 @@ +import { styleText } from 'node:util'; +import { diffLines } from 'diff'; + const normalizeMarkdown = (content: string): string => { return content .trim() @@ -5,6 +8,10 @@ const normalizeMarkdown = (content: string): string => { .replace(/\n{3,}/g, '\n\n'); }; +type ValidateMarkdownOutput = + | { isValid: true } + | { isValid: false; differences: string }; + /** * Validates if a markdown file matches the expected documentation * generated from the environment schema. @@ -12,39 +19,37 @@ const normalizeMarkdown = (content: string): string => { export const validateMarkdown = ( actualMarkdown: string, expectedMarkdown: string, -): { isValid: boolean; differences: string[] } => { - const differences: string[] = []; - +): ValidateMarkdownOutput => { const actual = normalizeMarkdown(actualMarkdown); const expected = normalizeMarkdown(expectedMarkdown); if (actual === expected) { - return { isValid: true, differences: [] }; + return { isValid: true }; } - const actualLines = actual.split('\n'); - const expectedLines = expected.split('\n'); - - const maxLines = Math.max(actualLines.length, expectedLines.length); - - for (let i = 0; i < maxLines; i++) { - const actualLine = actualLines[i] ?? ''; - const expectedLine = expectedLines[i] ?? ''; - - if (actualLine !== expectedLine) { - if (i >= actualLines.length) { - differences.push(`Line ${i + 1}: Missing in actual file`); - differences.push(` Expected: ${expectedLine}`); - } else if (i >= expectedLines.length) { - differences.push(`Line ${i + 1}: Extra line in actual file`); - differences.push(` Actual: ${actualLine}`); - } else { - differences.push(`Line ${i + 1}: Content mismatch`); - differences.push(` Expected: ${expectedLine}`); - differences.push(` Actual: ${actualLine}`); - } - } - } + const differences = diffLines(actual, expected) + .flatMap((change) => { + const lines = change.value.split('\n'); + + // Only remove last element if it's empty (due to trailing newline) + const linesToProcess = + lines.length > 0 && lines[lines.length - 1] === '' + ? lines.slice(0, -1) + : lines; + + return linesToProcess.map((line) => { + if (change.added) { + return styleText('green', `+ ${line}`); + } + if (change.removed) { + return styleText('red', `- ${line}`); + } + return styleText('gray', ` ${line}`); + }); + }) + .join('\n'); + + console.log(differences); return { isValid: false,