diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 75db78844a5..6929a3e8f0e 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -133,6 +133,73 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); + it.effect("resolves icon hrefs from object-literal route metadata", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "src/routes/__root.tsx", + `export const Route = createRootRoute({ + head: () => ({ + links: [ + { rel: "stylesheet", href: "/app.css" }, + { rel: "icon", href: "/brand/logo.svg" }, + ], + }), +});`, + ); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + + it.effect("resolves object-literal icon metadata when href precedes rel", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "src/root.tsx", + `const links = [{ href: "/brand/logo.svg", rel: "shortcut icon" }];`, + ); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + + // A large icon source with no icon metadata used to pin the server's event loop for + // minutes: the object pattern was unanchored, so it restarted at every offset and + // rescanned forward from each one. Anchoring keeps this proportional to file size. + it.effect("scans large icon sources without an icon in reasonable time", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + // Mirrors a generated single-file build: large, brace-sparse, and no icon metadata. + const filler = `

${"pokopia companion guide ".repeat(24)}

\n`; + yield* writeTextFile( + cwd, + "index.html", + `guide\n${filler.repeat(1200)}`, + ); + + const startedAt = performance.now(); + const resolved = yield* resolver.resolvePath(cwd); + const elapsedMs = performance.now() - startedAt; + + expect(resolved).toBeNull(); + expect(elapsedMs).toBeLessThan(5_000); + }), + ); + it.effect("returns null when no icon is present", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 2c7195de630..533993f2be3 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -55,10 +55,12 @@ const ICON_SOURCE_FILES = [ ] as const; // Matches tags or object-like icon metadata where rel/href can appear in any order. +// Both patterns stay anchored on a literal opening delimiter (`]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; -const LINK_ICON_OBJ_RE = - /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; +const LINK_ICON_OBJ_RE = /\{[^{}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'][^{}]*\}/i; +const ICON_HREF_RE = /\bhref\s*:\s*["']([^"'?]+)/i; export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass()( "ProjectFaviconResolutionError", @@ -99,7 +101,8 @@ function extractIconHref(source: string): string | null { const htmlMatch = source.match(LINK_ICON_HTML_RE); if (htmlMatch?.[1]) return htmlMatch[1]; const objMatch = source.match(LINK_ICON_OBJ_RE); - if (objMatch?.[1]) return objMatch[1]; + const objHref = objMatch?.[0].match(ICON_HREF_RE); + if (objHref?.[1]) return objHref[1]; return null; }