sitetile: every built site gets a favicon and apple-touch-icon (tile-lab#5) - #5
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes tile-lab#5.
The defect
A built site answered 404 for
/favicon.ico,/favicon.svgand/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:/favicon.ico/favicon.svg/apple-touch-icon.pngSiteLayouttakes 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-iconis now linked on every page rather than only on PWA sites.An owner-supplied mark wins
favicon:(falling back tosite-logo:/footer-logo:, unchanged order) is whatrel=iconlinks — only the mark, because tworel=iconlinks let each browser's preference order pick which brand a visitor sees.public/favicon.ico/public/apple-touch-icon.pngwins the file: Astro skips a route whose name apublic/file already claims (verified against a real build, it says so in the log). That is also why REEF with PWA'sgen-icons.shoutput 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: #rrggbboverrides; 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-jsare 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:zliband 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 setfavicon: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 againstnode:zlib's owncrc32— 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.icodirectory 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.build-og's shape: no page may claim an icon that is not on disk. The fixtures set nofavicon:, so what the smoke builds is the born-valid path.bash scripts/test.sh→ exit 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