Skip to content
Merged
11 changes: 9 additions & 2 deletions packages/plugin-docs-cli/src/bin/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ async function main() {
console.error('Commands:');
console.error(' serve Start the local docs preview server');
console.error(' build Build docs for publishing (generates manifest, copies to dist/)');
console.error(' validate Validate documentation');
console.error(' validate Validate documentation (--json for machine-readable output)');
process.exit(1);
}

Expand Down Expand Up @@ -65,7 +65,14 @@ async function main() {
break;
}
case 'validate': {
await validateCommand(docsPath);
const validateArgv = minimist(process.argv.slice(3), {
boolean: ['strict', 'json'],
default: {
strict: true,
json: false,
},
});
await validateCommand(docsPath, { strict: validateArgv.strict, json: validateArgv.json });
break;
}
default:
Expand Down
18 changes: 14 additions & 4 deletions packages/plugin-docs-cli/src/commands/build.command.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { cp, mkdir, rm, writeFile } from 'node:fs/promises';
import { join, relative } from 'node:path';
import { cp, lstat, mkdir, rm, writeFile } from 'node:fs/promises';
import { extname, join, relative } from 'node:path';
import createDebug from 'debug';
import { scanDocsFolder } from '../scanner.js';
import { ALLOWED_EXTENSIONS } from '../validation/rules/filesystem.js';

const debug = createDebug('plugin-docs-cli:build');

Expand All @@ -26,8 +27,17 @@ export async function buildDocs(projectRoot: string, docsPath: string): Promise<
await rm(outputDir, { recursive: true, force: true });
await mkdir(outputDir, { recursive: true });

// copy entire docs folder to output (preserves .md files, images and other assets)
await cp(docsPath, outputDir, { recursive: true });
// copy only allowed file types to output (mirrors the allowed-file-types validation rule)
await cp(docsPath, outputDir, {
recursive: true,
filter: async (src) => {
const stat = await lstat(src);
if (stat.isDirectory()) {
return true;
}
return ALLOWED_EXTENSIONS.has(extname(src).toLowerCase());
},
});
debug('Copied docs folder to %s', outputDir);

// write generated manifest
Expand Down
16 changes: 12 additions & 4 deletions packages/plugin-docs-cli/src/commands/validate.command.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import createDebug from 'debug';
import { validate } from '../validation/engine.js';
import { formatResult } from '../validation/format.js';
import { allRules } from '../validation/rules/index.js';

const debug = createDebug('plugin-docs-cli:validate');

export async function validateCommand(docsPath: string): Promise<void> {
debug('Validating docs at: %s', docsPath);
export async function validateCommand(
docsPath: string,
options: { strict: boolean; json: boolean } = { strict: true, json: false }
): Promise<void> {
debug('Validating docs at: %s (strict: %s, json: %s)', docsPath, options.strict, options.json);

const result = await validate({ docsPath }, allRules);
const result = await validate({ docsPath, strict: options.strict }, allRules);

console.log(JSON.stringify(result, null, 2));
if (options.json) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not for this PR but good to open an issue: make an option to output json to a file. sometimes is much easier to read an output file than stdout json output.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An alternative is to pipe the output to a file if that would be a good enough solution?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes piping could work for now! Added an issue so we can track this: https://github.com/grafana/grafana-catalog-team/issues/782

console.log(JSON.stringify(result, null, 2));
} else {
console.log(formatResult(result));
}

process.exitCode = result.valid ? 0 : 1;
}
2 changes: 1 addition & 1 deletion packages/plugin-docs-cli/src/validation/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { validate } from './engine.js';
import type { RuleRunner, ValidationInput } from './types.js';

describe('validate', () => {
const input: ValidationInput = { docsPath: '/fake' };
const input: ValidationInput = { docsPath: '/fake', strict: true };

it('should return valid when no rules are provided', async () => {
const result = await validate(input, []);
Expand Down
81 changes: 81 additions & 0 deletions packages/plugin-docs-cli/src/validation/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { formatResult } from './format.js';
import type { ValidationResult } from './types.js';

describe('formatResult', () => {
it('should show success for valid result with no diagnostics', () => {
const result: ValidationResult = { valid: true, diagnostics: [] };
const output = formatResult(result);
expect(output).toContain('✓');
expect(output).toContain('valid');
});

it('should show error count and ✗ icon when errors exist', () => {
const result: ValidationResult = {
valid: false,
diagnostics: [{ rule: 'test', severity: 'error', title: 'Bad thing', detail: 'Fix it' }],
};
const output = formatResult(result);
expect(output).toContain('✗');
expect(output).toContain('1 error');
expect(output).toContain('Bad thing');
expect(output).toContain('Fix it');
});

it('should show ⚠ icon when only warnings exist', () => {
const result: ValidationResult = {
valid: true,
diagnostics: [{ rule: 'test', severity: 'warning', title: 'Heads up', detail: '' }],
};
const output = formatResult(result);
expect(output).toContain('⚠');
expect(output).toContain('1 warning');
});

it('should pluralize counts correctly', () => {
const result: ValidationResult = {
valid: false,
diagnostics: [
{ rule: 'a', severity: 'error', title: 'A', detail: '' },
{ rule: 'b', severity: 'error', title: 'B', detail: '' },
{ rule: 'c', severity: 'warning', title: 'C', detail: '' },
],
};
const output = formatResult(result);
expect(output).toContain('2 errors');
expect(output).toContain('1 warning');
});

it('should include file path when present', () => {
const result: ValidationResult = {
valid: false,
diagnostics: [{ rule: 'test', severity: 'error', file: 'docs/page.md', title: 'Problem', detail: '' }],
};
const output = formatResult(result);
expect(output).toContain('docs/page.md');
});

it('should show file:line when line number is present', () => {
const result: ValidationResult = {
valid: false,
diagnostics: [{ rule: 'test', severity: 'error', file: 'page.md', line: 7, title: 'H1', detail: '' }],
};
const output = formatResult(result);
expect(output).toContain('page.md:7');
});

it('should handle mixed severities', () => {
const result: ValidationResult = {
valid: false,
diagnostics: [
{ rule: 'a', severity: 'error', title: 'Error', detail: '' },
{ rule: 'b', severity: 'warning', title: 'Warning', detail: '' },
{ rule: 'c', severity: 'info', title: 'Info', detail: '' },
],
};
const output = formatResult(result);
expect(output).toContain('1 error');
expect(output).toContain('1 warning');
expect(output).toContain('1 info');
});
});
54 changes: 54 additions & 0 deletions packages/plugin-docs-cli/src/validation/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { Severity, ValidationResult } from './types.js';

const SEVERITY_LABEL: Record<Severity, string> = {
Copy link
Contributor

@academo academo Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works, but my intention with my previous comment and example was to convinced you to turn Severity into an enum const and not an union type because it gives you more compile-time protections.

error: 'error',
warning: 'warn ',
info: 'info ',
};

/**
* Formats a validation result as human-readable text.
*/
export function formatResult(result: ValidationResult): string {
const lines: string[] = [];

if (result.diagnostics.length === 0) {
lines.push('✓ Documentation is valid');
return lines.join('\n');
}

const counts: Record<Severity, number> = { error: 0, warning: 0, info: 0 };
for (const d of result.diagnostics) {
counts[d.severity]++;
}
const { error: errors, warning: warnings, info: infos } = counts;

// summary line
const parts: string[] = [];
if (errors > 0) {
parts.push(`${errors} error${errors !== 1 ? 's' : ''}`);
}
if (warnings > 0) {
parts.push(`${warnings} warning${warnings !== 1 ? 's' : ''}`);
}
if (infos > 0) {
parts.push(`${infos} info`);
}

const icon = errors > 0 ? '✗' : '⚠';
lines.push(`${icon} Documentation has ${parts.join(' and ')}`);
lines.push('');

for (const d of result.diagnostics) {
const label = SEVERITY_LABEL[d.severity] ?? d.severity;
const location = d.file ? (d.line ? ` ${d.file}:${d.line}` : ` ${d.file}`) : '';
lines.push(` ${label}${location}`);
lines.push(` ${d.title}`);
if (d.detail) {
lines.push(` ${d.detail}`);
}
lines.push('');
}

return lines.join('\n');
}
Loading
Loading