webManifest
An app's web app manifest, from its namespace's pwa: the manifest as the spec has it, written as an object, with
its images as files of the codebase. keep stores a file the manifest needs among the build's assets and names it
(icon-<digest>.png); the manifest is one of them too, and its URLs (start_url, scope, shortcuts' url) are
the app's paths (/ is the app), whatever path it's served under. Defaults: the app's root as start_url and
scope, and display: "standalone". Returns the tags the page's <head> gets (its viewport, manifest, theme
color and icons), its title, and what an installed app would miss.
import { escapeHtml, pwaImage } from "#codebase/tcmts/cli/web"; export function webManifest( pwa: unknown, keep: (content: Uint8Array, stem: string, ext: string) => string, ): { head: string[]; title: string; warnings: string[] } { if (!pwa || typeof pwa !== "object" || Array.isArray(pwa)) throw new Error("the app's pwa is its web app manifest, an object: { name, icons, … }"); const given = pwa as Record<string, unknown>; if (typeof given.name !== "string" || !given.name.trim()) throw new Error("the app's pwa has no name: the app's name, as the installed app shows it"); // the app's paths, from the manifest (kept in _app/assets/): `/` and `./` are the app's root const appUrl = (u: unknown, field: string): string => { if (typeof u !== "string") throw new Error(`the app's pwa: ${field} is a path of the app, like "/"`); return /^[a-z][a-z0-9+.-]*:/i.test(u) ? u : `../../${u.replace(/^\.?\/+/, "")}`; }; type Image = Record<string, unknown> & { src: string; type?: string; sizes?: string; purpose?: string; }; const image = (img: unknown, field: string, stem: string): Image => { if (!img || typeof img !== "object" || Array.isArray(img)) throw new Error(`the app's pwa: ${field} is an image, { src, … }`); const { src, ...rest } = img as Record<string, unknown>; const found = pwaImage(src); if (!found) { if (typeof src === "string") return { ...rest, src }; throw new Error( `the app's pwa: ${field}.src is a file of the codebase (an .svg's text, a .png's bytes) or a URL`, ); } return { src: keep(found.bytes, stem, found.ext), type: found.type, ...(found.sizes ? { sizes: found.sizes } : {}), ...rest, }; }; const images = (v: unknown, field: string, stem: string): Image[] | undefined => { if (v === undefined) return undefined; if (!Array.isArray(v)) throw new Error(`the app's pwa: ${field} is a list of images`); return v.map((x, i) => image(x, `${field}[${i}]`, stem)); }; const icons = images(given.icons, "icons", "icon") ?? []; const screenshots = images(given.screenshots, "screenshots", "screenshot"); const shortcuts = given.shortcuts === undefined ? undefined : (Array.isArray(given.shortcuts) ? given.shortcuts : [given.shortcuts]).map( (s: unknown, i) => { const { url, icons: own, ...rest } = (s ?? {}) as Record<string, unknown>; return { ...rest, url: appUrl(url, `shortcuts[${i}].url`), ...(own === undefined ? {} : { icons: images(own, `shortcuts[${i}].icons`, "icon") }), }; }, ); const manifest = { ...given, start_url: appUrl(given.start_url ?? "/", "start_url"), scope: appUrl(given.scope ?? "/", "scope"), display: given.display ?? "standalone", icons, ...(screenshots ? { screenshots } : {}), ...(shortcuts ? { shortcuts } : {}), }; const asset = (src: string) => /^[a-z][a-z0-9+.-]*:|^\//i.test(src) ? src : `/_app/assets/${src}`; const attr = (s: string) => escapeHtml(s).replace(/"/g, """); const file = keep( new TextEncoder().encode(JSON.stringify(manifest, null, 2) + "\n"), "manifest", ".webmanifest", ); const head = [ `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">`, `<link rel="manifest" href="/_app/assets/${file}">`, ]; const plain = icons.filter((i) => !i.purpose || /\bany\b/.test(i.purpose)); // the tab's icon: an .svg if there's one, as it's sharp at every size const tab = plain.find((i) => i.type === "image/svg+xml") ?? plain[0]; if (tab) head.push( `<link rel="icon" href="${attr(asset(tab.src))}"${tab.type ? ` type="${attr(tab.type)}"` : ""}${tab.sizes ? ` sizes="${attr(tab.sizes)}"` : ""}>`, ); // iOS's home screen takes a .png, not the manifest's icons: the largest const side = (i: Image) => Math.max(...(i.sizes ?? "").split(/\s+/).map((s) => Number(s.split("x")[0]) || 0)); const touch = plain.filter((i) => i.type === "image/png").sort((a, b) => side(b) - side(a))[0]; if (touch) head.push(`<link rel="apple-touch-icon" href="${attr(asset(touch.src))}">`); if (typeof given.theme_color === "string") head.push(`<meta name="theme-color" content="${attr(given.theme_color)}">`); if (typeof given.description === "string") head.push(`<meta name="description" content="${attr(given.description)}">`); const short = typeof given.short_name === "string" ? given.short_name : given.name; head.push(`<meta name="apple-mobile-web-app-title" content="${attr(short)}">`); const warnings: string[] = []; if (!icons.length) warnings.push("the pwa has no icons: an app is installed with its icon"); else if (!touch || side(touch) < 180) warnings.push("the pwa has no .png icon of 180×180 or more, which iOS's home screen needs"); return { head, title: given.name, warnings }; }