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>
| M | README.md | 7 | + |
| M | apps/ | 1 | + |
| M | deno.json | 4 | +- |
| M | docs/ | 1 | + |
| M | docs/ | 13 | +- |
| M | docs/ | 17 | +- |
| A | docs/ | 32 | +++++ |
| A | docs/ | 141 | ++++++++++++++++++++++ |
| M | package/ | 14 | ++ |
| M | package/ | 2 | + |
| M | package/ | 12 | ++ |
| M | package/ | 9 | +- |
| A | package/ | 192 | ++++++++++++++++++++++++++++++ |
| A | package/ | 95 | +++++++++++++++ |
| M | package/ | 4 | +- |
| M | package/ | 92 | ++++++++++++++ |
| M | package/ | 44 | +++++++ |
| M | tools/ | 1 | + |
| M | tools/ | 145 | +++++++++++++++++++++++- |
@@ -131,6 +131,13 @@ Start with the [account and access API](docs/reference/access.md), then copy the
131131 compile ordinary Jazz policies, so unusual authorization models can still use raw Jazz schema and132132 permission APIs.133133 134+## Optional installed-app recipes135+136+OS-facing capabilities stay out of the starter until the product opts in. The137+[installed-app recipe catalog](docs/recipes/README.md) begins with a tested inbound/outbound Web138+Share flow, including an isolated `@nzip/lofi/recipes/web-share` entrypoint, manifest validation,139+untrusted-input parsing, and ordinary-web fallbacks.140+134141 ## Testing local-first behavior135142 136143 `@nzip/lofi/testing` provides Playwright-backed helpers for behavior that is difficult to test with@@ -12,6 +12,7 @@
1212 "@nzip/lofi/preview": "../../package/commands/preview.ts",1313 "@nzip/lofi/preact": "../../package/preact/mod.ts",1414 "@nzip/lofi/provision": "../../package/commands/provision.ts",15+ "@nzip/lofi/recipes/web-share": "../../package/recipes/web-share.ts",1516 "@nzip/lofi/test": "../../package/commands/run_tests.ts",1617 "@nzip/lofi/testing": "../../package/testing/mod.ts",1718 "astro": "npm:astro@7.0.9",@@ -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",@@ -42,6 +42,7 @@ The root [README](../README.md) provides the shortest product overview and comma
4242 - [Shared example](examples/shared.md)4343 - [Group example](examples/group.md)4444 - [Collaborative list recipe](examples/collaborative-list.md)45+- [Optional installed-app recipes](recipes/README.md)4546 - [Advanced device-auth primitive](auth.md)4647 4748 ## The author boundary@@ -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 @@ -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.@@ -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.@@ -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.@@ -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 {@@ -15,6 +15,7 @@ Deno.test("prepareLofiAstroConfig materializes package-owned vendor integration"
1515 "base: deploymentBase",1616 'trailingSlash: "always"',1717 ".lofi/package/runtime/mod.ts",18+ ".lofi/package/recipes/web-share.ts",1819 "^jsr:@nzip\\/lofi@[^/]+",1920 "^npm:preact@[^/]+",2021 "^npm:jazz-tools@[^/]+",@@ -32,6 +33,7 @@ Deno.test("prepareLofiAstroConfig materializes package-owned vendor integration"
3233 await Deno.stat(`${root}/.lofi/package/runtime/startup-recovery.ts`);3334 await Deno.stat(`${root}/.lofi/package/runtime/table-mutations.ts`);3435 await Deno.stat(`${root}/.lofi/package/access/mod.ts`);36+ await Deno.stat(`${root}/.lofi/package/recipes/web-share.ts`);3537 } finally {3638 await Deno.remove(root, { recursive: true });3739 }@@ -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 };@@ -106,8 +106,8 @@ Deno.test("createProject materializes the complete starter snapshot", async () =
106106 .filter(([name]) => name.startsWith("@nzip/lofi/"))107107 .map(([, specifier]) => String(specifier));108108 assert(109- lofiSpecifiers.length === 11,110- `expected one package prefix, access, two integrations, six commands, and testing, received ${lofiSpecifiers.length}`,109+ lofiSpecifiers.length === 12,110+ `expected one package prefix, access, two integrations, six commands, one recipe, and testing, received ${lofiSpecifiers.length}`,111111 );112112 assert(113113 lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.3.3/")),@@ -151,6 +151,7 @@ Deno.test("package manifest and generated version stay coupled", () => {
151151 "./preact",152152 "./preview",153153 "./provision",154+ "./recipes/web-share",154155 "./test",155156 "./testing",156157 ]);@@ -158,6 +159,10 @@ Deno.test("package manifest and generated version stay coupled", () => {
158159 assertEquals(packageManifest.exports["./access"], "./package/access/mod.ts");159160 assertEquals(packageManifest.exports["./astro"], "./package/astro/mod.ts");160161 assertEquals(packageManifest.exports["./preact"], "./package/preact/mod.ts");162+ assertEquals(163+ packageManifest.exports["./recipes/web-share"],164+ "./package/recipes/web-share.ts",165+ );161166 assertEquals(packageManifest.exports["./testing"], "./package/testing/mod.ts");162167 });163168 @@ -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+}@@ -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+});@@ -1,7 +1,7 @@
11 {22 ".env.example": "0cfad81a3ba6442ac15d7cbe44a00374bf4dbf8af68ca412e54fb100241ddeab",33 ".gitignore": "9b9fa4b95f8f728ce80e93fc224d430328786b620096b80f067a3190c410a00e",4- "deno.json": "ee47812226531b9043efcaf8e511bb66a801257a48d995dbcaf4dbac2a52d87b",4+ "deno.json": "28a044a9e34f2e6e189d3b4b0c8b7c3e8787f74f8d0d9cb2291fcb4819e5ad45",55 "public/apple-touch-icon.png": "be2b77066d9681709210388413ddcd94318906799ddd6645934b789a04bd4a61",66 "public/favicon.svg": "35b92ad48231c470b348eaebe6d6fe14acf2ec4a86e6726a03ccb130328a0d85",77 "public/icon-192.png": "6ee711f9ec990ec55f789d7943813c5193973bf2ca9199078ee85d285317e07c",@@ -11,7 +11,7 @@
1111 "public/manifest.webmanifest": "c76d1d487e86fca4ce4f8c71a5c3b22a15a2d0e3370583ae0fb40aafd4513560",1212 "public/screenshot-narrow.png": "8d11e3779e2e7437ef9e44d07bd382d161570846663338b3ad3f1cdb065b018f",1313 "public/screenshot-wide.png": "812786db28f6803d12adc8bd3af5fffefa8e4e20851b61de8a256fcc538c114e",14- "README.md": "4d4f37ec8f4cfc7dc127d7c3b0c74fd22658dd6ba9f59229316367095ebcdfd7",14+ "README.md": "962117ca61d07ba8d5de689dfd6697b40e9e97f0af8c45e10202306cbdb3d13c",1515 "src/app.ts": "4c4c6c3f6ad4123f6cffc7a480965c4ea38442676c2af662d1bbfe082d40194f",1616 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",1717 "src/islands/AccountGate.tsx": "ef541886720e984f989c2054266be8d2272b6c43fea722bde8ce615352cc0210",@@ -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) {@@ -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 }@@ -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);