Skip to content
Open
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
177 changes: 167 additions & 10 deletions apps/daemon/src/brands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand All @@ -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();
Expand Down
20 changes: 6 additions & 14 deletions apps/daemon/src/brands/logo-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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/<file>` 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 };
Expand All @@ -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;
Expand Down
49 changes: 49 additions & 0 deletions apps/daemon/src/brands/logo-priority.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading