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
87 changes: 87 additions & 0 deletions scripts/__tests__/validate-client-assets.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
checkClientAssets,
findMissingStoryAvatars,
findNonPngClientAssets,
} from '../validate-client-assets.js';

describe('findNonPngClientAssets', () => {
it('flags non-png image assets', () => {
const result = findNonPngClientAssets([
'beev-avatar.jpeg',
'beev-white.png',
'citron-avatar.png',
'combo-avatar.webp',
]);
expect(result).toEqual(['beev-avatar.jpeg', 'combo-avatar.webp']);
});

it('returns empty when all assets are png', () => {
expect(findNonPngClientAssets(['citron-avatar.png', 'citron-white.png'])).toEqual([]);
});

it('ignores non-image files', () => {
expect(findNonPngClientAssets(['notes.txt', 'logo.png'])).toEqual([]);
});
});

describe('findMissingStoryAvatars', () => {
it('flags stories without a <slug>-avatar.png', () => {
const result = findMissingStoryAvatars(
['beev', 'citron', 'dotworld'],
['beev-avatar.png', 'citron-avatar.png', 'dotworld-white.png'],
);
expect(result).toEqual(['dotworld']);
});

it('returns empty when every story has an avatar', () => {
expect(findMissingStoryAvatars(['beev'], ['beev-avatar.png'])).toEqual([]);
});
});

describe('checkClientAssets (fs)', () => {
let root;

beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'client-assets-'));
await mkdir(join(root, 'assets', 'clients'), { recursive: true });
});

afterEach(async () => {
await rm(root, { recursive: true, force: true });
});

const touch = (name) => writeFile(join(root, 'assets', 'clients', name), 'x');

it('errors on a non-png asset and warns on a missing avatar', async () => {
await touch('beev-avatar.jpeg');
await touch('dotworld-white.png');

const { errors, warnings } = checkClientAssets(root, ['beev', 'dotworld']);

expect(errors).toHaveLength(1);
expect(errors[0]).toContain('beev-avatar.jpeg');
// dotworld has no -avatar.png → warning; beev has a (wrong-ext) avatar so the
// missing-png warning fires for beev too since beev-avatar.png is absent.
expect(warnings.some((w) => w.includes('dotworld'))).toBe(true);
});

it('is clean when assets follow the png convention', async () => {
await touch('citron-avatar.png');
await touch('citron-white.png');

const { errors, warnings } = checkClientAssets(root, ['citron']);

expect(errors).toEqual([]);
expect(warnings).toEqual([]);
});

it('returns empty when assets/clients is absent', () => {
const { errors, warnings } = checkClientAssets(join(root, 'nope'), []);
expect(errors).toEqual([]);
expect(warnings).toEqual([]);
});
});
57 changes: 57 additions & 0 deletions scripts/validate-client-assets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { readdirSync } from 'node:fs';
import { extname, join } from 'node:path';

// Client logos/avatars are resolved by the website at
// `content/clients/<slug>-avatar.png` and `<slug>-white.png` with the `.png`
// extension HARDCODED — the story frontmatter carries no avatar field. A non-png
// asset (e.g. a .jpeg avatar) uploads to Blob fine but the site requests .png and
// 404s, rendering a broken image. validate-content has no frontmatter field to
// catch it, so these checks guard the asset directory directly.

const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);

// Pure: given a list of `assets/clients/` file paths, return the ones that are
// images but not `.png`. These are hard errors — the site can only find `.png`.
export const findNonPngClientAssets = (clientPaths) =>
clientPaths.filter((p) => {
const ext = extname(p).toLowerCase();
return IMAGE_EXTS.has(ext) && ext !== '.png';
});

// Pure: given story slugs and the set of existing `assets/clients/` basenames,
// return slugs whose expected `<slug>-avatar.png` is absent. These are warnings —
// some clients legitimately ship only a `-white` logo and no avatar.
export const findMissingStoryAvatars = (storySlugs, clientBasenames) => {
const present = new Set(clientBasenames);
return storySlugs.filter((slug) => !present.has(`${slug}-avatar.png`));
};

const listClientAssets = (rootDir) => {
const dir = join(rootDir, 'assets', 'clients');
try {
return readdirSync(dir).filter((name) => IMAGE_EXTS.has(extname(name).toLowerCase()));
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
};

// fs wrapper: returns { errors, warnings } string arrays for the CLI to surface.
export const checkClientAssets = (rootDir, storySlugs) => {
const basenames = listClientAssets(rootDir);

const errors = findNonPngClientAssets(basenames).map(
(name) =>
`assets/clients/${name}: client assets must be .png — the website resolves ` +
`<slug>-avatar.png / <slug>-white.png with a hardcoded .png extension, so a ` +
`non-png file 404s and renders broken. Transcode to PNG.`,
);

const warnings = findMissingStoryAvatars(storySlugs, basenames).map(
(slug) =>
`story "${slug}": no assets/clients/${slug}-avatar.png found — the speaker ` +
`avatar will render broken unless this client ships no avatar by design.`,
);

return { errors, warnings };
};
31 changes: 25 additions & 6 deletions scripts/validate-content.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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 REPO_ROOT = join(fileURLToPath(import.meta.url), '../..');

Expand Down Expand Up @@ -45,6 +46,7 @@ const validateCrossRefs = (type, slug, data, resolver) => {
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) {
Expand All @@ -54,6 +56,7 @@ const run = () => {
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) => ({
Expand All @@ -72,18 +75,34 @@ const run = () => {
}
}

if (failures.length === 0) {
const { errors: assetErrors, warnings: assetWarnings } = checkClientAssets(
REPO_ROOT,
[...storySlugs],
);

assetWarnings.forEach((w) => console.warn(`⚠️ ${w}`));

if (failures.length === 0 && assetErrors.length === 0) {
console.log('✓ All content valid');
process.exit(0);
}

console.error(`✗ ${failures.length} file(s) failed validation:\n`);
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 (failures.length > 0) {
console.error(`✗ ${failures.length} file(s) failed validation:\n`);
for (const { file, schemaErrors, crossRefErrors } of failures) {
console.error(`${file}`);
if (schemaErrors.length > 0) console.error(formatErrors(schemaErrors));
crossRefErrors.forEach((e) => console.error(e));
console.error('');
}
}

if (assetErrors.length > 0) {
console.error(`✗ ${assetErrors.length} client asset issue(s):\n`);
assetErrors.forEach((e) => console.error(` • ${e}`));
console.error('');
}

process.exit(1);
};

Expand Down
Loading