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
103 changes: 103 additions & 0 deletions apps/server/src/project/ProjectFaviconResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,109 @@ 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", "<svg>brand</svg>");

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", "<svg>brand</svg>");

const resolved = yield* resolver.resolvePath(cwd);

expect(resolved).not.toBeNull();
expect(resolved).toContain("public/brand/logo.svg");
}),
);

it.effect("resolves object-literal icon metadata alongside nested objects", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(
cwd,
"src/root.tsx",
`const links = [{ attributes: {}, rel: "icon", href: "/brand/logo.svg" }];`,
);
yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>");

const resolved = yield* resolver.resolvePath(cwd);

expect(resolved).not.toBeNull();
expect(resolved).toContain("public/brand/logo.svg");
}),
);

it.effect("skips icon metadata without an href and keeps scanning", () =>
Effect.gen(function* () {
const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(
cwd,
"src/root.tsx",
`const links = [{ rel: "icon" }, { rel: "icon", href: "/brand/logo.svg" }];`,
);
yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>");

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 = `<p>${"pokopia companion guide ".repeat(24)}</p>\n`;
yield* writeTextFile(
cwd,
"index.html",
`<!doctype html><html><head><title>guide</title></head><body>\n${filler.repeat(1200)}</body></html>`,
);

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;
Expand Down
16 changes: 12 additions & 4 deletions apps/server/src/project/ProjectFaviconResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,13 @@ const ICON_SOURCE_FILES = [
] as const;

// Matches <link ...> tags or object-like icon metadata where rel/href can appear in any order.
// The tag pattern is anchored on `<link`, so it only starts at real candidates. Object metadata
// is matched by scanning brace-free runs instead of by one combined pattern: an unanchored
// pattern restarts at every offset and rescans forward, which is quadratic on large sources.
const LINK_ICON_HTML_RE =
/<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i;
const LINK_ICON_OBJ_RE =
/(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i;
const ICON_REL_RE = /\brel\s*:\s*["'](?:icon|shortcut icon)["']/i;
const ICON_HREF_RE = /\bhref\s*:\s*["']([^"'?]+)/i;

export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass<ProjectFaviconResolutionError>()(
"ProjectFaviconResolutionError",
Expand Down Expand Up @@ -98,8 +101,13 @@ export class ProjectFaviconResolver extends Context.Service<
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];
// Icon metadata counts when `rel` and `href` share a brace-free run, so a run holding `rel`
// but no href falls through to the next one rather than ending the search.
for (const run of source.split("}")) {
if (!ICON_REL_RE.test(run)) continue;
const hrefMatch = run.match(ICON_HREF_RE);
if (hrefMatch?.[1]) return hrefMatch[1];
}
return null;
}

Expand Down
Loading