refactor: draw the project card's icons from the ones we already ship

The card fetched each integration icon from the hub as SVG markup, sanitized
it and injected it with `{@html}`. The hub renders those icons out of
`@windmill-labs/components` — this frontend's own package — so it was a
cross-origin round trip to get our own assets back, and it made the card
depend on a read that a hub with `API_SECRET` set refuses outright.

`hubAppIcon` resolves them through `appIconComponent` instead, so they are
components again: no fetch, no DOMPurify, no `{@html}`, and they paint on
first render rather than after a round trip. Integration icons now show even
against a gated hub; only the summary and the uploaded logo still need it.

The one thing the hub was doing for us was resolving `postgres` to the
`postgresql` mark, which its `aliasApp` bridges and our icon map does not —
so that single alias comes along, next to a note pointing at its counterpart.

`ImportProjectSummary.hub` goes with it: it existed to build icon URLs and
nothing read it afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-08-21 15:29:43 +02:00
parent f7cc582e0a
commit ea31f73ed3
2 changed files with 35 additions and 42 deletions
@@ -10,17 +10,14 @@
logoUrl?: string
/** Integration slugs to draw, most representative first. */
iconApps: string[]
/** Where the icons and the logo are fetched from. */
hub: string
counts: { apps: number; flows: number; scripts: number; resources: number }
}
</script>
<script lang="ts">
import { resource } from 'runed'
import { ExternalLink, LayoutGrid } from 'lucide-svelte'
import ProjectContentBadges from '$lib/components/ProjectContentBadges.svelte'
import { fetchAppIcon } from '$lib/hubProject'
import { hubAppIcon } from '$lib/hubProject'
interface Props {
project: ImportProjectSummary
@@ -35,20 +32,15 @@
// scheme the page itself was served over.
const hubProjectUrl = $derived(`//${hubHost}/projects/${project.slug}`)
// Icons come from the hub rather than a local map: it ships ~270 of them and is
// the thing that knows which slug an integration is filed under. They arrive as
// SVG markup so `fill="currentColor"` still resolves against this page.
// Keyed on a string so "same icons" is an equality the resource can see; the
// fetcher reads `project`, which the key changes with.
const iconKey = $derived(`${project.hub}|${project.iconApps.slice(0, 4).join(',')}`)
const iconResource = resource(
() => iconKey,
async () =>
(
await Promise.all(project.iconApps.slice(0, 4).map((a) => fetchAppIcon(project.hub, a)))
).filter((s): s is string => !!s)
// Resolved locally rather than fetched: these are Windmill's own bundled icons, so the
// card draws them synchronously instead of waiting on the hub — and keeps working on a
// hub that refuses uncredentialed reads.
const icons = $derived(
project.iconApps
.slice(0, 4)
.map(hubAppIcon)
.filter((c): c is NonNullable<typeof c> => !!c)
)
const icons = $derived(iconResource.current ?? [])
// The icon row shows the integrations the tile is not already showing: with an
// uploaded logo the tile shows none of them, so the row shows them all.
@@ -66,10 +58,10 @@
{#if project.logoUrl}
<img src={project.logoUrl} alt="" class="max-h-10 max-w-10 object-contain" />
{:else if icons[0]}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<span class="inline-flex h-7 w-7 text-primary [&>svg]:h-full [&>svg]:w-full"
>{@html icons[0]}</span
>
{@const Icon = icons[0]}
<span class="inline-flex h-7 w-7 text-primary [&>svg]:h-full [&>svg]:w-full">
<Icon size={28} />
</span>
{:else}
<LayoutGrid size={22} class="text-secondary" />
{/if}
@@ -107,11 +99,10 @@
<!-- The integrations, minus whichever one is already standing in as the logo. -->
{#if restIcons.length > 0}
<div class="flex shrink-0 items-center gap-1.5 pt-0.5">
{#each restIcons as svg, i (i)}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<span class="inline-flex h-4 w-4 text-primary opacity-80 [&>svg]:h-full [&>svg]:w-full"
>{@html svg}</span
>
{#each restIcons as Icon, i (i)}
<span class="inline-flex h-4 w-4 text-primary opacity-80 [&>svg]:h-full [&>svg]:w-full">
<Icon size={16} />
</span>
{/each}
</div>
{/if}
+18 -16
View File
@@ -1,4 +1,5 @@
import DOMPurify from 'dompurify'
import type { Component } from 'svelte'
import { appIconComponent } from '$lib/components/icons'
import { SettingService } from '$lib/gen'
import { DEFAULT_HUB_BASE_URL } from '$lib/hub'
import type { ImportProjectSummary } from '$lib/components/ImportProjectCard.svelte'
@@ -58,7 +59,6 @@ export async function fetchHubProject(slug: string): Promise<ImportProjectSummar
iconApps: [p.logoApp, ...(p.apps ?? [])].filter(
(a, i, all): a is string => !!a && all.indexOf(a) === i
),
hub,
counts: {
apps: p.counts?.apps ?? 0,
flows: p.counts?.flows ?? 0,
@@ -86,18 +86,20 @@ export async function fetchHubProject(slug: string): Promise<ImportProjectSummar
* including moving or hiding the wizard's own Import and Delete controls — and
* `<image href="https://…">` is a page-view beacon pointed at whoever it likes.
*/
export async function fetchAppIcon(hub: string, app: string): Promise<string | undefined> {
try {
const res = await fetch(`${hub}/icons/integrations/${encodeURIComponent(app)}.svg`)
if (!res.ok) return undefined
const raw = (await res.text()).trim()
if (!raw.startsWith('<svg')) return undefined
const clean = DOMPurify.sanitize(raw, {
USE_PROFILES: { svg: true, svgFilters: true },
FORBID_TAGS: ['style', 'image']
}).trim()
return clean.startsWith('<svg') ? clean : undefined
} catch {
return undefined
}
/**
* The icon for a hub integration slug, resolved from the icons Windmill already bundles.
*
* Not fetched from the hub: the hub renders these out of `@windmill-labs/components`, which
* is this frontend's own package, so asking it over HTTP is a round trip to get our own
* assets back — and it made the card depend on a cross-origin request that an `API_SECRET`
* hub refuses anyway.
*
* The alias exists because the two repos disagree on one slug: the hub files Postgres scripts
* under `postgres`, the icon set ships the mark as `postgresql`. The hub bridges it in
* `aliasApp`; this is the same bridge on the consuming side.
*/
const HUB_APP_ICON_ALIAS: Record<string, string> = { postgres: 'postgresql' }
export function hubAppIcon(app: string): Component | undefined {
return appIconComponent(HUB_APP_ICON_ALIAS[app] ?? app)
}