Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 55 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,9 @@ type Config = InferEnv<typeof envSchema>;
// { apiKey: string; db: { host: string } }
```

## CLI Documentation Generator
## CLI

Automatically generate markdown documentation from your environment variable schemas.
Automatically generate and validate markdown documentation from your environment variable schemas.

### Quick Start

Expand Down Expand Up @@ -168,15 +168,14 @@ export default envSchema
**2. Generate documentation:**

```bash
# Using TypeScript directly with Node.js type stripping feature
envase generate ./config.ts -o ./docs/env.md
```

# Or use tsx (recommended for older Node versions)
tsx node_modules/.bin/envase generate ./config.ts -o ./docs/env.md
**3. Validate documentation (optional):**

# Or compile first, then generate
tsc config.ts
envase generate ./config.js -o ./docs/env.md
```bash
# Verify the documentation matches your schema
envase validate ./config.ts ./docs/env.md
```

### Command Reference
Expand All @@ -186,12 +185,23 @@ envase generate ./config.js -o ./docs/env.md
Generates markdown documentation from an environment schema.

**Arguments:**
- `<schemaPath>` - Path to a file containing default export of env schema.
- `<schemaPath>` - Path to a file containing default export of env schema

**Options:**
- `-o, --output <file>` - Output file path (default: `./env-docs.md`)

### Example Output
**Usage:**
```bash
envase generate ./config.ts -o ./docs/env.md

# Or use tsx for TypeScript files (recommended for older Node versions)
tsx node_modules/.bin/envase generate ./config.ts -o ./docs/env.md

# Or compile first, then generate
tsc config.ts && envase generate ./config.js -o ./docs/env.md
```

**Generated output:**

The CLI generates readable markdown documentation with:
- Type information for each environment variable
Expand All @@ -202,32 +212,56 @@ The CLI generates readable markdown documentation with:
- Enum values (for enum types)
- Grouped by nested configuration structure

**Sample generated markdown:**
<details>
<summary>Sample generated markdown</summary>

```markdown
# Environment variables

## App / Listen

- \`PORT\` (optional)
Type: \`number\`
- `PORT` (optional)
Type: `number`
Description: Application listening port
Min value: \`1024\`
Max value: \`65535\`
Min value: `1024`
Max value: `65535`

- \`HOST\` (optional)
Type: \`string\`
- `HOST` (optional)
Type: `string`
Description: Bind host address
Default: \`0.0.0.0\`
Default: `0.0.0.0`

## Database

- \`DATABASE_URL\` (required)
Type: \`string\`
- `DATABASE_URL` (required)
Type: `string`
Description: PostgreSQL connection URL
Format: \`uri\`
Format: `uri`
```
</details>

#### `envase validate <schemaPath> <markdownPath>`

Validates if a markdown file matches the documentation that would be generated from the environment schema.

**Arguments:**
- `<schemaPath>` - Path to a file containing default export of env schema
- `<markdownPath>` - Path to the markdown file to validate

**Example:**
```bash
envase validate ./config.ts ./docs/env.md
```

This command is useful for:
- CI/CD pipelines to ensure documentation stays in sync with code
- Pre-commit hooks to verify documentation changes
- Detecting manual edits to generated documentation

**Exit codes:**
- `0` - Validation passed (markdown matches schema)
- `1` - Validation failed (differences found) or error occurred

## API Reference

### `envvar`
Expand Down
48 changes: 45 additions & 3 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
#!/usr/bin/env node

import { writeFile } from 'node:fs/promises';
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { cac } from 'cac';
import { extractEnvvars } from './extract-envvars.ts';
import { generateMarkdown } from './generate-markdown.ts';
import { loadSchema } from './load-schema.ts';
import { validateMarkdown } from './validate-markdown.ts';

const cli = cac('envase');

Expand All @@ -26,11 +27,11 @@ cli
},
) => {
try {
console.log('📖 Loading schema from:', schemaPath);
console.log('Loading schema from:', schemaPath);
const schema = await loadSchema(schemaPath);
const extractedEnvvars = extractEnvvars(schema);

console.log('📝 Generating markdown documentation...');
console.log('Generating markdown documentation...');
const markdown = generateMarkdown(extractedEnvvars);

const outputPath = resolve(process.cwd(), options.output);
Expand All @@ -46,5 +47,46 @@ cli
},
);

cli
.command(
'validate <schemaPath> <markdownPath>',
'Validate if markdown file matches the schema definition',
)
.example('envase validate ./config.js ./docs/env.md')
.action(async (schemaPath: string, markdownPath: string) => {
try {
console.log('Loading schema from:', schemaPath);
const schema = await loadSchema(schemaPath);
const extractedEnvvars = extractEnvvars(schema);

console.log('Generating expected markdown...');
const expectedMarkdown = generateMarkdown(extractedEnvvars);

console.log('Reading actual markdown from:', markdownPath);
const markdownFilePath = resolve(process.cwd(), markdownPath);
const actualMarkdown = await readFile(markdownFilePath, 'utf-8');

console.log('Validating...');
const result = validateMarkdown(actualMarkdown, expectedMarkdown);

if (result.isValid) {
console.log('Validation passed! The markdown file matches the schema.');
process.exit(0);
}

console.error('Validation failed! Found differences:\n');
for (const diff of result.differences) {
console.error(diff);
}
process.exit(1);
} catch (error) {
console.error(
'Error:',
error instanceof Error ? error.message : String(error),
);
process.exit(1);
}
});

cli.help();
cli.parse();
112 changes: 112 additions & 0 deletions src/cli/validate-markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest';
import { validateMarkdown } from './validate-markdown.ts';

describe('validateMarkdown', () => {
it('returns valid when markdown matches exactly', () => {
const markdown = `# Environment variables

## App

- \`PORT\` (required)
Type: \`number\`
Description: Application port

`;

const result = validateMarkdown(markdown, markdown);

expect(result.isValid).toBe(true);
expect(result.differences).toEqual([]);
});

it('normalizes line endings and multiple newlines', () => {
const actual = '# Environment variables\r\n\r\n\r\n## App\r\n';
const expected = '# Environment variables\n\n## App\n';

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', () => {
const actual = `# Environment variables

## App

- \`PORT\` (required)
- \`HOST\` (optional)`;
const expected = `# Environment variables

## 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)`;

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');
});
});
53 changes: 53 additions & 0 deletions src/cli/validate-markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const normalizeMarkdown = (content: string): string => {
return content
.trim()
.replace(/\r\n/g, '\n')
.replace(/\n{3,}/g, '\n\n');
};

/**
* Validates if a markdown file matches the expected documentation
* generated from the environment schema.
*/
export const validateMarkdown = (
actualMarkdown: string,
expectedMarkdown: string,
): { isValid: boolean; differences: string[] } => {
const differences: string[] = [];

const actual = normalizeMarkdown(actualMarkdown);
const expected = normalizeMarkdown(expectedMarkdown);

if (actual === expected) {
return { isValid: true, differences: [] };
}

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}`);
}
}
}

return {
isValid: false,
differences,
};
};