diff --git a/scripts/__tests__/validate-repository.test.js b/scripts/__tests__/validate-repository.test.js new file mode 100644 index 0000000..02d8acf --- /dev/null +++ b/scripts/__tests__/validate-repository.test.js @@ -0,0 +1,100 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { validateRepository } from '../validate-repository.js'; + +const TEAM_MEMBER = `--- +name: Alice +role: + fr: Consultante + en: Consultant +track: RevOps +avatar: https://example.com/alice.png +bio: + fr: Bio + en: Bio +displayOrder: 1 +--- +`; + +const TOOL = '---\nname: HubSpot\ncategory: CRM\n---\n'; + +const blogPost = ({ author = 'alice', title = 'A post' } = {}) => `--- +title: ${title} +author: ${author} +description: A description +date: 2026-01-15 +image: https://example.com/post.png +--- +Body +`; + +let repoRoot; + +const write = (file, content) => { + const abs = join(repoRoot, file); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); +}; + +beforeEach(() => { + repoRoot = mkdtempSync(join(tmpdir(), 'ocobo-validate-')); + write('team/alice.md', TEAM_MEMBER); + write('tools/hubspot.md', TOOL); +}); + +afterEach(() => { + rmSync(repoRoot, { recursive: true, force: true }); +}); + +describe('validateRepository', () => { + it('returns no failures for a valid repository', () => { + write('blog/fr/a-post.md', blogPost()); + + expect(validateRepository(repoRoot)).toEqual({ + failures: [], + assetErrors: [], + assetWarnings: [], + }); + }); + + it('reports a schema error against the failing file', () => { + write('blog/fr/a-post.md', blogPost({ title: '""' })); + + const { failures } = validateRepository(repoRoot); + + expect(failures).toHaveLength(1); + expect(failures[0].file).toBe('blog/fr/a-post.md'); + expect(failures[0].schemaErrors).toEqual([ + { path: ['title'], message: expect.any(String) }, + ]); + expect(failures[0].crossRefErrors).toEqual([]); + }); + + it('reports a dangling author cross-reference', () => { + write('blog/en/a-post.md', blogPost({ author: 'ghost' })); + + const { failures } = validateRepository(repoRoot); + + expect(failures).toEqual([ + { + file: 'blog/en/a-post.md', + schemaErrors: [], + crossRefErrors: [ + { path: ['author'], message: '"ghost" does not match any team slug' }, + ], + }, + ]); + }); + + it('returns client asset errors and warnings without failing any file', () => { + write('assets/clients/acme-avatar.jpeg', ''); + + const { failures, assetErrors, assetWarnings } = validateRepository(repoRoot); + + expect(failures).toEqual([]); + expect(assetErrors).toEqual([expect.stringContaining('assets/clients/acme-avatar.jpeg')]); + expect(assetWarnings).toEqual([]); + }); +}); diff --git a/scripts/validate-content.js b/scripts/validate-content.js index b4cad8b..f6e9d3e 100644 --- a/scripts/validate-content.js +++ b/scripts/validate-content.js @@ -1,84 +1,14 @@ -import { readFileSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { glob } from 'glob'; -import matter from 'gray-matter'; -import { validateBlogPost } from './schemas/blog-post.schema.js'; -import { validateJob } from './schemas/job.schema.js'; -import { validateStory } from './schemas/story.schema.js'; -import { validateTeamMember } from './schemas/team-member.schema.js'; -import { validateTool } from './schemas/tool.schema.js'; -import { createResolver } from './cross-ref-resolver.js'; -import { checkClientAssets } from './validate-client-assets.js'; +import { validateRepository } from './validate-repository.js'; const REPO_ROOT = join(fileURLToPath(import.meta.url), '../..'); -const VALIDATORS = { - tool: { patterns: ['tools/*.md'], validate: validateTool }, - 'team-member': { patterns: ['team/*.md'], validate: validateTeamMember }, - 'blog-post': { patterns: ['blog/fr/*.md', 'blog/en/*.md'], validate: validateBlogPost }, - story: { patterns: ['stories/fr/*.md', 'stories/en/*.md'], validate: validateStory }, - job: { patterns: ['jobs/fr/*.md', 'jobs/en/*.md'], validate: validateJob }, -}; - const formatErrors = (issues) => issues.map((i) => ` • ${i.path.join('.') || 'root'}: ${i.message}`).join('\n'); -const validateCrossRefs = (type, slug, data, resolver) => { - const errors = []; - if (type === 'blog-post' && data.author && !resolver.isValidTeamSlug(data.author)) { - errors.push(` • author: "${data.author}" does not match any team slug`); - } - if (type === 'story') { - (data.tools ?? []).forEach((t) => { - if (!resolver.isValidToolSlug(t)) errors.push(` • tools: "${t}" does not match any tool slug`); - }); - if (data.featuredTool && !resolver.isValidToolSlug(data.featuredTool)) { - errors.push(` • featuredTool: "${data.featuredTool}" does not match any tool slug`); - } - } - if (type === 'job' && data.hiringContact && !resolver.isValidTeamSlug(data.hiringContact)) { - errors.push(` • hiringContact: "${data.hiringContact}" does not match any team slug`); - } - return errors; -}; - const run = () => { - const resolver = createResolver(REPO_ROOT); - const failures = []; - const storySlugs = new Set(); - - for (const [type, { patterns, validate }] of Object.entries(VALIDATORS)) { - for (const pattern of patterns) { - const files = glob.sync(pattern, { cwd: REPO_ROOT }); - for (const file of files) { - const abs = join(REPO_ROOT, file); - const raw = readFileSync(abs, 'utf8'); - const { data } = matter(raw); - const slug = file.replace(/\.md$/, '').split('/').pop(); - if (type === 'story') storySlugs.add(slug); - - const result = validate(data); - const schemaErrors = result.success ? [] : result.error.issues.map((i) => ({ - path: i.path, message: i.message, - })); - const crossRefErrors = validateCrossRefs(type, slug, data, resolver); - - if (schemaErrors.length > 0 || crossRefErrors.length > 0) { - failures.push({ - file: relative(REPO_ROOT, abs), - schemaErrors, - crossRefErrors, - }); - } - } - } - } - - const { errors: assetErrors, warnings: assetWarnings } = checkClientAssets( - REPO_ROOT, - [...storySlugs], - ); + const { failures, assetErrors, assetWarnings } = validateRepository(REPO_ROOT); assetWarnings.forEach((w) => console.warn(`⚠️ ${w}`)); @@ -92,7 +22,7 @@ const run = () => { for (const { file, schemaErrors, crossRefErrors } of failures) { console.error(`${file}`); if (schemaErrors.length > 0) console.error(formatErrors(schemaErrors)); - crossRefErrors.forEach((e) => console.error(e)); + if (crossRefErrors.length > 0) console.error(formatErrors(crossRefErrors)); console.error(''); } } diff --git a/scripts/validate-repository.js b/scripts/validate-repository.js new file mode 100644 index 0000000..046c4b3 --- /dev/null +++ b/scripts/validate-repository.js @@ -0,0 +1,98 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { glob } from 'glob'; +import matter from 'gray-matter'; +import { validateBlogPost } from './schemas/blog-post.schema.js'; +import { validateJob } from './schemas/job.schema.js'; +import { validateStory } from './schemas/story.schema.js'; +import { validateTeamMember } from './schemas/team-member.schema.js'; +import { validateTool } from './schemas/tool.schema.js'; +import { createResolver } from './cross-ref-resolver.js'; +import { checkClientAssets } from './validate-client-assets.js'; + +const VALIDATORS = { + tool: { patterns: ['tools/*.md'], validate: validateTool }, + 'team-member': { patterns: ['team/*.md'], validate: validateTeamMember }, + 'blog-post': { patterns: ['blog/fr/*.md', 'blog/en/*.md'], validate: validateBlogPost }, + story: { patterns: ['stories/fr/*.md', 'stories/en/*.md'], validate: validateStory }, + job: { patterns: ['jobs/fr/*.md', 'jobs/en/*.md'], validate: validateJob }, +}; + +// Cross-reference errors share the schema error shape ({ path, message }) so +// the CLI formats both the same way. +const validateCrossRefs = (type, data, resolver) => { + const errors = []; + if (type === 'blog-post' && data.author && !resolver.isValidTeamSlug(data.author)) { + errors.push({ path: ['author'], message: `"${data.author}" does not match any team slug` }); + } + if (type === 'story') { + (data.tools ?? []).forEach((t) => { + if (!resolver.isValidToolSlug(t)) { + errors.push({ path: ['tools'], message: `"${t}" does not match any tool slug` }); + } + }); + if (data.featuredTool && !resolver.isValidToolSlug(data.featuredTool)) { + errors.push({ + path: ['featuredTool'], + message: `"${data.featuredTool}" does not match any tool slug`, + }); + } + } + if (type === 'job' && data.hiringContact && !resolver.isValidTeamSlug(data.hiringContact)) { + errors.push({ + path: ['hiringContact'], + message: `"${data.hiringContact}" does not match any team slug`, + }); + } + return errors; +}; + +/** + * Validates every content file under a repository root. Reads the files but + * never prints and never exits: the caller decides how to report. + * + * @param {string} repoRoot + * @returns {{ + * failures: Array<{ + * file: string, + * schemaErrors: Array<{ path: PropertyKey[], message: string }>, + * crossRefErrors: Array<{ path: PropertyKey[], message: string }>, + * }>, + * assetErrors: string[], + * assetWarnings: string[], + * }} + */ +export const validateRepository = (repoRoot) => { + const resolver = createResolver(repoRoot); + const failures = []; + const storySlugs = new Set(); + + for (const [type, { patterns, validate }] of Object.entries(VALIDATORS)) { + for (const pattern of patterns) { + const files = glob.sync(pattern, { cwd: repoRoot }); + for (const file of files) { + const raw = readFileSync(join(repoRoot, file), 'utf8'); + const { data } = matter(raw); + const slug = file.replace(/\.md$/, '').split('/').pop(); + if (type === 'story') storySlugs.add(slug); + + const result = validate(data); + const schemaErrors = result.success ? [] : result.error.issues.map((i) => ({ + path: i.path, message: i.message, + })); + const crossRefErrors = validateCrossRefs(type, data, resolver); + + if (schemaErrors.length > 0 || crossRefErrors.length > 0) { + failures.push({ file, schemaErrors, crossRefErrors }); + } + } + } + } + + const { errors: assetErrors, warnings: assetWarnings } = checkClientAssets( + repoRoot, + [...storySlugs], + ); + + return { failures, assetErrors, assetWarnings }; +};