Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions scripts/__tests__/validate-repository.test.js
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
78 changes: 4 additions & 74 deletions scripts/validate-content.js
Original file line number Diff line number Diff line change
@@ -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}`));

Expand All @@ -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('');
}
}
Expand Down
98 changes: 98 additions & 0 deletions scripts/validate-repository.js
Original file line number Diff line number Diff line change
@@ -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 };
};
Loading