diff --git a/scripts/__tests__/validate-client-assets.test.js b/scripts/__tests__/validate-client-assets.test.js new file mode 100644 index 0000000..993aef8 --- /dev/null +++ b/scripts/__tests__/validate-client-assets.test.js @@ -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 -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([]); + }); +}); diff --git a/scripts/validate-client-assets.js b/scripts/validate-client-assets.js new file mode 100644 index 0000000..f2ca23e --- /dev/null +++ b/scripts/validate-client-assets.js @@ -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/-avatar.png` and `-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 `-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 ` + + `-avatar.png / -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 }; +}; diff --git a/scripts/validate-content.js b/scripts/validate-content.js index 75fa232..b4cad8b 100644 --- a/scripts/validate-content.js +++ b/scripts/validate-content.js @@ -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), '../..'); @@ -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) { @@ -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) => ({ @@ -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); };