webManifest

tcmts.cli.web.webManifest · #sbwpxp7o · term · runs anywhere

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, "&quot;");
  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 };
}

uses

used by