clone

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

feat(pwa): add opt-in web share recipe

Dami committed 65d8a21
Add isolated inbound and outbound Web Share helpers, validate optional text share targets, document the copyable receiver flow, and exercise unsupported, cancelled, malformed, confirmed, and offline browser paths.
commit
65d8a21b944a784e2026abab1b0e9aeed43d9808
parent
ba1f47011a1cee5fdf14b3f33388c184389a2fd3
author
Dami <dami.etoile@gmail.com>
19 files changed, +807 -19
MREADME.md7+
Mapps/reference/deno.json1+
Mdeno.json4+-
Mdocs/README.md1+
Mdocs/deployment.md13+-
Mdocs/getting-started.md17+-
Adocs/recipes/README.md32+++++
Adocs/recipes/web-share.md141++++++++++++++++++++++
Mpackage/astro/mod.ts14++
Mpackage/astro/mod_test.ts2+
Mpackage/create_core.ts12++
Mpackage/create_core_test.ts9+-
Apackage/recipes/web-share.ts192++++++++++++++++++++++++++++++
Apackage/recipes/web-share_test.ts95+++++++++++++++
Mpackage/testdata/starter.snapshot.json4+-
Mpackage/tooling/pwa-validation.ts92++++++++++++++
Mpackage/tooling/pwa-validation_test.ts44+++++++
Mtools/doc_lint.ts1+
Mtools/golden.ts145+++++++++++++++++++++++-
M README.md +7 -0
M apps/reference/deno.json +1 -0
M deno.json +3 -1
@@ -14,6 +14,7 @@
1414 "./preview": "./package/commands/preview.ts",1515 "./preact": "./package/preact/mod.ts",1616 "./provision": "./package/commands/provision.ts",17+ "./recipes/web-share": "./package/recipes/web-share.ts",1718 "./testing": "./package/testing/mod.ts",1819 "./test": "./package/commands/run_tests.ts"1920 },
@@ -46,7 +47,7 @@
4647 "check": "deno fmt --check . && deno lint && deno task doc && deno task docs:links && deno task test:unit && deno task test:access",4748 "doc": "deno run --allow-run tools/doc_lint.ts",4849 "docs:links": "deno run --allow-read tools/docs_links.ts",49- "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/runtime package/testing package/tooling apps/reference/tests",50+ "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/recipes package/runtime package/testing package/tooling apps/reference/tests",5051 "test:access": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/access/*_test.ts",5152 "test:update:create": "LOFI_UPDATE_SNAPSHOT=1 deno test --allow-env=LOFI_UPDATE_SNAPSHOT --allow-read --allow-write=. --allow-run package/create_core_test.ts",5253 "test": "deno task test:unit",
@@ -80,6 +81,7 @@
8081 "docs/examples",8182 "docs/permissions.md",8283 "docs/reference",84+ "docs/recipes",8385 "docs/sync-and-recovery.md",8486 "docs/testing.md",8587 "docs/troubleshooting.md",
M docs/README.md +1 -0
M docs/deployment.md +8 -5
@@ -107,8 +107,9 @@ Build validation checks every referenced asset, requires labeled narrow and wide
107107 deliberately excluded from the required shell precache; deleting promotional captures cannot break108108 offline startup.109109 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:110+The starter does not claim a Web Share Target. If a product opts in, follow the tested111+[Web Share recipe](recipes/web-share.md): first add a same-scope, prerendered action route, then add112+a manifest member such as:112113 113114 ```json114115 {
@@ -121,9 +122,11 @@ prerendered action route, then add a manifest member such as:
121122 }122123 ```123124 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.125+Use `parseTextShareTarget()` in the receiving island to ignore unknown parameters, reject duplicate126+or oversized values, and accept a shared URL only after parsing it and allow-listing `https:` and127+`http:`. Present the result as a draft: receiving a share is not user confirmation to persist it.128+Build validation rejects malformed declarations, POST/file shares without a matching worker recipe,129+and action routes that were not emitted for offline startup.127130 128131 ## Deno Deploy129132
M docs/getting-started.md +9 -8
@@ -62,14 +62,15 @@ allowing framework fixes to arrive through a package upgrade.
6262 6363 The final package map is intentionally small:6464 65-| Import | Supported use |66-| ----------------------------- | --------------------------------------------------- |67-| `@nzip/lofi` | App configuration, runtime, session, stores, PWA |68-| `@nzip/lofi/access` | Private, direct-share, and fixed-role group helpers |69-| `@nzip/lofi/preact` | Package-owned Preact hooks and diagnostic UI |70-| `@nzip/lofi/astro` | Package-owned Astro/Vite integration |71-| `@nzip/lofi/testing` | Local-first Playwright helpers |72-| `@nzip/lofi/{create,dev,...}` | Generator and generated-project command entrypoints |65+| Import | Supported use |66+| ------------------------------ | --------------------------------------------------- |67+| `@nzip/lofi` | App configuration, runtime, session, stores, PWA |68+| `@nzip/lofi/access` | Private, direct-share, and fixed-role group helpers |69+| `@nzip/lofi/preact` | Package-owned Preact hooks and diagnostic UI |70+| `@nzip/lofi/astro` | Package-owned Astro/Vite integration |71+| `@nzip/lofi/recipes/web-share` | Optional inbound/outbound text and link sharing |72+| `@nzip/lofi/testing` | Local-first Playwright helpers |73+| `@nzip/lofi/{create,dev,...}` | Generator and generated-project command entrypoints |7374 7475 All of these imports resolve through the single version pinned in `deno.json`. Upgrade that pin to7576 receive framework fixes; do not copy package files into `src/` or import unpublished internal paths.
A docs/recipes/README.md +32 -0
@@ -0,0 +1,32 @@
1+# Optional installed-app recipes2+3+These recipes add OS-facing PWA capabilities only when a product has a concrete use case. They are4+not part of the generated starter and unsupported browsers keep the normal web experience.5+6+```mermaid7+flowchart LR8+ Need["Concrete product need"] --> Support{"Support and fallback understood?"}9+ Support -- "no" --> Web["Keep the normal web flow"]10+ Support -- "yes" --> OptIn["Add manifest/runtime recipe"]11+ OptIn --> Validate["Validate external input"]12+ Validate --> Test["Test direct, installed, offline, and unsupported paths"]13+```14+15+## Available16+17+- [Inbound and outbound Web Share](web-share.md) — share text or links through the OS sheet and18+ receive an untrusted draft through an optional installed-app share target.19+20+## Contract for every recipe21+22+- Keep the capability absent until the application opts in.23+- State the product use case and current browser/OS support.24+- Feature-detect and preserve a useful ordinary-web fallback.25+- Ask for permission only after a user action that explains the value.26+- Treat launch payloads, files, URLs, messages, and subscription data as untrusted input.27+- Explain whether an install, reinstall, manifest refresh, or worker update is required.28+- Test direct browser navigation, installed launch, offline startup, unsupported browsers, malformed29+ input, and removal of the capability.30+31+Foreground recovery remains lofi's portable baseline. A recipe never moves Jazz sync or OPFS into32+the service worker without a separate architecture decision.
A docs/recipes/web-share.md +141 -0
@@ -0,0 +1,141 @@
1+# Inbound and outbound Web Share2+3+Use this recipe when a user should explicitly share a small text/link payload through the OS share4+sheet or open an installed lofi app with shared text as an uncommitted draft.5+6+The [outbound Web Share API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API) and7+inbound8+[`share_target` manifest member](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/share_target)9+have limited browser support. Both require a secure deployed context; the inbound target appears10+only after installation. Unsupported browsers must retain an ordinary copy/link or form flow.11+12+## Outbound share13+14+Import the isolated recipe entrypoint. Call it directly from the click handler so the browser still15+has transient user activation:16+17+```tsx18+import { shareOrFallback } from "@nzip/lofi/recipes/web-share";19+20+<button21+ type="button"22+ onClick={() => {23+ void shareOrFallback(24+ { title: task.text, text: task.text, url: new URL(`#task-${task.id}`, location.href).href },25+ {26+ fallback: async (data) => {27+ await navigator.clipboard.writeText(data.url ?? data.text ?? "");28+ },29+ },30+ ).then((outcome) => {31+ // shared: native sheet completed32+ // cancelled: user closed the native sheet; do not show an error33+ // fallback: the normal copy action ran34+ // failed: offer an explicit retry or copy button35+ setShareOutcome(outcome);36+ });37+ }}38+>39+ Share task40+</button>;41+```42+43+The helper calls `navigator.canShare()` when present. It falls back only when native sharing is44+missing or rejects the payload before opening. Cancellation is a normal result; a runtime failure45+does not silently copy data after the native sheet may already have opened.46+47+If Clipboard is also unavailable, make the fallback reveal a normal selected text field or link48+instead of claiming that copying succeeded.49+50+## Inbound text/link target51+52+Add an explicit opt-in member to `public/manifest.webmanifest`:53+54+```json55+{56+ "share_target": {57+ "action": "./share/",58+ "method": "GET",59+ "enctype": "application/x-www-form-urlencoded",60+ "params": {61+ "title": "title",62+ "text": "text",63+ "url": "url"64+ }65+ }66+}67+```68+69+This recipe intentionally accepts text and HTTP(S) links with GET. POST/file shares require worker70+request handling, temporary storage, file validation, and a redirect; lofi does not pretend that the71+baseline worker provides those behaviors.72+73+Create `src/pages/share.astro` as a prerendered route:74+75+```astro76+---77+import ShareReceiver from "../islands/ShareReceiver.tsx";78+import Shell from "../layouts/Shell.astro";79+---80+81+<Shell title="Review shared content">82+ <ShareReceiver client:load />83+</Shell>84+```85+86+Parse the current query only in the browser. The parser ignores unknown parameters, rejects87+duplicates and over-limit values, and accepts only absolute HTTP(S) URLs:88+89+```tsx90+import { useEffect, useState } from "preact/hooks";91+import { parseTextShareTarget, type TextShareTargetResult } from "@nzip/lofi/recipes/web-share";92+93+export default function ShareReceiver() {94+ const [result, setResult] = useState<TextShareTargetResult>();95+96+ useEffect(() => {97+ setResult(parseTextShareTarget(location.search));98+ }, []);99+100+ if (!result) return <p role="status">Opening shared draft…</p>;101+ if (!result.ok) return <p role="alert">This shared content is not valid.</p>;102+103+ return (104+ <form105+ onSubmit={(event) => {106+ event.preventDefault();107+ // Persist result.draft only here, after the user confirms.108+ }}109+ >110+ <h1>Review shared content</h1>111+ {result.draft.title && <p>{result.draft.title}</p>}112+ {result.draft.text && <p>{result.draft.text}</p>}113+ {result.draft.url && <a href={result.draft.url}>{result.draft.url}</a>}114+ <button type="submit">Add to app</button>115+ </form>116+ );117+}118+```119+120+Change the default parameter names or limits only when the manifest and parser options change121+together. Never render a received URL as HTML, use it as an arbitrary redirect, or persist the draft122+on page load.123+124+## Build, install, and remove125+126+`deno task doctor` validates the member shape and same-scope action. `deno task build` also requires127+the action to map to a prerendered route, which puts that HTML and its assets in the offline shell.128+129+Manifest capability changes do not propagate uniformly across installed platforms. After adding or130+removing the target, verify a fresh install and an existing installation; some platforms require131+reinstallation before the OS share sheet changes. Removing the member and receiver route restores132+the ordinary web app without affecting stored lofi data.133+134+## Verification checklist135+136+1. Open `/share/` directly with no query and show a fixed invalid-draft message.137+2. Open it with valid title, text, and HTTP(S) URL values and require confirmation before saving.138+3. Reject duplicate, malformed, non-HTTP(S), and over-limit values without echoing them in errors.139+4. Install the app, share into it from another app, and verify an offline cold start of the route.140+5. Exercise outbound shared, cancelled, unsupported/fallback, and runtime-failed outcomes.141+6. Remove `share_target`, rebuild, and confirm the rest of the PWA remains installable and offline.
M package/astro/mod.ts +14 -0
@@ -61,6 +61,8 @@ const preactFiles = [
6161 "use-device-capabilities.ts",6262 ] as const;6363 64+const recipeFiles = ["web-share.ts"] as const;65+6466 async function readPackageFile(path: string): Promise<string> {6567 const url = new URL(path, import.meta.url);6668 if (url.protocol === "file:") return await Deno.readTextFile(url);
@@ -75,6 +77,7 @@ function renderConfig(projectRoot: string): string {
7577 const runtimeEntry = join(projectRoot, ".lofi", "package", "runtime", "mod.ts");7678 const accessEntry = join(projectRoot, ".lofi", "package", "access", "mod.ts");7779 const preactEntry = join(projectRoot, ".lofi", "package", "preact", "mod.ts");80+ const webShareRecipe = join(projectRoot, ".lofi", "package", "recipes", "web-share.ts");7881 return `import preact from "@astrojs/preact";7982 import { defineConfig } from "astro/config";8083 import { jazzPlugin } from "jazz-tools/dev/vite";
@@ -114,6 +117,9 @@ export default defineConfig({
114117 },115118 resolve: {116119 alias: [120+ { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/web-share$/, replacement: ${121+ JSON.stringify(webShareRecipe)122+ } },117123 { find: /^jsr:@nzip\\/lofi@[^/]+\\/preact$/, replacement: ${JSON.stringify(preactEntry)} },118124 { find: /^jsr:@nzip\\/lofi@[^/]+\\/access$/, replacement: ${JSON.stringify(accessEntry)} },119125 { find: /^jsr:@nzip\\/lofi@[^/]+$/, replacement: ${JSON.stringify(runtimeEntry)} },
@@ -124,6 +130,7 @@ export default defineConfig({
124130 { find: /^npm:jazz-tools@[^/]+\\/passkey-backup$/, replacement: "jazz-tools/passkey-backup" },125131 { find: /^npm:jazz-tools@[^/]+\\/passphrase$/, replacement: "jazz-tools/passphrase" },126132 { find: /^npm:jazz-tools@[^/]+$/, replacement: "jazz-tools" },133+ { find: "@nzip/lofi/recipes/web-share", replacement: ${JSON.stringify(webShareRecipe)} },127134 { find: "@nzip/lofi/preact", replacement: ${JSON.stringify(preactEntry)} },128135 { find: "@nzip/lofi/access", replacement: ${JSON.stringify(accessEntry)} },129136 { find: "@nzip/lofi", replacement: ${JSON.stringify(runtimeEntry)} },
@@ -150,6 +157,7 @@ export async function prepareLofiAstroConfig(options: LofiAstroOptions = {}): Pr
150157 await Deno.mkdir(join(packageDir, "runtime"), { recursive: true });151158 await Deno.mkdir(join(packageDir, "access"), { recursive: true });152159 await Deno.mkdir(join(packageDir, "preact"), { recursive: true });160+ await Deno.mkdir(join(packageDir, "recipes"), { recursive: true });153161 for (const file of runtimeFiles) {154162 await Deno.writeTextFile(155163 join(packageDir, "runtime", file),
@@ -168,6 +176,12 @@ export async function prepareLofiAstroConfig(options: LofiAstroOptions = {}): Pr
168176 await readPackageFile(`../access/${file}`),169177 );170178 }179+ for (const file of recipeFiles) {180+ await Deno.writeTextFile(181+ join(packageDir, "recipes", file),182+ await readPackageFile(`../recipes/${file}`),183+ );184+ }171185 const configSource = renderConfig(projectRoot);172186 let current = "";173187 try {
M package/astro/mod_test.ts +2 -0
M package/create_core.ts +12 -0
@@ -78,6 +78,14 @@ local-only mode. Start with the
7878 [permissions guide](https://github.com/FelineStateMachine/lofi/blob/main/docs/permissions.md) and the7979 [access API reference](https://github.com/FelineStateMachine/lofi/blob/main/docs/reference/access.md).8080 81+## Optional installed-app recipes82+83+OS-facing capabilities remain absent until the product opts in. Start with the84+[installed-app recipe catalog](https://github.com/FelineStateMachine/lofi/blob/main/docs/recipes/README.md)85+for tested manifest patches, feature detection, input validation, offline behavior, and normal-web86+fallbacks. The first recipe covers inbound and outbound text/link sharing through the isolated87+\`@nzip/lofi/recipes/web-share\` entrypoint.88+8189 Public tasks: \`dev\`, \`doctor\`, \`test\`, \`build\`, and \`preview\`. Sync/backup and schema tasks:8290 \`jazz:provision\`, \`schema:validate\`, \`schema:deploy\`, \`migrations:create\`, and \`migrations:push\`.8391
@@ -121,6 +129,10 @@ const lofiImportTargets: Record<string, { subpath: string; localPath: string }>
121129 "@nzip/lofi/preview": { subpath: "preview", localPath: "commands/preview.ts" },122130 "@nzip/lofi/preact": { subpath: "preact", localPath: "preact/mod.ts" },123131 "@nzip/lofi/provision": { subpath: "provision", localPath: "commands/provision.ts" },132+ "@nzip/lofi/recipes/web-share": {133+ subpath: "recipes/web-share",134+ localPath: "recipes/web-share.ts",135+ },124136 "@nzip/lofi/test": { subpath: "test", localPath: "commands/run_tests.ts" },125137 "@nzip/lofi/testing": { subpath: "testing", localPath: "testing/mod.ts" },126138 };
M package/create_core_test.ts +7 -2
A package/recipes/web-share.ts +192 -0
@@ -0,0 +1,192 @@
1+/**2+ * Opt-in helpers for outbound Web Share and inbound text/link share targets.3+ *4+ * The helpers feature-detect browser support and parse received values without5+ * mutating application data. Add the matching manifest member and receiver UI6+ * only when the product has a concrete sharing workflow.7+ *8+ * @module9+ */10+11+/** Data accepted by the browser Web Share API. */12+export type WebShareData = {13+ title?: string;14+ text?: string;15+ url?: string;16+ files?: readonly File[];17+};18+19+/** Minimal browser seam used by {@link shareOrFallback}. */20+export type WebShareClient = {21+ canShare?: (data: WebShareData) => boolean;22+ share?: (data: WebShareData) => Promise<void>;23+};24+25+/** Stable, value-free outcome from one outbound share attempt. */26+export type WebShareOutcome = "shared" | "cancelled" | "fallback" | "failed";27+28+/** Browser seam and fallback used for one outbound share attempt. */29+export type ShareOrFallbackOptions = {30+ /** Override the browser seam for tests or non-window environments. */31+ client?: WebShareClient;32+ /** Normal web action used when native sharing is unavailable. */33+ fallback: (data: WebShareData) => void | Promise<void>;34+};35+36+function browserShareClient(): WebShareClient | undefined {37+ if (!("navigator" in globalThis)) return undefined;38+ const candidate = globalThis.navigator as Navigator & WebShareClient;39+ return candidate;40+}41+42+function cancelled(error: unknown): boolean {43+ return error instanceof DOMException && error.name === "AbortError";44+}45+46+/**47+ * Attempt a user-triggered native share and use a normal web fallback only48+ * when the capability is unavailable or rejects the payload up front.49+ *50+ * Call this function directly from a click/submit handler: browsers require51+ * transient user activation for `navigator.share()`. A runtime share failure52+ * is reported as `failed` so callers can offer a retry or explicit copy action53+ * without unexpectedly copying data after the native sheet opened.54+ */55+export async function shareOrFallback(56+ data: WebShareData,57+ options: ShareOrFallbackOptions,58+): Promise<WebShareOutcome> {59+ const client = options.client ?? browserShareClient();60+ if (!client?.share) {61+ await options.fallback(data);62+ return "fallback";63+ }64+ if (client.canShare) {65+ try {66+ if (!client.canShare(data)) {67+ await options.fallback(data);68+ return "fallback";69+ }70+ } catch {71+ await options.fallback(data);72+ return "fallback";73+ }74+ }75+ try {76+ await client.share(data);77+ return "shared";78+ } catch (error) {79+ return cancelled(error) ? "cancelled" : "failed";80+ }81+}82+83+/** Length limits applied to received title, text, and URL values. */84+export type TextShareTargetLimits = {85+ title: number;86+ text: number;87+ url: number;88+};89+90+/** Default limits for the text/link share-target recipe. */91+export const TEXT_SHARE_LIMITS: Readonly<TextShareTargetLimits> = {92+ title: 120,93+ text: 2_000,94+ url: 2_048,95+} as const;96+97+/** Query-parameter names declared by the matching manifest share target. */98+export type TextShareTargetNames = {99+ title: string;100+ text: string;101+ url: string;102+};103+104+/** Parsed text/link values awaiting explicit user confirmation. */105+export type TextShareTargetDraft = {106+ title?: string;107+ text?: string;108+ url?: string;109+};110+111+/** Stable validation issue that never contains received share values. */112+export type TextShareTargetIssue =113+ | "duplicate-title"114+ | "duplicate-text"115+ | "duplicate-url"116+ | "title-too-long"117+ | "text-too-long"118+ | "url-too-long"119+ | "invalid-url"120+ | "unsupported-url-protocol"121+ | "empty-share";122+123+/** Accepted draft or value-free rejection from an inbound share target. */124+export type TextShareTargetResult =125+ | { ok: true; draft: TextShareTargetDraft; issues: readonly [] }126+ | { ok: false; issues: readonly TextShareTargetIssue[] };127+128+/** Optional manifest-name and input-limit overrides for the inbound parser. */129+export type ParseTextShareTargetOptions = {130+ names?: Partial<TextShareTargetNames>;131+ limits?: Partial<TextShareTargetLimits>;132+};133+134+const defaultNames: TextShareTargetNames = { title: "title", text: "text", url: "url" };135+136+/**137+ * Parse an inbound GET share target as an untrusted draft.138+ *139+ * Unknown parameters are ignored. Duplicate, oversized, malformed, and140+ * non-HTTP(S) values reject the whole draft. The caller must still present the141+ * returned draft for explicit user confirmation before persisting anything.142+ */143+export function parseTextShareTarget(144+ input: URL | URLSearchParams | string,145+ options: ParseTextShareTargetOptions = {},146+): TextShareTargetResult {147+ const parameters = input instanceof URL148+ ? input.searchParams149+ : input instanceof URLSearchParams150+ ? input151+ : new URLSearchParams(input.startsWith("?") ? input.slice(1) : input);152+ const names = { ...defaultNames, ...options.names };153+ const limits = { ...TEXT_SHARE_LIMITS, ...options.limits };154+ const issues: TextShareTargetIssue[] = [];155+ const draft: TextShareTargetDraft = {};156+157+ const read = (field: keyof TextShareTargetNames): string | undefined => {158+ const values = parameters.getAll(names[field]);159+ if (values.length > 1) {160+ issues.push(`duplicate-${field}` as TextShareTargetIssue);161+ return undefined;162+ }163+ const value = values[0]?.trim();164+ if (!value) return undefined;165+ if (value.length > limits[field]) {166+ issues.push(`${field}-too-long` as TextShareTargetIssue);167+ return undefined;168+ }169+ return value;170+ };171+172+ draft.title = read("title");173+ draft.text = read("text");174+ const rawUrl = read("url");175+ if (rawUrl) {176+ try {177+ const parsed = new URL(rawUrl);178+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {179+ issues.push("unsupported-url-protocol");180+ } else {181+ draft.url = parsed.href;182+ }183+ } catch {184+ issues.push("invalid-url");185+ }186+ }187+ if (!draft.title && !draft.text && !draft.url && issues.length === 0) {188+ issues.push("empty-share");189+ }190+ if (issues.length > 0) return { ok: false, issues };191+ return { ok: true, draft, issues: [] };192+}
A package/recipes/web-share_test.ts +95 -0
@@ -0,0 +1,95 @@
1+import { parseTextShareTarget, shareOrFallback, type WebShareClient } from "./web-share.ts";2+3+function assert(condition: unknown, message: string): asserts condition {4+ if (!condition) throw new Error(message);5+}6+7+Deno.test("share recipe uses native share only for a supported user-triggered payload", async () => {8+ const calls: string[] = [];9+ const client: WebShareClient = {10+ canShare: (data) => {11+ calls.push(`can:${data.title}`);12+ return true;13+ },14+ share: (data) => {15+ calls.push(`share:${data.title}`);16+ return Promise.resolve();17+ },18+ };19+ const outcome = await shareOrFallback({ title: "One task" }, {20+ client,21+ fallback: () => {22+ calls.push("fallback");23+ },24+ });25+ assert(outcome === "shared", `native share ended in ${outcome}`);26+ assert(calls.join(",") === "can:One task,share:One task", "native share order drifted");27+});28+29+Deno.test("share recipe falls back only when native sharing is unavailable", async () => {30+ let fallbackCount = 0;31+ const unavailable = await shareOrFallback({ text: "Task" }, {32+ client: {},33+ fallback: () => {34+ fallbackCount++;35+ },36+ });37+ const rejected = await shareOrFallback({ text: "Task" }, {38+ client: { canShare: () => false, share: () => Promise.resolve() },39+ fallback: () => {40+ fallbackCount++;41+ },42+ });43+ assert(unavailable === "fallback" && rejected === "fallback", "fallback state was not stable");44+ assert(fallbackCount === 2, `expected two fallback calls, received ${fallbackCount}`);45+});46+47+Deno.test("share recipe distinguishes cancellation from runtime failure", async () => {48+ let fallbackCalled = false;49+ const cancelled = await shareOrFallback({ text: "Task" }, {50+ client: { share: () => Promise.reject(new DOMException("closed", "AbortError")) },51+ fallback: () => {52+ fallbackCalled = true;53+ },54+ });55+ const failed = await shareOrFallback({ text: "Task" }, {56+ client: { share: () => Promise.reject(new Error("browser failure")) },57+ fallback: () => {58+ fallbackCalled = true;59+ },60+ });61+ assert(cancelled === "cancelled", `cancellation ended in ${cancelled}`);62+ assert(failed === "failed", `runtime failure ended in ${failed}`);63+ assert(!fallbackCalled, "native sheet failure unexpectedly copied shared data");64+});65+66+Deno.test("text share target accepts bounded title, text, and HTTP URLs", () => {67+ const result = parseTextShareTarget(68+ "?title=One+task&text=Review+it&url=https%3A%2F%2Fexample.com%2Fnotes&ignored=1",69+ );70+ assert(result.ok, `valid share was rejected: ${JSON.stringify(result)}`);71+ assert(result.draft.title === "One task", "share title was not decoded");72+ assert(result.draft.text === "Review it", "share text was not decoded");73+ assert(result.draft.url === "https://example.com/notes", "share URL was not normalized");74+ assert(!("ignored" in result.draft), "unknown input reached the draft");75+});76+77+Deno.test("text share target rejects duplicate, oversized, malformed, and dangerous input", () => {78+ const duplicate = parseTextShareTarget("?title=one&title=two");79+ assert(!duplicate.ok && duplicate.issues.includes("duplicate-title"), "duplicate title passed");80+81+ const oversized = parseTextShareTarget(`?text=${"x".repeat(2_001)}`);82+ assert(!oversized.ok && oversized.issues.includes("text-too-long"), "oversized text passed");83+84+ const malformed = parseTextShareTarget("?url=not-a-url");85+ assert(!malformed.ok && malformed.issues.includes("invalid-url"), "malformed URL passed");86+87+ const dangerous = parseTextShareTarget("?url=javascript%3Aalert(1)");88+ assert(89+ !dangerous.ok && dangerous.issues.includes("unsupported-url-protocol"),90+ "dangerous URL protocol passed",91+ );92+93+ const empty = parseTextShareTarget("?unknown=value");94+ assert(!empty.ok && empty.issues.includes("empty-share"), "empty share passed");95+});
M package/testdata/starter.snapshot.json +2 -2
M package/tooling/pwa-validation.ts +92 -0
@@ -430,6 +430,79 @@ async function parseAndValidateManifest(
430430 }431431 }432432 }433+434+ if (manifest.share_target !== undefined) {435+ const label = `${manifestFile}: share_target`;436+ const target = manifest.share_target;437+ if (!isObject(target)) {438+ issues.push(issue(`${label} must be an object`, remediation));439+ } else {440+ if (!nonEmptyString(target.action)) {441+ issues.push(issue(`${label}.action must be a non-empty URL`, remediation));442+ } else {443+ try {444+ const action = new URL(target.action, manifestUrl);445+ if (!withinScope(action, scope)) {446+ issues.push(issue(`${label}.action must stay inside manifest scope`, remediation));447+ }448+ if (action.search || action.hash) {449+ issues.push(450+ issue(`${label}.action must not contain a query or fragment`, remediation),451+ );452+ }453+ } catch {454+ issues.push(issue(`${label}.action is not a valid URL`, remediation));455+ }456+ }457+ if (target.method !== undefined && target.method !== "GET") {458+ issues.push(459+ issue(460+ `${label}.method must be GET; POST and file shares require a custom worker recipe`,461+ remediation,462+ ),463+ );464+ }465+ if (466+ target.enctype !== undefined &&467+ target.enctype !== "application/x-www-form-urlencoded"468+ ) {469+ issues.push(470+ issue(`${label}.enctype must be application/x-www-form-urlencoded`, remediation),471+ );472+ }473+ if (!isObject(target.params)) {474+ issues.push(issue(`${label}.params must be an object`, remediation));475+ } else {476+ const names: string[] = [];477+ for (const member of ["title", "text", "url"] as const) {478+ const value = target.params[member];479+ if (value === undefined) continue;480+ if (!nonEmptyString(value)) {481+ issues.push(482+ issue(`${label}.params.${member} must be a non-empty string`, remediation),483+ );484+ } else names.push(value);485+ }486+ if (target.params.files !== undefined) {487+ issues.push(488+ issue(`${label}.params.files requires a POST/file-share worker recipe`, remediation),489+ );490+ }491+ const unknown = Object.keys(target.params).filter((member) =>492+ !["title", "text", "url", "files"].includes(member)493+ );494+ if (unknown.length > 0) {495+ issues.push(issue(`${label}.params contains unsupported members`, remediation));496+ }497+ if (names.length === 0) {498+ issues.push(issue(`${label}.params needs title, text, or url`, remediation));499+ }500+ if (new Set(names).size !== names.length) {501+ issues.push(issue(`${label}.params names must be unique`, remediation));502+ }503+ }504+ }505+ }433506 }434507 return { issues, manifest, scope };435508 }
@@ -586,6 +659,25 @@ export async function productionPwaIssues(root: string, deploymentBase = "/") {
586659 }587660 }588661 }662+ if (isObject(result.manifest?.share_target)) {663+ const target = result.manifest.share_target;664+ if (nonEmptyString(target.action)) {665+ const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);666+ try {667+ const route = emittedRoutePath(new URL(target.action, manifestUrl), basePath);668+ if (!route || !htmlFiles.includes(route)) {669+ issues.push(670+ issue(671+ `dist/${manifestFile}: share_target.action has no emitted offline route`,672+ remediation,673+ ),674+ );675+ }676+ } catch {677+ // Source validation already reports malformed action URLs.678+ }679+ }680+ }589681 const expectedManifest = new URL(`${syntheticOrigin}${basePath}${manifestFile}`).href;590682 const appleIcons = new Set<string>();591683 for (const path of htmlFiles) {
M package/tooling/pwa-validation_test.ts +44 -0
@@ -140,6 +140,40 @@ Deno.test("source PWA validation checks optional shortcuts and screenshots", asy
140140 }141141 });142142 143+Deno.test("source PWA validation checks an opt-in text share target", async () => {144+ const { root, project } = await makeProject();145+ try {146+ const manifest = await readManifest(project);147+ manifest.share_target = {148+ action: "../outside?existing=1",149+ method: "POST",150+ enctype: "multipart/form-data",151+ params: { title: "shared", text: "shared", files: { name: "files" }, extra: "value" },152+ };153+ await writeManifest(project, manifest);154+ const issues = await sourcePwaIssues(project, "/field-notes/");155+ assert(hasIssue(issues, "action must stay inside"), "escaped share action was accepted");156+ assert(hasIssue(issues, "must not contain a query"), "share action query was accepted");157+ assert(hasIssue(issues, "method must be GET"), "POST share action was accepted");158+ assert(hasIssue(issues, "enctype must be application"), "multipart share action was accepted");159+ assert(hasIssue(issues, "params.files requires"), "file share params were accepted");160+ assert(hasIssue(issues, "unsupported members"), "unknown share param was accepted");161+ assert(hasIssue(issues, "params names must be unique"), "duplicate param names were accepted");162+163+ manifest.share_target = {164+ action: "./share/",165+ method: "GET",166+ enctype: "application/x-www-form-urlencoded",167+ params: { title: "title", text: "text", url: "url" },168+ };169+ await writeManifest(project, manifest);170+ const valid = await sourcePwaIssues(project, "/field-notes/");171+ assert(valid.length === 0, `valid share target was rejected: ${JSON.stringify(valid)}`);172+ } finally {173+ await Deno.remove(root, { recursive: true });174+ }175+});176+143177 Deno.test("source PWA validation permits replacement branding and optional members", async () => {144178 const { root, project } = await makeProject();145179 try {
@@ -236,6 +270,12 @@ Deno.test("production PWA validation catches nested links, precache drift, and w
236270 );237271 const manifest = JSON.parse(await Deno.readTextFile(join(dist, "manifest.webmanifest")));238272 manifest.shortcuts[0].url = "./missing/";273+ manifest.share_target = {274+ action: "./share/",275+ method: "GET",276+ enctype: "application/x-www-form-urlencoded",277+ params: { title: "title", text: "text", url: "url" },278+ };239279 await Deno.writeTextFile(240280 join(dist, "manifest.webmanifest"),241281 `${JSON.stringify(manifest, null, 2)}\n`,
@@ -245,6 +285,10 @@ Deno.test("production PWA validation catches nested links, precache drift, and w
245285 assert(hasIssue(issues, "entries do not match"), "precache drift passed");246286 assert(hasIssue(issues, "build revision does not match"), "worker revision drift passed");247287 assert(hasIssue(issues, "has no emitted offline route"), "missing shortcut route passed");288+ assert(289+ hasIssue(issues, "share_target.action has no emitted offline route"),290+ "missing share-target route passed",291+ );248292 } finally {249293 await Deno.remove(root, { recursive: true });250294 }
M tools/doc_lint.ts +1 -0
M tools/golden.ts +144 -1
@@ -392,7 +392,7 @@ async function assertGeneratedBoundary(
392392 const lofiImports = Object.entries(config.imports).filter(([name]) =>393393 name === "@nzip/lofi" || name.startsWith("@nzip/lofi/")394394 );395- assert(lofiImports.length === 12, `expected 12 lofi imports, received ${lofiImports.length}`);395+ assert(lofiImports.length === 13, `expected 13 lofi imports, received ${lofiImports.length}`);396396 const expected = source === "registry"397397 ? `jsr:@nzip/lofi@${version}`398398 : pathToFileURL(join(repositoryRoot, "package")).href;
@@ -423,6 +423,7 @@ async function assertProductionOutput(projectRoot: string) {
423423 for (424424 const required of [425425 "index.html",426+ "share/index.html",426427 "manifest.webmanifest",427428 "sw.js",428429 "lofi-precache.json",
@@ -444,7 +445,15 @@ async function assertProductionOutput(projectRoot: string) {
444445 form_factor: string;445446 label: string;446447 }>;448+ share_target: {449+ action: string;450+ method: string;451+ enctype: string;452+ params: { title: string; text: string; url: string };453+ };447454 };455+ assert(manifest.share_target.action === "./share/", "share target action drifted");456+ assert(manifest.share_target.method === "GET", "share target method drifted");448457 const expected = new Map([449458 [450459 "icon-192.png",
@@ -620,6 +629,72 @@ async function selectVirtualCredential(page: Page, credentialId: string): Promis
620629 }, credentialId);621630 }622631 632+async function installWebShareRecipe(projectRoot: string) {633+ const manifestPath = join(projectRoot, "public", "manifest.webmanifest");634+ const manifest = JSON.parse(await Deno.readTextFile(manifestPath));635+ manifest.share_target = {636+ action: "./share/",637+ method: "GET",638+ enctype: "application/x-www-form-urlencoded",639+ params: { title: "title", text: "text", url: "url" },640+ };641+ await Deno.writeTextFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);642+ await Deno.writeTextFile(643+ join(projectRoot, "src", "pages", "share.astro"),644+ `---645+import ShareReceiver from "../islands/ShareReceiver.tsx";646+import Shell from "../layouts/Shell.astro";647+import "../styles/global.css";648+---649+650+<Shell title="Review shared content">651+ <ShareReceiver client:load />652+</Shell>653+`,654+ );655+ await Deno.writeTextFile(656+ join(projectRoot, "src", "islands", "ShareReceiver.tsx"),657+ `import { useEffect, useState } from "preact/hooks";658+import {659+ parseTextShareTarget,660+ shareOrFallback,661+ type TextShareTargetResult,662+ type WebShareOutcome,663+} from "@nzip/lofi/recipes/web-share";664+665+export default function ShareReceiver() {666+ const [result, setResult] = useState<TextShareTargetResult>();667+ const [confirmed, setConfirmed] = useState(false);668+ const [outcome, setOutcome] = useState<WebShareOutcome>();669+670+ useEffect(() => setResult(parseTextShareTarget(location.search)), []);671+ if (!result) return <p role="status">Opening shared draft…</p>;672+ if (!result.ok) return <p role="alert">This shared content is not valid.</p>;673+ const draft = result.draft;674+ return (675+ <section class="island" data-share-draft>676+ <h1>Review shared content</h1>677+ {draft.title && <p data-share-title>{draft.title}</p>}678+ {draft.text && <p data-share-text>{draft.text}</p>}679+ {draft.url && <a data-share-url href={draft.url}>{draft.url}</a>}680+ <button type="button" onClick={() => setConfirmed(true)}>Add to app</button>681+ {confirmed && <p role="status">Draft confirmed.</p>}682+ <button683+ type="button"684+ onClick={() => void shareOrFallback(draft, {685+ fallback: () => setOutcome("fallback"),686+ }).then(setOutcome)}687+ >688+ Share this draft689+ </button>690+ {outcome && <output data-share-outcome>{outcome}</output>}691+ </section>692+ );693+}694+`,695+ );696+}697+623698 async function main() {624699 const { source, version } = parseArgs(Deno.args);625700 await Deno.remove(artifactsRoot, { recursive: true }).catch((error) => {
@@ -863,6 +938,8 @@ async function main() {
863938 await stage("development shutdown", () => dev!.stop());864939 dev = undefined;865940 941+ await stage("install web-share recipe", () => installWebShareRecipe(projectRoot!));942+866943 await stage(867944 "build",868945 () =>
@@ -978,6 +1055,72 @@ async function main() {
9781055 `unexpected public sync state ${state.sync}`,9791056 );9801057 });1058+ await stage("web-share recipe browser", async () => {1059+ await context!.setOffline(true);1060+ try {1061+ await page!.goto(1062+ `${origin}share/?title=One+task&text=Review+it&url=https%3A%2F%2Fexample.com%2Fnotes`,1063+ { waitUntil: "domcontentloaded" },1064+ );1065+ await page!.getByRole("heading", { name: "Review shared content" }).waitFor({1066+ state: "visible",1067+ });1068+ assert(1069+ await page!.locator("[data-share-title]").textContent() === "One task",1070+ "offline share title was not parsed",1071+ );1072+ assert(1073+ await page!.locator("[data-share-url]").getAttribute("href") ===1074+ "https://example.com/notes",1075+ "offline share URL was not normalized",1076+ );1077+ assert(1078+ await page!.getByText("Draft confirmed.").count() === 0,1079+ "received share was confirmed before the user action",1080+ );1081+ await page!.getByRole("button", { name: "Add to app" }).click();1082+ await page!.getByText("Draft confirmed.").waitFor({ state: "visible" });1083+ await page!.getByRole("button", { name: "Share this draft" }).click();1084+ await page!.locator("[data-share-outcome]").waitFor({ state: "visible" });1085+ assert(1086+ await page!.locator("[data-share-outcome]").textContent() === "fallback",1087+ "unsupported outbound share did not use the normal web fallback",1088+ );1089+1090+ await page!.goto(`${origin}share/?url=javascript%3Aalert%281%29`, {1091+ waitUntil: "domcontentloaded",1092+ });1093+ await page!.getByRole("alert").waitFor({ state: "visible" });1094+ const invalidText = await page!.locator("body").innerText();1095+ assert(!invalidText.includes("javascript:"), "invalid share value was echoed in the UI");1096+ } finally {1097+ await context!.setOffline(false);1098+ }1099+1100+ await page!.addInitScript(() => {1101+ Object.defineProperty(navigator, "canShare", {1102+ configurable: true,1103+ value: () => true,1104+ });1105+ Object.defineProperty(navigator, "share", {1106+ configurable: true,1107+ value: () => Promise.reject(new DOMException("closed", "AbortError")),1108+ });1109+ });1110+ await page!.goto(`${origin}share/?text=Cancelled+share`, {1111+ waitUntil: "domcontentloaded",1112+ });1113+ await page!.getByRole("button", { name: "Share this draft" }).click();1114+ await page!.locator("[data-share-outcome]").waitFor({ state: "visible" });1115+ assert(1116+ await page!.locator("[data-share-outcome]").textContent() === "cancelled",1117+ "browser cancellation was reported as a failure",1118+ );1119+ await page!.goto(origin, { waitUntil: "domcontentloaded" });1120+ await waitForReady(page!, taskAppReady, undefined, {1121+ description: "task app after share recipe exercise",1122+ });1123+ });9811124 await stage("waiting worker update browser", async () => {9821125 const workerPath = join(projectRoot!, "dist", "sw.js");9831126 const worker = await Deno.readTextFile(workerPath);