From 4da97ab0eee280ae9317154a1591ea100db14925 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 7 Oct 2026 17:56:47 +0530 Subject: [PATCH] feat: first-class app icon and manifest files, placeholder scaffold icon Every app scaffolded by webjs create served the WebJs brand mark as its favicon (public/favicon.svg declared through metadata.icons), so apps built on it looked like WebJs demos in every tab. Static app-root files now carry the icon: app/icon.svg|png|ico, app/apple-icon.png, app/favicon.ico and app/manifest.webmanifest|json are served at their own names, auto-linked into with type and real pixel sizes, and /favicon.ico falls back to the app icon. The manifest route or file is linked too. The scaffold ships a neutral, marked placeholder icon plus a manifest with the app's name, and webjs doctor (APP_ICON) warns until the placeholder or the old brand mark is replaced. Claude-Session: https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo --- .agents/skills/webjs/SKILL.md | 3 +- .agents/skills/webjs/references/built-ins.md | 2 + .../webjs/references/routing-and-pages.md | 22 ++- AGENTS.md | 1 + README.md | 2 +- gallery/app/icon.ts | 14 +- gallery/app/manifest.ts | 5 +- packages/cli/lib/app-icon.js | 55 ++++++++ packages/cli/lib/create.js | 23 ++-- packages/cli/lib/doctor/codes.js | 1 + packages/cli/lib/doctor/probes/app-icon.js | 36 +++++ packages/cli/lib/doctor/runner.js | 2 + packages/cli/lib/gallery-shell-files.js | 2 +- packages/cli/templates/public/favicon.svg | 12 -- .../cli/templates/scripts/clear-gallery.mjs | 6 +- packages/server/src/dev/serve.js | 30 +++- packages/server/src/router.js | 50 ++++++- packages/server/src/ssr/head.js | 130 ++++++++++++++---- test/bun/metadata-icon-routes.mjs | 51 +++++++ test/cli/doctor.test.mjs | 23 ++++ test/scaffolds/scaffold-integration.test.js | 18 ++- test/ssr/ssr.test.js | 77 +++++++++++ website/app/docs/configuration/page.ts | 2 + website/app/docs/metadata-routes/page.ts | 23 +++- 24 files changed, 511 insertions(+), 79 deletions(-) create mode 100644 packages/cli/lib/app-icon.js create mode 100644 packages/cli/lib/doctor/probes/app-icon.js delete mode 100644 packages/cli/templates/public/favicon.svg diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index 845d44f26..c8efff110 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -44,6 +44,7 @@ Rows point rather than explain. The reference is the authority on the rule, and | add a URL, static or with a dynamic segment | a file at `app//page.ts`, `[id]` for a param | registering the route in a table or config | `references/routing-and-pages.md` | `app/features/routing` | | abandon a render because something is missing or not allowed | throw `notFound()` / `forbidden()` / `unauthorized()` | returning an error object and branching in the template | `references/routing-and-pages.md` | `app/features/boundaries` | | set a page's title, description, or social preview | `export const metadata` or `generateMetadata()` | writing `` tags in the page | `references/routing-and-pages.md` | `app/features/metadata` | +| give the app its own favicon, home-screen icon and manifest | replace the placeholder `app/icon.svg` with a simple symbol for the app in its colours, add `app/apple-icon.png`, edit `app/manifest.webmanifest` | leaving the scaffold placeholder, or a hand-written `` | `references/routing-and-pages.md` (App icon and manifest) | `app/icon.ts` | | make part of the page respond to a click or hold state | a `WebComponent` custom element | expecting the page's own markup to hydrate | `references/components.md` | `app/features/components` | | render a keyed list, or swap one node when state changes | `repeat()` / `watch()` from `/directives` | re-rendering the component or diffing by hand | `references/components.md` | `app/features/directives` | | get server data into a component's first paint | `async render()` awaiting an action | fetching in `connectedCallback`, which SSR never calls | `references/components.md` | `app/features/async-render` | @@ -174,7 +175,7 @@ Find the right export fast. Load the linked reference for full examples. ### File conventions -`page.ts` (server-only fn), `layout.ts` (embeds `children`), `route.ts` (HTTP handler), `middleware.ts`, `*.server.ts` (server boundary), `error.ts` / `loading.ts` / `not-found.ts` / `forbidden.ts` / `unauthorized.ts` (boundaries), metadata routes (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, `opengraph-image.ts`). +`page.ts` (server-only fn), `layout.ts` (embeds `children`), `route.ts` (HTTP handler), `middleware.ts`, `*.server.ts` (server boundary), `error.ts` / `loading.ts` / `not-found.ts` / `forbidden.ts` / `unauthorized.ts` (boundaries), metadata routes (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, `opengraph-image.ts`), and the static app-root icon files (`icon.svg`, `apple-icon.png`, `manifest.webmanifest`, `favicon.ico`), auto-linked into ``. The scaffold's `app/icon.svg` is a placeholder: replace it with the app's own icon. ## Canonical Patterns diff --git a/.agents/skills/webjs/references/built-ins.md b/.agents/skills/webjs/references/built-ins.md index 1bdbdf847..bf385ef24 100644 --- a/.agents/skills/webjs/references/built-ins.md +++ b/.agents/skills/webjs/references/built-ins.md @@ -296,6 +296,8 @@ Three levels, the same scale ESLint uses: `error` fails the exit, `warn` reports Two guarantees worth knowing. A result that could not check (a network or toolchain outage) is capped at `warn` and can never be escalated, so a jspm or npm outage cannot red your CI. And a malformed gate exits 1 naming the offender rather than being ignored, so a typo cannot silently un-gate the build. That covers an unknown code, a bad severity, a wrong shape (a non-object `doctor` or `gate`), and a misspelled sibling of `gate` such as `gates`, since every one of those would otherwise leave the build un-gated while the `package.json` looks gated. Under `--json` the offenders come back as a `configErrors` array alongside an empty `results`, each entry a `{ kind }` of `malformed` / `unknown-key` / `unknown-code` / `bad-severity`. Wire it up with one workflow step, `npm run doctor`, and change what is fatal in `package.json` rather than in the workflow. +`APP_ICON` warns while the favicon is still the scaffold's: the placeholder `app/icon.svg` (marked `data-webjs-placeholder`) or the WebJs mark older scaffolds shipped at `public/favicon.svg`. Replace it with the app's own icon (`references/routing-and-pages.md`, "App icon and manifest"). + ### Dependency audit allowlist `webjs audit` runs `npm audit` or `bun audit` (by the nearest lockfile, so a workspace member uses the root's) and fails on any advisory at or above `webjs.audit.level` (default `high`) that `webjs.audit.ignore` does not list. The scaffold's `Security: dependency audit` CI step runs it. diff --git a/.agents/skills/webjs/references/routing-and-pages.md b/.agents/skills/webjs/references/routing-and-pages.md index 3d2266b59..af51a6112 100644 --- a/.agents/skills/webjs/references/routing-and-pages.md +++ b/.agents/skills/webjs/references/routing-and-pages.md @@ -278,12 +278,26 @@ export default function robots({ siteUrl }: MetadataRouteContext) { The IMAGE metadata routes (`icon`, `apple-icon`, `opengraph-image`, `twitter-image`) default-export a function returning a `Response` with an explicit `content-type`, so an inline SVG needs no asset file (buildless). -**`icon` and `apple-icon` are LINKED for you.** An app that declares no `metadata.icons` gets `` and `` in the head automatically, for whichever of the two routes it defines (base-path prefixed, since that is where the route answers). No `type` or `sizes` is asserted, because the route picks its content type at request time and the browser sniffs the served one. +### App icon and manifest (replace the placeholder) -Declaring `metadata.icons` **suppresses** the routes rather than merging with them, which is what Next does with its static icon files. So an app that outgrows a placeholder `app/icon.ts` names its real icons and the route stops being linked without having to be deleted: +**Every app needs its OWN icon.** `webjs create` ships `app/icon.svg` as a neutral PLACEHOLDER (a grey tile with a dashed frame, marked `data-webjs-placeholder="icon"`) and `app/manifest.webmanifest` with the app's name. Replacing the placeholder is part of building the app, not polish: until you do, the tab, the bookmark and the home-screen icon look like every other unfinished app, and `webjs doctor` warns (`APP_ICON`). Draw a simple symbol for what the app IS (a grid for a tic-tac-toe game, a cup for a cafe, a check for a task list), in the app's own colours, on a 32x32 or 24x24 `viewBox`: a filled rounded tile in the primary colour with one bold shape in its foreground colour reads at 16px. Avoid thin strokes (under 2px at 32px), text longer than one letter, and detail that blurs at tab size. Then set `name`, `short_name`, `theme_color` and `background_color` in `app/manifest.webmanifest` to match. + +The icon conventions, all at the app ROOT and all auto-linked into `` when the app declares no `metadata.icons`: + +| File | Served at | Linked as | +|---|---|---| +| `app/icon.svg` / `icon.png` / `icon.ico` | `/icon.svg` ... | `` with `type`, `sizes="any"` (SVG) or the PNG's real pixel size | +| `app/apple-icon.png` (180x180) | `/apple-icon.png` | `` (iOS needs PNG, not SVG) | +| `app/icon.ts` / `apple-icon.ts` | `/icon`, `/apple-icon` | bare link (the route picks its content type per request) | +| `app/manifest.webmanifest` / `manifest.json` / `manifest.ts` | `/manifest.webmanifest`, `/manifest.json` | `` (`metadata.manifest` wins; `manifest: null` opts out) | +| `app/favicon.ico` | `/favicon.ico` | not linked; browsers request it themselves | + +`/favicon.ico` always answers: `public/favicon.ico`, else `app/favicon.ico`, else the app's icon (raster preferred over SVG, then the `icon.ts` route), so a crawler or feed reader that reads no markup gets the same icon. A static icon file wins the link over an icon route when both exist (the route still serves at its URL). Raster icons are linked before SVG, because Google's favicon crawler takes the first usable icon and wants a square raster. Use `icon.ts` only when the mark must be computed per request (per theme, per tenant); a route can render a PNG for `apple-icon.ts` the same way. + +Declaring `metadata.icons` **suppresses** all of the above rather than merging with them, which is what Next does with its static icon files. So name icons explicitly only when they live elsewhere (a CDN, `public/`): ```ts -// app/layout.ts -> these win; /icon and /apple-icon are no longer linked +// app/layout.ts -> these win; app/icon.* and app/apple-icon.* are no longer linked export const metadata = { icons: { icon: [ @@ -295,7 +309,7 @@ export const metadata = { }; ``` -Declare a favicon through `metadata.icons` (or a metadata route), never as a hand-written ``: only the root layout may write a shell at all (invariant 8), so a hand-written tag is unavailable to every other layout. A `public/favicon.ico` needs no declaration either way, since the framework serves it at the origin root for crawlers that read no markup. +Never write a favicon as a hand-written ``: only the root layout may write a shell at all (invariant 8), so a hand-written tag is unavailable to every other layout. `opengraph-image` and `twitter-image` are LINKED too, Next's behaviour: a page that declares no `openGraph.images` gets `og:image` pointing at the nearest `opengraph-image` route above it (absolute against the site URL), and likewise `twitter:image`. A page that declares its own image keeps it. diff --git a/AGENTS.md b/AGENTS.md index bde35860a..b86cf7bda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -225,6 +225,7 @@ env.js optional boot-time env validation (schema or validat instrumentation.js optional boot-time hook (register(); wire APM via setOnError, #848) instrumentation-client.js optional client boot hook (runs first, before app modules, #848) sitemap.js robots.js manifest.js icon.js opengraph-image.js twitter-image.js apple-icon.js metadata routes +icon.svg icon.png apple-icon.png favicon.ico manifest.webmanifest static app-root metadata files, auto-linked (replace the placeholder icon.svg) lib/ app-wide code (lib/*.server.js infra, lib/utils/ browser-safe helpers) modules// feature-scoped: actions/ (mutations), queries/ (reads), components/, utils/, types.js components/*.js SHARED presentational primitives diff --git a/README.md b/README.md index 581f2c70a..7f20e91cc 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ older, unrelated Java framework also used the name WebJS. - **The essentials ship in the box.** Auth, sessions, caching, WebSocket broadcast, and rate limiting are all built in, sharing one pluggable cache store that is in-memory by default. Call `setStore(redisStore({ url: process.env.REDIS_URL }))` once at startup to move all four onto Redis for horizontal scaling. - **Components can lazy-load themselves.** Setting `static lazy = true` defers the module download until the component is first visible (scrolled into view, or its tab or dialog opened), even when another component imports it. The SSR content stays visible throughout, so only the JavaScript is lazy. - **Error boundaries and loading states are file conventions.** `error.ts` catches render failures at any route level, and `loading.ts` automatically wraps pages in Suspense boundaries. -- **Metadata routes are functions rather than static files.** `sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, and `opengraph-image.ts` generate SEO and PWA metadata dynamically. +- **Metadata routes are functions, or plain files.** `sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, and `opengraph-image.ts` generate SEO and PWA metadata dynamically; a static `app/icon.svg`, `app/apple-icon.png` or `app/manifest.webmanifest` is served and linked into `` with no declaration. - **REST endpoints come from `route.ts`.** Expose a server action over HTTP by importing it into a `route.ts` handler, or reach for the one-line `route(action)` adapter from `@webjsdev/server`. Input validation is optional. - **WebJs is production ready.** CSRF protection, gzip and brotli, HTTP/2, 103 Early Hints, CSP nonces, modulepreload, rate limiting, health probes, graceful shutdown, and streaming Suspense all ship with the framework. - **WebJs UI is the matching AI-first component library.** Its 35 primitives at [webjs.dev/ui](https://webjs.dev/ui) are written for AI agents, in two tiers: pure class-helper functions (`buttonClass`, `cardClass`, `inputClass`) for visual primitives, plus a small set of stateful custom elements (``, ``, ``) for the cases where state matters. Running `webjs ui add button card dialog` copies the source into your project, so you own it and can edit it. It ships as a hard dependency of `@webjsdev/cli`, so a WebJs app that installed the CLI needs no separate install, and a WebJs app that skipped the global install runs `npm install -D @webjsdev/ui` and `npm install @webjsdev/core` first, then `npx webjsui init` and `npx webjsui add button card dialog`. diff --git a/gallery/app/icon.ts b/gallery/app/icon.ts index 65dbb2f92..92023b009 100644 --- a/gallery/app/icon.ts +++ b/gallery/app/icon.ts @@ -3,12 +3,14 @@ // 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. +// This is the DEMO of that surface, not the gallery's own favicon. With no +// metadata.icons declared, the framework auto-links an app-root icon: a STATIC +// file (app/icon.svg, app/icon.png) wins that link over this route, and a +// declared metadata.icons wins over both. The gallery declares its WebJs brand +// mark in app/layout.ts, so this route stays browsable at /icon without being +// the tab icon. For an icon that never changes, write app/icon.svg instead +// (what `webjs create` ships); keep a route like this only when the mark must +// be computed at request time (per theme, per tenant). export default function Icon() { const svg = ` diff --git a/gallery/app/manifest.ts b/gallery/app/manifest.ts index db245d5ea..455dfb7eb 100644 --- a/gallery/app/manifest.ts +++ b/gallery/app/manifest.ts @@ -3,6 +3,9 @@ // icons to your app; pair it with the opt-in service worker for an installable // PWA. See agent-docs/service-worker.md. (Gallery files are copied verbatim, so // set the real app name here by hand rather than expecting substitution.) +// The framework links an app-root manifest into by itself. A static +// app/manifest.webmanifest (what `webjs create` ships) wins that link over this +// route; write a route like this only when a value must be computed. export default function Manifest() { return { name: 'webjs app', @@ -12,7 +15,7 @@ export default function Manifest() { background_color: '#ffffff', theme_color: '#1e2226', icons: [ - { src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' }, + { src: '/icon', sizes: 'any', type: 'image/svg+xml' }, ], }; } diff --git a/packages/cli/lib/app-icon.js b/packages/cli/lib/app-icon.js new file mode 100644 index 000000000..8add083b3 --- /dev/null +++ b/packages/cli/lib/app-icon.js @@ -0,0 +1,55 @@ +/** + * The app icon a new scaffold starts with, and how `webjs doctor` recognises + * an icon that was never replaced. + * + * A scaffold ships `app/icon.svg` (auto-linked as the favicon, served at + * /icon.svg and as the /favicon.ico fallback) and `app/manifest.webmanifest` + * (auto-linked as the web app manifest). The icon is a deliberately NEUTRAL + * placeholder: a grey tile with a dashed frame, so a tab strip shows at a + * glance that the app has no icon of its own yet, and no app ever ships + * looking like a WebJs demo. It carries `data-webjs-placeholder` so a check + * (webjs doctor, or any agent's own tester) can tell it apart from a real + * icon without comparing bytes. + */ + +/** The attribute that marks the scaffold's placeholder icon. */ +export const PLACEHOLDER_MARKER = 'data-webjs-placeholder'; + +/** The placeholder `app/icon.svg`. Replace it with the app's own mark. */ +export const PLACEHOLDER_ICON_SVG = ` + + + + +`; + +/** + * The `app/manifest.webmanifest` a scaffold starts with: the app's name, the + * neutral colours of the scaffold palette, and the icon. Grow it with the app + * (its real theme colour, a 192 and 512 PNG for installability). + * @param {string} name the app's display name + */ +export function appManifest(name) { + return JSON.stringify({ + name, + short_name: name, + start_url: '/', + display: 'standalone', + background_color: '#ffffff', + theme_color: '#ffffff', + icons: [{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml' }], + }, null, 2) + '\n'; +} + +/** + * Whether an SVG is the WebJs brand mark earlier scaffolds shipped as + * `public/favicon.svg` (a rounded square with the gallery's grey gradient). + * Apps made before the placeholder still serve it as their favicon. + * @param {string} svg + */ +export function isLegacyBrandFavicon(svg) { + return /aria-label="WebJs"/.test(svg) && / = const swSrc = join(TEMPLATES, 'public', swFile); if (existsSync(swSrc)) await cp(swSrc, join(publicDir, swFile)); } - // A base SVG favicon (the root layout links it). It ships with the app, not - // the gallery, so it survives `npm run gallery:clear`. - const faviconSrc = join(TEMPLATES, 'public', 'favicon.svg'); - if (existsSync(faviconSrc)) await cp(faviconSrc, join(publicDir, 'favicon.svg')); + // The app icon and web app manifest: `app/icon.svg` (a neutral PLACEHOLDER + // the agent replaces with the app's own icon; `webjs doctor` warns while it + // is still there) and `app/manifest.webmanifest` (the app's name). The + // framework links both into and answers /favicon.ico with the icon, + // so the layout declares nothing. They ship with the app, not the gallery, + // so they survive `gallery:clear`. + await mkdir(join(appDir, 'app'), { recursive: true }); + await writeFile(join(appDir, 'app', 'icon.svg'), PLACEHOLDER_ICON_SVG); + await writeFile(join(appDir, 'app', 'manifest.webmanifest'), appManifest(displayName)); // The gallery-reset script (wired as `gallery:clear`). Only UI templates have // a gallery, so it ships here (NOT in the flat templateFiles list, which would @@ -1355,11 +1361,10 @@ import '#components/theme-toggle.ts'; * text-foreground, bg-card, bg-primary, and border-border all work. */ -// Declare the favicon via metadata.icons (NOT a hand-written in the -// template): the framework emits metadata links into , whereas a -// written in the layout body stays in , where browsers ignore it. The SVG -// lives at public/favicon.svg and serves at /public/favicon.svg. -export const metadata = { icons: '/public/favicon.svg' }; +// The favicon and manifest are app/icon.svg and app/manifest.webmanifest: the +// framework links them into itself, so nothing is declared here. Replace +// the placeholder icon with this app's own (see the skill's routing-and-pages +// reference, "App icon"). // LayoutProps types every layout argument (children, params, searchParams, // url) from the framework, so children is a TemplateResult rather than an diff --git a/packages/cli/lib/doctor/codes.js b/packages/cli/lib/doctor/codes.js index 3b961689a..8182bcc45 100644 --- a/packages/cli/lib/doctor/codes.js +++ b/packages/cli/lib/doctor/codes.js @@ -50,6 +50,7 @@ export const DOCTOR_CODES = { 'Static build outputs (dev.regenerate freshness)': 'STATIC_ASSET_FRESHNESS', 'Asset urls (unmarked stylesheet links)': 'UNMARKED_ASSET_LINKS', 'workspace-overrides': 'WORKSPACE_OVERRIDES', + 'app-icon': 'APP_ICON', }; /** diff --git a/packages/cli/lib/doctor/probes/app-icon.js b/packages/cli/lib/doctor/probes/app-icon.js new file mode 100644 index 000000000..87946eb5d --- /dev/null +++ b/packages/cli/lib/doctor/probes/app-icon.js @@ -0,0 +1,36 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { PLACEHOLDER_MARKER, isLegacyBrandFavicon } from '../../app-icon.js'; + +/** + * @typedef {import('../codes.js').DoctorResult} DoctorResult + */ + +/** @param {string} file */ +function read(file) { + try { return readFileSync(file, 'utf8'); } catch { return ''; } +} + +/** + * Warn when the app's favicon is still the one `webjs create` shipped: the + * neutral `app/icon.svg` placeholder, or the WebJs brand mark earlier + * scaffolds put at `public/favicon.svg`. Either way every app made from the + * scaffold shows the same tab icon, which reads as a demo rather than a + * product. An app with no `app/` directory (a library, the api template with + * no pages) passes. + * + * @param {string} appDir + * @returns {DoctorResult} + */ +export function checkAppIcon(appDir) { + const name = 'app-icon'; + if (!existsSync(join(appDir, 'app'))) return { name, status: 'pass', message: 'no app/ directory to analyse' }; + const fix = 'Replace app/icon.svg with an icon for this app (a simple symbol for what it is, in its own colours, legible at 16px), and delete public/favicon.svg plus any metadata.icons that still points at it.'; + if (read(join(appDir, 'app', 'icon.svg')).includes(PLACEHOLDER_MARKER)) { + return { name, status: 'warn', message: 'app/icon.svg is still the scaffold placeholder icon', fix }; + } + if (isLegacyBrandFavicon(read(join(appDir, 'public', 'favicon.svg')))) { + return { name, status: 'warn', message: 'public/favicon.svg is still the WebJs mark an earlier scaffold shipped', fix }; + } + return { name, status: 'pass', message: 'the app has its own icon (or declares none)' }; +} diff --git a/packages/cli/lib/doctor/runner.js b/packages/cli/lib/doctor/runner.js index dd771055d..398b8342b 100644 --- a/packages/cli/lib/doctor/runner.js +++ b/packages/cli/lib/doctor/runner.js @@ -12,6 +12,7 @@ import { checkStaticAssetFreshness } from './probes/static-asset-freshness.js'; import { checkUnmarkedAssetLinks } from './probes/unmarked-asset-links.js'; import { checkFrameworkResolves, checkFrameworkLinks } from './probes/framework-resolves.js'; import { checkWorkspaceOverrides } from './probes/workspace-overrides.js'; +import { checkAppIcon } from './probes/app-icon.js'; /** * @typedef {import('./codes.js').DoctorResult} DoctorResult @@ -66,6 +67,7 @@ export async function runDoctorChecks(appDir, opts = {}) { checkStaticAssetFreshness(appDir), checkUnmarkedAssetLinks(appDir), Promise.resolve(checkWorkspaceOverrides(appDir)), + Promise.resolve(checkAppIcon(appDir)), ]); // Attach the stable machine code to every result (#975). Centralized here so // each check function stays free of the code-contract concern. diff --git a/packages/cli/lib/gallery-shell-files.js b/packages/cli/lib/gallery-shell-files.js index 9051d1f86..ec449a6f5 100644 --- a/packages/cli/lib/gallery-shell-files.js +++ b/packages/cli/lib/gallery-shell-files.js @@ -6,7 +6,7 @@ * live app deployed on its own, so it needs a root layout, a home page, a theme * toggle, and the `cn()` helper. The scaffold writes all four itself, with * things the gallery's copies cannot carry: the app's `displayName`, the - * `cspNonce()` wiring, `LayoutProps` typing, the `metadata.icons` favicon, and + * `cspNonce()` wiring, `LayoutProps` typing, and * a `cn.ts` read verbatim from the `@webjsdev/ui` registry so `webjs ui add` * stays in lockstep with the kit. * diff --git a/packages/cli/templates/public/favicon.svg b/packages/cli/templates/public/favicon.svg deleted file mode 100644 index 18151a183..000000000 --- a/packages/cli/templates/public/favicon.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - diff --git a/packages/cli/templates/scripts/clear-gallery.mjs b/packages/cli/templates/scripts/clear-gallery.mjs index 4cada7026..a6e9fe243 100644 --- a/packages/cli/templates/scripts/clear-gallery.mjs +++ b/packages/cli/templates/scripts/clear-gallery.mjs @@ -191,9 +191,9 @@ import type { LayoutProps } from '@webjsdev/core'; * to pull primitives, then theme them here. */ -// Favicon via metadata.icons so the framework emits the into (a -// hand-written in the template body is ignored by browsers). -export const metadata = { icons: '/public/favicon.svg' }; +// The favicon is app/icon.svg (and the manifest app/manifest.webmanifest): the +// framework links both into , so nothing is declared here. Replace the +// placeholder icon with this app's own. export default function RootLayout({ children }: LayoutProps) { return html\` diff --git a/packages/server/src/dev/serve.js b/packages/server/src/dev/serve.js index 2ca087dc6..785b2af9d 100644 --- a/packages/server/src/dev/serve.js +++ b/packages/server/src/dev/serve.js @@ -361,7 +361,15 @@ export async function handleCore(req, ctx) { // Metadata routes: /sitemap.xml, /robots.txt, /icon, /opengraph-image, etc. if (method === 'GET' && state.routeTable.metadataRoutes) { - const meta = state.routeTable.metadataRoutes.find((r) => r.urlPath === path); + const meta = state.routeTable.metadataRoutes.find((r) => r.urlPath === path) + || (path === '/favicon.ico' ? faviconFallback(state.routeTable.metadataRoutes) : undefined); + if (meta && meta.static) { + // A static metadata file (`app/icon.svg`, `app/manifest.webmanifest`): + // the file's bytes with the convention's content type. + const res = await fileResponse(meta.file, { dev, immutable: versioned }); + if (res.ok && meta.contentType) res.headers.set('content-type', meta.contentType); + return res; + } if (meta) { try { // The route gets `{ request, url, siteUrl, pages }` and may return a @@ -633,6 +641,26 @@ export async function loadMiddleware(appDir, dev, logger) { * @param {string} abs * @param {{ dev: boolean, immutable: boolean }} opts */ +/** + * What answers `/favicon.ico` when neither `public/favicon.ico` nor + * `app/favicon.ico` exists: the app's own icon. Browsers and crawlers request + * `/favicon.ico` without reading the page's (a feed reader, a bookmark + * import, a page served without HTML), so an app with only `app/icon.svg` + * would otherwise answer them with a 404. An `.ico` or raster file is + * preferred over SVG, which some of those clients cannot read; the dynamic + * `app/icon.ts` route is the last resort. + * + * @param {Array<{ stem: string, urlPath: string, static?: boolean, contentType?: string }>} routes + */ +export function faviconFallback(routes) { + const icons = routes.filter((r) => r.stem === 'icon' && (r.static || r.urlPath === '/icon')); + const rank = (/** @type {typeof icons[number]} */ r) => !r.static ? 9 + : r.contentType === 'image/x-icon' ? 0 + : r.contentType === 'image/png' ? 1 + : r.contentType === 'image/svg+xml' ? 3 : 2; + return icons.sort((a, b) => rank(a) - rank(b))[0]; +} + export async function fileResponse(abs, opts) { try { let data = await readFile(abs); diff --git a/packages/server/src/router.js b/packages/server/src/router.js index bad0d3605..346820875 100644 --- a/packages/server/src/router.js +++ b/packages/server/src/router.js @@ -26,7 +26,7 @@ import { findInstrumentationClient } from './instrumentation.js'; * middlewares: string[], * }} ApiRoute * - * @typedef {{ stem: string, file: string, urlPath: string }} MetadataRoute + * @typedef {{ stem: string, file: string, urlPath: string, static?: boolean, contentType?: string }} MetadataRoute * * @typedef {{ * pages: PageRoute[], @@ -60,6 +60,10 @@ import { findInstrumentationClient } from './instrumentation.js'; * app/sitemap.js → serves /sitemap.xml * app/robots.js → serves /robots.txt * app/icon.js → serves /icon (dynamic) + * app/icon.svg | icon.png → serves /icon.svg | /icon.png (static file) + * app/apple-icon.png → serves /apple-icon.png (static file) + * app/favicon.ico → serves /favicon.ico (static file) + * app/manifest.webmanifest → serves /manifest.webmanifest (static file) * app/opengraph-image.js → serves /opengraph-image (dynamic) * * @param {string} appDir @@ -114,6 +118,19 @@ export async function buildRouteTable(appDir) { // Private folders (any segment starting with _) are excluded from routing. if (dir !== '.' && dir.split('/').some((s) => s.startsWith('_'))) continue; + // Static metadata FILES at the app root (`app/icon.svg`, `app/icon.png`, + // `app/apple-icon.png`, `app/favicon.ico`, `app/manifest.webmanifest`): + // served as is at `/` and auto-linked into like their + // `.ts` counterparts. Root only: an icon or manifest describes the whole + // app, and a nested copy has no meaning a browser would act on. + if (dir === '.') { + const staticMeta = staticMetadataFile(base); + if (staticMeta) { + metadataRoutes.push({ ...staticMeta, file, urlPath: '/' + base, static: true }); + continue; + } + } + // Match `.` conventions. Stem is the name without ext. const stem = stemOf(base); if (!stem) continue; @@ -215,6 +232,37 @@ export async function buildRouteTable(appDir) { * @param {string} base * @returns {string | null} */ +/** + * The static metadata file conventions, keyed by file name pattern. Each maps + * to the metadata stem it stands for and the content type it is served with. + * @type {Array<[RegExp, string, Record]>} + */ +const STATIC_METADATA_FILES = [ + [/^icon\.(svg|png|ico|jpg|jpeg|webp|gif)$/, 'icon', {}], + [/^apple-icon\.(png|jpg|jpeg)$/, 'apple-icon', {}], + [/^favicon\.(ico)$/, 'favicon', {}], + [/^manifest\.(json|webmanifest)$/, 'manifest', { json: 'application/manifest+json', webmanifest: 'application/manifest+json' }], +]; + +/** @type {Record} */ +const IMAGE_TYPES = { + svg: 'image/svg+xml', png: 'image/png', ico: 'image/x-icon', jpg: 'image/jpeg', + jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', +}; + +/** + * Classify an app-root file name as a static metadata file, or null. + * @param {string} base + * @returns {{ stem: string, contentType: string } | null} + */ +export function staticMetadataFile(base) { + for (const [re, stem, types] of STATIC_METADATA_FILES) { + const m = re.exec(base); + if (m) return { stem, contentType: types[m[1]] || IMAGE_TYPES[m[1]] }; + } + return null; +} + function stemOf(base) { const m = /^([A-Za-z0-9_.-]+)\.(?:m?[jt]s)$/.exec(base); return m ? m[1] : null; diff --git a/packages/server/src/ssr/head.js b/packages/server/src/ssr/head.js index 5346f87bd..cdd6fcf8a 100644 --- a/packages/server/src/ssr/head.js +++ b/packages/server/src/ssr/head.js @@ -1,3 +1,4 @@ +import { openSync, readSync, closeSync } from 'node:fs'; import { basePath, buildImportMap, importMapTag, vendorPreconnectOrigins } from '../importmap.js'; import { withBasePath } from '../base-path.js'; import { escapeAttr, escapeHtml } from './escape.js'; @@ -10,62 +11,128 @@ import { embedScriptTag } from '../dev-embed.js'; import { devReloadState } from '../dev-reload-state.js'; import { openGraphPairs, twitterPairs, setMetadataImageRoutes } from './seo.js'; -// Which icon metadata ROUTES the app has (`app/icon.*`, `app/apple-icon.*`). -// Set at boot and on each route rebuild from the route table, the same shape -// as setClientRouterEnabled, so no opt has to thread through every render -// path. Empty by default, which keeps an app that declares its icons (or has -// neither route) byte-identical. +// Which icon and manifest metadata the app has: the `app/icon.*`, +// `app/apple-icon.*` and `app/manifest.*` files (a static file such as +// `app/icon.svg`, or a route such as `app/icon.ts`). Set at boot and on each +// route rebuild from the route table, the same shape as setClientRouterEnabled, +// so no opt has to thread through every render path. Empty by default, which +// keeps an app that declares its icons (or has none) byte-identical. // // This sits in head.js rather than in a module of its own the way the // client-router flag does: `wrapHead` is the only reader, and module state // belongs with the code that uses and writes it. The flag moved out only // because it has a second reader in render.js. -/** @type {{ icon: boolean, apple: boolean }} */ -let _metadataIconRoutes = { icon: false, apple: false }; +/** @typedef {{ url: string, type?: string, sizes?: string }} AutoIcon */ +/** @type {{ icon: AutoIcon[], apple: AutoIcon[], manifest: string | null }} */ +let _metadataIconRoutes = { icon: [], apple: [], manifest: null }; /** - * Record the icon metadata routes the app defines. + * The pixel size of a PNG file (`"180x180"`), read from its IHDR header, or + * undefined when the file is not a readable PNG. Only the first 24 bytes are + * read, once per route rebuild. + * @param {string} file + */ +function pngSizes(file) { + let fd; + try { + fd = openSync(file, 'r'); + const buf = Buffer.alloc(24); + if (readSync(fd, buf, 0, 24, 0) < 24) return undefined; + if (buf.readUInt32BE(0) !== 0x89504e47 || buf.toString('latin1', 12, 16) !== 'IHDR') return undefined; + return `${buf.readUInt32BE(16)}x${buf.readUInt32BE(20)}`; + } catch { + return undefined; + } finally { + if (fd !== undefined) closeSync(fd); + } +} + +/** + * Record the icon and manifest metadata the app defines. + * + * A static file is linked with its type, plus its pixel size for a PNG and + * `sizes="any"` for an SVG. A route (`app/icon.ts`) is linked bare: it picks + * its own content type at request time, which is the reason to use one, so a + * declared type could contradict the bytes. When an app has both a static + * icon and an icon route, the static file wins the link: the route then + * stays reachable at its URL without becoming the favicon. + * + * Raster icons are linked before SVG. Google's favicon crawler takes the + * first usable icon and wants a square raster, and a browser that reads SVG + * picks it from the list regardless of order. * - * @param {Iterable<{ stem: string }> | null | undefined} metadataRoutes + * @param {Iterable<{ stem: string, urlPath?: string, file?: string, static?: boolean, contentType?: string }> | null | undefined} metadataRoutes * The route table's `metadataRoutes`, or nullish to clear. */ export function setMetadataIconRoutes(metadataRoutes) { - const stems = new Set(); - for (const r of metadataRoutes || []) if (r && r.stem) stems.add(r.stem); - _metadataIconRoutes = { icon: stems.has('icon'), apple: stems.has('apple-icon') }; - // The share-image routes (#1564) are recorded from the same table so one - // call keeps both in step on boot and on every rebuild. + /** @type {Record>} */ + const byStem = { icon: [], 'apple-icon': [], manifest: [] }; + for (const r of metadataRoutes || []) { + if (!r || !byStem[r.stem]) continue; + // Only the app-root ones describe the app. A route nested in a segment + // (`app/blog/icon.ts`) answers at its own URL but is not the favicon. + const url = r.urlPath || '/' + r.stem; + if (url.lastIndexOf('/') !== 0) continue; + byStem[r.stem].push({ ...r, urlPath: url }); + } + /** @param {typeof byStem.icon} list @returns {AutoIcon[]} */ + const links = (list) => { + const statics = list.filter((r) => r.static); + const chosen = statics.length ? statics : list.filter((r) => !r.static); + const rank = (/** @type {typeof list[number]} */ r) => (r.contentType === 'image/svg+xml' ? 1 : 0); + return chosen.sort((a, b) => rank(a) - rank(b)).map((r) => { + /** @type {AutoIcon} */ + const out = { url: /** @type {string} */ (r.urlPath) }; + if (r.static && r.contentType) out.type = r.contentType; + if (r.contentType === 'image/svg+xml') out.sizes = 'any'; + else if (r.contentType === 'image/png' && r.file) { + const sizes = pngSizes(r.file); + if (sizes) out.sizes = sizes; + } + return out; + }); + }; + const manifest = byStem.manifest.find((r) => r.static) || byStem.manifest[0]; + _metadataIconRoutes = { + icon: links(byStem.icon), + apple: links(byStem['apple-icon']), + manifest: manifest ? /** @type {string} */ (manifest.urlPath) : null, + }; // The og/twitter image routes ride the same rebuild hook (seo.js). setMetadataImageRoutes(metadataRoutes); } /** - * The implicit `metadata.icons` an app's icon routes stand for, or null when - * it has none. Base-path prefixed, because that is where the routes are + * The implicit `metadata.icons` an app's icon files and routes stand for, or + * null when it has none. Base-path prefixed, because that is where they are * SERVED: the listener strips the base path before matching, so under - * `webjs.basePath` the route answers at `/icon`. A user-authored + * `webjs.basePath` the icon answers at `/icon.svg`. A user-authored * `icons` URL is deliberately left alone (it may be cross-origin, and the * author writes the path they mean), so only these framework-emitted ones * are prefixed. * - * No `type` or `sizes` is emitted. A metadata route picks its own content - * type at request time, which is the reason to use one, so declaring a type - * here could contradict the bytes; and `sizes` is unknowable without reading - * the response. Both are optional in HTML, and a browser sniffs the served - * content type. - * - * @returns {{ icon?: string, apple?: string } | null} + * @returns {{ icon?: AutoIcon[], apple?: AutoIcon[] } | null} */ function autoMetadataRouteIcons() { const { icon, apple } = _metadataIconRoutes; - if (!icon && !apple) return null; + if (!icon.length && !apple.length) return null; const bp = basePath(); - /** @type {{ icon?: string, apple?: string }} */ + const prefix = (/** @type {AutoIcon} */ i) => ({ ...i, url: withBasePath(i.url, bp) }); + /** @type {{ icon?: AutoIcon[], apple?: AutoIcon[] }} */ const out = {}; - if (icon) out.icon = withBasePath('/icon', bp); - if (apple) out.apple = withBasePath('/apple-icon', bp); + if (icon.length) out.icon = icon.map(prefix); + if (apple.length) out.apple = apple.map(prefix); return out; } +/** + * The app's `app/manifest.*` URL (base-path prefixed), or null. + * @returns {string | null} + */ +function autoManifestUrl() { + const { manifest } = _metadataIconRoutes; + return manifest ? withBasePath(manifest, basePath()) : null; +} + /** * HTML-safe-escape a JSON string for embedding inside a * `