diff --git a/packages/plugin-docs-cli/src/bin/run.ts b/packages/plugin-docs-cli/src/bin/run.ts index 28493f141f..0cd3b544ea 100644 --- a/packages/plugin-docs-cli/src/bin/run.ts +++ b/packages/plugin-docs-cli/src/bin/run.ts @@ -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); } @@ -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: diff --git a/packages/plugin-docs-cli/src/commands/build.command.ts b/packages/plugin-docs-cli/src/commands/build.command.ts index 602f4d2208..01404613a9 100644 --- a/packages/plugin-docs-cli/src/commands/build.command.ts +++ b/packages/plugin-docs-cli/src/commands/build.command.ts @@ -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'); @@ -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 diff --git a/packages/plugin-docs-cli/src/commands/validate.command.ts b/packages/plugin-docs-cli/src/commands/validate.command.ts index 44fe176bb3..bb58b324f1 100644 --- a/packages/plugin-docs-cli/src/commands/validate.command.ts +++ b/packages/plugin-docs-cli/src/commands/validate.command.ts @@ -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 { - debug('Validating docs at: %s', docsPath); +export async function validateCommand( + docsPath: string, + options: { strict: boolean; json: boolean } = { strict: true, json: false } +): Promise { + 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) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatResult(result)); + } process.exitCode = result.valid ? 0 : 1; } diff --git a/packages/plugin-docs-cli/src/validation/engine.test.ts b/packages/plugin-docs-cli/src/validation/engine.test.ts index 86df64dff2..48a9a2e9e5 100644 --- a/packages/plugin-docs-cli/src/validation/engine.test.ts +++ b/packages/plugin-docs-cli/src/validation/engine.test.ts @@ -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, []); diff --git a/packages/plugin-docs-cli/src/validation/format.test.ts b/packages/plugin-docs-cli/src/validation/format.test.ts new file mode 100644 index 0000000000..d75a89223b --- /dev/null +++ b/packages/plugin-docs-cli/src/validation/format.test.ts @@ -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'); + }); +}); diff --git a/packages/plugin-docs-cli/src/validation/format.ts b/packages/plugin-docs-cli/src/validation/format.ts new file mode 100644 index 0000000000..2a00e1cb82 --- /dev/null +++ b/packages/plugin-docs-cli/src/validation/format.ts @@ -0,0 +1,54 @@ +import type { Severity, ValidationResult } from './types.js'; + +const SEVERITY_LABEL: Record = { + 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 = { 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'); +} diff --git a/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts b/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts index c427f4b3d3..b864abdfd8 100644 --- a/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts +++ b/packages/plugin-docs-cli/src/validation/rules/filesystem.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { join } from 'node:path'; -import { mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { checkFilesystem } from './filesystem.js'; @@ -8,7 +8,7 @@ describe('checkFilesystem', () => { const testDocsPath = join(__dirname, '..', '..', '__fixtures__', 'test-docs'); it('should report missing root index.md', async () => { - const findings = await checkFilesystem({ docsPath: testDocsPath }); + const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); const finding = findings.find((f) => f.rule === 'root-index-exists'); expect(finding).toBeDefined(); @@ -16,7 +16,7 @@ describe('checkFilesystem', () => { }); it('should not report has-markdown-files when docs folder has .md files', async () => { - const findings = await checkFilesystem({ docsPath: testDocsPath }); + const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); const finding = findings.find((f) => f.rule === 'has-markdown-files'); expect(finding).toBeUndefined(); @@ -29,14 +29,14 @@ describe('checkFilesystem', () => { '---\ntitle: Home\ndescription: Home page\nsidebar_position: 1\n---\n# Home\n' ); - const findings = await checkFilesystem({ docsPath: tmp }); + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); expect(findings.find((f) => f.rule === 'root-index-exists')).toBeUndefined(); expect(findings.find((f) => f.rule === 'has-markdown-files')).toBeUndefined(); }); it('should report has-markdown-files when docs path does not exist', async () => { - const findings = await checkFilesystem({ docsPath: '/nonexistent/path' }); + const findings = await checkFilesystem({ docsPath: '/nonexistent/path', strict: true }); expect(findings.find((f) => f.rule === 'has-markdown-files')).toBeDefined(); }); @@ -44,8 +44,157 @@ describe('checkFilesystem', () => { it('should report has-markdown-files for empty directory', async () => { const tmp = await mkdtemp(join(tmpdir(), 'docs-empty-')); - const findings = await checkFilesystem({ docsPath: tmp }); + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); expect(findings.find((f) => f.rule === 'has-markdown-files')).toBeDefined(); }); + + it('should report nested-dir-has-index when subdir lacks index.md', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'sub')); + await writeFile(join(tmp, 'sub', 'page.md'), '---\ntitle: Page\n---\n'); + + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); + + expect(findings.find((f) => f.rule === 'nested-dir-has-index')).toBeDefined(); + }); + + it('should not report nested-dir-has-index when subdir has index.md', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'sub')); + await writeFile(join(tmp, 'sub', 'index.md'), '---\ntitle: Sub\n---\n'); + + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); + + expect(findings.find((f) => f.rule === 'nested-dir-has-index')).toBeUndefined(); + }); + + it('should not report nested-dir-has-index for image-only subdir', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'img')); + await writeFile(join(tmp, 'img', 'screenshot.png'), ''); + + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); + + expect(findings.find((f) => f.rule === 'nested-dir-has-index')).toBeUndefined(); + }); + + it('should not report no-empty-directories for directory with only image files', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'images')); + await writeFile(join(tmp, 'images', 'screenshot.png'), ''); + + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); + + expect(findings.find((f) => f.rule === 'no-empty-directories')).toBeUndefined(); + }); + + it('should report no-empty-directories for subdir with no allowed files', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'assets')); + + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); + + expect(findings.find((f) => f.rule === 'no-empty-directories')).toBeDefined(); + }); + + it('should not report no-empty-directories for subdir containing markdown files', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'sub')); + await writeFile(join(tmp, 'sub', 'index.md'), '---\ntitle: Sub\n---\n'); + + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); + + expect(findings.find((f) => f.rule === 'no-empty-directories')).toBeUndefined(); + }); + + it('should not report naming rules for files and dirs with safe names', async () => { + const findings = await checkFilesystem({ docsPath: testDocsPath, strict: true }); + + expect(findings.find((f) => f.rule === 'no-spaces-in-names')).toBeUndefined(); + expect(findings.find((f) => f.rule === 'valid-file-naming')).toBeUndefined(); + }); + + it('should report no-spaces-in-names for filename with a space', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await writeFile(join(tmp, 'my guide.md'), '---\ntitle: Guide\n---\n'); + + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); + + const finding = findings.find((f) => f.rule === 'no-spaces-in-names'); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('error'); + expect(finding!.file).toContain('my guide.md'); + }); + + it('should report no-spaces-in-names for directory name with a space', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'my section')); + await writeFile(join(tmp, 'my section', 'index.md'), '---\ntitle: Section\n---\n'); + + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); + + const finding = findings.find((f) => f.rule === 'no-spaces-in-names' && f.file?.endsWith('my section')); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('error'); + }); + + it('should report valid-file-naming for filename with non-slug characters', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await writeFile(join(tmp, 'guide!important.md'), '---\ntitle: Guide\n---\n'); + + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); + + const finding = findings.find((f) => f.rule === 'valid-file-naming'); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('error'); + }); + + it('should report valid-file-naming for directory name with uppercase', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'MySection')); + await writeFile(join(tmp, 'MySection', 'index.md'), '---\ntitle: Section\n---\n'); + + const findings = await checkFilesystem({ docsPath: tmp, strict: true }); + + const finding = findings.find((f) => f.rule === 'valid-file-naming' && f.file?.endsWith('MySection')); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('error'); + }); + + describe('non-strict mode', () => { + it('should report valid-file-naming as warning in non-strict mode', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await writeFile(join(tmp, 'guide!important.md'), '---\ntitle: Guide\n---\n'); + + const findings = await checkFilesystem({ docsPath: tmp, strict: false }); + + const finding = findings.find((f) => f.rule === 'valid-file-naming'); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('warning'); + }); + + it('should report no-empty-directories as warning in non-strict mode', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'docs-test-')); + await writeFile(join(tmp, 'index.md'), '---\ntitle: Home\n---\n'); + await mkdir(join(tmp, 'assets')); + + const findings = await checkFilesystem({ docsPath: tmp, strict: false }); + + const finding = findings.find((f) => f.rule === 'no-empty-directories'); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe('warning'); + }); + }); }); diff --git a/packages/plugin-docs-cli/src/validation/rules/filesystem.ts b/packages/plugin-docs-cli/src/validation/rules/filesystem.ts index 3a9fcbab46..77bf3e063d 100644 --- a/packages/plugin-docs-cli/src/validation/rules/filesystem.ts +++ b/packages/plugin-docs-cli/src/validation/rules/filesystem.ts @@ -1,23 +1,65 @@ -import { access, readdir } from 'node:fs/promises'; -import { join } from 'node:path'; +import { readdir } from 'node:fs/promises'; +import type { Dirent } from 'node:fs'; +import { join, extname, sep } from 'node:path'; import type { Diagnostic, ValidationInput } from '../types.js'; const RULE_HAS_MARKDOWN = 'has-markdown-files'; const RULE_ROOT_INDEX = 'root-index-exists'; +const RULE_NESTED_DIR_INDEX = 'nested-dir-has-index'; +const RULE_NO_SPACES = 'no-spaces-in-names'; +const RULE_VALID_NAMING = 'valid-file-naming'; +const RULE_NO_EMPTY_DIR = 'no-empty-directories'; +const RULE_NO_SYMLINKS = 'no-symlinks'; +const RULE_ALLOWED_FILE_TYPES = 'allowed-file-types'; + +// slug-safe: lowercase letters, digits and hyphens only +const SLUG_SAFE_RE = /^[a-z0-9-]+$/; + +// allowed file extensions in the docs folder (.md + permitted image formats) +export const ALLOWED_EXTENSIONS = new Set(['.md', '.png', '.jpg', '.jpeg', '.webp', '.gif']); export async function checkFilesystem(input: ValidationInput): Promise { const diagnostics: Diagnostic[] = []; - // check for at least one .md file - let hasMarkdown = false; + let entries: Dirent[] = []; try { - const entries = await readdir(input.docsPath, { recursive: true }); - hasMarkdown = entries.some((entry) => entry.endsWith('.md')); + entries = await readdir(input.docsPath, { recursive: true, withFileTypes: true }); } catch { - // docsPath doesn't exist or isn't readable - will be caught by has-markdown-files + // docsPath doesn't exist or isn't readable + } + + const mdFiles = entries.filter((e) => e.isFile() && extname(e.name).toLowerCase() === '.md'); + const dirs = entries.filter((e) => e.isDirectory()); + const symlinks = entries.filter((e) => e.isSymbolicLink()); + const nonMdFiles = entries.filter((e) => e.isFile() && extname(e.name).toLowerCase() !== '.md'); + + // no-symlinks + for (const link of symlinks) { + diagnostics.push({ + rule: RULE_NO_SYMLINKS, + severity: 'error', + file: join(link.parentPath, link.name), + title: 'Symlinks are not allowed', + detail: `"${link.name}" is a symbolic link. Use actual files instead of symlinks.`, + }); + } + + // allowed-file-types: non-.md files must be permitted image formats + for (const file of nonMdFiles) { + const ext = extname(file.name).toLowerCase(); + if (!ALLOWED_EXTENSIONS.has(ext)) { + diagnostics.push({ + rule: RULE_ALLOWED_FILE_TYPES, + severity: input.strict ? 'error' : 'info', + file: join(file.parentPath, file.name), + title: 'File type not allowed', + detail: `"${file.name}" is not an allowed file type. Only .md and image files (png, jpg, jpeg, webp, gif) are permitted in the docs folder.`, + }); + } } - if (!hasMarkdown) { + // has-markdown-files + if (mdFiles.length === 0) { diagnostics.push({ rule: RULE_HAS_MARKDOWN, severity: 'error', @@ -27,10 +69,9 @@ export async function checkFilesystem(input: ValidationInput): Promise e.name === 'index.md' && e.parentPath === input.docsPath); + if (!hasRootIndex) { diagnostics.push({ rule: RULE_ROOT_INDEX, severity: 'error', @@ -40,5 +81,69 @@ export async function checkFilesystem(input: ValidationInput): Promise + e.isFile() && + ALLOWED_EXTENSIONS.has(extname(e.name).toLowerCase()) && + (e.parentPath === dirPath || e.parentPath.startsWith(dirPath + sep)) + ); + if (!hasAllowed) { + diagnostics.push({ + rule: RULE_NO_EMPTY_DIR, + severity: input.strict ? 'error' : 'warning', + file: dirPath, + title: 'Directory contains no documentation files', + detail: `"${dir.name}" contains no .md or image files and serves no purpose in the documentation structure. Remove it or add documentation files.`, + }); + continue; + } + + // nested-dir-has-index: only relevant for dirs that have .md files; image-only dirs don't need one + const hasMd = mdFiles.some((e) => e.parentPath === dirPath || e.parentPath.startsWith(dirPath + sep)); + if (!hasMd) { + continue; + } + + const hasIndex = mdFiles.some((e) => e.name === 'index.md' && e.parentPath === dirPath); + if (!hasIndex) { + diagnostics.push({ + rule: RULE_NESTED_DIR_INDEX, + severity: 'warning', + file: dirPath, + title: 'Subdirectory is missing index.md', + detail: `"${dir.name}" has no index.md. Without one, it will appear as an unnamed category using a title-cased directory name.`, + }); + } + } + + // no-spaces-in-names (error) and valid-file-naming (strict-dependent): applies to file stems and dir names + const namesToCheck = [ + ...mdFiles.map((e) => ({ slug: e.name.slice(0, -3), label: e.name, path: join(e.parentPath, e.name) })), + ...dirs.map((e) => ({ slug: e.name, label: e.name, path: join(e.parentPath, e.name) })), + ]; + for (const item of namesToCheck) { + if (/\s/.test(item.slug)) { + diagnostics.push({ + rule: RULE_NO_SPACES, + severity: 'error', + file: item.path, + title: 'Name contains spaces', + detail: `"${item.label}" contains spaces which break URL slugs. Use hyphens instead.`, + }); + } else if (!SLUG_SAFE_RE.test(item.slug)) { + diagnostics.push({ + rule: RULE_VALID_NAMING, + severity: input.strict ? 'error' : 'warning', + file: item.path, + title: 'Name contains non-slug characters', + detail: `"${item.label}" should use only lowercase letters, digits and hyphens for clean URL slugs.`, + }); + } + } + return diagnostics; } diff --git a/packages/plugin-docs-cli/src/validation/types.ts b/packages/plugin-docs-cli/src/validation/types.ts index c2491efe1f..97882ca1cf 100644 --- a/packages/plugin-docs-cli/src/validation/types.ts +++ b/packages/plugin-docs-cli/src/validation/types.ts @@ -17,6 +17,7 @@ export interface Diagnostic { */ export interface ValidationInput { docsPath: string; + strict: boolean; } /**