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'); +});