diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index 159f00086..7d0aa0cec 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -69,6 +69,26 @@ describe('detectRepoIcon', () => { }) }) + it('resolves relative declared icon hrefs from nested source files', async () => { + const repoPath = await makeTempRepoDir() + await mkdir(join(repoPath, 'src', 'routes', 'brand'), { recursive: true }) + await writeFile( + join(repoPath, 'src', 'routes', '__root.tsx'), + 'export const links = () => [{ rel: "icon", href: "./brand/icon.png" }]' + ) + await writeFile( + join(repoPath, 'src', 'routes', 'brand', 'icon.png'), + Buffer.from(PNG_1X1_BASE64, 'base64') + ) + + await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toEqual({ + type: 'image', + src: `data:image/png;base64,${PNG_1X1_BASE64}`, + source: 'file', + label: 'src/routes/brand/icon.png' + }) + }) + it('skips oversized source files when looking for declared icon hrefs', async () => { const repoPath = await makeTempRepoDir() await writeFile( diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts index 3ade240a3..6101c3a79 100644 --- a/src/main/repo-icon-autodetect.ts +++ b/src/main/repo-icon-autodetect.ts @@ -8,6 +8,7 @@ import { import { getRepoSlug } from './github/client' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import type { IFilesystemProvider } from './providers/types' +import { iconHrefCandidates } from './repo-icon-href-candidates' import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths' const REPO_ICON_FILE_CANDIDATES = [ @@ -79,27 +80,6 @@ function extractIconHref(source: string): string | null { return source.match(LINK_ICON_HTML_RE)?.[1] ?? source.match(LINK_ICON_OBJECT_RE)?.[1] ?? null } -function normalizeIconHrefPath(href: string): string | null { - const trimmed = href.trim() - if (!trimmed || trimmed.startsWith('//') || /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) { - return null - } - - const pathOnly = (trimmed.split(/[?#]/)[0] ?? '').replace(/^\/+/, '').replace(/\\/g, '/') - const parts = pathOnly.split('/').filter((part) => part && part !== '.') - // Why: declared icon hrefs are repo content. Never let a best-effort icon - // probe resolve outside the worktree through `../` path segments. - if (parts.length === 0 || parts.some((part) => part === '..')) { - return null - } - return parts.join('/') -} - -function iconHrefCandidates(href: string): string[] { - const clean = normalizeIconHrefPath(href) - return clean ? [`public/${clean}`, clean] : [] -} - async function readLocalPngIcon(repoPath: string, relativePath: string): Promise { const filePath = joinWorktreeRelativePath(repoPath, relativePath) const info = await stat(filePath) @@ -167,7 +147,7 @@ async function detectLocalPngIcon(repoPath: string): Promise { if (!href) { continue } - for (const relativePath of iconHrefCandidates(href)) { + for (const relativePath of iconHrefCandidates(href, sourceFile)) { try { const icon = await readLocalPngIcon(repoPath, relativePath) if (icon) { @@ -213,7 +193,7 @@ async function detectRemotePngIcon( if (!href) { continue } - for (const relativePath of iconHrefCandidates(href)) { + for (const relativePath of iconHrefCandidates(href, sourceFile)) { try { const icon = await readRemotePngIcon(repoPath, fsProvider, relativePath) if (icon) { diff --git a/src/main/repo-icon-href-candidates.ts b/src/main/repo-icon-href-candidates.ts new file mode 100644 index 000000000..4e2c52acc --- /dev/null +++ b/src/main/repo-icon-href-candidates.ts @@ -0,0 +1,38 @@ +import { posix } from 'path' + +function normalizeIconHrefPath(href: string): { path: string; rootRelative: boolean } | null { + const trimmed = href.trim() + if (!trimmed || trimmed.startsWith('//') || /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) { + return null + } + + const rootRelative = trimmed.startsWith('/') + const pathOnly = (trimmed.split(/[?#]/)[0] ?? '').replace(/^\/+/, '').replace(/\\/g, '/') + const parts = pathOnly.split('/').filter((part) => part && part !== '.') + // Why: declared icon hrefs are repo content. Never let a best-effort icon + // probe resolve outside the worktree through `../` path segments. + if (parts.length === 0 || parts.some((part) => part === '..')) { + return null + } + return { path: parts.join('/'), rootRelative } +} + +export function iconHrefCandidates(href: string, sourceFile: string): string[] { + const clean = normalizeIconHrefPath(href) + if (!clean) { + return [] + } + + const candidates = new Set() + if (!clean.rootRelative) { + const sourceDirectory = posix.dirname(sourceFile) + if (sourceDirectory && sourceDirectory !== '.') { + // Why: relative hrefs in nested route/root files resolve next to that + // source file, not from the repository root. + candidates.add(posix.join(sourceDirectory, clean.path)) + } + } + candidates.add(`public/${clean.path}`) + candidates.add(clean.path) + return [...candidates] +}