From 6fce60ba72b3c5993db80867bfd127c839ca2237 Mon Sep 17 00:00:00 2001 From: Vivek Date: Mon, 10 Aug 2026 20:48:57 +0530 Subject: [PATCH] fix(gallery): use the WebJs brand favicon, not the demo placeholder #1375 fixed the blank tab by linking /icon, but that route is the metadata-route DEMO: it generates a placeholder grey "w", so the gallery rendered as a different product beside webjs.dev in a tab strip. Ship the website's committed brand assets in gallery/public/ and declare them through metadata.icons in the same shape and order the website uses, raster ahead of SVG for Google's favicon crawler. favicon.ico rides along unlinked; the framework serves it at the origin root for crawlers that read no markup, and it 404'd before. app/icon.ts stays as the dynamic-favicon demo, which is what it always was: the scaffold copies it into generated apps and gallery:clear prunes it with the other metadata routes. Its comment now says the gallery's own mark is the static one, so the route is not mistaken for live wiring. Move the test to the repo suite. It reads website/public/, which exists only here, and gallery/test/** is scaffold payload that copyGallery() copies into every generated app, where a stray directory also left gallery:clear unable to prune an empty test/ (red on main since #1375). --- gallery/app/icon.ts | 15 ++- gallery/app/layout.ts | 26 +++-- gallery/public/apple-touch-icon.png | Bin 0 -> 7238 bytes gallery/public/favicon-192.png | Bin 0 -> 7646 bytes gallery/public/favicon.ico | Bin 0 -> 15086 bytes gallery/public/favicon.svg | 23 +++++ gallery/test/seo/favicon.test.ts | 72 ------------- test/repo-health/gallery-favicon.test.mjs | 119 ++++++++++++++++++++++ 8 files changed, 171 insertions(+), 84 deletions(-) create mode 100644 gallery/public/apple-touch-icon.png create mode 100644 gallery/public/favicon-192.png create mode 100644 gallery/public/favicon.ico create mode 100644 gallery/public/favicon.svg delete mode 100644 gallery/test/seo/favicon.test.ts create mode 100644 test/repo-health/gallery-favicon.test.mjs diff --git a/gallery/app/icon.ts b/gallery/app/icon.ts index 676e391b4..65dbb2f92 100644 --- a/gallery/app/icon.ts +++ b/gallery/app/icon.ts @@ -1,9 +1,14 @@ -// app/icon.ts serves /icon (the dynamic favicon). The default export is a +// app/icon.ts serves /icon (a dynamic favicon). The default export is a // (possibly async) server function; returning a Response lets you set the exact -// content type, so an inline SVG needs no asset file. For a favicon that never -// changes, put a static file in public/ instead (e.g. public/favicon.ico) and -// delete this route. Generate it dynamically (per-theme, per-tenant) when the -// mark must be computed at request time. +// content type, so an inline SVG needs no asset file. Generate it dynamically +// (per-theme, per-tenant) when the mark must be computed at request time. +// +// This is the DEMO of that surface, not the gallery's own favicon. A metadata +// route is not auto-linked: the framework emits `` only from +// metadata.icons, so the gallery declares the static WebJs brand mark from +// public/ there (see app/layout.ts) and this route stays browsable at /icon. +// For a favicon that never changes, that static path is the one to copy; drop +// this route when your app has no request-time mark to compute. export default function Icon() { const svg = ` diff --git a/gallery/app/layout.ts b/gallery/app/layout.ts index 7e38dff90..d049da73f 100644 --- a/gallery/app/layout.ts +++ b/gallery/app/layout.ts @@ -4,13 +4,25 @@ import '#components/theme-toggle.ts'; export const metadata = { title: 'WebJs Gallery', description: 'Interactive showcase and single-concept feature gallery for WebJs applications.', - // app/icon.ts SERVES the favicon at /icon, but a metadata route is not - // auto-linked: the framework emits ` is ignored by browsers, so landing in the head - // is the assertion, not merely appearing in the document. - assert.match(link[0], /href="[^"]*\/icon"/, 'it points at the /icon route'); -}); - -test('the declared favicon URL actually serves an image', async () => { - const app = await makeHandler(); - const res = await testRequest(app.handle, '/icon'); - assert.equal(res.status, 200, '/icon answers'); - assert.match( - res.headers.get('content-type') ?? '', - /^image\//, - 'served as an image, so a browser renders it rather than downloading markup', - ); -}); - -test('the head links no favicon the app does not serve', async () => { - // The failure mode this whole file exists for is a head that names a URL - // nothing answers. Resolve every icon href the layout emits, so a later edit - // pointing at /public/favicon.svg (which the gallery has never shipped) - // fails here instead of on a live tab. - const app = await makeHandler(); - const head = (await (await testRequest(app.handle, '/')).text()).split('')[0]; - - // One pattern covering every rel the framework emits from metadata.icons: - // "icon", "shortcut icon" and "apple-touch-icon" all carry `icon` in the rel. - const hrefs = [...head.matchAll(/]*href="([^"]+)"/g)].map((m) => m[1]); - assert.ok(hrefs.length > 0, 'at least one icon is declared'); - - for (const href of new Set(hrefs)) { - // absUrl() may have made it absolute; the handler routes on the path. - const path = href.startsWith('http') ? new URL(href).pathname : href; - const res = await testRequest(app.handle, path); - assert.equal(res.status, 200, `${href} is served, not a 404`); - } -}); diff --git a/test/repo-health/gallery-favicon.test.mjs b/test/repo-health/gallery-favicon.test.mjs new file mode 100644 index 000000000..4e35320e8 --- /dev/null +++ b/test/repo-health/gallery-favicon.test.mjs @@ -0,0 +1,119 @@ +/** + * The gallery's favicon: linked, served, and the real brand mark. + * + * gallery.webjs.dev rendered with a blank tab. `gallery/app/icon.ts` answered + * /icon the whole time, but a metadata ROUTE is not auto-linked: the framework + * emits `` only from `metadata.icons` + * (packages/server/src/ssr.js), and the gallery's root layout declared only a + * title and a description. So the head named no icon, the browser fell back to + * /favicon.ico, and the gallery shipped no such file. Linking /icon then fixed + * the blank tab with the DEMO route's placeholder grey "w" rather than the + * WebJs mark webjs.dev serves, so the gallery read as a different product in a + * tab strip. + * + * Three independent things have to hold and each stayed green while another was + * broken, so all three are asserted: the head must LINK an icon, every URL it + * links must be SERVED, and the bytes must be the BRAND mark. The last is why + * the middle is not enough, since a linked-and-served placeholder is + * indistinguishable from the real thing to the request pipeline. + * + * This lives in the REPO suite rather than in `gallery/test/`, for two reasons. + * It is a cross-app assertion (it reads `website/public/`, which exists only + * here), and `gallery/test/**` is scaffold PAYLOAD: `copyGallery()` copies it + * into every generated app, where a `website/` to compare against never exists + * and where a stray directory also defeats `gallery:clear`'s prune of an empty + * `test/` (asserted by test/scaffolds/scaffold-gallery.test.js). + * + * Companion to site-seo-tags.test.mjs, which covers the website's own icons. + * That one asserts hand-written `` markup because the website writes its + * icons that way; this renders the page instead, because the gallery declares + * them through metadata.icons and there is no markup in the layout to read. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { createRequestHandler } from '@webjsdev/server'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const GALLERY = resolve(REPO_ROOT, 'gallery'); + +/** The website's committed asset, which is the canonical copy of the mark. */ +const canonical = (file) => readFileSync(resolve(REPO_ROOT, 'website', 'public', file)); + +const makeHandler = () => createRequestHandler({ appDir: GALLERY, dev: true }); + +/** Every icon href the rendered head declares, as request paths. */ +async function declaredIconPaths(handle) { + const res = await handle(new Request('http://localhost/')); + assert.equal(res.status, 200, 'the gallery home page renders'); + // A favicon in is ignored by browsers, so landing in the HEAD + // is the assertion, not merely appearing somewhere in the document. + const head = (await res.text()).split('')[0]; + // One pattern covering every rel the framework emits from metadata.icons: + // "icon", "shortcut icon" and "apple-touch-icon" all carry `icon` in the rel. + return [...head.matchAll(/]*href="([^"]+)"/g)] + // absUrl() may have made the href absolute; the handler routes on the path. + .map((m) => (m[1].startsWith('http') ? new URL(m[1]).pathname : m[1])); +} + +test('the gallery head declares a favicon', async () => { + const app = await makeHandler(); + const paths = await declaredIconPaths(app.handle); + assert.ok(paths.length > 0, 'the head emits at least one '); +}); + +test('every favicon the gallery head declares is actually served', async () => { + // The original failure mode: a head naming a URL nothing answers. Resolve + // each one rather than trusting the markup. + const app = await makeHandler(); + for (const path of new Set(await declaredIconPaths(app.handle))) { + const res = await app.handle(new Request(`http://localhost${path}`)); + assert.equal(res.status, 200, `${path} is served, not a 404`); + assert.match( + res.headers.get('content-type') ?? '', + /^image\//, + `${path} is served as an image, so a browser renders it rather than downloading markup`, + ); + } +}); + +test('the gallery answers /favicon.ico at the origin root', async () => { + // The no-markup fallback: crawlers that parse no HTML fetch this path + // directly. The framework serves public/favicon.ico from the root, so + // shipping the file is the whole wiring, and it 404'd before. + const app = await makeHandler(); + const res = await app.handle(new Request('http://localhost/favicon.ico')); + assert.equal(res.status, 200, '/favicon.ico is served'); +}); + +test('the gallery serves the same brand mark as the website', async () => { + const app = await makeHandler(); + const bytes = async (path) => + Buffer.from(await (await app.handle(new Request(`http://localhost${path}`))).arrayBuffer()); + + for (const file of ['favicon.svg', 'favicon-192.png', 'apple-touch-icon.png']) { + assert.ok( + (await bytes(`/public/${file}`)).equals(canonical(file)), + `/public/${file} is byte-identical to the website's copy`, + ); + } + assert.ok( + (await bytes('/favicon.ico')).equals(canonical('favicon.ico')), + "/favicon.ico is byte-identical to the website's copy", + ); +}); + +test('the gallery declares the raster icon ahead of the SVG', async () => { + // Same rule the website follows: Google's favicon crawler takes the first + // usable icon and renders raster reliably. metadata.icons emits array order, + // so the order in the layout is the order in the head. + const app = await makeHandler(); + const paths = await declaredIconPaths(app.handle); + const png = paths.findIndex((p) => p.endsWith('favicon-192.png')); + const svg = paths.findIndex((p) => p.endsWith('favicon.svg')); + assert.ok(png > -1 && svg > -1, 'declares both a PNG and an SVG icon'); + assert.ok(png < svg, 'the PNG is declared ahead of the SVG'); +});