clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat(pwa): complete core web manifest

Dami committed 84b0276
commit
84b0276146beeb13bc5e53b236df9b7b989b0996
parent
e8f63e21006ac73b406ffcf677cd12eab80d088e
author
Dami <dami.etoile@gmail.com>
8 files changed, +168 -11
Aapps/reference/public/icon-monochrome.svg7++
Mapps/reference/public/manifest.webmanifest25+++++++
Mapps/reference/src/islands/TaskList.tsx2+-
Mdocs/deployment.md30++++++++-
Mdocs/reference/project-layout.md3+-
Mpackage/pwa_assets_test.ts106++++++++++++++++++++++++++++--
Mpackage/starter_template.ts1+
Mpackage/testdata/starter.snapshot.json5+-
A apps/reference/public/icon-monochrome.svg +7 -0
M apps/reference/public/manifest.webmanifest +25 -0
M apps/reference/src/islands/TaskList.tsx +1 -1
M docs/deployment.md +29 -1
@@ -39,7 +39,8 @@ deno task preview --port 4173
3939 Before deploying, review:4040 4141 - `src/app.ts` — name, database namespace, stable credential origins, and repository URL;42-- `public/manifest.webmanifest` — installed name, icons, colors, start URL, and display mode;42+- `public/manifest.webmanifest` — stable install identity, locale, icons, colors, launch behavior,43+ and shortcuts;4344 - `public/favicon.svg` and any added icon files;4445 - page titles, descriptions, and starter copy;4546 - `.env` — either no public Jazz pair for local-only mode or a complete pair for optional sync.
@@ -58,6 +59,33 @@ and a subpath build are different deployment artifacts; rebuild after changing `
5859 5960 Run `deno task doctor` and `deno task test` before the production build.6061 62+### Customize the web manifest before launch63+64+Treat `public/manifest.webmanifest` as product source, not generated build metadata. Before users65+install the app:66+67+- replace `name`, `short_name`, `description`, `id`, `lang`, and `dir` with product values;68+- choose an `id` URL token that will remain stable for the lifetime of the installed app; unlike69+ `start_url`, it identifies the app and does not need to be a navigable page;70+- keep `scope`, `start_url`, every shortcut URL, and every shortcut icon aligned with the deployed71+ base path;72+- replace the regular, maskable, Apple touch, and transparent monochrome icon assets while keeping73+ their purposes intact;74+- keep `orientation: "any"` unless the product genuinely needs a screen-orientation lock; and75+- replace or remove the starter `Open tasks` shortcut when replacing the task example.76+77+After launch, changing the name or start page is routine; changing `id` can make a browser treat the78+manifest as a different installed application. Choose it once. If the product ships in multiple79+languages, keep the default strings plus `lang` and `dir`, then add language maps such as80+`name_localized`, `short_name_localized`, and `description_localized`. Localized values whose text81+direction differs from the manifest default should declare their own `dir`.82+83+Storefront metadata is optional and product-specific. Add lowercase `categories` only when they84+truthfully describe the finished app. Add `iarc_rating_id` only after obtaining a real IARC85+certification code; never copy a placeholder rating. Rich screenshots are covered separately.86+Experimental capabilities such as file, protocol, share-target, and launch handlers are deliberately87+absent from the starter and should be added only with matching product behavior and tests.88+6189 ## Deno Deploy6290 6391 Create the static application once:
M docs/reference/project-layout.md +2 -1
M package/pwa_assets_test.ts +100 -6
@@ -1,6 +1,25 @@
11 import { basename } from "node:path";22 33 type ManifestIcon = { src: string; sizes: string; type: string; purpose: string };4+type ManifestShortcut = {5+ name: string;6+ short_name?: string;7+ description?: string;8+ url: string;9+ icons?: Array<Omit<ManifestIcon, "purpose">>;10+};11+12+type Manifest = {13+ id: string;14+ lang: string;15+ dir: string;16+ start_url: string;17+ scope: string;18+ orientation: string;19+ icons: ManifestIcon[];20+ shortcuts: ManifestShortcut[];21+ [member: string]: unknown;22+};423 524 function assert(condition: unknown, message: string): asserts condition {625 if (!condition) throw new Error(message);
@@ -14,20 +33,54 @@ function pngDimensions(bytes: Uint8Array): { width: number; height: number } {
1433 return { width: view.getUint32(16), height: view.getUint32(20) };1534 }1635 17-Deno.test("starter manifest references install-grade raster icons with exact dimensions", async () => {36+Deno.test("starter manifest declares stable identity, locale, and orientation defaults", async () => {37+ const publicRoot = new URL("../apps/reference/public/", import.meta.url);38+ const manifest = JSON.parse(39+ await Deno.readTextFile(new URL("manifest.webmanifest", publicRoot)),40+ ) as Manifest;41+ assert(typeof manifest.id === "string" && manifest.id.length > 0, "manifest id is missing");42+ assert(manifest.id !== manifest.start_url, "manifest id must not be derived from start_url");43+ assert(manifest.lang === "en", "starter manifest language must be explicit");44+ assert(manifest.dir === "ltr", "starter manifest direction must be explicit");45+ assert(manifest.orientation === "any", "starter manifest must explicitly allow any orientation");46+47+ const manifestUrl = new URL("https://example.test/field-notes/manifest.webmanifest");48+ const identity = new URL(manifest.id, manifestUrl);49+ const startUrl = new URL(manifest.start_url, manifestUrl);50+ assert(identity.origin === startUrl.origin, "manifest id must remain same-origin");51+52+ for (53+ const member of [54+ "capture_links",55+ "display_override",56+ "edge_side_panel",57+ "file_handlers",58+ "handle_links",59+ "launch_handler",60+ "protocol_handlers",61+ "scope_extensions",62+ "share_target",63+ "tab_strip",64+ ]65+ ) {66+ assert(!(member in manifest), `experimental member ${member} must be opt-in`);67+ }68+ assert(!("iarc_rating_id" in manifest), "starter manifest must not claim an IARC rating");69+ assert(!("categories" in manifest), "product categories must be chosen by the app author");70+});71+72+Deno.test("starter manifest covers regular, maskable, monochrome, and Apple icons", async () => {1873 const publicRoot = new URL("../apps/reference/public/", import.meta.url);1974 const manifest = JSON.parse(2075 await Deno.readTextFile(new URL("manifest.webmanifest", publicRoot)),21- ) as {22- icons: ManifestIcon[];23- };76+ ) as Manifest;2477 const expected = new Map([2578 ["icon-192.png", { size: 192, purpose: "any" }],2679 ["icon-512.png", { size: 512, purpose: "any" }],2780 ["icon-maskable-512.png", { size: 512, purpose: "maskable" }],2881 ]);29- assert(manifest.icons.length === expected.size, "manifest icon set changed unexpectedly");30- for (const icon of manifest.icons) {82+ const rasterIcons = manifest.icons.filter((icon) => icon.type === "image/png");83+ for (const icon of rasterIcons) {3184 const file = basename(icon.src);3285 const contract = expected.get(file);3386 assert(contract, `manifest references unexpected icon ${file}`);
@@ -43,6 +96,19 @@ Deno.test("starter manifest references install-grade raster icons with exact dim
4396 `${file} is ${dimensions.width}x${dimensions.height}`,4497 );4598 }99+ assert(rasterIcons.length === expected.size, "starter raster icon roles are incomplete");100+101+ const monochrome = manifest.icons.find((icon) => icon.purpose === "monochrome");102+ assert(monochrome, "manifest has no monochrome icon");103+ assert(monochrome.type === "image/svg+xml", "monochrome icon must be a vector silhouette");104+ assert(monochrome.sizes === "any", "vector monochrome icon must declare any size");105+ const monochromeSource = await Deno.readTextFile(new URL(monochrome.src, publicRoot));106+ assert(monochromeSource.includes("<svg"), "monochrome icon is not SVG");107+ assert(108+ !monochromeSource.includes("<rect"),109+ "monochrome icon must keep its background transparent",110+ );111+46112 const apple = pngDimensions(await Deno.readFile(new URL("apple-touch-icon.png", publicRoot)));47113 assert(apple.width === 180 && apple.height === 180, "apple-touch-icon.png must be 180x180");48114 const shell = await Deno.readTextFile(
@@ -55,3 +121,31 @@ Deno.test("starter manifest references install-grade raster icons with exact dim
55121 "install assets are not linked through the deployment base",56122 );57123 });124+125+Deno.test("starter shortcut and its icons stay inside the application scope", async () => {126+ const publicRoot = new URL("../apps/reference/public/", import.meta.url);127+ const manifest = JSON.parse(128+ await Deno.readTextFile(new URL("manifest.webmanifest", publicRoot)),129+ ) as Manifest;130+ assert(manifest.shortcuts.length === 1, "starter must carry one clearly replaceable shortcut");131+132+ const manifestUrl = new URL("https://example.test/field-notes/manifest.webmanifest");133+ const scope = new URL(manifest.scope, manifestUrl);134+ const shortcut = manifest.shortcuts[0];135+ const shortcutUrl = new URL(shortcut.url, manifestUrl);136+ assert(shortcutUrl.href.startsWith(scope.href), "starter shortcut escapes manifest scope");137+ assert(shortcut.icons?.length, "starter shortcut has no icon");138+ for (const icon of shortcut.icons) {139+ const iconUrl = new URL(icon.src, manifestUrl);140+ assert(iconUrl.href.startsWith(scope.href), "starter shortcut icon escapes manifest scope");141+ assert(142+ manifest.icons.some((candidate) => candidate.src === icon.src),143+ "starter shortcut icon is not part of the install icon set",144+ );145+ }146+147+ const taskIsland = await Deno.readTextFile(148+ new URL("../apps/reference/src/islands/TaskList.tsx", import.meta.url),149+ );150+ assert(taskIsland.includes('id="tasks"'), "starter shortcut has no matching task anchor");151+});
M package/starter_template.ts +1 -0
M package/testdata/starter.snapshot.json +3 -2