diff --git a/apps/daemon/src/brands/index.ts b/apps/daemon/src/brands/index.ts index 78ab551d91d..077ede4c5fd 100644 --- a/apps/daemon/src/brands/index.ts +++ b/apps/daemon/src/brands/index.ts @@ -17,7 +17,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import type { Brand, @@ -58,6 +58,11 @@ import { BRAND_KIT_FILE, writeBrandKitPreview, type BrandKitStatus } from './kit import { normalizeBrandKitLocale } from './kit-i18n.js'; import { selfHostGoogleFonts } from './fonts.js'; import { adoptExistingLogos, ensureLogoFallback, type LogoFallbackFn, type LogoSlot } from './logo-fallback.js'; +import { + compareLogoFileNames, + compareLogoFileNamesByExtension, + isLikelyLogoAssetFileName, +} from './logo-priority.js'; import { ensureImageryFallback, type ImageryFallbackFn, type ImagerySlot } from './imagery-fallback.js'; import { ensureBrandSeed, type SeedFallbackFn, type SeedSlot } from './seed-fallback.js'; import { @@ -1325,6 +1330,7 @@ export async function finalizeBrand( // Pull the agent's downloaded assets into the brand workspace so the // deterministic builder and the design system see them. + mirrorProjectLogoAssets(projectsRoot, projectId); copyProjectDirToBrand(projectsRoot, projectId, brandsRoot, id, 'logos'); copyProjectDirToBrand(projectsRoot, projectId, brandsRoot, id, 'fonts'); copyProjectDirToBrand(projectsRoot, projectId, brandsRoot, id, 'imagery'); @@ -1845,6 +1851,7 @@ export async function renderBrandPreviewIntoProject( brandId: id, brandSourceUrl: meta.sourceUrl, }); + mirrorProjectLogoAssetsInDir(projectDir); const logoSlot = brandLogoSlot(brand.logo); if (!logoSlot.primary) { const adopted = adoptExistingLogos(path.join(projectDir, 'logos'), logoSlot); @@ -2061,6 +2068,86 @@ function copyProjectDirToBrand( copyDirectorySync(source, target); } +/** + * Agents sometimes save uploaded brand marks under `assets/` even though the + * brand kit expects logo candidates under `logos/`. Mirror obvious logo-like + * asset filenames into `logos/` before preview/finalize adoption runs. Keep the + * originals because `assets/` can still be part of the user's source evidence. + */ +function mirrorProjectLogoAssets(projectsRoot: string, projectId: string): void { + let projectDir: string; + try { + projectDir = resolveProjectDir(projectsRoot, projectId); + } catch { + return; + } + mirrorProjectLogoAssetsInDir(projectDir); +} + +function mirrorProjectLogoAssetsInDir(projectDir: string): void { + const assetsDir = path.join(projectDir, 'assets'); + if (!isDirectory(assetsDir)) return; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(assetsDir, { withFileTypes: true }); + } catch { + return; + } + const logoAssets = entries + .filter((entry) => entry.isFile() && isLikelyLogoAssetFileName(entry.name)) + .map((entry) => entry.name) + // Stable copy order keeps hash-suffixed collision names reproducible. + .sort(compareLogoFileNames); + if (logoAssets.length === 0) return; + + const logosDir = path.join(projectDir, 'logos'); + fs.mkdirSync(logosDir, { recursive: true }); + for (const name of logoAssets) { + const source = path.join(assetsDir, name); + const target = uniqueLogoAssetTarget(logosDir, name, source); + if (!target) continue; + try { + fs.copyFileSync(source, target); + } catch { + // Best-effort mirror; preview/finalize should not fail on one bad asset. + } + } +} + +function uniqueLogoAssetTarget(logosDir: string, name: string, source: string): string | null { + const parsed = path.parse(name); + const safeBase = sanitizeLogoAssetBase(parsed.name); + const ext = parsed.ext.toLowerCase(); + // Preserve existing logo files: same-content mirrors with the same sanitized + // base are skipped, while a different file with that base is appended instead + // of overwriting the user's prior logo evidence. Case variants are treated as + // collisions, but new mirrors keep the uploaded asset's original spelling. + const existing = matchingLogoAssetTargets(logosDir, safeBase, ext); + for (const target of existing) { + if (filesAreIdentical(source, target)) return null; + } + const preferred = path.join(logosDir, `${safeBase}${ext}`); + const preferredLower = preferred.toLowerCase(); + if (!existing.some((target) => target.toLowerCase() === preferredLower) && !fs.existsSync(preferred)) return preferred; + + const sourceHash = hashFile(source); + if (!sourceHash) return null; + const shortHash = sourceHash.slice(0, 12); + const hashed = path.join(logosDir, `${safeBase}-${shortHash}${ext}`); + if (!fs.existsSync(hashed)) return hashed; + if (filesAreIdentical(source, hashed)) return null; + for (let i = 2; i <= 99; i += 1) { + const counted = path.join(logosDir, `${safeBase}-${shortHash}-${i}${ext}`); + if (!fs.existsSync(counted)) return counted; + if (filesAreIdentical(source, counted)) return null; + } + return null; +} + +function sanitizeLogoAssetBase(name: string): string { + return name.replace(/[^a-z0-9_-]+/giu, '-').replace(/^-+|-+$/g, '') || 'logo'; +} + async function syncBrandFilesToProject(input: { brandsRoot: string; projectsRoot: string; @@ -2239,12 +2326,12 @@ export async function removeBrand( return deleteBrandDir(brandsRoot, id); } -const LOGO_EXT_PRIORITY = ['.svg', '.png', '.webp', '.jpg', '.jpeg', '.gif', '.ico']; - /** * Absolute path to the brand's primary logo file, or null when none exists. * Prefers brand.logo.primary, then the first logo in `logos/` by extension - * priority (vector/raster before icon). + * priority (vector/raster before icon). Name heuristics are intentionally only + * used while adopting an empty logo slot; raw path resolution preserves the + * older extension-first behavior. */ export function resolveBrandLogoPath(brandsRoot: string, id: string): string | null { const brand = readBrand(brandsRoot, id); @@ -2265,16 +2352,11 @@ export function resolveBrandLogoPath(brandsRoot: string, id: string): string | n } const ranked = names .filter((n) => isFile(path.join(logosDir, n))) - .sort((a, b) => extRank(a) - extRank(b) || a.localeCompare(b)); + .sort(compareLogoFileNamesByExtension); const pick = ranked[0]; return pick ? path.join(logosDir, pick) : null; } -function extRank(name: string): number { - const i = LOGO_EXT_PRIORITY.indexOf(path.extname(name).toLowerCase()); - return i === -1 ? LOGO_EXT_PRIORITY.length : i; -} - function isFile(p: string): boolean { try { return fs.statSync(p).isFile(); @@ -2283,6 +2365,81 @@ function isFile(p: string): boolean { } } +function filesAreIdentical(a: string, b: string): boolean { + let aFd: number | null = null; + let bFd: number | null = null; + try { + if (fs.statSync(a).size !== fs.statSync(b).size) return false; + aFd = fs.openSync(a, 'r'); + bFd = fs.openSync(b, 'r'); + const aBuf = Buffer.allocUnsafe(64 * 1024); + const bBuf = Buffer.allocUnsafe(64 * 1024); + while (true) { + const aRead = fs.readSync(aFd, aBuf, 0, aBuf.length, null); + const bRead = fs.readSync(bFd, bBuf, 0, bBuf.length, null); + if (aRead !== bRead) return false; + if (aRead === 0) return true; + if (!aBuf.subarray(0, aRead).equals(bBuf.subarray(0, bRead))) return false; + } + } catch { + return false; + } finally { + if (aFd !== null) { + try { + fs.closeSync(aFd); + } catch {} + } + if (bFd !== null) { + try { + fs.closeSync(bFd); + } catch {} + } + } +} + +function hashFile(file: string): string | null { + let fd: number | null = null; + try { + fd = fs.openSync(file, 'r'); + const hash = createHash('sha256'); + const buffer = Buffer.allocUnsafe(64 * 1024); + while (true) { + const read = fs.readSync(fd, buffer, 0, buffer.length, null); + if (read === 0) break; + hash.update(buffer.subarray(0, read)); + } + return hash.digest('hex'); + } catch { + return null; + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch {} + } + } +} + +function matchingLogoAssetTargets(logosDir: string, safeBase: string, ext: string): string[] { + let names: string[]; + try { + names = fs.readdirSync(logosDir); + } catch { + return []; + } + const normalizedBase = safeBase.toLowerCase(); + const normalizedExt = ext.toLowerCase(); + return names + .filter((name) => { + const parsed = path.parse(name); + if (parsed.ext.toLowerCase() !== normalizedExt) return false; + const stem = sanitizeLogoAssetBase(parsed.name).toLowerCase(); + return stem === normalizedBase || stem.startsWith(`${normalizedBase}-`); + }) + .map((name) => path.join(logosDir, name)) + .filter(isFile); +} + function isDirectory(p: string): boolean { try { return fs.statSync(p).isDirectory(); diff --git a/apps/daemon/src/brands/logo-fallback.ts b/apps/daemon/src/brands/logo-fallback.ts index 2b3f73d9aa5..9da9831a59c 100644 --- a/apps/daemon/src/brands/logo-fallback.ts +++ b/apps/daemon/src/brands/logo-fallback.ts @@ -12,6 +12,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import { compareLogoFileNames, isLogoFileName } from './logo-priority.js'; import { fetchExternalBrandAsset } from './safe-fetch.js'; const UA = @@ -274,23 +275,14 @@ export async function ensureLogoFallback( return { changed: true }; } -/** Extension priority for ranking on-disk logo files: vector/transparent marks - * before raster, raster before icons. Mirrors `resolveBrandLogoPath`. */ -const LOGO_EXT_PRIORITY = ['.svg', '.png', '.webp', '.jpg', '.jpeg', '.gif', '.ico']; -const LOGO_FILE_RE = /\.(svg|png|webp|jpe?g|gif|ico)$/i; - -function logoExtRank(name: string): number { - const i = LOGO_EXT_PRIORITY.indexOf(path.extname(name).toLowerCase()); - return i === -1 ? LOGO_EXT_PRIORITY.length : i; -} - /** * Wire the image files already present in `logosDir` into an empty `logo` slot: * the best mark (by extension priority, then name) becomes `logo.primary` and * the rest become `logo.alternates`, each as a `logos/` path relative to * the dir owner. No-op when `logo.primary` is set or the dir holds no image - * files. Mutates and returns the slot. Pure filesystem — never touches the - * network. + * files. Name rank is intentionally only consulted here, when the slot is + * empty, not when resolving an explicit logo path. Mutates and returns the + * slot. Pure filesystem — never touches the network. */ export function adoptExistingLogos(logosDir: string, logo: LogoSlot): { changed: boolean } { if (logo.primary) return { changed: false }; @@ -301,8 +293,8 @@ export function adoptExistingLogos(logosDir: string, logo: LogoSlot): { changed: return { changed: false }; } const ranked = names - .filter((n) => LOGO_FILE_RE.test(n) && isFileIn(logosDir, n)) - .sort((a, b) => logoExtRank(a) - logoExtRank(b) || a.localeCompare(b)); + .filter((n) => isLogoFileName(n) && isFileIn(logosDir, n)) + .sort(compareLogoFileNames); if (ranked.length === 0) return { changed: false }; const rels = ranked.map((n) => `logos/${n}`); logo.primary = rels[0] ?? null; diff --git a/apps/daemon/src/brands/logo-priority.ts b/apps/daemon/src/brands/logo-priority.ts new file mode 100644 index 00000000000..e4fe53f6d5b --- /dev/null +++ b/apps/daemon/src/brands/logo-priority.ts @@ -0,0 +1,49 @@ +// Deterministic logo-file ranking shared by brand extraction fallbacks. +// +// Extension rank is the conservative resolver order. Name rank is only used +// when adopting or mirroring otherwise empty logo slots from existing files. +import path from 'node:path'; + +export const LOGO_EXT_PRIORITY = Object.freeze(['.svg', '.png', '.webp', '.jpg', '.jpeg', '.gif', '.ico']); + +const LOGO_FILE_RE = /\.(svg|png|webp|jpe?g|gif|ico)$/i; +// This intentionally mirrors a few broad "logo-ish" names out of assets/. +// A false positive is preferable to leaving a brand kit logo-less; imagery +// adoption still owns hero/product/gallery files. +const LOGO_ASSET_NAME_RE = /(?:^|[-_.\s])(logo|logotype|wordmark|brandmark|symbol|lockup|mark|favicon)(?:[-_.\s]|$)/i; +const LOGO_NAME_PRIORITY = Object.freeze([ + /(?:^|[-_.\s])(logo|logotype|wordmark|lockup)(?:[-_.\s]|$)/i, + /(?:^|[-_.\s])(brandmark|symbol|mark)(?:[-_.\s]|$)/i, + // Existing `logos/` icons can be valid favicon fallbacks, but raw + // `assets/icon.*` files are too generic to mirror as primary logo candidates. + /(?:^|[-_.\s])(favicon|icon)(?:[-_.\s]|$)/i, +]); + +// Locale-independent tie-breaker so CI runners do not disagree on raw names. +const compareStableName = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +export function logoExtRank(name: string): number { + const i = LOGO_EXT_PRIORITY.indexOf(path.extname(name).toLowerCase()); + return i === -1 ? LOGO_EXT_PRIORITY.length : i; +} + +export function logoNameRank(name: string): number { + const i = LOGO_NAME_PRIORITY.findIndex((pattern) => pattern.test(name)); + return i === -1 ? LOGO_NAME_PRIORITY.length : i; +} + +export function compareLogoFileNames(a: string, b: string): number { + return logoExtRank(a) - logoExtRank(b) || logoNameRank(a) - logoNameRank(b) || compareStableName(a, b); +} + +export function compareLogoFileNamesByExtension(a: string, b: string): number { + return logoExtRank(a) - logoExtRank(b) || compareStableName(a, b); +} + +export function isLogoFileName(name: string): boolean { + return LOGO_FILE_RE.test(name); +} + +export function isLikelyLogoAssetFileName(name: string): boolean { + return isLogoFileName(name) && LOGO_ASSET_NAME_RE.test(name); +} diff --git a/apps/daemon/tests/brand-extraction-engine.test.ts b/apps/daemon/tests/brand-extraction-engine.test.ts index 7c49007dd90..14cffa1cc9e 100644 --- a/apps/daemon/tests/brand-extraction-engine.test.ts +++ b/apps/daemon/tests/brand-extraction-engine.test.ts @@ -3,6 +3,7 @@ import fs, { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync, exists import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; import type { Brand } from '@open-design/contracts'; import { @@ -23,10 +24,11 @@ import { readBrandDetail, reconcileProgrammaticExtractionTranscript, renderBrandPreviewIntoProject, + resolveBrandLogoPath, startBrandExtraction, } from '../src/brands/index.js'; import { patchMeta } from '../src/brands/store.js'; -import { ensureLogoFallback } from '../src/brands/logo-fallback.js'; +import { adoptExistingLogos, ensureLogoFallback } from '../src/brands/logo-fallback.js'; import { brandFromMaterial } from '../src/brands/provisional.js'; import { listDesignSystems } from '../src/design-systems/index.js'; import { @@ -1663,6 +1665,105 @@ describe('agent-driven brand extraction engine', () => { expect(projectBrandJson.logo?.primary).toBe('logos/header.svg'); }); + it('adoptExistingLogos ranks wordmarks ahead of same-extension symbols', () => { + const logosDir = path.join(tempDir, 'logos'); + mkdirSync(logosDir, { recursive: true }); + writeFileSync(path.join(logosDir, 'Symbol.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync(path.join(logosDir, 'Wordmark.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + + const logo = { primary: null, alternates: [], notes: '' }; + expect(adoptExistingLogos(logosDir, logo).changed).toBe(true); + expect(logo.primary).toBe('logos/Wordmark.png'); + expect(logo.alternates).toContain('logos/Symbol.png'); + }); + + it('adoptExistingLogos keeps vector files ahead of raster wordmarks', () => { + const logosDir = path.join(tempDir, 'logos'); + mkdirSync(logosDir, { recursive: true }); + writeFileSync(path.join(logosDir, 'Symbol.svg'), ''); + writeFileSync(path.join(logosDir, 'Wordmark.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + + const logo = { primary: null, alternates: [], notes: '' }; + expect(adoptExistingLogos(logosDir, logo).changed).toBe(true); + expect(logo.primary).toBe('logos/Symbol.svg'); + expect(logo.alternates).toContain('logos/Wordmark.png'); + }); + + it('resolveBrandLogoPath preserves extension priority over name heuristics', () => { + const id = 'brand-test'; + const brandDir = path.join(brandsRoot, id); + const logosDir = path.join(brandDir, 'logos'); + mkdirSync(logosDir, { recursive: true }); + writeFileSync( + path.join(brandDir, 'brand.json'), + JSON.stringify({ ...VALID_BRAND, logo: { primary: null, alternates: [], notes: '' } }, null, 2), + 'utf8', + ); + writeFileSync(path.join(logosDir, 'Symbol.svg'), ''); + writeFileSync(path.join(logosDir, 'Wordmark.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + + expect(resolveBrandLogoPath(brandsRoot, id)).toBe(path.join(logosDir, 'Symbol.svg')); + }); + + it('finalizeBrand mirrors logo-like assets before adopting an empty logo slot', async () => { + const db = openDatabase(tempDir, { dataDir: tempDir }); + const started = await startOfflineBrandExtraction({ + url: 'acme.com', + brandsRoot, + projectsRoot, + skillsRoot: SKILLS_ROOT, + db, + logoFallback: NO_LOGO_FALLBACK, + }); + + const projectDir = path.join(projectsRoot, started.projectId); + mkdirSync(path.join(projectDir, 'assets'), { recursive: true }); + mkdirSync(path.join(projectDir, 'logos'), { recursive: true }); + writeFileSync(path.join(projectDir, 'logos', 'brand-mark.png'), Buffer.from([0x00])); + writeFileSync(path.join(projectDir, 'assets', 'Symbol.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync(path.join(projectDir, 'assets', 'Wordmark.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync(path.join(projectDir, 'assets', 'brand mark.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync(path.join(projectDir, 'assets', 'photo.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync(path.join(projectDir, 'assets', 'hero.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync(path.join(projectDir, 'assets', 'site-header.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync(path.join(projectDir, 'assets', 'wordmark.pdf'), Buffer.from([0x25, 0x50, 0x44, 0x46])); + writeFileSync( + path.join(projectDir, 'brand.json'), + JSON.stringify( + { ...VALID_BRAND, sourceUrl: started.sourceUrl, logo: { primary: null, alternates: [], notes: '' } }, + null, + 2, + ), + 'utf8', + ); + + const finalized = await finalizeBrand({ + id: started.id, + brandsRoot, + userDesignSystemsRoot, + projectsRoot, + skillsRoot: SKILLS_ROOT, + db, + logoFallback: ensureLogoFallback, + imageryFallback: NO_IMAGERY_FALLBACK, + }); + + expect(finalized.brand.logo.primary).toBe('logos/Wordmark.png'); + expect(finalized.brand.logo.alternates).toContain('logos/Symbol.png'); + expect(existsSync(path.join(projectDir, 'assets', 'Wordmark.png'))).toBe(true); + expect(existsSync(path.join(projectDir, 'assets', 'brand mark.png'))).toBe(true); + expect(existsSync(path.join(projectDir, 'logos', 'Wordmark.png'))).toBe(true); + expect(readdirSync(path.join(projectDir, 'logos')).filter((name) => /^brand-mark-[a-f0-9]{12}\.png$/i.test(name))) + .toHaveLength(1); + expect(existsSync(path.join(projectDir, 'logos', 'photo.png'))).toBe(false); + expect(existsSync(path.join(projectDir, 'logos', 'hero.png'))).toBe(false); + expect(existsSync(path.join(projectDir, 'logos', 'site-header.png'))).toBe(false); + expect(existsSync(path.join(projectDir, 'logos', 'wordmark.pdf'))).toBe(false); + + const html = readFileSync(path.join(projectDir, 'brand.html'), 'utf8'); + expect(html).toContain('"primary":"logos/Wordmark.png"'); + }); + it('preview adopts on-disk project logos so the live page is never logo-less', async () => { const db = openDatabase(tempDir, { dataDir: tempDir }); const started = await startOfflineBrandExtraction({ @@ -1706,6 +1807,255 @@ describe('agent-driven brand extraction engine', () => { expect(html).toContain('"primary":"logos/apple-touch-icon.png"'); }); + it('preview mirrors logo-like assets before rendering the live kit page', async () => { + const db = openDatabase(tempDir, { dataDir: tempDir }); + const started = await startOfflineBrandExtraction({ + url: 'acme.com', + brandsRoot, + projectsRoot, + skillsRoot: SKILLS_ROOT, + db, + logoFallback: NO_LOGO_FALLBACK, + }); + + const projectDir = path.join(projectsRoot, started.projectId); + mkdirSync(path.join(projectDir, 'assets'), { recursive: true }); + writeFileSync(path.join(projectDir, 'assets', 'Wordmark.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync( + path.join(projectDir, 'brand.json'), + JSON.stringify({ + name: 'Acme', + sourceUrl: started.sourceUrl, + colors: [VALID_BRAND.colors[0], VALID_BRAND.colors[2], VALID_BRAND.colors[5]], + logo: { primary: null, alternates: [], notes: '' }, + }), + 'utf8', + ); + + await renderBrandPreviewIntoProject({ + id: started.id, + brandsRoot, + skillsRoot: SKILLS_ROOT, + projectsRoot, + }); + + const html = readFileSync(path.join(projectDir, 'brand.html'), 'utf8'); + expect(existsSync(path.join(projectDir, 'assets', 'Wordmark.png'))).toBe(true); + expect(existsSync(path.join(projectDir, 'logos', 'Wordmark.png'))).toBe(true); + expect(html).toContain('"primary":"logos/Wordmark.png"'); + + await renderBrandPreviewIntoProject({ + id: started.id, + brandsRoot, + skillsRoot: SKILLS_ROOT, + projectsRoot, + }); + const promoted = readdirSync(path.join(projectDir, 'logos')).filter((name) => name.startsWith('Wordmark')); + expect(promoted).toEqual(['Wordmark.png']); + }); + + it('preview ignores unsupported asset files without creating logo mirrors', async () => { + const db = openDatabase(tempDir, { dataDir: tempDir }); + const started = await startOfflineBrandExtraction({ + url: 'acme.com', + brandsRoot, + projectsRoot, + skillsRoot: SKILLS_ROOT, + db, + logoFallback: NO_LOGO_FALLBACK, + }); + + const projectDir = path.join(projectsRoot, started.projectId); + mkdirSync(path.join(projectDir, 'assets'), { recursive: true }); + writeFileSync(path.join(projectDir, 'assets', 'wordmark.pdf'), Buffer.from([0x25, 0x50, 0x44, 0x46])); + writeFileSync(path.join(projectDir, 'assets', 'site-header.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync( + path.join(projectDir, 'brand.json'), + JSON.stringify({ + name: 'Acme', + sourceUrl: started.sourceUrl, + colors: [VALID_BRAND.colors[0], VALID_BRAND.colors[2], VALID_BRAND.colors[5]], + logo: { primary: null, alternates: [], notes: '' }, + }), + 'utf8', + ); + + await renderBrandPreviewIntoProject({ + id: started.id, + brandsRoot, + skillsRoot: SKILLS_ROOT, + projectsRoot, + }); + + expect(existsSync(path.join(projectDir, 'logos', 'wordmark.pdf'))).toBe(false); + expect(existsSync(path.join(projectDir, 'logos', 'site-header.png'))).toBe(false); + }); + + it('preview treats sanitized same-base logo assets as duplicates', async () => { + const db = openDatabase(tempDir, { dataDir: tempDir }); + const started = await startOfflineBrandExtraction({ + url: 'acme.com', + brandsRoot, + projectsRoot, + skillsRoot: SKILLS_ROOT, + db, + logoFallback: NO_LOGO_FALLBACK, + }); + + const projectDir = path.join(projectsRoot, started.projectId); + const logoBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + mkdirSync(path.join(projectDir, 'assets'), { recursive: true }); + mkdirSync(path.join(projectDir, 'logos'), { recursive: true }); + writeFileSync(path.join(projectDir, 'assets', 'brand mark.png'), logoBytes); + writeFileSync(path.join(projectDir, 'logos', 'brand-mark.png'), logoBytes); + writeFileSync( + path.join(projectDir, 'brand.json'), + JSON.stringify({ + name: 'Acme', + sourceUrl: started.sourceUrl, + colors: [VALID_BRAND.colors[0], VALID_BRAND.colors[2], VALID_BRAND.colors[5]], + logo: { primary: null, alternates: [], notes: '' }, + }), + 'utf8', + ); + + await renderBrandPreviewIntoProject({ + id: started.id, + brandsRoot, + skillsRoot: SKILLS_ROOT, + projectsRoot, + }); + + const mirrored = readdirSync(path.join(projectDir, 'logos')) + .filter((name) => /^brand-mark(?:-[a-f0-9]+)?\.png$/i.test(name)) + .sort(); + expect(mirrored).toEqual(['brand-mark.png']); + }); + + it('preview keeps sanitized logo asset targets inside logos', async () => { + const db = openDatabase(tempDir, { dataDir: tempDir }); + const started = await startOfflineBrandExtraction({ + url: 'acme.com', + brandsRoot, + projectsRoot, + skillsRoot: SKILLS_ROOT, + db, + logoFallback: NO_LOGO_FALLBACK, + }); + + const projectDir = path.join(projectsRoot, started.projectId); + mkdirSync(path.join(projectDir, 'assets'), { recursive: true }); + writeFileSync(path.join(projectDir, 'assets', '...logo...png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync( + path.join(projectDir, 'brand.json'), + JSON.stringify({ + name: 'Acme', + sourceUrl: started.sourceUrl, + colors: [VALID_BRAND.colors[0], VALID_BRAND.colors[2], VALID_BRAND.colors[5]], + logo: { primary: null, alternates: [], notes: '' }, + }), + 'utf8', + ); + + await renderBrandPreviewIntoProject({ + id: started.id, + brandsRoot, + skillsRoot: SKILLS_ROOT, + projectsRoot, + }); + + expect(existsSync(path.join(projectDir, 'logos', 'logo.png'))).toBe(true); + expect(existsSync(path.join(projectDir, '..', 'logo.png'))).toBe(false); + }); + + it('preview bounds hash-collision filenames while preserving changed assets', async () => { + const db = openDatabase(tempDir, { dataDir: tempDir }); + const started = await startOfflineBrandExtraction({ + url: 'acme.com', + brandsRoot, + projectsRoot, + skillsRoot: SKILLS_ROOT, + db, + logoFallback: NO_LOGO_FALLBACK, + }); + + const projectDir = path.join(projectsRoot, started.projectId); + const logoBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const shortHash = createHash('sha256').update(logoBytes).digest('hex').slice(0, 12); + mkdirSync(path.join(projectDir, 'assets'), { recursive: true }); + mkdirSync(path.join(projectDir, 'logos'), { recursive: true }); + writeFileSync(path.join(projectDir, 'assets', 'Wordmark.png'), logoBytes); + writeFileSync(path.join(projectDir, 'logos', 'Wordmark.png'), Buffer.from([0x01])); + writeFileSync(path.join(projectDir, 'logos', `Wordmark-${shortHash}.png`), Buffer.from([0x00])); + writeFileSync( + path.join(projectDir, 'brand.json'), + JSON.stringify({ + name: 'Acme', + sourceUrl: started.sourceUrl, + colors: [VALID_BRAND.colors[0], VALID_BRAND.colors[2], VALID_BRAND.colors[5]], + logo: { primary: null, alternates: [], notes: '' }, + }), + 'utf8', + ); + + await renderBrandPreviewIntoProject({ + id: started.id, + brandsRoot, + skillsRoot: SKILLS_ROOT, + projectsRoot, + }); + + expect(existsSync(path.join(projectDir, 'logos', `Wordmark-${shortHash}-2.png`))).toBe(true); + expect(existsSync(path.join(projectDir, 'assets', 'Wordmark.png'))).toBe(true); + }); + + it('preview mirrors same-name changed assets once without growing duplicates', async () => { + const db = openDatabase(tempDir, { dataDir: tempDir }); + const started = await startOfflineBrandExtraction({ + url: 'acme.com', + brandsRoot, + projectsRoot, + skillsRoot: SKILLS_ROOT, + db, + logoFallback: NO_LOGO_FALLBACK, + }); + + const projectDir = path.join(projectsRoot, started.projectId); + mkdirSync(path.join(projectDir, 'assets'), { recursive: true }); + mkdirSync(path.join(projectDir, 'logos'), { recursive: true }); + writeFileSync(path.join(projectDir, 'logos', 'Wordmark.png'), Buffer.from([0x00])); + writeFileSync(path.join(projectDir, 'assets', 'Wordmark.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync( + path.join(projectDir, 'brand.json'), + JSON.stringify({ + name: 'Acme', + sourceUrl: started.sourceUrl, + colors: [VALID_BRAND.colors[0], VALID_BRAND.colors[2], VALID_BRAND.colors[5]], + logo: { primary: null, alternates: [], notes: '' }, + }), + 'utf8', + ); + + await renderBrandPreviewIntoProject({ + id: started.id, + brandsRoot, + skillsRoot: SKILLS_ROOT, + projectsRoot, + }); + await renderBrandPreviewIntoProject({ + id: started.id, + brandsRoot, + skillsRoot: SKILLS_ROOT, + projectsRoot, + }); + + const mirrored = readdirSync(path.join(projectDir, 'logos')) + .filter((name) => /^Wordmark(?:-[a-f0-9]{12})?\.png$/i.test(name)) + .sort(); + expect(mirrored).toHaveLength(2); + expect(mirrored.some((name) => /^Wordmark-[a-f0-9]{12}\.png$/i.test(name))).toBe(true); + }); + it('finalizeBrand mirrors imagery/ and renders the gallery on the ready page', async () => { const db = openDatabase(tempDir, { dataDir: tempDir }); const started = await startOfflineBrandExtraction({ diff --git a/apps/daemon/tests/logo-priority.test.ts b/apps/daemon/tests/logo-priority.test.ts new file mode 100644 index 00000000000..a32f2d80d75 --- /dev/null +++ b/apps/daemon/tests/logo-priority.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { + compareLogoFileNames, + compareLogoFileNamesByExtension, + isLikelyLogoAssetFileName, + isLogoFileName, +} from '../src/brands/logo-priority.js'; + +describe('logo priority helpers', () => { + it('prefers vector files before name heuristics across extensions', () => { + expect(['Wordmark.png', 'Symbol.svg'].sort(compareLogoFileNames)).toEqual(['Symbol.svg', 'Wordmark.png']); + }); + + it('prefers wordmarks over symbols when extensions tie', () => { + expect(['Symbol.png', 'Wordmark.png'].sort(compareLogoFileNames)).toEqual(['Wordmark.png', 'Symbol.png']); + }); + + it('prefers wordmarks over broad header asset names when extensions tie', () => { + expect(['header.png', 'wordmark.png'].sort(compareLogoFileNames)).toEqual(['wordmark.png', 'header.png']); + }); + + it('keeps extension-only sorting separate from name heuristics', () => { + expect(['wordmark.png', 'symbol.svg'].sort(compareLogoFileNamesByExtension)).toEqual(['symbol.svg', 'wordmark.png']); + expect(['wordmark.png', 'symbol.png'].sort(compareLogoFileNamesByExtension)).toEqual(['symbol.png', 'wordmark.png']); + }); + + it('matches logo file extensions case-insensitively', () => { + expect(isLogoFileName('LOGO.PNG')).toBe(true); + expect(isLogoFileName('logo.txt')).toBe(false); + }); + + it('only accepts logo-like asset names', () => { + expect(isLikelyLogoAssetFileName('Brand Mark.PNG')).toBe(true); + expect(isLikelyLogoAssetFileName('favicon.ico')).toBe(true); + expect(isLikelyLogoAssetFileName('site-header.png')).toBe(false); + expect(isLikelyLogoAssetFileName('icon.png')).toBe(false); + expect(isLikelyLogoAssetFileName('photo.png')).toBe(false); + expect(isLikelyLogoAssetFileName('screenshot.jpg')).toBe(false); + expect(isLikelyLogoAssetFileName('wordmark.pdf')).toBe(false); + expect(isLikelyLogoAssetFileName('logo.pdf')).toBe(false); + }); +});