diff --git a/.github/workflows/validate-content.yml b/.github/workflows/validate-content.yml new file mode 100644 index 000000000000..2f726690c389 --- /dev/null +++ b/.github/workflows/validate-content.yml @@ -0,0 +1,49 @@ +name: Validate Content + +on: + pull_request: + branches: + - master + paths: + - 'roadmaps/**' + - 'scripts/**' + - '.github/workflows/validate-content.yml' + workflow_dispatch: + +jobs: + validate-content: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm@v11 + uses: pnpm/action-setup@v4 + with: + version: 11 + run_install: false + + - name: Setup Node.js Version 24 (LTS) + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + + - name: Install Dependencies + run: pnpm install + + - name: Validate Content + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + echo "Validating All Content Files" + npm run validate:content -- --all + else + CHANGED_FILES=$(git diff --name-only origin/master...HEAD -- 'roadmaps/**/*.md' | tr '\n' ',') + if [ -z "$CHANGED_FILES" ]; then + echo "No content files changed, skipping validation" + exit 0 + fi + echo "Validating Changed Content Files" + npm run validate:content -- --files=$CHANGED_FILES + fi diff --git a/package.json b/package.json index bb7da4247b14..59fe163fd930 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "format": "prettier --write .", "sync:content-to-repo": "tsx ./scripts/sync-content-to-repo.ts", "sync:repo-to-database": "tsx ./scripts/sync-repo-to-database.ts", - "cleanup:orphaned-content": "tsx ./scripts/cleanup-orphaned-content.ts" + "cleanup:orphaned-content": "tsx ./scripts/cleanup-orphaned-content.ts", + "validate:content": "tsx ./scripts/validate-content.ts" }, "dependencies": { "markdown-it": "^14.1.0", diff --git a/scripts/validate-content.ts b/scripts/validate-content.ts new file mode 100644 index 000000000000..2613ab1871c7 --- /dev/null +++ b/scripts/validate-content.ts @@ -0,0 +1,285 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { allowedOfficialRoadmapTopicResourceType } from './lib/official-roadmap-topic'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const ROADMAP_CONTENT_DIR = path.join(__dirname, '../roadmaps'); + +const args = process.argv.slice(2); +const filesArg = args + .find((arg) => arg.startsWith('--files=')) + ?.replace('--files=', ''); +const validateAll = args.includes('--all'); + +const contentFilenamePattern = /^(.+)@([^.]+)\.md$/; +const resourceLinkPattern = /^- \[@([a-z.]+)@(.+?)\]\((\S+)\)$/; +const maxResourcesPerTopic = 8; + +interface ValidationIssue { + line: number | null; + message: string; +} + +interface ValidationResult { + file: string; + issues: ValidationIssue[]; +} + +function resolveFiles(files: string[]): string[] { + const resolved: string[] = []; + + for (const file of files) { + const normalizedPath = file.replace(/\\/g, '/'); + const isContentFile = + normalizedPath.endsWith('.md') && normalizedPath.includes('content/'); + if (!isContentFile) { + console.log(`🚨 Skipping ${file} because it is not a content file`); + continue; + } + + const filePath = path.join(process.cwd(), file); + resolved.push(filePath); + } + + return resolved; +} + +async function getAllContentFiles(): Promise { + const roadmapsDir = await fs.readdir(ROADMAP_CONTENT_DIR); + const files: string[] = []; + + for (const roadmapSlug of roadmapsDir) { + const contentDir = path.join(ROADMAP_CONTENT_DIR, roadmapSlug, 'content'); + const stat = await fs + .stat(contentDir) + .then(() => true) + .catch(() => false); + if (!stat) { + continue; + } + + const contentFiles = await fs.readdir(contentDir); + for (const file of contentFiles) { + if (file.endsWith('.md')) { + files.push(path.join(contentDir, file)); + } + } + } + + return files; +} + +async function validateFile(filePath: string): Promise { + const relativePath = path + .relative(process.cwd(), filePath) + .replace(/\\/g, '/'); + const issues: ValidationIssue[] = []; + + const fileName = path.basename(filePath); + const filenameMatch = fileName.match(contentFilenamePattern); + if (!filenameMatch) { + issues.push({ + line: null, + message: `Invalid file name "${fileName}", expected @.md`, + }); + } + + const content = await fs.readFile(filePath, 'utf8'); + const lines = content.split(/\r?\n/); + + const firstNonEmptyLineIndex = lines.findIndex((line) => line.trim() !== ''); + if (firstNonEmptyLineIndex === -1) { + issues.push({ line: null, message: 'File is empty' }); + return { file: relativePath, issues }; + } + + const firstLine = lines[firstNonEmptyLineIndex]; + const titleMatch = firstLine.match(/^# (.+)$/); + if (!titleMatch || !titleMatch[1].trim()) { + issues.push({ + line: firstNonEmptyLineIndex + 1, + message: 'Missing "# Topic Title" heading as the first line', + }); + } + + const resourceLines: { line: number; raw: string }[] = []; + + lines.forEach((line, index) => { + if (line.trim().startsWith('- [@')) { + resourceLines.push({ line: index + 1, raw: line.trim() }); + } + }); + + for (const resourceLine of resourceLines) { + const match = resourceLine.raw.match(resourceLinkPattern); + if (!match) { + issues.push({ + line: resourceLine.line, + message: `Malformed resource link, expected - [@type@Title](url)`, + }); + continue; + } + + const [, type, title, url] = match; + + if ( + !(allowedOfficialRoadmapTopicResourceType as readonly string[]).includes( + type, + ) + ) { + issues.push({ + line: resourceLine.line, + message: `Invalid resource type "@${type}@", expected one of: ${allowedOfficialRoadmapTopicResourceType.join(', ')}`, + }); + } + + if (!title.trim()) { + issues.push({ + line: resourceLine.line, + message: 'Resource link title is empty', + }); + } + + if (url.includes('geeksforgeeks.org')) { + issues.push({ + line: resourceLine.line, + message: 'GeeksforGeeks links are not allowed', + }); + } + } + + if (resourceLines.length > maxResourcesPerTopic) { + issues.push({ + line: resourceLines[maxResourcesPerTopic].line, + message: `Found ${resourceLines.length} resource links, maximum allowed is ${maxResourcesPerTopic}`, + }); + } + + return { file: relativePath, issues }; +} + +function getNodeIdKey(filePath: string): string | null { + const fileName = path.basename(filePath); + const match = fileName.match(contentFilenamePattern); + if (!match) { + return null; + } + + const roadmapSlug = path + .dirname(path.dirname(filePath)) + .split(path.sep) + .pop(); + + return `${roadmapSlug}/${match[2]}`; +} + +async function findDuplicateNodeIds( + targetFiles: string[], + scanFiles: string[], +): Promise> { + const byNodeId = new Map(); + + for (const filePath of scanFiles) { + const key = getNodeIdKey(filePath); + if (!key) { + continue; + } + + const existing = byNodeId.get(key) ?? []; + existing.push(path.basename(filePath)); + byNodeId.set(key, existing); + } + + const targetKeys = new Set( + targetFiles + .map((filePath) => getNodeIdKey(filePath)) + .filter((key): key is string => key !== null), + ); + + const duplicates = new Map(); + for (const [key, fileNames] of byNodeId) { + if (fileNames.length > 1 && targetKeys.has(key)) { + duplicates.set(key, fileNames); + } + } + + return duplicates; +} + +async function main() { + let filesToValidate: string[]; + + if (validateAll) { + console.log('🔍 Validating all content files...'); + filesToValidate = await getAllContentFiles(); + } else { + const files = + filesArg + ?.split(',') + .map((file) => file.trim()) + .filter(Boolean) || []; + if (files.length === 0) { + console.error( + 'Usage: tsx scripts/validate-content.ts --files=path/to/file.md,path/to/other.md', + ); + console.error(' tsx scripts/validate-content.ts --all'); + process.exit(1); + } + + filesToValidate = resolveFiles(files); + if (filesToValidate.length === 0) { + console.error('❌ No content files found to validate'); + process.exit(1); + } + } + + console.log(`🚀 Validating ${filesToValidate.length} file(s)...`); + + const results: ValidationResult[] = []; + for (const filePath of filesToValidate) { + results.push(await validateFile(filePath)); + } + + const scanFiles = validateAll ? filesToValidate : await getAllContentFiles(); + const duplicateNodeIds = await findDuplicateNodeIds( + filesToValidate, + scanFiles, + ); + + let failedFiles = 0; + for (const result of results) { + if (result.issues.length === 0) { + continue; + } + + failedFiles++; + console.error(`❌ ${result.file}`); + for (const issue of result.issues) { + const lineInfo = issue.line !== null ? `:${issue.line}` : ''; + console.error(` - ${lineInfo} ${issue.message}`); + } + } + + for (const [key, fileNames] of duplicateNodeIds) { + failedFiles++; + console.error( + `❌ ${key} is used by multiple content files: ${fileNames.join(', ')}`, + ); + } + + if (failedFiles > 0) { + console.error(`❌ Validation failed for ${failedFiles} file(s)`); + process.exit(1); + } + + console.log(`✅ Successfully validated ${results.length} file(s)`); +} + +main().catch((error) => { + console.error('❌ Validation failed with error:'); + console.error(error); + process.exit(1); +});