refactor: replace SVG sprite with client-side icon fetching and Rust caching

Eliminates server-side batch rendering and IconSprite pattern in favor of:
- Rust handler with moka cache (10k icons, 24h TTL, immutable HTTP headers)
- Client Icon component fetches SVGs on-demand with shimmer loading states
- Removes renderIconsBatch, tag-icons collection logic, and page load icon preprocessing
- Reduces SSR complexity and data serialization overhead
This commit is contained in:
2026-01-15 10:33:43 -06:00
parent 935c5e6475
commit 8aa14a2cab
26 changed files with 327 additions and 441 deletions
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts">
interface Props {
icon: string; // "collection:name" format, e.g., "lucide:home"
class?: string;
size?: string; // Tailwind size class, e.g., "size-4"
}
let { icon, class: className = "", size = "size-4" }: Props = $props();
let svg = $state<string | null>(null);
let loading = $state(true);
let error = $state(false);
// Validate and parse icon identifier into collection and name
const iconParts = $derived.by(() => {
const colonIndex = icon.indexOf(":");
if (
colonIndex === -1 ||
colonIndex === 0 ||
colonIndex === icon.length - 1
) {
console.warn(
`Invalid icon identifier: "${icon}" (expected "collection:name" format)`,
);
return null;
}
return {
collection: icon.slice(0, colonIndex),
name: icon.slice(colonIndex + 1),
};
});
// Fetch icon when identifier changes
$effect(() => {
const parts = iconParts;
if (!parts) {
error = true;
loading = false;
return;
}
const url = `/api/icons/${parts.collection}/${parts.name}.svg`;
loading = true;
error = false;
svg = null;
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`Icon not found: ${icon}`);
return res.text();
})
.then((svgText) => {
svg = svgText;
loading = false;
})
.catch(() => {
error = true;
loading = false;
});
});
</script>
{#if loading}
<!-- Shimmer placeholder - reserves space to prevent layout shift -->
<span
class="inline-block {size} animate-pulse rounded bg-zinc-200 dark:bg-zinc-700"
aria-hidden="true"
></span>
{:else if error}
<!-- Error fallback - subtle empty indicator -->
<span class="inline-block {size} rounded opacity-30" aria-hidden="true"
></span>
{:else if svg}
<!-- Render SVG inline - [&>svg]:size-full makes SVG fill container -->
<span
class="inline-flex items-center justify-center {size} {className} [&>svg]:size-full"
aria-hidden="true"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- SVG from our API (trusted @iconify/json) -->
{@html svg}
</span>
{/if}
-50
View File
@@ -1,50 +0,0 @@
<script lang="ts" module>
/**
* Convert icon identifier to valid HTML ID.
* "simple-icons:rust" → "icon-simple-icons-rust"
*/
export function toSymbolId(identifier: string): string {
return `icon-${identifier.replace(/:/g, "-")}`;
}
</script>
<script lang="ts">
interface Props {
icons: Record<string, string>;
}
let { icons }: Props = $props();
/**
* Extract the inner content and viewBox from an SVG string.
* Input: '<svg viewBox="0 0 24 24" ...>content</svg>'
* Output: { viewBox: "0 0 24 24", content: "content" }
*/
function parseSvg(svg: string): { viewBox: string; content: string } {
// Extract viewBox attribute
const viewBoxMatch = svg.match(/viewBox=["']([^"']+)["']/);
const viewBox = viewBoxMatch?.[1] ?? "0 0 24 24";
// Extract content between <svg...> and </svg>
const contentMatch = svg.match(/<svg[^>]*>([\s\S]*)<\/svg>/);
const content = contentMatch?.[1] ?? "";
return { viewBox, content };
}
</script>
<!--
Hidden SVG sprite containing all icon definitions as symbols.
Icons are referenced elsewhere via <use href="#icon-{identifier}" />
-->
<svg style="display: none;" aria-hidden="true">
<defs>
{#each Object.entries(icons) as [id, svg] (id)}
{@const parsed = parseSvg(svg)}
<symbol id={toSymbolId(id)} viewBox={parsed.viewBox}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html parsed.content}
</symbol>
{/each}
</defs>
</svg>
+2 -6
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn } from "$lib/utils";
import { toSymbolId } from "./IconSprite.svelte";
import Icon from "./Icon.svelte";
interface Props {
name: string;
@@ -20,11 +20,7 @@
{#snippet iconAndName()}
{#if icon}
<span class="size-4.25 sm:size-3.75">
<svg class="w-full h-full" aria-hidden="true">
<use href="#{toSymbolId(icon)}" />
</svg>
</span>
<Icon {icon} size="size-4.25 sm:size-3.75" />
{/if}
<span>{name}</span>
{/snippet}
+1 -131
View File
@@ -24,9 +24,6 @@ const PRE_CACHE_COLLECTIONS = [
"feather",
];
// Default fallback icon
const DEFAULT_FALLBACK_ICON: IconIdentifier = "lucide:help-circle";
/**
* Parse icon identifier into collection and name
*/
@@ -143,134 +140,7 @@ function renderIconData(
}
/**
* Render the default fallback icon (internal helper)
*/
async function renderFallbackIcon(
options: IconRenderOptions,
): Promise<string | null> {
const parsed = parseIdentifier(DEFAULT_FALLBACK_ICON);
if (!parsed) return null;
const iconSet = await loadCollection(parsed.collection);
if (!iconSet) return null;
const iconData = getIconData(iconSet, parsed.name);
if (!iconData) return null;
return renderIconData(iconData, options);
}
/**
* Render multiple icons efficiently in a single batch.
* Groups icons by collection, loads each collection once, then renders all icons.
*
* @param identifiers - Array of icon identifiers (e.g., ["lucide:home", "simple-icons:github"])
* @param options - Render options applied to all icons
* @returns Map of identifier to rendered SVG string (missing icons get fallback)
*/
export async function renderIconsBatch(
identifiers: string[],
options: IconRenderOptions = {},
): Promise<Map<string, string>> {
const results = new Map<string, string>();
if (identifiers.length === 0) {
return results;
}
// Parse and group by collection
const byCollection = new Map<
string,
{ identifier: string; name: string }[]
>();
const invalidIdentifiers: string[] = [];
for (const identifier of identifiers) {
const parsed = parseIdentifier(identifier);
if (!parsed) {
invalidIdentifiers.push(identifier);
continue;
}
const group = byCollection.get(parsed.collection) || [];
group.push({ identifier, name: parsed.name });
byCollection.set(parsed.collection, group);
}
if (invalidIdentifiers.length > 0) {
logger.warn("Invalid icon identifiers in batch", {
identifiers: invalidIdentifiers,
});
}
// Load all needed collections in parallel
const collections = Array.from(byCollection.keys());
const loadedCollections = await Promise.all(
collections.map(async (collection) => ({
collection,
iconSet: await loadCollection(collection),
})),
);
// Build lookup map
const collectionMap = new Map<string, IconifyJSON>();
for (const { collection, iconSet } of loadedCollections) {
if (iconSet) {
collectionMap.set(collection, iconSet);
}
}
// Render all icons
const missingIcons: string[] = [];
for (const [collection, icons] of byCollection) {
const iconSet = collectionMap.get(collection);
if (!iconSet) {
missingIcons.push(...icons.map((i) => i.identifier));
continue;
}
for (const { identifier, name } of icons) {
const iconData = getIconData(iconSet, name);
if (!iconData) {
missingIcons.push(identifier);
continue;
}
try {
const svg = renderIconData(iconData, options);
results.set(identifier, svg);
} catch (error) {
logger.warn("Failed to render icon", {
identifier,
error: error instanceof Error ? error.message : String(error),
});
missingIcons.push(identifier);
}
}
}
// Add fallback for missing icons
if (missingIcons.length > 0) {
logger.warn("Icons not found in batch, using fallback", {
missing: missingIcons,
fallback: DEFAULT_FALLBACK_ICON,
});
// Render fallback icon once
const fallbackSvg = await renderFallbackIcon(options);
if (fallbackSvg) {
for (const identifier of missingIcons) {
results.set(identifier, fallbackSvg);
}
}
}
return results;
}
/**
* Get single icon data (for API endpoint use only)
* Get single icon data (for API endpoint use - IconPicker)
*/
export async function getIconForApi(identifier: string): Promise<{
identifier: string;
-37
View File
@@ -1,37 +0,0 @@
import { renderIconsBatch } from "./icons";
import type { AdminTag } from "$lib/admin-types";
/**
* Collect and render icons from an array of tags.
* Returns a record mapping icon identifiers to rendered SVG strings.
*
* @param tags - Array of tags to extract icons from
* @returns Record of icon identifier to SVG string
*/
export async function collectTagIcons(
tags: AdminTag[],
): Promise<Record<string, string>> {
// Collect unique icon identifiers
const iconIds = new Set<string>();
for (const tag of tags) {
if (tag.icon) {
iconIds.add(tag.icon);
}
}
// Return early if no icons
if (iconIds.size === 0) {
return {};
}
// Batch render all icons
const iconsMap = await renderIconsBatch([...iconIds]);
// Convert Map to plain object for serialization
const icons: Record<string, string> = {};
for (const [id, svg] of iconsMap) {
icons[id] = svg;
}
return icons;
}
+1 -31
View File
@@ -1,6 +1,5 @@
import type { PageServerLoad } from "./$types";
import { apiFetch } from "$lib/api.server";
import { renderIconsBatch } from "$lib/server/icons";
import type { AdminProject } from "$lib/admin-types";
export const load: PageServerLoad = async ({ fetch, parent }) => {
@@ -10,37 +9,8 @@ export const load: PageServerLoad = async ({ fetch, parent }) => {
const projects = await apiFetch<AdminProject[]>("/api/projects", { fetch });
// Collect all unique icon identifiers for batch rendering
const iconIds = new Set<string>();
// Collect tag icons
for (const project of projects) {
for (const tag of project.tags) {
if (tag.icon) {
iconIds.add(tag.icon);
}
}
}
// Collect social link icons
for (const link of settings.socialLinks) {
if (link.icon) {
iconIds.add(link.icon);
}
}
// Batch render all icons (single size, CSS handles scaling)
const iconsMap = await renderIconsBatch([...iconIds]);
// Convert Map to plain object for serialization
const icons: Record<string, string> = {};
for (const [id, svg] of iconsMap) {
icons[id] = svg;
}
return {
projects,
icons,
socialLinksWithIcons: settings.socialLinks,
socialLinks: settings.socialLinks,
};
};
+17 -20
View File
@@ -3,14 +3,14 @@
import { page } from "$app/state";
import ProjectCard from "$lib/components/ProjectCard.svelte";
import DiscordProfileModal from "$lib/components/DiscordProfileModal.svelte";
import IconSprite, { toSymbolId } from "$lib/components/IconSprite.svelte";
import Icon from "$lib/components/Icon.svelte";
import { telemetry } from "$lib/telemetry";
import type { PageData } from "./$types";
import MaterialSymbolsVpnKey from "~icons/material-symbols/vpn-key";
let { data }: { data: PageData } = $props();
const projects = $derived(data.projects);
const socialLinks = $derived(data.socialLinksWithIcons);
const socialLinks = $derived(data.socialLinks);
// Filter visible social links
const visibleSocialLinks = $derived(
@@ -26,9 +26,6 @@
}
</script>
<!-- Icon sprite containing all unique icons for symbol references -->
<IconSprite icons={data.icons} />
<main class="page-main overflow-x-hidden font-schibsted">
<div class="flex items-center flex-col pt-14">
<div
@@ -63,11 +60,11 @@
onclick={() => trackSocialClick(link.value)}
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500 cursor-pointer"
>
<span class="size-4 text-zinc-600 dark:text-zinc-300">
<svg class="w-full h-full" aria-hidden="true">
<use href="#{toSymbolId(link.icon)}" />
</svg>
</span>
<Icon
icon={link.icon}
size="size-4"
class="text-zinc-600 dark:text-zinc-300"
/>
<span
class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
>{link.label}</span
@@ -83,11 +80,11 @@
openDiscordModal(link.value);
}}
>
<span class="size-4 text-zinc-600 dark:text-zinc-300">
<svg class="w-full h-full" aria-hidden="true">
<use href="#{toSymbolId(link.icon)}" />
</svg>
</span>
<Icon
icon={link.icon}
size="size-4"
class="text-zinc-600 dark:text-zinc-300"
/>
<span
class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
>{link.label}</span
@@ -100,11 +97,11 @@
onclick={() => trackSocialClick(`mailto:${link.value}`)}
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-500 cursor-pointer"
>
<span class="size-4.5 text-zinc-600 dark:text-zinc-300">
<svg class="w-full h-full" aria-hidden="true">
<use href="#{toSymbolId(link.icon)}" />
</svg>
</span>
<Icon
icon={link.icon}
size="size-4.5"
class="text-zinc-600 dark:text-zinc-300"
/>
<span
class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
>{link.label}</span
@@ -1,17 +1,11 @@
import type { PageServerLoad } from "./$types";
import { apiFetch } from "$lib/api.server";
import { collectTagIcons } from "$lib/server/tag-icons";
import type { AdminProject } from "$lib/admin-types";
export const load: PageServerLoad = async ({ fetch }) => {
const projects = await apiFetch<AdminProject[]>("/api/projects", { fetch });
// Collect all tag icons across all projects
const allTags = projects.flatMap((project) => project.tags);
const icons = await collectTagIcons(allTags);
return {
projects,
icons,
};
};
@@ -2,7 +2,6 @@
import Button from "$lib/components/admin/Button.svelte";
import Table from "$lib/components/admin/Table.svelte";
import TagChip from "$lib/components/TagChip.svelte";
import IconSprite from "$lib/components/IconSprite.svelte";
import { goto } from "$app/navigation";
import type { PageData } from "./$types";
import type { ProjectStatus } from "$lib/admin-types";
@@ -63,8 +62,6 @@
<title>Projects | Admin</title>
</svelte:head>
<IconSprite icons={data.icons} />
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
@@ -1,11 +1,6 @@
import type { PageServerLoad } from "./$types";
import { apiFetch } from "$lib/api.server";
import { collectTagIcons } from "$lib/server/tag-icons";
import type {
AdminProject,
AdminTagWithCount,
AdminTag,
} from "$lib/admin-types";
import type { AdminProject, AdminTagWithCount } from "$lib/admin-types";
export const load: PageServerLoad = async ({ params, fetch }) => {
const { id } = params;
@@ -16,16 +11,8 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
apiFetch<AdminTagWithCount[]>("/api/tags", { fetch }),
]);
// Collect icons for sprite (from available tags + project tags)
const allTags: AdminTag[] = [...availableTags];
if (project) {
allTags.push(...project.tags);
}
const icons = await collectTagIcons(allTags);
return {
project,
availableTags,
icons,
};
};
@@ -3,7 +3,6 @@
import { resolve } from "$app/paths";
import ProjectForm from "$lib/components/admin/ProjectForm.svelte";
import Modal from "$lib/components/admin/Modal.svelte";
import IconSprite from "$lib/components/IconSprite.svelte";
import { updateAdminProject, deleteAdminProject } from "$lib/api";
import type { UpdateProjectData, CreateProjectData } from "$lib/admin-types";
import type { PageData } from "./$types";
@@ -61,8 +60,6 @@
<title>Edit Project | Admin</title>
</svelte:head>
<IconSprite icons={data.icons} />
<div class="max-w-3xl space-y-6">
<!-- Header -->
<div>
@@ -1,6 +1,5 @@
import type { PageServerLoad } from "./$types";
import { apiFetch } from "$lib/api.server";
import { collectTagIcons } from "$lib/server/tag-icons";
import type { AdminTagWithCount } from "$lib/admin-types";
export const load: PageServerLoad = async ({ fetch }) => {
@@ -8,11 +7,7 @@ export const load: PageServerLoad = async ({ fetch }) => {
fetch,
});
// Collect icons for sprite
const icons = await collectTagIcons(availableTags);
return {
availableTags,
icons,
};
};
@@ -2,7 +2,6 @@
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import ProjectForm from "$lib/components/admin/ProjectForm.svelte";
import IconSprite from "$lib/components/IconSprite.svelte";
import { createAdminProject } from "$lib/api";
import type { CreateProjectData } from "$lib/admin-types";
import type { PageData } from "./$types";
@@ -19,8 +18,6 @@
<title>New Project | Admin</title>
</svelte:head>
<IconSprite icons={data.icons} />
<div class="max-w-3xl space-y-6">
<!-- Header -->
<div>
@@ -1,6 +1,5 @@
import type { PageServerLoad } from "./$types";
import { apiFetch } from "$lib/api.server";
import { collectTagIcons } from "$lib/server/tag-icons";
import type { AdminTagWithCount } from "$lib/admin-types";
export const load: PageServerLoad = async ({ fetch }) => {
@@ -9,11 +8,7 @@ export const load: PageServerLoad = async ({ fetch }) => {
// Sort by project count descending (popularity)
const sortedTags = [...tags].sort((a, b) => b.projectCount - a.projectCount);
// Collect icons for sprite
const icons = await collectTagIcons(sortedTags);
return {
tags: sortedTags,
icons,
};
};
-3
View File
@@ -5,7 +5,6 @@
import ColorPicker from "$lib/components/admin/ColorPicker.svelte";
import IconPicker from "$lib/components/admin/IconPicker.svelte";
import TagChip from "$lib/components/TagChip.svelte";
import IconSprite from "$lib/components/IconSprite.svelte";
import { createAdminTag, deleteAdminTag } from "$lib/api";
import type { CreateTagData, AdminTagWithCount } from "$lib/admin-types";
import type { PageData } from "./$types";
@@ -146,8 +145,6 @@
<title>Tags | Admin</title>
</svelte:head>
<IconSprite icons={data.icons} />
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
@@ -1,6 +1,5 @@
import type { PageServerLoad } from "./$types";
import { apiFetch } from "$lib/api.server";
import { renderIconsBatch } from "$lib/server/icons";
import { error } from "@sveltejs/kit";
import type { AdminTag, AdminProject } from "$lib/admin-types";
@@ -37,30 +36,9 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
// Non-fatal - just show empty related tags
}
// Collect all unique icons
const iconIds = new Set<string>();
if (tagData.tag.icon) {
iconIds.add(tagData.tag.icon);
}
for (const tag of relatedTags) {
if (tag.icon) {
iconIds.add(tag.icon);
}
}
// Batch render all icons
const iconsMap = await renderIconsBatch([...iconIds]);
// Convert Map to plain object for serialization
const icons: Record<string, string> = {};
for (const [id, svg] of iconsMap) {
icons[id] = svg;
}
return {
tag: tagData.tag,
projects: tagData.projects,
relatedTags,
icons,
};
};
+4 -52
View File
@@ -5,7 +5,7 @@
import ColorPicker from "$lib/components/admin/ColorPicker.svelte";
import IconPicker from "$lib/components/admin/IconPicker.svelte";
import TagChip from "$lib/components/TagChip.svelte";
import IconSprite from "$lib/components/IconSprite.svelte";
import Icon from "$lib/components/Icon.svelte";
import { updateAdminTag, deleteAdminTag } from "$lib/api";
import { goto, invalidateAll } from "$app/navigation";
import type { PageData } from "./$types";
@@ -28,49 +28,6 @@
let color = $state<string | undefined>(data.tag.color);
let saving = $state(false);
// Preview icon SVG - starts with server-rendered, updates on icon change
// svelte-ignore state_referenced_locally
let previewIconSvg = $state(
data.tag.icon ? (data.icons[data.tag.icon] ?? "") : "",
);
let iconLoadTimeout: ReturnType<typeof setTimeout> | null = null;
// Watch for icon changes and fetch new preview
$effect(() => {
const currentIcon = icon;
// Clear pending timeout
if (iconLoadTimeout) {
clearTimeout(iconLoadTimeout);
}
if (!currentIcon) {
previewIconSvg = "";
return;
}
// Check if icon is already in sprite
if (data.icons[currentIcon]) {
previewIconSvg = data.icons[currentIcon];
return;
}
// Debounce icon fetching for new icons
iconLoadTimeout = setTimeout(async () => {
try {
const response = await fetch(
`/api/icons/${currentIcon.replace(":", "/")}`,
);
if (response.ok) {
const iconData = await response.json();
previewIconSvg = iconData.svg ?? "";
}
} catch {
// Keep existing preview on error
}
}, 200);
});
// Delete state
let deleteModalOpen = $state(false);
let deleteConfirmReady = $state(false);
@@ -145,8 +102,6 @@
<title>Edit {data.tag.name} | Tags | Admin</title>
</svelte:head>
<IconSprite icons={data.icons} />
<div class="space-y-6 max-w-3xl">
<!-- Back Link -->
<a
@@ -193,7 +148,7 @@
<ColorPicker bind:selectedColor={color} />
</div>
<!-- Preview - rendered inline with dynamic icon SVG -->
<!-- Preview -->
<div class="mt-6 pt-4 border-t border-admin-border">
<span class="block text-sm font-medium text-admin-text mb-2">
Preview
@@ -202,11 +157,8 @@
class={tagBaseClasses}
style="border-left-color: #{color || '06b6d4'}"
>
{#if previewIconSvg}
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html previewIconSvg}
</span>
{#if icon}
<Icon {icon} size="size-4.25 sm:size-3.75" />
{/if}
<span>{name || "Tag Name"}</span>
</span>
@@ -1,12 +1,8 @@
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { requireAuth } from "$lib/server/auth";
import { getIconForApi } from "$lib/server/icons";
export const GET: RequestHandler = async (event) => {
// Require authentication
requireAuth(event);
const { collection, name } = event.params;
const identifier = `${collection}:${name}`;