Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions gallery/app/icon.ts
Original file line number Diff line number Diff line change
@@ -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 `<link rel="icon">` 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#1e2226"/>
Expand Down
26 changes: 19 additions & 7 deletions gallery/app/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<link rel="icon">` only from
// metadata.icons. Without this the head pointed at nothing and the browser
// fell back to /favicon.ico, which the gallery does not ship, so the tab
// showed no mark at all. Declare it here, never as a hand-written <link>,
// so the URL stays in one place.
icons: { icon: { url: '/icon', type: 'image/svg+xml', sizes: 'any' } },
// The WebJs brand mark, byte-identical to what webjs.dev serves, so the
// gallery reads as the same product in a tab strip rather than as a
// separate site. Declared here and never as a hand-written <link>: the
// framework emits `<link rel="icon">` only from metadata.icons, which is
// also what the scaffold's generated layout does.
//
// Raster is declared FIRST on purpose. Google's favicon crawler takes the
// first usable icon and wants a square raster whose side is a multiple of
// 48px, which is why the 192 exists (512 % 48 is 32, so the full-size mark
// does not qualify). public/favicon.ico rides along unlinked: the framework
// serves it at the origin root as the fallback for crawlers that read no
// markup at all.
icons: {
icon: [
{ url: '/public/favicon-192.png', type: 'image/png', sizes: '192x192' },
{ url: '/public/favicon.svg', type: 'image/svg+xml', sizes: 'any' },
],
apple: { url: '/public/apple-touch-icon.png', sizes: '180x180' },
},
};

export default function RootLayout({ children }: { children: unknown }) {
Expand Down
Binary file added gallery/public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added gallery/public/favicon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added gallery/public/favicon.ico
Binary file not shown.
23 changes: 23 additions & 0 deletions gallery/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
72 changes: 0 additions & 72 deletions gallery/test/seo/favicon.test.ts

This file was deleted.

119 changes: 119 additions & 0 deletions test/repo-health/gallery-favicon.test.mjs
Original file line number Diff line number Diff line change
@@ -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 `<link rel="icon">` 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 `<link>` 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 <link> in <body> 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('</head>')[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(/<link rel="[^"]*icon[^"]*"[^>]*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 <link rel="icon">');
});

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