Skip to content

Commit 6cd76a6

Browse files
authored
Add verify cli command (#14)
* feat: add verify cli command * chore: adjust README * chore: exec lint fix
1 parent 86f960c commit 6cd76a6

4 files changed

Lines changed: 265 additions & 24 deletions

File tree

README.md

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,9 @@ type Config = InferEnv<typeof envSchema>;
132132
// { apiKey: string; db: { host: string } }
133133
```
134134

135-
## CLI Documentation Generator
135+
## CLI
136136

137-
Automatically generate markdown documentation from your environment variable schemas.
137+
Automatically generate and validate markdown documentation from your environment variable schemas.
138138

139139
### Quick Start
140140

@@ -168,15 +168,14 @@ export default envSchema
168168
**2. Generate documentation:**
169169

170170
```bash
171-
# Using TypeScript directly with Node.js type stripping feature
172171
envase generate ./config.ts -o ./docs/env.md
172+
```
173173

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

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

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

188187
**Arguments:**
189-
- `<schemaPath>` - Path to a file containing default export of env schema.
188+
- `<schemaPath>` - Path to a file containing default export of env schema
190189

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

194-
### Example Output
193+
**Usage:**
194+
```bash
195+
envase generate ./config.ts -o ./docs/env.md
196+
197+
# Or use tsx for TypeScript files (recommended for older Node versions)
198+
tsx node_modules/.bin/envase generate ./config.ts -o ./docs/env.md
199+
200+
# Or compile first, then generate
201+
tsc config.ts && envase generate ./config.js -o ./docs/env.md
202+
```
203+
204+
**Generated output:**
195205

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

205-
**Sample generated markdown:**
215+
<details>
216+
<summary>Sample generated markdown</summary>
206217

207218
```markdown
208219
# Environment variables
209220

210221
## App / Listen
211222

212-
- \`PORT\` (optional)
213-
Type: \`number\`
223+
- `PORT` (optional)
224+
Type: `number`
214225
Description: Application listening port
215-
Min value: \`1024\`
216-
Max value: \`65535\`
226+
Min value: `1024`
227+
Max value: `65535`
217228

218-
- \`HOST\` (optional)
219-
Type: \`string\`
229+
- `HOST` (optional)
230+
Type: `string`
220231
Description: Bind host address
221-
Default: \`0.0.0.0\`
232+
Default: `0.0.0.0`
222233

223234
## Database
224235

225-
- \`DATABASE_URL\` (required)
226-
Type: \`string\`
236+
- `DATABASE_URL` (required)
237+
Type: `string`
227238
Description: PostgreSQL connection URL
228-
Format: \`uri\`
239+
Format: `uri`
240+
```
241+
</details>
242+
243+
#### `envase validate <schemaPath> <markdownPath>`
244+
245+
Validates if a markdown file matches the documentation that would be generated from the environment schema.
246+
247+
**Arguments:**
248+
- `<schemaPath>` - Path to a file containing default export of env schema
249+
- `<markdownPath>` - Path to the markdown file to validate
250+
251+
**Example:**
252+
```bash
253+
envase validate ./config.ts ./docs/env.md
229254
```
230255

256+
This command is useful for:
257+
- CI/CD pipelines to ensure documentation stays in sync with code
258+
- Pre-commit hooks to verify documentation changes
259+
- Detecting manual edits to generated documentation
260+
261+
**Exit codes:**
262+
- `0` - Validation passed (markdown matches schema)
263+
- `1` - Validation failed (differences found) or error occurred
264+
231265
## API Reference
232266

233267
### `envvar`

src/cli/main.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
#!/usr/bin/env node
22

3-
import { writeFile } from 'node:fs/promises';
3+
import { readFile, writeFile } from 'node:fs/promises';
44
import { resolve } from 'node:path';
55
import { cac } from 'cac';
66
import { extractEnvvars } from './extract-envvars.ts';
77
import { generateMarkdown } from './generate-markdown.ts';
88
import { loadSchema } from './load-schema.ts';
9+
import { validateMarkdown } from './validate-markdown.ts';
910

1011
const cli = cac('envase');
1112

@@ -26,11 +27,11 @@ cli
2627
},
2728
) => {
2829
try {
29-
console.log('📖 Loading schema from:', schemaPath);
30+
console.log('Loading schema from:', schemaPath);
3031
const schema = await loadSchema(schemaPath);
3132
const extractedEnvvars = extractEnvvars(schema);
3233

33-
console.log('📝 Generating markdown documentation...');
34+
console.log('Generating markdown documentation...');
3435
const markdown = generateMarkdown(extractedEnvvars);
3536

3637
const outputPath = resolve(process.cwd(), options.output);
@@ -46,5 +47,46 @@ cli
4647
},
4748
);
4849

50+
cli
51+
.command(
52+
'validate <schemaPath> <markdownPath>',
53+
'Validate if markdown file matches the schema definition',
54+
)
55+
.example('envase validate ./config.js ./docs/env.md')
56+
.action(async (schemaPath: string, markdownPath: string) => {
57+
try {
58+
console.log('Loading schema from:', schemaPath);
59+
const schema = await loadSchema(schemaPath);
60+
const extractedEnvvars = extractEnvvars(schema);
61+
62+
console.log('Generating expected markdown...');
63+
const expectedMarkdown = generateMarkdown(extractedEnvvars);
64+
65+
console.log('Reading actual markdown from:', markdownPath);
66+
const markdownFilePath = resolve(process.cwd(), markdownPath);
67+
const actualMarkdown = await readFile(markdownFilePath, 'utf-8');
68+
69+
console.log('Validating...');
70+
const result = validateMarkdown(actualMarkdown, expectedMarkdown);
71+
72+
if (result.isValid) {
73+
console.log('Validation passed! The markdown file matches the schema.');
74+
process.exit(0);
75+
}
76+
77+
console.error('Validation failed! Found differences:\n');
78+
for (const diff of result.differences) {
79+
console.error(diff);
80+
}
81+
process.exit(1);
82+
} catch (error) {
83+
console.error(
84+
'Error:',
85+
error instanceof Error ? error.message : String(error),
86+
);
87+
process.exit(1);
88+
}
89+
});
90+
4991
cli.help();
5092
cli.parse();

src/cli/validate-markdown.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { validateMarkdown } from './validate-markdown.ts';
3+
4+
describe('validateMarkdown', () => {
5+
it('returns valid when markdown matches exactly', () => {
6+
const markdown = `# Environment variables
7+
8+
## App
9+
10+
- \`PORT\` (required)
11+
Type: \`number\`
12+
Description: Application port
13+
14+
`;
15+
16+
const result = validateMarkdown(markdown, markdown);
17+
18+
expect(result.isValid).toBe(true);
19+
expect(result.differences).toEqual([]);
20+
});
21+
22+
it('normalizes line endings and multiple newlines', () => {
23+
const actual = '# Environment variables\r\n\r\n\r\n## App\r\n';
24+
const expected = '# Environment variables\n\n## App\n';
25+
26+
const result = validateMarkdown(actual, expected);
27+
28+
expect(result.isValid).toBe(true);
29+
expect(result.differences).toEqual([]);
30+
});
31+
32+
it('detects missing lines in actual file', () => {
33+
const actual = `# Environment variables
34+
35+
## App`;
36+
const expected = `# Environment variables
37+
38+
## App
39+
40+
- \`PORT\` (required)`;
41+
42+
const result = validateMarkdown(actual, expected);
43+
44+
expect(result.isValid).toBe(false);
45+
expect(result.differences.length).toBeGreaterThan(0);
46+
expect(
47+
result.differences.some((d) => d.includes('Missing in actual file')),
48+
).toBe(true);
49+
});
50+
51+
it('detects extra lines in actual file', () => {
52+
const actual = `# Environment variables
53+
54+
## App
55+
56+
- \`PORT\` (required)
57+
- \`HOST\` (optional)`;
58+
const expected = `# Environment variables
59+
60+
## App
61+
62+
- \`PORT\` (required)`;
63+
64+
const result = validateMarkdown(actual, expected);
65+
66+
expect(result.isValid).toBe(false);
67+
expect(result.differences).toContain('Line 6: Extra line in actual file');
68+
});
69+
70+
it('detects content mismatches', () => {
71+
const actual = `# Environment variables
72+
73+
- \`PORT\` (optional)`;
74+
const expected = `# Environment variables
75+
76+
- \`PORT\` (required)`;
77+
78+
const result = validateMarkdown(actual, expected);
79+
80+
expect(result.isValid).toBe(false);
81+
expect(result.differences).toContain('Line 3: Content mismatch');
82+
expect(result.differences).toContain(' Expected: - `PORT` (required)');
83+
expect(result.differences).toContain(' Actual: - `PORT` (optional)');
84+
});
85+
86+
it('handles empty strings', () => {
87+
const result = validateMarkdown('', '');
88+
89+
expect(result.isValid).toBe(true);
90+
expect(result.differences).toEqual([]);
91+
});
92+
93+
it('reports all differences in a complex mismatch', () => {
94+
const actual = `# Environment variables
95+
96+
## Database
97+
98+
- \`DB_URL\` (optional)`;
99+
const expected = `# Environment variables
100+
101+
## App
102+
103+
- \`PORT\` (required)`;
104+
105+
const result = validateMarkdown(actual, expected);
106+
107+
expect(result.isValid).toBe(false);
108+
expect(result.differences.length).toBeGreaterThan(0);
109+
expect(result.differences).toContain('Line 3: Content mismatch');
110+
expect(result.differences).toContain('Line 5: Content mismatch');
111+
});
112+
});

src/cli/validate-markdown.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const normalizeMarkdown = (content: string): string => {
2+
return content
3+
.trim()
4+
.replace(/\r\n/g, '\n')
5+
.replace(/\n{3,}/g, '\n\n');
6+
};
7+
8+
/**
9+
* Validates if a markdown file matches the expected documentation
10+
* generated from the environment schema.
11+
*/
12+
export const validateMarkdown = (
13+
actualMarkdown: string,
14+
expectedMarkdown: string,
15+
): { isValid: boolean; differences: string[] } => {
16+
const differences: string[] = [];
17+
18+
const actual = normalizeMarkdown(actualMarkdown);
19+
const expected = normalizeMarkdown(expectedMarkdown);
20+
21+
if (actual === expected) {
22+
return { isValid: true, differences: [] };
23+
}
24+
25+
const actualLines = actual.split('\n');
26+
const expectedLines = expected.split('\n');
27+
28+
const maxLines = Math.max(actualLines.length, expectedLines.length);
29+
30+
for (let i = 0; i < maxLines; i++) {
31+
const actualLine = actualLines[i] ?? '';
32+
const expectedLine = expectedLines[i] ?? '';
33+
34+
if (actualLine !== expectedLine) {
35+
if (i >= actualLines.length) {
36+
differences.push(`Line ${i + 1}: Missing in actual file`);
37+
differences.push(` Expected: ${expectedLine}`);
38+
} else if (i >= expectedLines.length) {
39+
differences.push(`Line ${i + 1}: Extra line in actual file`);
40+
differences.push(` Actual: ${actualLine}`);
41+
} else {
42+
differences.push(`Line ${i + 1}: Content mismatch`);
43+
differences.push(` Expected: ${expectedLine}`);
44+
differences.push(` Actual: ${actualLine}`);
45+
}
46+
}
47+
}
48+
49+
return {
50+
isValid: false,
51+
differences,
52+
};
53+
};

0 commit comments

Comments
 (0)