fix: resolve nested repo icon hrefs (#4305)
This commit is contained in:
parent
b3f4d50036
commit
7c13205ec7
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
Loading…
Reference in New Issue