clone

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

feat(pwa): add rich install presentation

Dami committed bd5a682
Add real narrow and wide install screenshots, validate rich manifest metadata and offline shortcut routes, keep presentation assets out of the shell precache, and document opt-in share targets.
commit
bd5a682310679c1faa4dc3a70f0ae4897bf6aaa6
parent
adbae75db3476675cec34e57e40ef6095f7dbe9a
author
Dami <dami.etoile@gmail.com>
14 files changed, +243 -13
Mapps/reference/public/manifest.webmanifest16+++++
Aapps/reference/public/screenshot-narrow.pngbin
Aapps/reference/public/screenshot-wide.pngbin
Mdocs/deployment.md43+++++++++++++-
Mdocs/reference/project-layout.md2+
Mpackage/commands/build.ts8++-
Mpackage/pwa_assets_test.ts36++++++++++++
Mpackage/starter_template.ts2+
Mpackage/testdata/starter.snapshot.json4+-
Mpackage/tooling/project.ts7++-
Mpackage/tooling/project_test.ts3+-
Mpackage/tooling/pwa-validation.ts92+++++++++++++++++++++++++++++-
Mpackage/tooling/pwa-validation_test.ts15+++++-
Mtools/golden.ts28+++++++++
M apps/reference/public/manifest.webmanifest +16 -0
A apps/reference/public/screenshot-narrow.png +0 -0

Binary file.

A apps/reference/public/screenshot-wide.png +0 -0

Binary file.

M docs/deployment.md +40 -3
@@ -84,9 +84,46 @@ direction differs from the manifest default should declare their own `dir`.
8484 8585 Storefront metadata is optional and product-specific. Add lowercase `categories` only when they8686 truthfully describe the finished app. Add `iarc_rating_id` only after obtaining a real IARC87-certification code; never copy a placeholder rating. Rich screenshots are covered separately.88-Experimental capabilities such as file, protocol, share-target, and launch handlers are deliberately89-absent from the starter and should be added only with matching product behavior and tests.87+certification code; never copy a placeholder rating. Experimental capabilities such as file,88+protocol, share-target, and launch handlers are deliberately absent from the starter and should be89+added only with matching product behavior and tests.90+91+### Replace or remove install presentation92+93+The starter manifest includes one labeled `540x720` narrow screenshot, one labeled `1280x720` wide94+screenshot, and one **Open tasks** shortcut. They are examples of the real generated app, not95+generic promotional art. Before launch:96+97+- replace both screenshots with current product captures and update each `sizes`, `label`, and98+ `form_factor`, or remove the entire `screenshots` member and both files;99+- replace the shortcut name, description, route, and icon with one useful product entry point, or100+ remove the entire `shortcuts` member;101+- keep shortcut routes inside manifest scope and backed by a prerendered route so they cold-start102+ offline; and103+- keep shortcut icons inside scope with truthful MIME types and dimensions.104+105+Build validation checks every referenced asset, requires labeled narrow and wide variants when the106+`screenshots` member is present, and rejects shortcut routes that were not emitted. Screenshots are107+deliberately excluded from the required shell precache; deleting promotional captures cannot break108+offline startup.109+110+The starter does not claim a Web Share Target. If a product opts in, first add a same-scope,111+prerendered action route, then add a manifest member such as:112+113+```json114+{115+ "share_target": {116+ "action": "./share/",117+ "method": "GET",118+ "enctype": "application/x-www-form-urlencoded",119+ "params": { "title": "title", "text": "text", "url": "url" }120+ }121+}122+```123+124+The receiving island must ignore unknown parameters, cap title/text lengths, and accept a shared URL125+only after parsing it and allow-listing the intended protocols (normally `https:` and `http:`). Test126+direct, malformed, oversized, and offline launches before shipping the opt-in route.90127 91128 ## Deno Deploy92129
M docs/reference/project-layout.md +2 -0
M package/commands/build.ts +6 -2
M package/pwa_assets_test.ts +36 -0
@@ -8,6 +8,13 @@ type ManifestShortcut = {
88 url: string;99 icons?: Array<Omit<ManifestIcon, "purpose">>;1010 };11+type ManifestScreenshot = {12+ src: string;13+ sizes: string;14+ type: string;15+ form_factor: "narrow" | "wide";16+ label: string;17+};1118 1219 type Manifest = {1320 id: string;
@@ -17,6 +24,7 @@ type Manifest = {
1724 scope: string;1825 orientation: string;1926 icons: ManifestIcon[];27+ screenshots: ManifestScreenshot[];2028 shortcuts: ManifestShortcut[];2129 [member: string]: unknown;2230 };
@@ -149,3 +157,31 @@ Deno.test("starter shortcut and its icons stay inside the application scope", as
149157 );150158 assert(taskIsland.includes('id="tasks"'), "starter shortcut has no matching task anchor");151159 });160+161+Deno.test("starter install screenshots are labeled narrow and wide captures", async () => {162+ const publicRoot = new URL("../apps/reference/public/", import.meta.url);163+ const manifest = JSON.parse(164+ await Deno.readTextFile(new URL("manifest.webmanifest", publicRoot)),165+ ) as Manifest;166+ const expected = new Map([167+ ["narrow", { file: "screenshot-narrow.png", width: 540, height: 720 }],168+ ["wide", { file: "screenshot-wide.png", width: 1280, height: 720 }],169+ ]);170+ assert(manifest.screenshots.length === expected.size, "starter screenshot set must stay small");171+ for (const screenshot of manifest.screenshots) {172+ const contract = expected.get(screenshot.form_factor);173+ assert(contract, `unexpected screenshot form factor ${screenshot.form_factor}`);174+ assert(basename(screenshot.src) === contract.file, `${screenshot.form_factor} file drifted`);175+ assert(screenshot.type === "image/png", `${contract.file} must declare image/png`);176+ assert(screenshot.label.trim().length > 0, `${contract.file} needs an accessible label`);177+ assert(178+ screenshot.sizes === `${contract.width}x${contract.height}`,179+ `${contract.file} has the wrong declared size`,180+ );181+ const dimensions = pngDimensions(await Deno.readFile(new URL(contract.file, publicRoot)));182+ assert(183+ dimensions.width === contract.width && dimensions.height === contract.height,184+ `${contract.file} is ${dimensions.width}x${dimensions.height}`,185+ );186+ }187+});
M package/starter_template.ts +2 -0
M package/testdata/starter.snapshot.json +3 -1
M package/tooling/project.ts +5 -2
M package/tooling/project_test.ts +2 -1
M package/tooling/pwa-validation.ts +89 -3
@@ -401,6 +401,34 @@ async function parseAndValidateManifest(
401401 remediation,402402 );403403 issues.push(...screenshots.issues);404+ const validSources = new Set(screenshots.images.map((image) => image.src));405+ const formFactors = new Set<string>();406+ if (Array.isArray(manifest.screenshots)) {407+ for (const [index, raw] of manifest.screenshots.entries()) {408+ if (!isObject(raw)) continue;409+ const label = `${manifestFile}: screenshots[${index}]`;410+ if (!nonEmptyString(raw.label)) {411+ issues.push(issue(`${label}.label must describe the screenshot`, remediation));412+ }413+ if (raw.form_factor !== "narrow" && raw.form_factor !== "wide") {414+ issues.push(415+ issue(`${label}.form_factor must be narrow or wide`, remediation),416+ );417+ } else if (nonEmptyString(raw.src) && validSources.has(raw.src)) {418+ formFactors.add(raw.form_factor);419+ }420+ }421+ }422+ for (const formFactor of ["narrow", "wide"]) {423+ if (!formFactors.has(formFactor)) {424+ issues.push(425+ issue(426+ `${manifestFile}: screenshots need a valid labeled ${formFactor} image`,427+ remediation,428+ ),429+ );430+ }431+ }404432 }405433 }406434 return { issues, manifest, scope };
@@ -425,6 +453,20 @@ function links(html: string): Array<{ rel: string; href: string }> {
425453 });426454 }427455 456+function emittedRoutePath(url: URL, basePath: string): string | undefined {457+ if (url.origin !== syntheticOrigin || !url.pathname.startsWith(basePath)) return undefined;458+ let path: string;459+ try {460+ path = decodeURIComponent(url.pathname.slice(basePath.length));461+ } catch {462+ return undefined;463+ }464+ if (path.split("/").some((part) => part === "..")) return undefined;465+ if (!path) return "index.html";466+ if (path.endsWith("/")) return `${path}index.html`;467+ return path.endsWith(".html") ? path : `${path}/index.html`;468+}469+428470 async function filesUnder(root: string): Promise<string[]> {429471 const files: string[] = [];430472 async function visit(path: string) {
@@ -440,11 +482,33 @@ async function filesUnder(root: string): Promise<string[]> {
440482 441483 const precacheExcludedPaths = new Set(["lofi-build.json", "lofi-precache.json", "sw.js"]);442484 485+/** Returns manifest screenshot paths, which are install presentation rather than shell resources. */486+export function screenshotAssetPaths(manifest: unknown, deploymentBase = "/"): string[] {487+ if (!isObject(manifest) || !Array.isArray(manifest.screenshots)) return [];488+ const basePath = normalizeDeploymentBase(deploymentBase);489+ const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);490+ return [491+ ...new Set(manifest.screenshots.flatMap((raw) => {492+ if (!isObject(raw) || !nonEmptyString(raw.src)) return [];493+ try {494+ const path = assetPath(new URL(raw.src, manifestUrl), basePath);495+ return path ? [path] : [];496+ } catch {497+ return [];498+ }499+ })),500+ ].sort();501+}502+443503 /** Map build output paths to portable URLs expected in the service-worker precache. */444-export function expectedPrecacheUrls(paths: readonly string[]): string[] {504+export function expectedPrecacheUrls(505+ paths: readonly string[],506+ presentationPaths: readonly string[] = [],507+): string[] {508+ const presentation = new Set(presentationPaths.map((path) => path.replaceAll("\\", "/")));445509 return paths446510 .map((path) => path.replaceAll("\\", "/"))447- .filter((path) => !precacheExcludedPaths.has(path))511+ .filter((path) => !precacheExcludedPaths.has(path) && !presentation.has(path))448512 .map((path) => path === "index.html" ? "./" : `./${path}`)449513 .sort();450514 }
@@ -503,6 +567,25 @@ export async function productionPwaIssues(root: string, deploymentBase = "/") {
503567 if (htmlFiles.length === 0) {504568 issues.push(issue("dist/: no prerendered HTML routes were emitted", remediation));505569 }570+ if (Array.isArray(result.manifest?.shortcuts)) {571+ const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);572+ for (const [index, raw] of result.manifest.shortcuts.entries()) {573+ if (!isObject(raw) || !nonEmptyString(raw.url)) continue;574+ try {575+ const route = emittedRoutePath(new URL(raw.url, manifestUrl), basePath);576+ if (!route || !htmlFiles.includes(route)) {577+ issues.push(578+ issue(579+ `dist/${manifestFile}: shortcuts[${index}].url has no emitted offline route`,580+ remediation,581+ ),582+ );583+ }584+ } catch {585+ // Source validation already reports malformed shortcut URLs.586+ }587+ }588+ }506589 const expectedManifest = new URL(`${syntheticOrigin}${basePath}${manifestFile}`).href;507590 const appleIcons = new Set<string>();508591 for (const path of htmlFiles) {
@@ -566,7 +649,10 @@ export async function productionPwaIssues(root: string, deploymentBase = "/") {
566649 issue("dist/lofi-precache.json: missing or malformed precache manifest", remediation),567650 );568651 }569- const expectedPrecache = expectedPrecacheUrls(files);652+ const expectedPrecache = expectedPrecacheUrls(653+ files,654+ screenshotAssetPaths(result.manifest, basePath),655+ );570656 if (571657 !Array.isArray(precache) || !precache.every((entry) => typeof entry === "string") ||572658 JSON.stringify([...precache].sort()) !== JSON.stringify(expectedPrecache)
M package/tooling/pwa-validation_test.ts +14 -1
@@ -4,6 +4,7 @@ import {
44 expectedPrecacheUrls,55 productionContentType,66 productionPwaIssues,7+ screenshotAssetPaths,78 sourcePwaIssues,89 } from "./pwa-validation.ts";910
@@ -132,6 +133,8 @@ Deno.test("source PWA validation checks optional shortcuts and screenshots", asy
132133 const issues = await sourcePwaIssues(project, "/field-notes/");133134 assert(hasIssue(issues, "shortcuts[0].url must stay inside"), "escaped shortcut was accepted");134135 assert(hasIssue(issues, "missing asset missing-shot.png"), "missing screenshot was accepted");136+ assert(hasIssue(issues, "label must describe"), "unlabeled screenshot was accepted");137+ assert(hasIssue(issues, "form_factor must be narrow or wide"), "missing form factor passed");135138 } finally {136139 await Deno.remove(root, { recursive: true });137140 }
@@ -198,9 +201,12 @@ async function makeProductionFixture(): Promise<string> {
198201 `const revision = "fixture-hash"; new URL("./lofi-precache.json", self.registration.scope);\n`,199202 );200203 files.push("index.html", "settings/index.html", "lofi-build.json", "sw.js");204+ const manifest = JSON.parse(await Deno.readTextFile(join(dist, "manifest.webmanifest")));201205 await Deno.writeTextFile(202206 join(dist, "lofi-precache.json"),203- `${JSON.stringify(expectedPrecacheUrls(files))}\n`,207+ `${208+ JSON.stringify(expectedPrecacheUrls(files, screenshotAssetPaths(manifest, "/field-notes/")))209+ }\n`,204210 );205211 return root;206212 }
@@ -228,10 +234,17 @@ Deno.test("production PWA validation catches nested links, precache drift, and w
228234 join(dist, "sw.js"),229235 `new URL("./lofi-precache.json", self.registration.scope);\n`,230236 );237+ const manifest = JSON.parse(await Deno.readTextFile(join(dist, "manifest.webmanifest")));238+ manifest.shortcuts[0].url = "./missing/";239+ await Deno.writeTextFile(240+ join(dist, "manifest.webmanifest"),241+ `${JSON.stringify(manifest, null, 2)}\n`,242+ );231243 const issues = await productionPwaIssues(root, "/field-notes/");232244 assert(hasIssue(issues, "settings/index.html: must link"), "nested manifest link drift passed");233245 assert(hasIssue(issues, "entries do not match"), "precache drift passed");234246 assert(hasIssue(issues, "build revision does not match"), "worker revision drift passed");247+ assert(hasIssue(issues, "has no emitted offline route"), "missing shortcut route passed");235248 } finally {236249 await Deno.remove(root, { recursive: true });237250 }
M tools/golden.ts +28 -0
@@ -430,11 +430,20 @@ async function assertProductionOutput(projectRoot: string) {
430430 "icon-192.png",431431 "icon-512.png",432432 "icon-maskable-512.png",433+ "screenshot-narrow.png",434+ "screenshot-wide.png",433435 ]434436 ) assert(files.includes(required), `production output is missing ${required}`);435437 436438 const manifest = JSON.parse(await Deno.readTextFile(join(dist, "manifest.webmanifest"))) as {437439 icons: Array<{ src: string; sizes: string; type: string; purpose: string }>;440+ screenshots: Array<{441+ src: string;442+ sizes: string;443+ type: string;444+ form_factor: string;445+ label: string;446+ }>;438447 };439448 const expected = new Map([440449 [
@@ -471,6 +480,22 @@ async function assertProductionOutput(projectRoot: string) {
471480 }472481 }473482 assert(manifest.icons.length === expected.size, "manifest does not contain the exact icon set");483+ const screenshots = new Map([484+ ["narrow", ["screenshot-narrow.png", 540, 720] as const],485+ ["wide", ["screenshot-wide.png", 1280, 720] as const],486+ ]);487+ for (const screenshot of manifest.screenshots) {488+ const contract = screenshots.get(screenshot.form_factor);489+ assert(contract, `manifest references unexpected ${screenshot.form_factor} screenshot`);490+ const file = basename(screenshot.src);491+ assert(file === contract[0], `${screenshot.form_factor} screenshot file drifted`);492+ assert(screenshot.type === "image/png", `${file} has incorrect manifest type`);493+ assert(screenshot.sizes === `${contract[1]}x${contract[2]}`, `${file} size drifted`);494+ assert(screenshot.label.trim().length > 0, `${file} has no label`);495+ const [width, height] = pngDimensions(await Deno.readFile(join(dist, file)));496+ assert(width === contract[1] && height === contract[2], `${file} dimensions are incorrect`);497+ }498+ assert(manifest.screenshots.length === screenshots.size, "manifest screenshot set is incomplete");474499 const [appleWidth, appleHeight] = pngDimensions(475500 await Deno.readFile(join(dist, "apple-touch-icon.png")),476501 );
@@ -489,6 +514,9 @@ async function assertProductionOutput(projectRoot: string) {
489514 "./icon-maskable-512.png",490515 ]491516 ) assert(precache.includes(shellAsset), `precache is missing ${shellAsset}`);517+ for (const screenshot of ["screenshot-narrow.png", "screenshot-wide.png"]) {518+ assert(!precache.includes(`./${screenshot}`), `${screenshot} leaked into the required shell`);519+ }492520 const worker = await Deno.readTextFile(join(dist, "sw.js"));493521 assert(494522 !worker.includes("__LOFI_BUILD_REVISION__"),