Skip to content

sitetile: every built site gets a favicon and apple-touch-icon (tile-lab#5) - #5

Merged
cverorg merged 1 commit into
mainfrom
feat/sitetile-favicon
Aug 28, 2026
Merged

sitetile: every built site gets a favicon and apple-touch-icon (tile-lab#5)#5
cverorg merged 1 commit into
mainfrom
feat/sitetile-favicon

Conversation

@cverorg

@cverorg cverorg commented Aug 28, 2026

Copy link
Copy Markdown
Member

Closes tile-lab#5.

The defect

A built site answered 404 for /favicon.ico, /favicon.svg and /apple-touch-icon.png — the three paths a browser, a crawler and iOS all request without being told to. Measured from outside on a built site, not inferred.

The renderer has had a favicon: frontmatter field the whole time. The link was written {favicon && <link rel="icon" …>}, so a site that had never set one emitted no link either — two absences that hide each other. There was no dead link to find, which is why this survived every gate we have.

What lands

Three endpoint routes beside the other generated documents (rss.xml, manifest.webmanifest), emitted on every build, no opt-in, no frontmatter, no build flag:

path what
/favicon.ico one 32×32 PNG in an ICO container — the path a browser asks for unprompted
/favicon.svg crisp at any size, and the only one of the three that can carry any script's initial (the browser draws the text with its own fonts)
/apple-touch-icon.png 180×180, full-bleed, opaque — iOS ignores SVG here, and an icon that rounds its own corners wears black wedges

SiteLayout takes its <link rel=icon> / <link rel=apple-touch-icon> hrefs from the same model that draws the files (packages/sitetile/icon-core.mjs, aliased @icons), so a tag can never name a file the build did not emit. apple-touch-icon is now linked on every page rather than only on PWA sites.

An owner-supplied mark wins

  • favicon: (falling back to site-logo: / footer-logo:, unchanged order) is what rel=icon links — only the mark, because two rel=icon links let each browser's preference order pick which brand a visitor sees.
  • It takes the apple-touch slot too when it is a raster. An SVG mark linked there is an icon iOS silently drops, which is how a site with a perfectly good logo ends up with a screenshot on someone's home screen; those sites now get the badge instead of nothing.
  • A hand-placed public/favicon.ico / public/apple-touch-icon.png wins the file: Astro skips a route whose name a public/ file already claims (verified against a real build, it says so in the log). That is also why REEF with PWA's gen-icons.sh output keeps winning, unchanged.

The fallback badge, and what it costs

Nothing configured → the site's initial on the site's own theme-color (dark first; icon-color: #rrggbb overrides; ink is contrast-picked, never configurable — a site cannot set itself an unreadable icon). Deterministic: same site → byte-identical files, so a rebuild never churns them.

Zero new dependencies. iOS needs a real raster, and neither a native image library nor a font engine belongs on the critical path of every site build — a site build that can fail because an icon could not be drawn is a worse defect than the one being fixed. (satori/@resvg/resvg-js are already deps of the Astro layer, for OG cards; they are deliberately not used here, and og-card's own header explains why that renderer runs after the build rather than inside it.) So the PNG is drawn in-process: an analytic distance-field rasterizer over a stroked geometric alphabet, node:zlib and a CRC32 table.

The honest limitation (in README's Known limits, and asserted rather than described): that alphabet covers A–Z and 0–9; accents fold to their base letter (É → E). A site whose initial is 山, Ж or 한 gets its real character in favicon.svg — which is what browsers use for the tab — and a plain colour badge on the iOS home screen. A wrong letter would be worse than none, so nothing is guessed at, and the test asserts the blank case. Such a site can still set favicon: or hand-place its own file.

Gates

  • packages/sitetile/icon-core.test.mjs — 16 checks, deliberately about the artifacts: the PNG is decoded and looked at (IHDR really says 180×180, every chunk CRC checked against node:zlib's own crc32 — an implementation this file does not own, the corner pixel really is the site's colour, the apple-touch icon really is 100% opaque, the .ico directory entry's declared size is the real one). Ink is asserted present for a Latin initial and absent for 山 — the limitation as a control group, so the day the table starts drawing a letter that is not the site's initial, this goes red.
  • 5 new assertions in the Astro smoke, on a real build, including the dead-link gate in build-og's shape: no page may claim an icon that is not on disk. The fixtures set no favicon:, so what the smoke builds is the born-valid path.
  • Mutation-checked before commit: dropping the apple-touch link and zeroing the stroke width each turn the new gates red.

bash scripts/test.shexit 0, ✅ ALL GREEN, smoke 59/59 (Node 22, with the Astro deps installed so the smoke actually runs; CI skips it, which is why the model's own suite decodes bytes instead of trusting the build).

— KITT

Measured from outside: sites built by this renderer answered 404 for
/favicon.ico, /favicon.svg AND /apple-touch-icon.png — the three paths a
browser, a crawler and iOS all request without being told to. The site's own
`favicon:` frontmatter had existed the whole time, but the link was written
`{favicon && <link rel="icon" …>}`, so a site that had not set one emitted no
link either: two absences hiding each other, with no dead link to find.

Three routes now emit an icon set on every build, no opt-in — /favicon.ico
(32×32), /favicon.svg and /apple-touch-icon.png (180×180, opaque) — and
SiteLayout takes its <link> hrefs from the SAME model that draws them, so a tag
can never name a file the build did not emit.

An owner-supplied mark still wins: `favicon:` (or site-logo/footer-logo) is what
rel=icon links, and the apple-touch slot too when the mark is a raster (iOS
ignores SVG, which is how a site with a good logo ends up with a screenshot on
somebody's home screen). A hand-placed public/favicon.ico or
public/apple-touch-icon.png — what REEF with PWA's gen-icons.sh writes — wins the
file itself, because Astro skips a route whose name a public/ file claims.

With nothing configured, the fallback is a badge: the site's initial on the
site's own theme-color (`icon-color:` overrides), drawn in packages/sitetile/
icon-core.mjs. Zero new dependencies: iOS needs a raster and neither a native
image library nor a font engine belongs on the critical path of every site
build, so the PNG is an analytic distance-field rasterizer over a stroked
geometric alphabet plus node:zlib and a CRC32 table. Honest cost, documented in
README's Known limits and asserted by a control test: that alphabet covers A–Z
and 0–9 (accents fold to their base letter) and nothing else — a site whose
initial is 山 gets its real character in favicon.svg, which is what browsers use
for the tab, and a plain colour badge on the iOS home screen. Deterministic:
same site → byte-identical files, so a rebuild never churns them.

Guarded by icon-core.test.mjs (16 checks — the bytes are decoded and looked at:
IHDR dims, chunk CRCs against node's own crc32, corner colour, ink present for a
letter and ABSENT for 山, full opacity, an identical second render) and by five
new smoke assertions on a real build, including the dead-link gate: no page may
claim an icon that is not on disk.
@cverorg
cverorg merged commit 70122cd into main Aug 28, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant