Skip to content

Logos and media

Markdown

A listing’s logo arrives one of three ways: in the logo field of its Markdown file (files mode), seeded into R2 by pnpm db:seed:local or pnpm db:seed:remote (D1 mode), or uploaded by a submitter on /submit/. A null logo becomes a letter tile with the name’s initial.

src/content/listings/acme.md
---
name: Acme
url: https://acme.com
logo: /logos/acme.svg
# the rest of the frontmatter is unchanged
---

logo is an https:// URL or a /-rooted path under public/. Anything else — ./acme.png, logos/acme.png, http:// — fails the schema and the build stops, naming the file. Leave the key out, or write logo: null, for the letter tile.

logo Rendered as
/logos/acme.svg a plain <img>, never fetched at build
https://acme.com/logo.png the same, from that host
null a tile with the initial of name (? when empty)

A card shows the tile at 48 px, the detail page at 128 px; the image is always 128×128.

Cards with logo tiles and letter tiles

Terminal
pnpm db:seed:local --with-images --with-favicons

db:seed:local writes the local database and bucket, db:seed:remote the production ones. The bucket is the MEDIA binding in wrangler.jsonc; without one the script stops: wrangler.jsonc has no r2_buckets entry bound as MEDIA; --with-images needs one.

Three passes run in order, each skipping rows that already have a key, so an explicit URL beats a fetched icon, which beats a letter tile.

Pass Flag Uploads
1 --with-images each https:// logo in the dataset, downloaded once
2 --with-favicons for each listing still without a key, its site’s own icon
3 --with-images each /logos/<file> from the dataset’s logos/ directory

A favicon comes from the page’s <link rel="…icon…"> tags — storable formats ahead of .ico, larger first — then from /apple-touch-icon.png, /apple-touch-icon-precomposed.png, /favicon.svg, /favicon.png and /favicon.ico: at most 8 candidates, 12 s and 3 SSRF-checked redirects each, pages read up to 512 KiB, images over limits.logoMaxBytes refused. An .ico is decoded to the PNG of its largest frame of 32 px or more.

Objects are keyed logos/<id>.<ext>, <id> a UUID derived from the slug, so a re-seed overwrites its own objects; each is stored with Cache-Control: public, max-age=300, short for the same reason. A D1-mode build turns each key into media.baseUrl + / + key, so media.baseUrl must be the bucket’s public host, https:// without a trailing slash; each non-SVG logo there becomes one 128×128 webp.

db:seed — src/content/listings → D1 (local)
downloaded N/M logo(s); uploading to R2...
stored N dataset logo URL(s) in R2 in 9s (not stored: unreachable=2)
downloaded N/M favicon(s); uploading to R2...
fetched N favicon(s) into R2 in 48s (no icon: unreachable=3, not-an-image=1)
uploaded N /logos/ file(s) to R2
N listing(s), 0 click row(s) in K statement(s); largest B bytes (limit L)
seeded N listing(s) into the local database

--dry-run prints the SQL, touches nothing and skips both passes: --with-images ignored in a dry run (nothing is uploaded, logo_key stays NULL).

The form’s optional file field accepts PNG, JPEG, WebP and SVG, with the hint PNG, JPEG, WebP or SVG, up to 512 KB. Optional — we use the one on your site if you have none. — 512 being limits.logoMaxBytes in KB. A hidden logoUrls field carries the icons the metadata fetcher found.

The server ignores the file’s name, extension and declared type: it refuses a file over the limit unread, then sniffs the bytes — PNG, JPEG, WebP, SVG without <script>, or ICO (absent from the accept list) decoded to PNG. The ceiling is 1 MiB.

The upload is tried first, then up to three logoUrls candidates through the metadata fetcher’s SSRF list. Each failure is one warning — submit: uploaded logo rejected (too-large), submit: remote logo not stored (unreachable) — and a null logo_key; the submission is stored either way, and without a MEDIA binding the listing has no logo. The object is logos/<id>.<ext>, <id> random. /admin/listings/<id>/ shows it as the public page will; deleting says 1 object in R2, deleted with the row. See The submission form and The admin.

File What it is Change it
public/favicon.svg the browser icon and, through site.logo: '/favicon.svg', the header mark and Organization.logo in the JSON-LD replace the file, or point site.logo elsewhere under public/
public/og-default.png the OG image of any page without one, and of every page under seo.ogImages: 'static' (the free edition) pnpm og:default renders it; --check exits 1 when it is stale

No apple-touch-icon or web manifest exists to maintain. Generated images are on OG images.

Where You should see
A files-mode logo pnpm dev, then a listing page the file from public/logos/ at 128 px; 48 px on the index
A seeded logo curl -sI https://<media host>/logos/<id>.png 200, content-type: image/png, cache-control: public, max-age=300
The build’s view of R2 pnpm check:config --r2-logos logos optimised from R2 (+1 webp each), not served directly
A submitted logo /admin/listings/<id>/, then the listing page after approval the picture, in the preview then live

/admin/ reports nothing about media: its report covers secrets, email, payments and rebuilds.

Config state The visitor sees /admin/ reports Fix
media.baseUrl left at https://media.example.com files mode: nothing. D1 mode: the build stops with Failed to load remote image https://media.example.com/logos/<id>.png nothing set it to the bucket’s public host, rebuild
media.baseUrl on a host that is not the bucket the same: a PNG, JPEG or WebP key stops the build; an SVG key is a broken-image box nothing the host where curl -sI answers 200
MEDIA binding missing submissions work; the listing gets a letter tile; the seed refuses (message above) nothing add the r2_buckets entry; pnpm cf:setup creates the bucket
A logo URL that 404s a broken-image box; nothing catches it. The OG renderer logs [zerodirs:og] logo <url>: HTTP 404; using the initial-letter tile nothing fix the URL or set logo: null (murf-ai in the demo is broken on purpose)
An SVG on the media host a plain <img>; converting it would stop the build with UnsupportedImageFormat nothing expected
A logo over limits.logoMaxBytes upload rejected, candidates tried, then the letter tile; the seed counts it under too-large nothing raise the limit (ceiling 1 MiB) or shrink the file
Field Default What it changes
media.baseUrl https://media.example.com the host every logo_key becomes a URL on
limits.logoMaxBytes 512 * 1024 the cap on an uploaded, fetched or seeded logo
limits.screenshotMaxBytes 2 * 1024 * 1024 the cap on one submitted screenshot
site.logo unset the header mark, a /-rooted path under public/
listing.screenshotMax 3 screenshots per listing

See media, limits, site and listing.

  • Directorysrc/
    • content/schema.ts the logo rule
    • components/listing/Logo.astro the rendering point
    • lib/media.ts mediaUrl(), isOptimizableLogo()
    • server/media.ts sniffing, putLogo(), fetchLogo()
    • lib/ico.ts ICO to PNG
  • Directoryscripts/
    • seed.ts the three upload passes
    • favicon.ts the favicon fetcher
    • og-default.ts pnpm og:default
  • Directorypublic/
    • favicon.svg the browser icon
    • og-default.png the OG fallback
    • Directorylogos/ the shipped letter tiles
  • wrangler.jsonc the MEDIA binding