fix: resolve nested repo icon hrefs (#4305)

This commit is contained in:
Neil 2026-05-31 11:46:24 -07:00 committed by GitHub
parent b3f4d50036
commit 7c13205ec7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 61 additions and 23 deletions

View File

@ -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(

View File

@ -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<RepoIcon | null> {
const filePath = joinWorktreeRelativePath(repoPath, relativePath)
const info = await stat(filePath)
@ -167,7 +147,7 @@ async function detectLocalPngIcon(repoPath: string): Promise<RepoIcon | null> {
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) {

View File

@ -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<string>()
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]
}