@broberg/pwa 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,6 +59,23 @@ skeleton: it renders `role="status"` + `aria-live="polite"`, carries the stable
59
59
  testids `pwa-update-confirm` / `pwa-update-dismiss` / `pwa-update-close`, and
60
60
  ships **no** colours or design-system classes.
61
61
 
62
+ ## Preact usage
63
+
64
+ Same API for the Stack B (Bun / Hono / Preact) apps — just the `/preact` subpath
65
+ (`preact` is the optional peer instead of `react`):
66
+
67
+ ```tsx
68
+ import { usePwaUpdate, PwaUpdateBanner } from "@broberg/pwa/preact";
69
+
70
+ export function PwaUpdater() {
71
+ const { updateReady, applyUpdate } = usePwaUpdate({ disabled: isNative || isDev });
72
+ return <PwaUpdateBanner updateReady={updateReady} onUpdate={applyUpdate} className="my-banner" />;
73
+ }
74
+ ```
75
+
76
+ `usePwaUpdate()` and `<PwaUpdateBanner>` behave exactly as their React
77
+ counterparts.
78
+
62
79
  ## Framework-agnostic core
63
80
 
64
81
  No React? Drive the controller directly (Preact, Svelte, vanilla):
@@ -101,6 +118,39 @@ With **Serwist**/**Workbox**, keep activation user-gated:
101
118
  new Serwist({ /* … */, skipWaiting: false, clientsClaim: true });
102
119
  ```
103
120
 
121
+ ## Install setup — manifest, icons, meta (`@broberg/pwa/manifest`, v0.2.0)
122
+
123
+ The *other* half of a PWA: the `manifest.webmanifest`, the icon set, and the
124
+ apple-touch `<meta>` tags — all as **pure, zero-dep** factories so you stop
125
+ hand-rolling `app/manifest.ts`, a `gen-pwa-icons.cjs` script, and a wall of
126
+ `<meta>` tags. Icons are emitted as self-contained **SVG** (modern manifests +
127
+ apple-touch accept SVG); no rasteriser is bundled.
128
+
129
+ ```ts
130
+ import { defineManifest, serializeManifest, buildIconSet, pwaMetaTags } from "@broberg/pwa/manifest";
131
+
132
+ // 1. Icons from a monogram (or pass `svg: "<svg…>"` for real artwork).
133
+ const { files, icons } = buildIconSet({ monogram: "AK", background: "#141969", color: "#fff" });
134
+ // files → [{ path:"/icons/icon-180.svg", content:"<svg…>", … }, …] (write to public/)
135
+ // icons → manifest icons[] incl. a padded maskable-512
136
+
137
+ // 2. Manifest — required members defaulted; `extra` merges last.
138
+ const manifest = defineManifest({ name: "Aalborg Klinik", shortName: "AK", themeColor: "#141969", icons });
139
+ writeFile("public/manifest.webmanifest", serializeManifest(manifest));
140
+
141
+ // 3. Head tags — typed descriptors you render in Next metadata / a Hono head / plain HTML.
142
+ pwaMetaTags({ themeColor: "#141969", title: "AK" });
143
+ // → [{tag:"link",attrs:{rel:"manifest",href:"/manifest.webmanifest"}}, {tag:"meta",attrs:{name:"theme-color",…}}, …]
144
+ ```
145
+
146
+ - **`buildIconSet`** emits apple-touch (180), 192, 512 + a maskable-512 (10 % safe-zone
147
+ inset) by default; override `sizes`, `basePath`, `maskable`. `180` goes in the
148
+ apple-touch `<link>`, not the manifest `icons[]`.
149
+ - **Everything is a pure return value** — you own the filesystem write and the head
150
+ render. Runs in a build script, a Route Handler, or the browser.
151
+ - Need PNG? Rasterise the returned SVGs with `sharp` on your side — the package
152
+ stays dependency-free.
153
+
104
154
  ## Gotchas (baked into this package so you don't rediscover them)
105
155
 
106
156
  - **First install is suppressed.** A worker reaching `installed` with no existing
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ // src/manifest.ts
4
+ function defineManifest(opts) {
5
+ if (!opts?.name) throw new Error("defineManifest: `name` is required");
6
+ const manifest = {
7
+ name: opts.name,
8
+ short_name: opts.shortName ?? opts.name,
9
+ start_url: opts.startUrl ?? "/",
10
+ scope: opts.scope ?? "/",
11
+ display: opts.display ?? "standalone",
12
+ theme_color: opts.themeColor ?? "#ffffff",
13
+ background_color: opts.backgroundColor ?? "#ffffff",
14
+ icons: opts.icons ?? []
15
+ };
16
+ if (opts.description) manifest.description = opts.description;
17
+ if (opts.lang) manifest.lang = opts.lang;
18
+ if (opts.orientation) manifest.orientation = opts.orientation;
19
+ if (opts.categories) manifest.categories = opts.categories;
20
+ if (opts.id) manifest.id = opts.id;
21
+ return { ...manifest, ...opts.extra ?? {} };
22
+ }
23
+ function serializeManifest(manifest, opts = {}) {
24
+ return JSON.stringify(manifest, null, opts.pretty === false ? void 0 : 2);
25
+ }
26
+ var DEFAULT_SIZES = [180, 192, 512];
27
+ function escapeXml(s) {
28
+ return s.replace(
29
+ /[<>&"']/g,
30
+ (c) => c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === "&" ? "&amp;" : c === '"' ? "&quot;" : "&apos;"
31
+ );
32
+ }
33
+ function toBase64(s) {
34
+ const g = globalThis;
35
+ if (typeof g.Buffer !== "undefined") return g.Buffer.from(s, "utf-8").toString("base64");
36
+ const bytes = new TextEncoder().encode(s);
37
+ let bin = "";
38
+ for (const b of bytes) bin += String.fromCharCode(b);
39
+ return g.btoa(bin);
40
+ }
41
+ function renderIcon(size, inset, opts) {
42
+ const bg = opts.background ?? "#111827";
43
+ const fg = opts.color ?? "#ffffff";
44
+ const pad = Math.round(size * inset);
45
+ const inner = size - pad * 2;
46
+ let art;
47
+ if (opts.svg) {
48
+ const href = `data:image/svg+xml;base64,${toBase64(opts.svg)}`;
49
+ art = `<image x="${pad}" y="${pad}" width="${inner}" height="${inner}" href="${href}" preserveAspectRatio="xMidYMid meet"/>`;
50
+ } else {
51
+ const text = escapeXml((opts.monogram ?? "").slice(0, 3).toUpperCase());
52
+ const fontSize = Math.round(inner * (text.length >= 3 ? 0.42 : 0.52));
53
+ art = `<text x="50%" y="50%" fill="${fg}" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-weight="700" font-size="${fontSize}" text-anchor="middle" dominant-baseline="central">${text}</text>`;
54
+ }
55
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect width="${size}" height="${size}" fill="${bg}"/>` + art + `</svg>`;
56
+ }
57
+ function buildIconSet(opts) {
58
+ if (!opts?.svg && !opts?.monogram) {
59
+ throw new Error("buildIconSet: provide either `svg` or `monogram`");
60
+ }
61
+ const base = (opts.basePath ?? "/icons").replace(/\/$/, "");
62
+ const sizes = opts.sizes ?? DEFAULT_SIZES;
63
+ const files = [];
64
+ const icons = [];
65
+ for (const size of sizes) {
66
+ const path = `${base}/icon-${size}.svg`;
67
+ files.push({ path, content: renderIcon(size, 0, opts), type: "image/svg+xml", size, purpose: "any" });
68
+ if (size !== 180) {
69
+ icons.push({ src: path, sizes: `${size}x${size}`, type: "image/svg+xml", purpose: "any" });
70
+ }
71
+ }
72
+ if (opts.maskable !== false) {
73
+ const path = `${base}/icon-maskable-512.svg`;
74
+ files.push({ path, content: renderIcon(512, 0.1, opts), type: "image/svg+xml", size: 512, purpose: "maskable" });
75
+ icons.push({ src: path, sizes: "512x512", type: "image/svg+xml", purpose: "maskable" });
76
+ }
77
+ return { files, icons };
78
+ }
79
+ function pwaMetaTags(opts = {}) {
80
+ const tags = [];
81
+ tags.push({ tag: "link", attrs: { rel: "manifest", href: opts.manifest ?? "/manifest.webmanifest" } });
82
+ tags.push({ tag: "meta", attrs: { name: "theme-color", content: opts.themeColor ?? "#ffffff" } });
83
+ tags.push({ tag: "link", attrs: { rel: "apple-touch-icon", href: opts.appleTouchIcon ?? "/icons/icon-180.svg" } });
84
+ if (opts.capable !== false) {
85
+ tags.push({ tag: "meta", attrs: { name: "mobile-web-app-capable", content: "yes" } });
86
+ tags.push({ tag: "meta", attrs: { name: "apple-mobile-web-app-capable", content: "yes" } });
87
+ tags.push({
88
+ tag: "meta",
89
+ attrs: { name: "apple-mobile-web-app-status-bar-style", content: opts.statusBarStyle ?? "default" }
90
+ });
91
+ }
92
+ if (opts.title) {
93
+ tags.push({ tag: "meta", attrs: { name: "apple-mobile-web-app-title", content: opts.title } });
94
+ }
95
+ return tags;
96
+ }
97
+
98
+ exports.buildIconSet = buildIconSet;
99
+ exports.defineManifest = defineManifest;
100
+ exports.pwaMetaTags = pwaMetaTags;
101
+ exports.serializeManifest = serializeManifest;
102
+ //# sourceMappingURL=manifest.cjs.map
103
+ //# sourceMappingURL=manifest.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/manifest.ts"],"names":[],"mappings":"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","file":"manifest.cjs","sourcesContent":["/**\n * @broberg/pwa/manifest — the PWA *install* half (the update half lives in the\n * root export). A dependency-free manifest/meta factory + an SVG-based icon\n * generator, so a repo stops hand-rolling `app/manifest.ts`, a\n * `gen-pwa-icons.cjs` script, and a pile of apple-touch `<meta>` tags.\n *\n * Everything here is PURE string/object generation — no filesystem, no\n * rasteriser, no runtime deps — so it runs in Node, Bun, edge, a build script,\n * or the browser, and every piece is offline-unit-testable. Icons are emitted\n * as self-contained SVG documents (modern manifests + apple-touch accept SVG);\n * a consumer that specifically needs PNG can rasterise the SVGs with `sharp`\n * on their side — the package stays dep-free.\n */\n\n// ---------------------------------------------------------------------------\n// Manifest\n// ---------------------------------------------------------------------------\n\nexport type Display = \"standalone\" | \"fullscreen\" | \"minimal-ui\" | \"browser\";\n\nexport interface ManifestIcon {\n src: string;\n sizes: string;\n type: string;\n /** \"any\" | \"maskable\" | \"monochrome\" (space-separated allowed). */\n purpose?: string;\n}\n\nexport interface WebAppManifest {\n name: string;\n short_name: string;\n start_url: string;\n scope: string;\n display: Display;\n theme_color: string;\n background_color: string;\n icons: ManifestIcon[];\n description?: string;\n lang?: string;\n orientation?: string;\n categories?: string[];\n id?: string;\n [key: string]: unknown;\n}\n\nexport interface DefineManifestOptions {\n /** App name (required). */\n name: string;\n /** Home-screen label. Default: `name`. */\n shortName?: string;\n /** Default `/`. */\n startUrl?: string;\n /** Default `/`. */\n scope?: string;\n /** Default `standalone`. */\n display?: Display;\n /** Address-bar / task-switcher colour. Default `#ffffff`. */\n themeColor?: string;\n /** Splash background. Default `#ffffff`. */\n backgroundColor?: string;\n description?: string;\n lang?: string;\n orientation?: string;\n categories?: string[];\n id?: string;\n /** Manifest `icons[]` — typically `buildIconSet(...).icons`. */\n icons?: ManifestIcon[];\n /** Escape hatch: extra top-level manifest members, merged last. */\n extra?: Record<string, unknown>;\n}\n\n/**\n * Build a spec-valid Web App Manifest object. Required members are defaulted so\n * a one-liner produces an installable manifest; every default is overridable\n * and `extra` merges last for anything not modelled (shortcuts, screenshots…).\n */\nexport function defineManifest(opts: DefineManifestOptions): WebAppManifest {\n if (!opts?.name) throw new Error(\"defineManifest: `name` is required\");\n const manifest: WebAppManifest = {\n name: opts.name,\n short_name: opts.shortName ?? opts.name,\n start_url: opts.startUrl ?? \"/\",\n scope: opts.scope ?? \"/\",\n display: opts.display ?? \"standalone\",\n theme_color: opts.themeColor ?? \"#ffffff\",\n background_color: opts.backgroundColor ?? \"#ffffff\",\n icons: opts.icons ?? [],\n };\n if (opts.description) manifest.description = opts.description;\n if (opts.lang) manifest.lang = opts.lang;\n if (opts.orientation) manifest.orientation = opts.orientation;\n if (opts.categories) manifest.categories = opts.categories;\n if (opts.id) manifest.id = opts.id;\n return { ...manifest, ...(opts.extra ?? {}) };\n}\n\n/** JSON-stringify a manifest for a `manifest.webmanifest` file. */\nexport function serializeManifest(\n manifest: WebAppManifest,\n opts: { pretty?: boolean } = {},\n): string {\n return JSON.stringify(manifest, null, opts.pretty === false ? undefined : 2);\n}\n\n// ---------------------------------------------------------------------------\n// Icon generation (SVG, zero-dep)\n// ---------------------------------------------------------------------------\n\nexport interface GeneratedIcon {\n /** URL path, e.g. `/icons/icon-192.svg`. */\n path: string;\n /** The SVG document. */\n content: string;\n type: \"image/svg+xml\";\n size: number;\n purpose: \"any\" | \"maskable\";\n}\n\nexport interface IconSet {\n files: GeneratedIcon[];\n /** Ready to drop into `defineManifest({ icons })`. */\n icons: ManifestIcon[];\n}\n\nexport interface IconSetOptions {\n /**\n * Source artwork as a full SVG document (`<svg …>…</svg>`). Embedded via a\n * data-URI `<image>` so it scales cleanly at any size regardless of its\n * internal coordinate system. Mutually exclusive with `monogram`.\n */\n svg?: string;\n /** Fallback artwork: 1–3 letters rendered centred (e.g. \"AK\"). */\n monogram?: string;\n /** Foreground (monogram text) colour. Default `#ffffff`. */\n color?: string;\n /** Background fill. Default `#111827`. */\n background?: string;\n /** Icon sizes to emit. Default `[180, 192, 512]` (180 = apple-touch). */\n sizes?: number[];\n /** Also emit a maskable 512 with a padded safe-zone. Default `true`. */\n maskable?: boolean;\n /** URL prefix for every icon `src`. Default `/icons`. */\n basePath?: string;\n}\n\nconst DEFAULT_SIZES = [180, 192, 512];\n\nfunction escapeXml(s: string): string {\n return s.replace(/[<>&\"']/g, (c) =>\n c === \"<\" ? \"&lt;\" : c === \">\" ? \"&gt;\" : c === \"&\" ? \"&amp;\" : c === '\"' ? \"&quot;\" : \"&apos;\",\n );\n}\n\n/** UTF-8-safe base64 for the data-URI embed (works in Node/Bun/browser). */\nfunction toBase64(s: string): string {\n // Reached via globalThis so the package needs no @types/node (browser-safe).\n const g = globalThis as unknown as {\n Buffer?: { from(input: string, enc: string): { toString(enc: string): string } };\n btoa?: (data: string) => string;\n };\n if (typeof g.Buffer !== \"undefined\") return g.Buffer.from(s, \"utf-8\").toString(\"base64\");\n const bytes = new TextEncoder().encode(s);\n let bin = \"\";\n for (const b of bytes) bin += String.fromCharCode(b);\n return g.btoa!(bin);\n}\n\n/** Render one square SVG icon document at `size`. `inset` = safe-zone fraction (maskable). */\nfunction renderIcon(size: number, inset: number, opts: IconSetOptions): string {\n const bg = opts.background ?? \"#111827\";\n const fg = opts.color ?? \"#ffffff\";\n const pad = Math.round(size * inset);\n const inner = size - pad * 2;\n let art: string;\n if (opts.svg) {\n const href = `data:image/svg+xml;base64,${toBase64(opts.svg)}`;\n art = `<image x=\"${pad}\" y=\"${pad}\" width=\"${inner}\" height=\"${inner}\" href=\"${href}\" preserveAspectRatio=\"xMidYMid meet\"/>`;\n } else {\n const text = escapeXml((opts.monogram ?? \"\").slice(0, 3).toUpperCase());\n // Font sized to the safe inner box; a touch smaller for breathing room.\n const fontSize = Math.round(inner * (text.length >= 3 ? 0.42 : 0.52));\n art =\n `<text x=\"50%\" y=\"50%\" fill=\"${fg}\" font-family=\"system-ui, -apple-system, Segoe UI, Roboto, sans-serif\" ` +\n `font-weight=\"700\" font-size=\"${fontSize}\" text-anchor=\"middle\" dominant-baseline=\"central\">${text}</text>`;\n }\n return (\n `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${size}\" height=\"${size}\" viewBox=\"0 0 ${size} ${size}\">` +\n `<rect width=\"${size}\" height=\"${size}\" fill=\"${bg}\"/>` +\n art +\n `</svg>`\n );\n}\n\n/**\n * Generate a full icon set (SVG) + the matching manifest `icons[]`. Deterministic,\n * zero-dep. Give it either a source `svg` or a `monogram`; it emits apple-touch\n * (180), 192, 512 by default plus a padded maskable-512.\n */\nexport function buildIconSet(opts: IconSetOptions): IconSet {\n if (!opts?.svg && !opts?.monogram) {\n throw new Error(\"buildIconSet: provide either `svg` or `monogram`\");\n }\n const base = (opts.basePath ?? \"/icons\").replace(/\\/$/, \"\");\n const sizes = opts.sizes ?? DEFAULT_SIZES;\n const files: GeneratedIcon[] = [];\n const icons: ManifestIcon[] = [];\n\n for (const size of sizes) {\n const path = `${base}/icon-${size}.svg`;\n files.push({ path, content: renderIcon(size, 0, opts), type: \"image/svg+xml\", size, purpose: \"any\" });\n // apple-touch-icon is referenced via a <link> (see pwaMetaTags), not the\n // manifest, so only real manifest sizes go into icons[].\n if (size !== 180) {\n icons.push({ src: path, sizes: `${size}x${size}`, type: \"image/svg+xml\", purpose: \"any\" });\n }\n }\n\n if (opts.maskable !== false) {\n // Maskable safe-zone: keep content within the central 80% (10% inset).\n const path = `${base}/icon-maskable-512.svg`;\n files.push({ path, content: renderIcon(512, 0.1, opts), type: \"image/svg+xml\", size: 512, purpose: \"maskable\" });\n icons.push({ src: path, sizes: \"512x512\", type: \"image/svg+xml\", purpose: \"maskable\" });\n }\n\n return { files, icons };\n}\n\n// ---------------------------------------------------------------------------\n// Head meta tags\n// ---------------------------------------------------------------------------\n\nexport interface MetaTag {\n tag: \"meta\" | \"link\";\n attrs: Record<string, string>;\n}\n\nexport interface MetaTagsOptions {\n /** `<meta name=\"theme-color\">`. Default `#ffffff`. */\n themeColor?: string;\n /** apple-touch-icon href. Default `/icons/icon-180.svg`. */\n appleTouchIcon?: string;\n /** Manifest href for the `<link rel=\"manifest\">`. Default `/manifest.webmanifest`. */\n manifest?: string;\n /** apple-mobile-web-app-title. Default: omitted. */\n title?: string;\n /** apple-mobile-web-app-status-bar-style. Default `default`. */\n statusBarStyle?: \"default\" | \"black\" | \"black-translucent\";\n /** Emit the `*-web-app-capable` tags. Default `true`. */\n capable?: boolean;\n}\n\n/**\n * The head tags a PWA needs beyond the manifest link — returned as typed\n * descriptors so a consumer renders them in Next.js `metadata`, a Hono/JSX\n * head, or plain HTML. No framework assumption.\n */\nexport function pwaMetaTags(opts: MetaTagsOptions = {}): MetaTag[] {\n const tags: MetaTag[] = [];\n tags.push({ tag: \"link\", attrs: { rel: \"manifest\", href: opts.manifest ?? \"/manifest.webmanifest\" } });\n tags.push({ tag: \"meta\", attrs: { name: \"theme-color\", content: opts.themeColor ?? \"#ffffff\" } });\n tags.push({ tag: \"link\", attrs: { rel: \"apple-touch-icon\", href: opts.appleTouchIcon ?? \"/icons/icon-180.svg\" } });\n if (opts.capable !== false) {\n tags.push({ tag: \"meta\", attrs: { name: \"mobile-web-app-capable\", content: \"yes\" } });\n tags.push({ tag: \"meta\", attrs: { name: \"apple-mobile-web-app-capable\", content: \"yes\" } });\n tags.push({\n tag: \"meta\",\n attrs: { name: \"apple-mobile-web-app-status-bar-style\", content: opts.statusBarStyle ?? \"default\" },\n });\n }\n if (opts.title) {\n tags.push({ tag: \"meta\", attrs: { name: \"apple-mobile-web-app-title\", content: opts.title } });\n }\n return tags;\n}\n"]}
@@ -0,0 +1,138 @@
1
+ /**
2
+ * @broberg/pwa/manifest — the PWA *install* half (the update half lives in the
3
+ * root export). A dependency-free manifest/meta factory + an SVG-based icon
4
+ * generator, so a repo stops hand-rolling `app/manifest.ts`, a
5
+ * `gen-pwa-icons.cjs` script, and a pile of apple-touch `<meta>` tags.
6
+ *
7
+ * Everything here is PURE string/object generation — no filesystem, no
8
+ * rasteriser, no runtime deps — so it runs in Node, Bun, edge, a build script,
9
+ * or the browser, and every piece is offline-unit-testable. Icons are emitted
10
+ * as self-contained SVG documents (modern manifests + apple-touch accept SVG);
11
+ * a consumer that specifically needs PNG can rasterise the SVGs with `sharp`
12
+ * on their side — the package stays dep-free.
13
+ */
14
+ type Display = "standalone" | "fullscreen" | "minimal-ui" | "browser";
15
+ interface ManifestIcon {
16
+ src: string;
17
+ sizes: string;
18
+ type: string;
19
+ /** "any" | "maskable" | "monochrome" (space-separated allowed). */
20
+ purpose?: string;
21
+ }
22
+ interface WebAppManifest {
23
+ name: string;
24
+ short_name: string;
25
+ start_url: string;
26
+ scope: string;
27
+ display: Display;
28
+ theme_color: string;
29
+ background_color: string;
30
+ icons: ManifestIcon[];
31
+ description?: string;
32
+ lang?: string;
33
+ orientation?: string;
34
+ categories?: string[];
35
+ id?: string;
36
+ [key: string]: unknown;
37
+ }
38
+ interface DefineManifestOptions {
39
+ /** App name (required). */
40
+ name: string;
41
+ /** Home-screen label. Default: `name`. */
42
+ shortName?: string;
43
+ /** Default `/`. */
44
+ startUrl?: string;
45
+ /** Default `/`. */
46
+ scope?: string;
47
+ /** Default `standalone`. */
48
+ display?: Display;
49
+ /** Address-bar / task-switcher colour. Default `#ffffff`. */
50
+ themeColor?: string;
51
+ /** Splash background. Default `#ffffff`. */
52
+ backgroundColor?: string;
53
+ description?: string;
54
+ lang?: string;
55
+ orientation?: string;
56
+ categories?: string[];
57
+ id?: string;
58
+ /** Manifest `icons[]` — typically `buildIconSet(...).icons`. */
59
+ icons?: ManifestIcon[];
60
+ /** Escape hatch: extra top-level manifest members, merged last. */
61
+ extra?: Record<string, unknown>;
62
+ }
63
+ /**
64
+ * Build a spec-valid Web App Manifest object. Required members are defaulted so
65
+ * a one-liner produces an installable manifest; every default is overridable
66
+ * and `extra` merges last for anything not modelled (shortcuts, screenshots…).
67
+ */
68
+ declare function defineManifest(opts: DefineManifestOptions): WebAppManifest;
69
+ /** JSON-stringify a manifest for a `manifest.webmanifest` file. */
70
+ declare function serializeManifest(manifest: WebAppManifest, opts?: {
71
+ pretty?: boolean;
72
+ }): string;
73
+ interface GeneratedIcon {
74
+ /** URL path, e.g. `/icons/icon-192.svg`. */
75
+ path: string;
76
+ /** The SVG document. */
77
+ content: string;
78
+ type: "image/svg+xml";
79
+ size: number;
80
+ purpose: "any" | "maskable";
81
+ }
82
+ interface IconSet {
83
+ files: GeneratedIcon[];
84
+ /** Ready to drop into `defineManifest({ icons })`. */
85
+ icons: ManifestIcon[];
86
+ }
87
+ interface IconSetOptions {
88
+ /**
89
+ * Source artwork as a full SVG document (`<svg …>…</svg>`). Embedded via a
90
+ * data-URI `<image>` so it scales cleanly at any size regardless of its
91
+ * internal coordinate system. Mutually exclusive with `monogram`.
92
+ */
93
+ svg?: string;
94
+ /** Fallback artwork: 1–3 letters rendered centred (e.g. "AK"). */
95
+ monogram?: string;
96
+ /** Foreground (monogram text) colour. Default `#ffffff`. */
97
+ color?: string;
98
+ /** Background fill. Default `#111827`. */
99
+ background?: string;
100
+ /** Icon sizes to emit. Default `[180, 192, 512]` (180 = apple-touch). */
101
+ sizes?: number[];
102
+ /** Also emit a maskable 512 with a padded safe-zone. Default `true`. */
103
+ maskable?: boolean;
104
+ /** URL prefix for every icon `src`. Default `/icons`. */
105
+ basePath?: string;
106
+ }
107
+ /**
108
+ * Generate a full icon set (SVG) + the matching manifest `icons[]`. Deterministic,
109
+ * zero-dep. Give it either a source `svg` or a `monogram`; it emits apple-touch
110
+ * (180), 192, 512 by default plus a padded maskable-512.
111
+ */
112
+ declare function buildIconSet(opts: IconSetOptions): IconSet;
113
+ interface MetaTag {
114
+ tag: "meta" | "link";
115
+ attrs: Record<string, string>;
116
+ }
117
+ interface MetaTagsOptions {
118
+ /** `<meta name="theme-color">`. Default `#ffffff`. */
119
+ themeColor?: string;
120
+ /** apple-touch-icon href. Default `/icons/icon-180.svg`. */
121
+ appleTouchIcon?: string;
122
+ /** Manifest href for the `<link rel="manifest">`. Default `/manifest.webmanifest`. */
123
+ manifest?: string;
124
+ /** apple-mobile-web-app-title. Default: omitted. */
125
+ title?: string;
126
+ /** apple-mobile-web-app-status-bar-style. Default `default`. */
127
+ statusBarStyle?: "default" | "black" | "black-translucent";
128
+ /** Emit the `*-web-app-capable` tags. Default `true`. */
129
+ capable?: boolean;
130
+ }
131
+ /**
132
+ * The head tags a PWA needs beyond the manifest link — returned as typed
133
+ * descriptors so a consumer renders them in Next.js `metadata`, a Hono/JSX
134
+ * head, or plain HTML. No framework assumption.
135
+ */
136
+ declare function pwaMetaTags(opts?: MetaTagsOptions): MetaTag[];
137
+
138
+ export { type DefineManifestOptions, type Display, type GeneratedIcon, type IconSet, type IconSetOptions, type ManifestIcon, type MetaTag, type MetaTagsOptions, type WebAppManifest, buildIconSet, defineManifest, pwaMetaTags, serializeManifest };
@@ -0,0 +1,138 @@
1
+ /**
2
+ * @broberg/pwa/manifest — the PWA *install* half (the update half lives in the
3
+ * root export). A dependency-free manifest/meta factory + an SVG-based icon
4
+ * generator, so a repo stops hand-rolling `app/manifest.ts`, a
5
+ * `gen-pwa-icons.cjs` script, and a pile of apple-touch `<meta>` tags.
6
+ *
7
+ * Everything here is PURE string/object generation — no filesystem, no
8
+ * rasteriser, no runtime deps — so it runs in Node, Bun, edge, a build script,
9
+ * or the browser, and every piece is offline-unit-testable. Icons are emitted
10
+ * as self-contained SVG documents (modern manifests + apple-touch accept SVG);
11
+ * a consumer that specifically needs PNG can rasterise the SVGs with `sharp`
12
+ * on their side — the package stays dep-free.
13
+ */
14
+ type Display = "standalone" | "fullscreen" | "minimal-ui" | "browser";
15
+ interface ManifestIcon {
16
+ src: string;
17
+ sizes: string;
18
+ type: string;
19
+ /** "any" | "maskable" | "monochrome" (space-separated allowed). */
20
+ purpose?: string;
21
+ }
22
+ interface WebAppManifest {
23
+ name: string;
24
+ short_name: string;
25
+ start_url: string;
26
+ scope: string;
27
+ display: Display;
28
+ theme_color: string;
29
+ background_color: string;
30
+ icons: ManifestIcon[];
31
+ description?: string;
32
+ lang?: string;
33
+ orientation?: string;
34
+ categories?: string[];
35
+ id?: string;
36
+ [key: string]: unknown;
37
+ }
38
+ interface DefineManifestOptions {
39
+ /** App name (required). */
40
+ name: string;
41
+ /** Home-screen label. Default: `name`. */
42
+ shortName?: string;
43
+ /** Default `/`. */
44
+ startUrl?: string;
45
+ /** Default `/`. */
46
+ scope?: string;
47
+ /** Default `standalone`. */
48
+ display?: Display;
49
+ /** Address-bar / task-switcher colour. Default `#ffffff`. */
50
+ themeColor?: string;
51
+ /** Splash background. Default `#ffffff`. */
52
+ backgroundColor?: string;
53
+ description?: string;
54
+ lang?: string;
55
+ orientation?: string;
56
+ categories?: string[];
57
+ id?: string;
58
+ /** Manifest `icons[]` — typically `buildIconSet(...).icons`. */
59
+ icons?: ManifestIcon[];
60
+ /** Escape hatch: extra top-level manifest members, merged last. */
61
+ extra?: Record<string, unknown>;
62
+ }
63
+ /**
64
+ * Build a spec-valid Web App Manifest object. Required members are defaulted so
65
+ * a one-liner produces an installable manifest; every default is overridable
66
+ * and `extra` merges last for anything not modelled (shortcuts, screenshots…).
67
+ */
68
+ declare function defineManifest(opts: DefineManifestOptions): WebAppManifest;
69
+ /** JSON-stringify a manifest for a `manifest.webmanifest` file. */
70
+ declare function serializeManifest(manifest: WebAppManifest, opts?: {
71
+ pretty?: boolean;
72
+ }): string;
73
+ interface GeneratedIcon {
74
+ /** URL path, e.g. `/icons/icon-192.svg`. */
75
+ path: string;
76
+ /** The SVG document. */
77
+ content: string;
78
+ type: "image/svg+xml";
79
+ size: number;
80
+ purpose: "any" | "maskable";
81
+ }
82
+ interface IconSet {
83
+ files: GeneratedIcon[];
84
+ /** Ready to drop into `defineManifest({ icons })`. */
85
+ icons: ManifestIcon[];
86
+ }
87
+ interface IconSetOptions {
88
+ /**
89
+ * Source artwork as a full SVG document (`<svg …>…</svg>`). Embedded via a
90
+ * data-URI `<image>` so it scales cleanly at any size regardless of its
91
+ * internal coordinate system. Mutually exclusive with `monogram`.
92
+ */
93
+ svg?: string;
94
+ /** Fallback artwork: 1–3 letters rendered centred (e.g. "AK"). */
95
+ monogram?: string;
96
+ /** Foreground (monogram text) colour. Default `#ffffff`. */
97
+ color?: string;
98
+ /** Background fill. Default `#111827`. */
99
+ background?: string;
100
+ /** Icon sizes to emit. Default `[180, 192, 512]` (180 = apple-touch). */
101
+ sizes?: number[];
102
+ /** Also emit a maskable 512 with a padded safe-zone. Default `true`. */
103
+ maskable?: boolean;
104
+ /** URL prefix for every icon `src`. Default `/icons`. */
105
+ basePath?: string;
106
+ }
107
+ /**
108
+ * Generate a full icon set (SVG) + the matching manifest `icons[]`. Deterministic,
109
+ * zero-dep. Give it either a source `svg` or a `monogram`; it emits apple-touch
110
+ * (180), 192, 512 by default plus a padded maskable-512.
111
+ */
112
+ declare function buildIconSet(opts: IconSetOptions): IconSet;
113
+ interface MetaTag {
114
+ tag: "meta" | "link";
115
+ attrs: Record<string, string>;
116
+ }
117
+ interface MetaTagsOptions {
118
+ /** `<meta name="theme-color">`. Default `#ffffff`. */
119
+ themeColor?: string;
120
+ /** apple-touch-icon href. Default `/icons/icon-180.svg`. */
121
+ appleTouchIcon?: string;
122
+ /** Manifest href for the `<link rel="manifest">`. Default `/manifest.webmanifest`. */
123
+ manifest?: string;
124
+ /** apple-mobile-web-app-title. Default: omitted. */
125
+ title?: string;
126
+ /** apple-mobile-web-app-status-bar-style. Default `default`. */
127
+ statusBarStyle?: "default" | "black" | "black-translucent";
128
+ /** Emit the `*-web-app-capable` tags. Default `true`. */
129
+ capable?: boolean;
130
+ }
131
+ /**
132
+ * The head tags a PWA needs beyond the manifest link — returned as typed
133
+ * descriptors so a consumer renders them in Next.js `metadata`, a Hono/JSX
134
+ * head, or plain HTML. No framework assumption.
135
+ */
136
+ declare function pwaMetaTags(opts?: MetaTagsOptions): MetaTag[];
137
+
138
+ export { type DefineManifestOptions, type Display, type GeneratedIcon, type IconSet, type IconSetOptions, type ManifestIcon, type MetaTag, type MetaTagsOptions, type WebAppManifest, buildIconSet, defineManifest, pwaMetaTags, serializeManifest };
@@ -0,0 +1,98 @@
1
+ // src/manifest.ts
2
+ function defineManifest(opts) {
3
+ if (!opts?.name) throw new Error("defineManifest: `name` is required");
4
+ const manifest = {
5
+ name: opts.name,
6
+ short_name: opts.shortName ?? opts.name,
7
+ start_url: opts.startUrl ?? "/",
8
+ scope: opts.scope ?? "/",
9
+ display: opts.display ?? "standalone",
10
+ theme_color: opts.themeColor ?? "#ffffff",
11
+ background_color: opts.backgroundColor ?? "#ffffff",
12
+ icons: opts.icons ?? []
13
+ };
14
+ if (opts.description) manifest.description = opts.description;
15
+ if (opts.lang) manifest.lang = opts.lang;
16
+ if (opts.orientation) manifest.orientation = opts.orientation;
17
+ if (opts.categories) manifest.categories = opts.categories;
18
+ if (opts.id) manifest.id = opts.id;
19
+ return { ...manifest, ...opts.extra ?? {} };
20
+ }
21
+ function serializeManifest(manifest, opts = {}) {
22
+ return JSON.stringify(manifest, null, opts.pretty === false ? void 0 : 2);
23
+ }
24
+ var DEFAULT_SIZES = [180, 192, 512];
25
+ function escapeXml(s) {
26
+ return s.replace(
27
+ /[<>&"']/g,
28
+ (c) => c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === "&" ? "&amp;" : c === '"' ? "&quot;" : "&apos;"
29
+ );
30
+ }
31
+ function toBase64(s) {
32
+ const g = globalThis;
33
+ if (typeof g.Buffer !== "undefined") return g.Buffer.from(s, "utf-8").toString("base64");
34
+ const bytes = new TextEncoder().encode(s);
35
+ let bin = "";
36
+ for (const b of bytes) bin += String.fromCharCode(b);
37
+ return g.btoa(bin);
38
+ }
39
+ function renderIcon(size, inset, opts) {
40
+ const bg = opts.background ?? "#111827";
41
+ const fg = opts.color ?? "#ffffff";
42
+ const pad = Math.round(size * inset);
43
+ const inner = size - pad * 2;
44
+ let art;
45
+ if (opts.svg) {
46
+ const href = `data:image/svg+xml;base64,${toBase64(opts.svg)}`;
47
+ art = `<image x="${pad}" y="${pad}" width="${inner}" height="${inner}" href="${href}" preserveAspectRatio="xMidYMid meet"/>`;
48
+ } else {
49
+ const text = escapeXml((opts.monogram ?? "").slice(0, 3).toUpperCase());
50
+ const fontSize = Math.round(inner * (text.length >= 3 ? 0.42 : 0.52));
51
+ art = `<text x="50%" y="50%" fill="${fg}" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-weight="700" font-size="${fontSize}" text-anchor="middle" dominant-baseline="central">${text}</text>`;
52
+ }
53
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect width="${size}" height="${size}" fill="${bg}"/>` + art + `</svg>`;
54
+ }
55
+ function buildIconSet(opts) {
56
+ if (!opts?.svg && !opts?.monogram) {
57
+ throw new Error("buildIconSet: provide either `svg` or `monogram`");
58
+ }
59
+ const base = (opts.basePath ?? "/icons").replace(/\/$/, "");
60
+ const sizes = opts.sizes ?? DEFAULT_SIZES;
61
+ const files = [];
62
+ const icons = [];
63
+ for (const size of sizes) {
64
+ const path = `${base}/icon-${size}.svg`;
65
+ files.push({ path, content: renderIcon(size, 0, opts), type: "image/svg+xml", size, purpose: "any" });
66
+ if (size !== 180) {
67
+ icons.push({ src: path, sizes: `${size}x${size}`, type: "image/svg+xml", purpose: "any" });
68
+ }
69
+ }
70
+ if (opts.maskable !== false) {
71
+ const path = `${base}/icon-maskable-512.svg`;
72
+ files.push({ path, content: renderIcon(512, 0.1, opts), type: "image/svg+xml", size: 512, purpose: "maskable" });
73
+ icons.push({ src: path, sizes: "512x512", type: "image/svg+xml", purpose: "maskable" });
74
+ }
75
+ return { files, icons };
76
+ }
77
+ function pwaMetaTags(opts = {}) {
78
+ const tags = [];
79
+ tags.push({ tag: "link", attrs: { rel: "manifest", href: opts.manifest ?? "/manifest.webmanifest" } });
80
+ tags.push({ tag: "meta", attrs: { name: "theme-color", content: opts.themeColor ?? "#ffffff" } });
81
+ tags.push({ tag: "link", attrs: { rel: "apple-touch-icon", href: opts.appleTouchIcon ?? "/icons/icon-180.svg" } });
82
+ if (opts.capable !== false) {
83
+ tags.push({ tag: "meta", attrs: { name: "mobile-web-app-capable", content: "yes" } });
84
+ tags.push({ tag: "meta", attrs: { name: "apple-mobile-web-app-capable", content: "yes" } });
85
+ tags.push({
86
+ tag: "meta",
87
+ attrs: { name: "apple-mobile-web-app-status-bar-style", content: opts.statusBarStyle ?? "default" }
88
+ });
89
+ }
90
+ if (opts.title) {
91
+ tags.push({ tag: "meta", attrs: { name: "apple-mobile-web-app-title", content: opts.title } });
92
+ }
93
+ return tags;
94
+ }
95
+
96
+ export { buildIconSet, defineManifest, pwaMetaTags, serializeManifest };
97
+ //# sourceMappingURL=manifest.js.map
98
+ //# sourceMappingURL=manifest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/manifest.ts"],"names":[],"mappings":"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","file":"manifest.js","sourcesContent":["/**\n * @broberg/pwa/manifest — the PWA *install* half (the update half lives in the\n * root export). A dependency-free manifest/meta factory + an SVG-based icon\n * generator, so a repo stops hand-rolling `app/manifest.ts`, a\n * `gen-pwa-icons.cjs` script, and a pile of apple-touch `<meta>` tags.\n *\n * Everything here is PURE string/object generation — no filesystem, no\n * rasteriser, no runtime deps — so it runs in Node, Bun, edge, a build script,\n * or the browser, and every piece is offline-unit-testable. Icons are emitted\n * as self-contained SVG documents (modern manifests + apple-touch accept SVG);\n * a consumer that specifically needs PNG can rasterise the SVGs with `sharp`\n * on their side — the package stays dep-free.\n */\n\n// ---------------------------------------------------------------------------\n// Manifest\n// ---------------------------------------------------------------------------\n\nexport type Display = \"standalone\" | \"fullscreen\" | \"minimal-ui\" | \"browser\";\n\nexport interface ManifestIcon {\n src: string;\n sizes: string;\n type: string;\n /** \"any\" | \"maskable\" | \"monochrome\" (space-separated allowed). */\n purpose?: string;\n}\n\nexport interface WebAppManifest {\n name: string;\n short_name: string;\n start_url: string;\n scope: string;\n display: Display;\n theme_color: string;\n background_color: string;\n icons: ManifestIcon[];\n description?: string;\n lang?: string;\n orientation?: string;\n categories?: string[];\n id?: string;\n [key: string]: unknown;\n}\n\nexport interface DefineManifestOptions {\n /** App name (required). */\n name: string;\n /** Home-screen label. Default: `name`. */\n shortName?: string;\n /** Default `/`. */\n startUrl?: string;\n /** Default `/`. */\n scope?: string;\n /** Default `standalone`. */\n display?: Display;\n /** Address-bar / task-switcher colour. Default `#ffffff`. */\n themeColor?: string;\n /** Splash background. Default `#ffffff`. */\n backgroundColor?: string;\n description?: string;\n lang?: string;\n orientation?: string;\n categories?: string[];\n id?: string;\n /** Manifest `icons[]` — typically `buildIconSet(...).icons`. */\n icons?: ManifestIcon[];\n /** Escape hatch: extra top-level manifest members, merged last. */\n extra?: Record<string, unknown>;\n}\n\n/**\n * Build a spec-valid Web App Manifest object. Required members are defaulted so\n * a one-liner produces an installable manifest; every default is overridable\n * and `extra` merges last for anything not modelled (shortcuts, screenshots…).\n */\nexport function defineManifest(opts: DefineManifestOptions): WebAppManifest {\n if (!opts?.name) throw new Error(\"defineManifest: `name` is required\");\n const manifest: WebAppManifest = {\n name: opts.name,\n short_name: opts.shortName ?? opts.name,\n start_url: opts.startUrl ?? \"/\",\n scope: opts.scope ?? \"/\",\n display: opts.display ?? \"standalone\",\n theme_color: opts.themeColor ?? \"#ffffff\",\n background_color: opts.backgroundColor ?? \"#ffffff\",\n icons: opts.icons ?? [],\n };\n if (opts.description) manifest.description = opts.description;\n if (opts.lang) manifest.lang = opts.lang;\n if (opts.orientation) manifest.orientation = opts.orientation;\n if (opts.categories) manifest.categories = opts.categories;\n if (opts.id) manifest.id = opts.id;\n return { ...manifest, ...(opts.extra ?? {}) };\n}\n\n/** JSON-stringify a manifest for a `manifest.webmanifest` file. */\nexport function serializeManifest(\n manifest: WebAppManifest,\n opts: { pretty?: boolean } = {},\n): string {\n return JSON.stringify(manifest, null, opts.pretty === false ? undefined : 2);\n}\n\n// ---------------------------------------------------------------------------\n// Icon generation (SVG, zero-dep)\n// ---------------------------------------------------------------------------\n\nexport interface GeneratedIcon {\n /** URL path, e.g. `/icons/icon-192.svg`. */\n path: string;\n /** The SVG document. */\n content: string;\n type: \"image/svg+xml\";\n size: number;\n purpose: \"any\" | \"maskable\";\n}\n\nexport interface IconSet {\n files: GeneratedIcon[];\n /** Ready to drop into `defineManifest({ icons })`. */\n icons: ManifestIcon[];\n}\n\nexport interface IconSetOptions {\n /**\n * Source artwork as a full SVG document (`<svg …>…</svg>`). Embedded via a\n * data-URI `<image>` so it scales cleanly at any size regardless of its\n * internal coordinate system. Mutually exclusive with `monogram`.\n */\n svg?: string;\n /** Fallback artwork: 1–3 letters rendered centred (e.g. \"AK\"). */\n monogram?: string;\n /** Foreground (monogram text) colour. Default `#ffffff`. */\n color?: string;\n /** Background fill. Default `#111827`. */\n background?: string;\n /** Icon sizes to emit. Default `[180, 192, 512]` (180 = apple-touch). */\n sizes?: number[];\n /** Also emit a maskable 512 with a padded safe-zone. Default `true`. */\n maskable?: boolean;\n /** URL prefix for every icon `src`. Default `/icons`. */\n basePath?: string;\n}\n\nconst DEFAULT_SIZES = [180, 192, 512];\n\nfunction escapeXml(s: string): string {\n return s.replace(/[<>&\"']/g, (c) =>\n c === \"<\" ? \"&lt;\" : c === \">\" ? \"&gt;\" : c === \"&\" ? \"&amp;\" : c === '\"' ? \"&quot;\" : \"&apos;\",\n );\n}\n\n/** UTF-8-safe base64 for the data-URI embed (works in Node/Bun/browser). */\nfunction toBase64(s: string): string {\n // Reached via globalThis so the package needs no @types/node (browser-safe).\n const g = globalThis as unknown as {\n Buffer?: { from(input: string, enc: string): { toString(enc: string): string } };\n btoa?: (data: string) => string;\n };\n if (typeof g.Buffer !== \"undefined\") return g.Buffer.from(s, \"utf-8\").toString(\"base64\");\n const bytes = new TextEncoder().encode(s);\n let bin = \"\";\n for (const b of bytes) bin += String.fromCharCode(b);\n return g.btoa!(bin);\n}\n\n/** Render one square SVG icon document at `size`. `inset` = safe-zone fraction (maskable). */\nfunction renderIcon(size: number, inset: number, opts: IconSetOptions): string {\n const bg = opts.background ?? \"#111827\";\n const fg = opts.color ?? \"#ffffff\";\n const pad = Math.round(size * inset);\n const inner = size - pad * 2;\n let art: string;\n if (opts.svg) {\n const href = `data:image/svg+xml;base64,${toBase64(opts.svg)}`;\n art = `<image x=\"${pad}\" y=\"${pad}\" width=\"${inner}\" height=\"${inner}\" href=\"${href}\" preserveAspectRatio=\"xMidYMid meet\"/>`;\n } else {\n const text = escapeXml((opts.monogram ?? \"\").slice(0, 3).toUpperCase());\n // Font sized to the safe inner box; a touch smaller for breathing room.\n const fontSize = Math.round(inner * (text.length >= 3 ? 0.42 : 0.52));\n art =\n `<text x=\"50%\" y=\"50%\" fill=\"${fg}\" font-family=\"system-ui, -apple-system, Segoe UI, Roboto, sans-serif\" ` +\n `font-weight=\"700\" font-size=\"${fontSize}\" text-anchor=\"middle\" dominant-baseline=\"central\">${text}</text>`;\n }\n return (\n `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${size}\" height=\"${size}\" viewBox=\"0 0 ${size} ${size}\">` +\n `<rect width=\"${size}\" height=\"${size}\" fill=\"${bg}\"/>` +\n art +\n `</svg>`\n );\n}\n\n/**\n * Generate a full icon set (SVG) + the matching manifest `icons[]`. Deterministic,\n * zero-dep. Give it either a source `svg` or a `monogram`; it emits apple-touch\n * (180), 192, 512 by default plus a padded maskable-512.\n */\nexport function buildIconSet(opts: IconSetOptions): IconSet {\n if (!opts?.svg && !opts?.monogram) {\n throw new Error(\"buildIconSet: provide either `svg` or `monogram`\");\n }\n const base = (opts.basePath ?? \"/icons\").replace(/\\/$/, \"\");\n const sizes = opts.sizes ?? DEFAULT_SIZES;\n const files: GeneratedIcon[] = [];\n const icons: ManifestIcon[] = [];\n\n for (const size of sizes) {\n const path = `${base}/icon-${size}.svg`;\n files.push({ path, content: renderIcon(size, 0, opts), type: \"image/svg+xml\", size, purpose: \"any\" });\n // apple-touch-icon is referenced via a <link> (see pwaMetaTags), not the\n // manifest, so only real manifest sizes go into icons[].\n if (size !== 180) {\n icons.push({ src: path, sizes: `${size}x${size}`, type: \"image/svg+xml\", purpose: \"any\" });\n }\n }\n\n if (opts.maskable !== false) {\n // Maskable safe-zone: keep content within the central 80% (10% inset).\n const path = `${base}/icon-maskable-512.svg`;\n files.push({ path, content: renderIcon(512, 0.1, opts), type: \"image/svg+xml\", size: 512, purpose: \"maskable\" });\n icons.push({ src: path, sizes: \"512x512\", type: \"image/svg+xml\", purpose: \"maskable\" });\n }\n\n return { files, icons };\n}\n\n// ---------------------------------------------------------------------------\n// Head meta tags\n// ---------------------------------------------------------------------------\n\nexport interface MetaTag {\n tag: \"meta\" | \"link\";\n attrs: Record<string, string>;\n}\n\nexport interface MetaTagsOptions {\n /** `<meta name=\"theme-color\">`. Default `#ffffff`. */\n themeColor?: string;\n /** apple-touch-icon href. Default `/icons/icon-180.svg`. */\n appleTouchIcon?: string;\n /** Manifest href for the `<link rel=\"manifest\">`. Default `/manifest.webmanifest`. */\n manifest?: string;\n /** apple-mobile-web-app-title. Default: omitted. */\n title?: string;\n /** apple-mobile-web-app-status-bar-style. Default `default`. */\n statusBarStyle?: \"default\" | \"black\" | \"black-translucent\";\n /** Emit the `*-web-app-capable` tags. Default `true`. */\n capable?: boolean;\n}\n\n/**\n * The head tags a PWA needs beyond the manifest link — returned as typed\n * descriptors so a consumer renders them in Next.js `metadata`, a Hono/JSX\n * head, or plain HTML. No framework assumption.\n */\nexport function pwaMetaTags(opts: MetaTagsOptions = {}): MetaTag[] {\n const tags: MetaTag[] = [];\n tags.push({ tag: \"link\", attrs: { rel: \"manifest\", href: opts.manifest ?? \"/manifest.webmanifest\" } });\n tags.push({ tag: \"meta\", attrs: { name: \"theme-color\", content: opts.themeColor ?? \"#ffffff\" } });\n tags.push({ tag: \"link\", attrs: { rel: \"apple-touch-icon\", href: opts.appleTouchIcon ?? \"/icons/icon-180.svg\" } });\n if (opts.capable !== false) {\n tags.push({ tag: \"meta\", attrs: { name: \"mobile-web-app-capable\", content: \"yes\" } });\n tags.push({ tag: \"meta\", attrs: { name: \"apple-mobile-web-app-capable\", content: \"yes\" } });\n tags.push({\n tag: \"meta\",\n attrs: { name: \"apple-mobile-web-app-status-bar-style\", content: opts.statusBarStyle ?? \"default\" },\n });\n }\n if (opts.title) {\n tags.push({ tag: \"meta\", attrs: { name: \"apple-mobile-web-app-title\", content: opts.title } });\n }\n return tags;\n}\n"]}
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ var hooks = require('preact/hooks');
4
+ var jsxRuntime = require('preact/jsx-runtime');
5
+
6
+ // src/preact.tsx
7
+
8
+ // src/message.ts
9
+ var SKIP_WAITING = "SKIP_WAITING";
10
+ var SKIP_WAITING_MESSAGE = { type: SKIP_WAITING };
11
+
12
+ // src/index.ts
13
+ var DEFAULT_POLL_MS = 60 * 60 * 1e3;
14
+ function createPwaUpdater(options = {}) {
15
+ const {
16
+ swUrl = "/sw.js",
17
+ register = true,
18
+ pollIntervalMs = DEFAULT_POLL_MS,
19
+ updateOnFocus = true,
20
+ reloadOnControllerChange = true,
21
+ disabled = false
22
+ } = options;
23
+ const listeners = /* @__PURE__ */ new Set();
24
+ let updateReady = false;
25
+ let waitingWorker = null;
26
+ const getState = () => ({ updateReady });
27
+ const emit = () => {
28
+ const state = getState();
29
+ for (const listener of listeners) listener(state);
30
+ };
31
+ const subscribe = (listener) => {
32
+ listeners.add(listener);
33
+ return () => listeners.delete(listener);
34
+ };
35
+ const supported = typeof navigator !== "undefined" && "serviceWorker" in navigator;
36
+ if (disabled || !supported) {
37
+ return {
38
+ subscribe,
39
+ getState,
40
+ applyUpdate() {
41
+ },
42
+ destroy() {
43
+ listeners.clear();
44
+ }
45
+ };
46
+ }
47
+ const container = navigator.serviceWorker;
48
+ let pollId = null;
49
+ let removeFocusListeners = null;
50
+ let reloading = false;
51
+ let destroyed = false;
52
+ const markReady = (worker) => {
53
+ waitingWorker = worker;
54
+ if (!updateReady) {
55
+ updateReady = true;
56
+ emit();
57
+ }
58
+ };
59
+ const onControllerChange = () => {
60
+ if (!reloadOnControllerChange || reloading) return;
61
+ reloading = true;
62
+ if (typeof location !== "undefined") location.reload();
63
+ };
64
+ container.addEventListener("controllerchange", onControllerChange);
65
+ const watchInstalling = (registration) => {
66
+ const installing = registration.installing;
67
+ if (!installing) return;
68
+ installing.addEventListener("statechange", () => {
69
+ if (installing.state === "installed" && container.controller) {
70
+ markReady(installing);
71
+ }
72
+ });
73
+ };
74
+ const wireFocusChecks = (registration) => {
75
+ if (!updateOnFocus) return;
76
+ const check = () => {
77
+ registration.update().catch(() => {
78
+ });
79
+ };
80
+ const onFocus = () => check();
81
+ const onVisibility = () => {
82
+ if (typeof document !== "undefined" && document.visibilityState === "visible") {
83
+ check();
84
+ }
85
+ };
86
+ if (typeof window !== "undefined") window.addEventListener("focus", onFocus);
87
+ if (typeof document !== "undefined") {
88
+ document.addEventListener("visibilitychange", onVisibility);
89
+ }
90
+ removeFocusListeners = () => {
91
+ if (typeof window !== "undefined") window.removeEventListener("focus", onFocus);
92
+ if (typeof document !== "undefined") {
93
+ document.removeEventListener("visibilitychange", onVisibility);
94
+ }
95
+ };
96
+ };
97
+ const registrationPromise = register ? container.register(swUrl) : container.ready;
98
+ registrationPromise.then((registration) => {
99
+ if (destroyed) return;
100
+ if (registration.waiting) markReady(registration.waiting);
101
+ registration.addEventListener("updatefound", () => watchInstalling(registration));
102
+ if (pollIntervalMs > 0) {
103
+ pollId = setInterval(() => {
104
+ registration.update().catch(() => {
105
+ });
106
+ }, pollIntervalMs);
107
+ }
108
+ wireFocusChecks(registration);
109
+ }).catch(() => {
110
+ });
111
+ return {
112
+ subscribe,
113
+ getState,
114
+ applyUpdate() {
115
+ if (waitingWorker) waitingWorker.postMessage(SKIP_WAITING_MESSAGE);
116
+ },
117
+ destroy() {
118
+ destroyed = true;
119
+ if (pollId !== null) clearInterval(pollId);
120
+ removeFocusListeners?.();
121
+ container.removeEventListener("controllerchange", onControllerChange);
122
+ listeners.clear();
123
+ }
124
+ };
125
+ }
126
+ function usePwaUpdate(options = {}) {
127
+ const { swUrl, pollIntervalMs, reloadOnControllerChange, disabled } = options;
128
+ const [updateReady, setUpdateReady] = hooks.useState(false);
129
+ const updaterRef = hooks.useRef(null);
130
+ hooks.useEffect(() => {
131
+ const updater = createPwaUpdater({
132
+ swUrl,
133
+ pollIntervalMs,
134
+ reloadOnControllerChange,
135
+ disabled
136
+ });
137
+ updaterRef.current = updater;
138
+ setUpdateReady(updater.getState().updateReady);
139
+ const unsubscribe = updater.subscribe((state) => setUpdateReady(state.updateReady));
140
+ return () => {
141
+ unsubscribe();
142
+ updater.destroy();
143
+ updaterRef.current = null;
144
+ };
145
+ }, [swUrl, pollIntervalMs, reloadOnControllerChange, disabled]);
146
+ return {
147
+ updateReady,
148
+ applyUpdate: () => updaterRef.current?.applyUpdate()
149
+ };
150
+ }
151
+ var DEFAULT_LABELS = {
152
+ title: "New version available",
153
+ body: "Reload to get the latest version.",
154
+ update: "Update",
155
+ dismiss: "Later",
156
+ close: "Close"
157
+ };
158
+ function PwaUpdateBanner({
159
+ updateReady,
160
+ onUpdate,
161
+ onDismiss,
162
+ labels,
163
+ className
164
+ }) {
165
+ if (!updateReady) return null;
166
+ const l = { ...DEFAULT_LABELS, ...labels };
167
+ const dismiss = () => onDismiss?.();
168
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { role: "status", "aria-live": "polite", "data-testid": "pwa-update-banner", className, children: [
169
+ /* @__PURE__ */ jsxRuntime.jsx("p", { "data-testid": "pwa-update-title", children: l.title }),
170
+ l.body ? /* @__PURE__ */ jsxRuntime.jsx("p", { "data-testid": "pwa-update-body", children: l.body }) : null,
171
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", "data-testid": "pwa-update-confirm", onClick: onUpdate, children: l.update }),
172
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", "data-testid": "pwa-update-dismiss", onClick: dismiss, children: l.dismiss }),
173
+ /* @__PURE__ */ jsxRuntime.jsx(
174
+ "button",
175
+ {
176
+ type: "button",
177
+ "data-testid": "pwa-update-close",
178
+ "aria-label": l.close,
179
+ onClick: dismiss,
180
+ children: "\xD7"
181
+ }
182
+ )
183
+ ] });
184
+ }
185
+
186
+ exports.PwaUpdateBanner = PwaUpdateBanner;
187
+ exports.usePwaUpdate = usePwaUpdate;
188
+ //# sourceMappingURL=preact.cjs.map
189
+ //# sourceMappingURL=preact.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/message.ts","../src/index.ts","../src/preact.tsx"],"names":["useState","useRef","useEffect","jsxs","jsx"],"mappings":"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","file":"preact.cjs","sourcesContent":["/**\n * The single source of the client↔service-worker \"activate the waiting worker\"\n * message. Both the client controller (`applyUpdate`) and the SW helper\n * (`listenForSkipWaiting`) import it so the contract can never drift.\n */\nexport const SKIP_WAITING = \"SKIP_WAITING\" as const;\n\nexport const SKIP_WAITING_MESSAGE = { type: SKIP_WAITING } as const;\n\nexport type SkipWaitingMessage = typeof SKIP_WAITING_MESSAGE;\n","import { SKIP_WAITING_MESSAGE } from \"./message.js\";\n\nexport { SKIP_WAITING, SKIP_WAITING_MESSAGE } from \"./message.js\";\nexport type { SkipWaitingMessage } from \"./message.js\";\n\nexport interface PwaUpdaterOptions {\n /** Service-worker script URL to register. Default `/sw.js`. */\n swUrl?: string;\n /**\n * Register `swUrl` ourselves (manual `sw.js` style). Set `false` when the\n * app already registers the SW (e.g. Serwist's `registerSW` / `@serwist/next`)\n * — the updater then attaches to the existing registration via\n * `navigator.serviceWorker.ready` instead of registering again. Default `true`.\n */\n register?: boolean;\n /** How often to poll `registration.update()` (ms). `0` disables. Default 60 min. */\n pollIntervalMs?: number;\n /**\n * Also check for an update when the tab regains focus / becomes visible.\n * A constantly-focused tab never fires `visibilitychange`, so `focus` is the\n * one that catches \"user came back to the app\". Default `true`.\n */\n updateOnFocus?: boolean;\n /** Reload the page once the new worker takes control. Default `true`. */\n reloadOnControllerChange?: boolean;\n /**\n * Consumer guard. When `true` the updater is an inert no-op (registers\n * nothing). Pass your own policy, e.g. `disabled: isNativeCapacitor || isDev`\n * — the package never hardcodes an environment or a `.native` check.\n */\n disabled?: boolean;\n}\n\nexport interface PwaUpdaterState {\n /** A new service worker is installed and waiting to activate. */\n updateReady: boolean;\n}\n\nexport interface PwaUpdater {\n /** Subscribe to state changes. Returns an unsubscribe fn. */\n subscribe(listener: (state: PwaUpdaterState) => void): () => void;\n getState(): PwaUpdaterState;\n /** Tell the waiting worker to activate (posts SKIP_WAITING). No-op if none waits. */\n applyUpdate(): void;\n /** Stop polling + remove all listeners. */\n destroy(): void;\n}\n\nconst DEFAULT_POLL_MS = 60 * 60 * 1000;\n\n/**\n * Framework- and bundler-agnostic controller for the PWA \"new version\n * available\" lifecycle: register (or attach) → detect a waiting worker\n * (suppressing the first install) → let the consumer activate it → reload on\n * takeover, re-checking on interval + focus/visibility.\n *\n * Distilled from the pattern hand-rolled across the fleet (fds, cardmem,\n * pitch-vault). Works with any service worker (Serwist, Workbox, hand-rolled)\n * as long as the SW answers a `SKIP_WAITING` message — see `@broberg/pwa/sw`.\n */\nexport function createPwaUpdater(options: PwaUpdaterOptions = {}): PwaUpdater {\n const {\n swUrl = \"/sw.js\",\n register = true,\n pollIntervalMs = DEFAULT_POLL_MS,\n updateOnFocus = true,\n reloadOnControllerChange = true,\n disabled = false,\n } = options;\n\n const listeners = new Set<(state: PwaUpdaterState) => void>();\n let updateReady = false;\n let waitingWorker: ServiceWorker | null = null;\n\n const getState = (): PwaUpdaterState => ({ updateReady });\n const emit = (): void => {\n const state = getState();\n for (const listener of listeners) listener(state);\n };\n const subscribe = (listener: (state: PwaUpdaterState) => void): (() => void) => {\n listeners.add(listener);\n return () => listeners.delete(listener);\n };\n\n const supported =\n typeof navigator !== \"undefined\" && \"serviceWorker\" in navigator;\n\n // Inert no-op: disabled by the consumer, or no service-worker support at all.\n if (disabled || !supported) {\n return {\n subscribe,\n getState,\n applyUpdate() {},\n destroy() {\n listeners.clear();\n },\n };\n }\n\n const container = navigator.serviceWorker;\n let pollId: ReturnType<typeof setInterval> | null = null;\n let removeFocusListeners: (() => void) | null = null;\n let reloading = false;\n let destroyed = false;\n\n const markReady = (worker: ServiceWorker): void => {\n waitingWorker = worker;\n if (!updateReady) {\n updateReady = true;\n emit();\n }\n };\n\n const onControllerChange = (): void => {\n if (!reloadOnControllerChange || reloading) return;\n reloading = true; // guard against a reload-loop\n if (typeof location !== \"undefined\") location.reload();\n };\n container.addEventListener(\"controllerchange\", onControllerChange);\n\n const watchInstalling = (registration: ServiceWorkerRegistration): void => {\n const installing = registration.installing;\n if (!installing) return;\n installing.addEventListener(\"statechange\", () => {\n // A new worker reaching `installed` WHILE a controller already exists is\n // an update. Reaching `installed` with no controller is the FIRST install\n // — nothing to update, so we stay quiet.\n if (installing.state === \"installed\" && container.controller) {\n markReady(installing);\n }\n });\n };\n\n const wireFocusChecks = (registration: ServiceWorkerRegistration): void => {\n if (!updateOnFocus) return;\n const check = (): void => {\n registration.update().catch(() => {});\n };\n const onFocus = (): void => check();\n const onVisibility = (): void => {\n if (typeof document !== \"undefined\" && document.visibilityState === \"visible\") {\n check();\n }\n };\n if (typeof window !== \"undefined\") window.addEventListener(\"focus\", onFocus);\n if (typeof document !== \"undefined\") {\n document.addEventListener(\"visibilitychange\", onVisibility);\n }\n removeFocusListeners = () => {\n if (typeof window !== \"undefined\") window.removeEventListener(\"focus\", onFocus);\n if (typeof document !== \"undefined\") {\n document.removeEventListener(\"visibilitychange\", onVisibility);\n }\n };\n };\n\n // Manual style registers swUrl; serwist/registerSW style attaches to the\n // registration the app already made via `.ready`.\n const registrationPromise = register ? container.register(swUrl) : container.ready;\n\n registrationPromise\n .then((registration) => {\n if (destroyed) return;\n if (registration.waiting) markReady(registration.waiting);\n registration.addEventListener(\"updatefound\", () => watchInstalling(registration));\n if (pollIntervalMs > 0) {\n pollId = setInterval(() => {\n registration.update().catch(() => {\n // ignore transient update-check network errors\n });\n }, pollIntervalMs);\n }\n wireFocusChecks(registration);\n })\n .catch(() => {\n // registration failed — stay inert rather than throw into the app\n });\n\n return {\n subscribe,\n getState,\n applyUpdate() {\n if (waitingWorker) waitingWorker.postMessage(SKIP_WAITING_MESSAGE);\n },\n destroy() {\n destroyed = true;\n if (pollId !== null) clearInterval(pollId);\n removeFocusListeners?.();\n container.removeEventListener(\"controllerchange\", onControllerChange);\n listeners.clear();\n },\n };\n}\n","/** @jsxImportSource preact */\n\nimport { useEffect, useRef, useState } from \"preact/hooks\";\nimport { createPwaUpdater, type PwaUpdater, type PwaUpdaterOptions } from \"./index.js\";\n\n/**\n * Preact binding for {@link createPwaUpdater} — API-identical to the React\n * adapter, for the Stack B (Bun/Hono/Preact/Vite) apps. Returns whether an\n * update is ready plus the `applyUpdate` action; the controller is created\n * once and torn down on unmount.\n */\nexport function usePwaUpdate(options: PwaUpdaterOptions = {}): {\n updateReady: boolean;\n applyUpdate: () => void;\n} {\n const { swUrl, pollIntervalMs, reloadOnControllerChange, disabled } = options;\n const [updateReady, setUpdateReady] = useState(false);\n const updaterRef = useRef<PwaUpdater | null>(null);\n\n useEffect(() => {\n const updater = createPwaUpdater({\n swUrl,\n pollIntervalMs,\n reloadOnControllerChange,\n disabled,\n });\n updaterRef.current = updater;\n setUpdateReady(updater.getState().updateReady);\n const unsubscribe = updater.subscribe((state) => setUpdateReady(state.updateReady));\n return () => {\n unsubscribe();\n updater.destroy();\n updaterRef.current = null;\n };\n }, [swUrl, pollIntervalMs, reloadOnControllerChange, disabled]);\n\n return {\n updateReady,\n applyUpdate: () => updaterRef.current?.applyUpdate(),\n };\n}\n\nexport interface PwaUpdateBannerLabels {\n title?: string;\n body?: string;\n update?: string;\n dismiss?: string;\n close?: string;\n}\n\nconst DEFAULT_LABELS: Required<PwaUpdateBannerLabels> = {\n title: \"New version available\",\n body: \"Reload to get the latest version.\",\n update: \"Update\",\n dismiss: \"Later\",\n close: \"Close\",\n};\n\nexport interface PwaUpdateBannerProps {\n /** From `usePwaUpdate().updateReady`. Nothing renders when false. */\n updateReady: boolean;\n /** From `usePwaUpdate().applyUpdate`. */\n onUpdate: () => void;\n /** Optional dismiss handler (wired to both \"Later\" and the close ✕). */\n onDismiss?: () => void;\n /** Override any of the built-in strings (i18n). */\n labels?: PwaUpdateBannerLabels;\n /** Your class(es) — the skeleton carries NO colours or design-system styling. */\n className?: string;\n}\n\n/**\n * Unstyled, accessible \"new version available\" banner skeleton for Preact.\n * Structure + a11y + stable testids only; you own the look via `className`\n * and your design tokens. Prefer {@link usePwaUpdate} directly if you'd rather\n * build the UI yourself.\n */\nexport function PwaUpdateBanner({\n updateReady,\n onUpdate,\n onDismiss,\n labels,\n className,\n}: PwaUpdateBannerProps) {\n if (!updateReady) return null;\n const l = { ...DEFAULT_LABELS, ...labels };\n const dismiss = () => onDismiss?.();\n return (\n <div role=\"status\" aria-live=\"polite\" data-testid=\"pwa-update-banner\" className={className}>\n <p data-testid=\"pwa-update-title\">{l.title}</p>\n {l.body ? <p data-testid=\"pwa-update-body\">{l.body}</p> : null}\n <button type=\"button\" data-testid=\"pwa-update-confirm\" onClick={onUpdate}>\n {l.update}\n </button>\n <button type=\"button\" data-testid=\"pwa-update-dismiss\" onClick={dismiss}>\n {l.dismiss}\n </button>\n <button\n type=\"button\"\n data-testid=\"pwa-update-close\"\n aria-label={l.close}\n onClick={dismiss}\n >\n {\"×\"}\n </button>\n </div>\n );\n}\n"]}
@@ -0,0 +1,68 @@
1
+ import * as preact from 'preact';
2
+
3
+ interface PwaUpdaterOptions {
4
+ /** Service-worker script URL to register. Default `/sw.js`. */
5
+ swUrl?: string;
6
+ /**
7
+ * Register `swUrl` ourselves (manual `sw.js` style). Set `false` when the
8
+ * app already registers the SW (e.g. Serwist's `registerSW` / `@serwist/next`)
9
+ * — the updater then attaches to the existing registration via
10
+ * `navigator.serviceWorker.ready` instead of registering again. Default `true`.
11
+ */
12
+ register?: boolean;
13
+ /** How often to poll `registration.update()` (ms). `0` disables. Default 60 min. */
14
+ pollIntervalMs?: number;
15
+ /**
16
+ * Also check for an update when the tab regains focus / becomes visible.
17
+ * A constantly-focused tab never fires `visibilitychange`, so `focus` is the
18
+ * one that catches "user came back to the app". Default `true`.
19
+ */
20
+ updateOnFocus?: boolean;
21
+ /** Reload the page once the new worker takes control. Default `true`. */
22
+ reloadOnControllerChange?: boolean;
23
+ /**
24
+ * Consumer guard. When `true` the updater is an inert no-op (registers
25
+ * nothing). Pass your own policy, e.g. `disabled: isNativeCapacitor || isDev`
26
+ * — the package never hardcodes an environment or a `.native` check.
27
+ */
28
+ disabled?: boolean;
29
+ }
30
+
31
+ /**
32
+ * Preact binding for {@link createPwaUpdater} — API-identical to the React
33
+ * adapter, for the Stack B (Bun/Hono/Preact/Vite) apps. Returns whether an
34
+ * update is ready plus the `applyUpdate` action; the controller is created
35
+ * once and torn down on unmount.
36
+ */
37
+ declare function usePwaUpdate(options?: PwaUpdaterOptions): {
38
+ updateReady: boolean;
39
+ applyUpdate: () => void;
40
+ };
41
+ interface PwaUpdateBannerLabels {
42
+ title?: string;
43
+ body?: string;
44
+ update?: string;
45
+ dismiss?: string;
46
+ close?: string;
47
+ }
48
+ interface PwaUpdateBannerProps {
49
+ /** From `usePwaUpdate().updateReady`. Nothing renders when false. */
50
+ updateReady: boolean;
51
+ /** From `usePwaUpdate().applyUpdate`. */
52
+ onUpdate: () => void;
53
+ /** Optional dismiss handler (wired to both "Later" and the close ✕). */
54
+ onDismiss?: () => void;
55
+ /** Override any of the built-in strings (i18n). */
56
+ labels?: PwaUpdateBannerLabels;
57
+ /** Your class(es) — the skeleton carries NO colours or design-system styling. */
58
+ className?: string;
59
+ }
60
+ /**
61
+ * Unstyled, accessible "new version available" banner skeleton for Preact.
62
+ * Structure + a11y + stable testids only; you own the look via `className`
63
+ * and your design tokens. Prefer {@link usePwaUpdate} directly if you'd rather
64
+ * build the UI yourself.
65
+ */
66
+ declare function PwaUpdateBanner({ updateReady, onUpdate, onDismiss, labels, className, }: PwaUpdateBannerProps): preact.JSX.Element | null;
67
+
68
+ export { PwaUpdateBanner, type PwaUpdateBannerLabels, type PwaUpdateBannerProps, usePwaUpdate };
@@ -0,0 +1,68 @@
1
+ import * as preact from 'preact';
2
+
3
+ interface PwaUpdaterOptions {
4
+ /** Service-worker script URL to register. Default `/sw.js`. */
5
+ swUrl?: string;
6
+ /**
7
+ * Register `swUrl` ourselves (manual `sw.js` style). Set `false` when the
8
+ * app already registers the SW (e.g. Serwist's `registerSW` / `@serwist/next`)
9
+ * — the updater then attaches to the existing registration via
10
+ * `navigator.serviceWorker.ready` instead of registering again. Default `true`.
11
+ */
12
+ register?: boolean;
13
+ /** How often to poll `registration.update()` (ms). `0` disables. Default 60 min. */
14
+ pollIntervalMs?: number;
15
+ /**
16
+ * Also check for an update when the tab regains focus / becomes visible.
17
+ * A constantly-focused tab never fires `visibilitychange`, so `focus` is the
18
+ * one that catches "user came back to the app". Default `true`.
19
+ */
20
+ updateOnFocus?: boolean;
21
+ /** Reload the page once the new worker takes control. Default `true`. */
22
+ reloadOnControllerChange?: boolean;
23
+ /**
24
+ * Consumer guard. When `true` the updater is an inert no-op (registers
25
+ * nothing). Pass your own policy, e.g. `disabled: isNativeCapacitor || isDev`
26
+ * — the package never hardcodes an environment or a `.native` check.
27
+ */
28
+ disabled?: boolean;
29
+ }
30
+
31
+ /**
32
+ * Preact binding for {@link createPwaUpdater} — API-identical to the React
33
+ * adapter, for the Stack B (Bun/Hono/Preact/Vite) apps. Returns whether an
34
+ * update is ready plus the `applyUpdate` action; the controller is created
35
+ * once and torn down on unmount.
36
+ */
37
+ declare function usePwaUpdate(options?: PwaUpdaterOptions): {
38
+ updateReady: boolean;
39
+ applyUpdate: () => void;
40
+ };
41
+ interface PwaUpdateBannerLabels {
42
+ title?: string;
43
+ body?: string;
44
+ update?: string;
45
+ dismiss?: string;
46
+ close?: string;
47
+ }
48
+ interface PwaUpdateBannerProps {
49
+ /** From `usePwaUpdate().updateReady`. Nothing renders when false. */
50
+ updateReady: boolean;
51
+ /** From `usePwaUpdate().applyUpdate`. */
52
+ onUpdate: () => void;
53
+ /** Optional dismiss handler (wired to both "Later" and the close ✕). */
54
+ onDismiss?: () => void;
55
+ /** Override any of the built-in strings (i18n). */
56
+ labels?: PwaUpdateBannerLabels;
57
+ /** Your class(es) — the skeleton carries NO colours or design-system styling. */
58
+ className?: string;
59
+ }
60
+ /**
61
+ * Unstyled, accessible "new version available" banner skeleton for Preact.
62
+ * Structure + a11y + stable testids only; you own the look via `className`
63
+ * and your design tokens. Prefer {@link usePwaUpdate} directly if you'd rather
64
+ * build the UI yourself.
65
+ */
66
+ declare function PwaUpdateBanner({ updateReady, onUpdate, onDismiss, labels, className, }: PwaUpdateBannerProps): preact.JSX.Element | null;
67
+
68
+ export { PwaUpdateBanner, type PwaUpdateBannerLabels, type PwaUpdateBannerProps, usePwaUpdate };
package/dist/preact.js ADDED
@@ -0,0 +1,186 @@
1
+ import { useState, useRef, useEffect } from 'preact/hooks';
2
+ import { jsxs, jsx } from 'preact/jsx-runtime';
3
+
4
+ // src/preact.tsx
5
+
6
+ // src/message.ts
7
+ var SKIP_WAITING = "SKIP_WAITING";
8
+ var SKIP_WAITING_MESSAGE = { type: SKIP_WAITING };
9
+
10
+ // src/index.ts
11
+ var DEFAULT_POLL_MS = 60 * 60 * 1e3;
12
+ function createPwaUpdater(options = {}) {
13
+ const {
14
+ swUrl = "/sw.js",
15
+ register = true,
16
+ pollIntervalMs = DEFAULT_POLL_MS,
17
+ updateOnFocus = true,
18
+ reloadOnControllerChange = true,
19
+ disabled = false
20
+ } = options;
21
+ const listeners = /* @__PURE__ */ new Set();
22
+ let updateReady = false;
23
+ let waitingWorker = null;
24
+ const getState = () => ({ updateReady });
25
+ const emit = () => {
26
+ const state = getState();
27
+ for (const listener of listeners) listener(state);
28
+ };
29
+ const subscribe = (listener) => {
30
+ listeners.add(listener);
31
+ return () => listeners.delete(listener);
32
+ };
33
+ const supported = typeof navigator !== "undefined" && "serviceWorker" in navigator;
34
+ if (disabled || !supported) {
35
+ return {
36
+ subscribe,
37
+ getState,
38
+ applyUpdate() {
39
+ },
40
+ destroy() {
41
+ listeners.clear();
42
+ }
43
+ };
44
+ }
45
+ const container = navigator.serviceWorker;
46
+ let pollId = null;
47
+ let removeFocusListeners = null;
48
+ let reloading = false;
49
+ let destroyed = false;
50
+ const markReady = (worker) => {
51
+ waitingWorker = worker;
52
+ if (!updateReady) {
53
+ updateReady = true;
54
+ emit();
55
+ }
56
+ };
57
+ const onControllerChange = () => {
58
+ if (!reloadOnControllerChange || reloading) return;
59
+ reloading = true;
60
+ if (typeof location !== "undefined") location.reload();
61
+ };
62
+ container.addEventListener("controllerchange", onControllerChange);
63
+ const watchInstalling = (registration) => {
64
+ const installing = registration.installing;
65
+ if (!installing) return;
66
+ installing.addEventListener("statechange", () => {
67
+ if (installing.state === "installed" && container.controller) {
68
+ markReady(installing);
69
+ }
70
+ });
71
+ };
72
+ const wireFocusChecks = (registration) => {
73
+ if (!updateOnFocus) return;
74
+ const check = () => {
75
+ registration.update().catch(() => {
76
+ });
77
+ };
78
+ const onFocus = () => check();
79
+ const onVisibility = () => {
80
+ if (typeof document !== "undefined" && document.visibilityState === "visible") {
81
+ check();
82
+ }
83
+ };
84
+ if (typeof window !== "undefined") window.addEventListener("focus", onFocus);
85
+ if (typeof document !== "undefined") {
86
+ document.addEventListener("visibilitychange", onVisibility);
87
+ }
88
+ removeFocusListeners = () => {
89
+ if (typeof window !== "undefined") window.removeEventListener("focus", onFocus);
90
+ if (typeof document !== "undefined") {
91
+ document.removeEventListener("visibilitychange", onVisibility);
92
+ }
93
+ };
94
+ };
95
+ const registrationPromise = register ? container.register(swUrl) : container.ready;
96
+ registrationPromise.then((registration) => {
97
+ if (destroyed) return;
98
+ if (registration.waiting) markReady(registration.waiting);
99
+ registration.addEventListener("updatefound", () => watchInstalling(registration));
100
+ if (pollIntervalMs > 0) {
101
+ pollId = setInterval(() => {
102
+ registration.update().catch(() => {
103
+ });
104
+ }, pollIntervalMs);
105
+ }
106
+ wireFocusChecks(registration);
107
+ }).catch(() => {
108
+ });
109
+ return {
110
+ subscribe,
111
+ getState,
112
+ applyUpdate() {
113
+ if (waitingWorker) waitingWorker.postMessage(SKIP_WAITING_MESSAGE);
114
+ },
115
+ destroy() {
116
+ destroyed = true;
117
+ if (pollId !== null) clearInterval(pollId);
118
+ removeFocusListeners?.();
119
+ container.removeEventListener("controllerchange", onControllerChange);
120
+ listeners.clear();
121
+ }
122
+ };
123
+ }
124
+ function usePwaUpdate(options = {}) {
125
+ const { swUrl, pollIntervalMs, reloadOnControllerChange, disabled } = options;
126
+ const [updateReady, setUpdateReady] = useState(false);
127
+ const updaterRef = useRef(null);
128
+ useEffect(() => {
129
+ const updater = createPwaUpdater({
130
+ swUrl,
131
+ pollIntervalMs,
132
+ reloadOnControllerChange,
133
+ disabled
134
+ });
135
+ updaterRef.current = updater;
136
+ setUpdateReady(updater.getState().updateReady);
137
+ const unsubscribe = updater.subscribe((state) => setUpdateReady(state.updateReady));
138
+ return () => {
139
+ unsubscribe();
140
+ updater.destroy();
141
+ updaterRef.current = null;
142
+ };
143
+ }, [swUrl, pollIntervalMs, reloadOnControllerChange, disabled]);
144
+ return {
145
+ updateReady,
146
+ applyUpdate: () => updaterRef.current?.applyUpdate()
147
+ };
148
+ }
149
+ var DEFAULT_LABELS = {
150
+ title: "New version available",
151
+ body: "Reload to get the latest version.",
152
+ update: "Update",
153
+ dismiss: "Later",
154
+ close: "Close"
155
+ };
156
+ function PwaUpdateBanner({
157
+ updateReady,
158
+ onUpdate,
159
+ onDismiss,
160
+ labels,
161
+ className
162
+ }) {
163
+ if (!updateReady) return null;
164
+ const l = { ...DEFAULT_LABELS, ...labels };
165
+ const dismiss = () => onDismiss?.();
166
+ return /* @__PURE__ */ jsxs("div", { role: "status", "aria-live": "polite", "data-testid": "pwa-update-banner", className, children: [
167
+ /* @__PURE__ */ jsx("p", { "data-testid": "pwa-update-title", children: l.title }),
168
+ l.body ? /* @__PURE__ */ jsx("p", { "data-testid": "pwa-update-body", children: l.body }) : null,
169
+ /* @__PURE__ */ jsx("button", { type: "button", "data-testid": "pwa-update-confirm", onClick: onUpdate, children: l.update }),
170
+ /* @__PURE__ */ jsx("button", { type: "button", "data-testid": "pwa-update-dismiss", onClick: dismiss, children: l.dismiss }),
171
+ /* @__PURE__ */ jsx(
172
+ "button",
173
+ {
174
+ type: "button",
175
+ "data-testid": "pwa-update-close",
176
+ "aria-label": l.close,
177
+ onClick: dismiss,
178
+ children: "\xD7"
179
+ }
180
+ )
181
+ ] });
182
+ }
183
+
184
+ export { PwaUpdateBanner, usePwaUpdate };
185
+ //# sourceMappingURL=preact.js.map
186
+ //# sourceMappingURL=preact.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/message.ts","../src/index.ts","../src/preact.tsx"],"names":[],"mappings":"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","file":"preact.js","sourcesContent":["/**\n * The single source of the client↔service-worker \"activate the waiting worker\"\n * message. Both the client controller (`applyUpdate`) and the SW helper\n * (`listenForSkipWaiting`) import it so the contract can never drift.\n */\nexport const SKIP_WAITING = \"SKIP_WAITING\" as const;\n\nexport const SKIP_WAITING_MESSAGE = { type: SKIP_WAITING } as const;\n\nexport type SkipWaitingMessage = typeof SKIP_WAITING_MESSAGE;\n","import { SKIP_WAITING_MESSAGE } from \"./message.js\";\n\nexport { SKIP_WAITING, SKIP_WAITING_MESSAGE } from \"./message.js\";\nexport type { SkipWaitingMessage } from \"./message.js\";\n\nexport interface PwaUpdaterOptions {\n /** Service-worker script URL to register. Default `/sw.js`. */\n swUrl?: string;\n /**\n * Register `swUrl` ourselves (manual `sw.js` style). Set `false` when the\n * app already registers the SW (e.g. Serwist's `registerSW` / `@serwist/next`)\n * — the updater then attaches to the existing registration via\n * `navigator.serviceWorker.ready` instead of registering again. Default `true`.\n */\n register?: boolean;\n /** How often to poll `registration.update()` (ms). `0` disables. Default 60 min. */\n pollIntervalMs?: number;\n /**\n * Also check for an update when the tab regains focus / becomes visible.\n * A constantly-focused tab never fires `visibilitychange`, so `focus` is the\n * one that catches \"user came back to the app\". Default `true`.\n */\n updateOnFocus?: boolean;\n /** Reload the page once the new worker takes control. Default `true`. */\n reloadOnControllerChange?: boolean;\n /**\n * Consumer guard. When `true` the updater is an inert no-op (registers\n * nothing). Pass your own policy, e.g. `disabled: isNativeCapacitor || isDev`\n * — the package never hardcodes an environment or a `.native` check.\n */\n disabled?: boolean;\n}\n\nexport interface PwaUpdaterState {\n /** A new service worker is installed and waiting to activate. */\n updateReady: boolean;\n}\n\nexport interface PwaUpdater {\n /** Subscribe to state changes. Returns an unsubscribe fn. */\n subscribe(listener: (state: PwaUpdaterState) => void): () => void;\n getState(): PwaUpdaterState;\n /** Tell the waiting worker to activate (posts SKIP_WAITING). No-op if none waits. */\n applyUpdate(): void;\n /** Stop polling + remove all listeners. */\n destroy(): void;\n}\n\nconst DEFAULT_POLL_MS = 60 * 60 * 1000;\n\n/**\n * Framework- and bundler-agnostic controller for the PWA \"new version\n * available\" lifecycle: register (or attach) → detect a waiting worker\n * (suppressing the first install) → let the consumer activate it → reload on\n * takeover, re-checking on interval + focus/visibility.\n *\n * Distilled from the pattern hand-rolled across the fleet (fds, cardmem,\n * pitch-vault). Works with any service worker (Serwist, Workbox, hand-rolled)\n * as long as the SW answers a `SKIP_WAITING` message — see `@broberg/pwa/sw`.\n */\nexport function createPwaUpdater(options: PwaUpdaterOptions = {}): PwaUpdater {\n const {\n swUrl = \"/sw.js\",\n register = true,\n pollIntervalMs = DEFAULT_POLL_MS,\n updateOnFocus = true,\n reloadOnControllerChange = true,\n disabled = false,\n } = options;\n\n const listeners = new Set<(state: PwaUpdaterState) => void>();\n let updateReady = false;\n let waitingWorker: ServiceWorker | null = null;\n\n const getState = (): PwaUpdaterState => ({ updateReady });\n const emit = (): void => {\n const state = getState();\n for (const listener of listeners) listener(state);\n };\n const subscribe = (listener: (state: PwaUpdaterState) => void): (() => void) => {\n listeners.add(listener);\n return () => listeners.delete(listener);\n };\n\n const supported =\n typeof navigator !== \"undefined\" && \"serviceWorker\" in navigator;\n\n // Inert no-op: disabled by the consumer, or no service-worker support at all.\n if (disabled || !supported) {\n return {\n subscribe,\n getState,\n applyUpdate() {},\n destroy() {\n listeners.clear();\n },\n };\n }\n\n const container = navigator.serviceWorker;\n let pollId: ReturnType<typeof setInterval> | null = null;\n let removeFocusListeners: (() => void) | null = null;\n let reloading = false;\n let destroyed = false;\n\n const markReady = (worker: ServiceWorker): void => {\n waitingWorker = worker;\n if (!updateReady) {\n updateReady = true;\n emit();\n }\n };\n\n const onControllerChange = (): void => {\n if (!reloadOnControllerChange || reloading) return;\n reloading = true; // guard against a reload-loop\n if (typeof location !== \"undefined\") location.reload();\n };\n container.addEventListener(\"controllerchange\", onControllerChange);\n\n const watchInstalling = (registration: ServiceWorkerRegistration): void => {\n const installing = registration.installing;\n if (!installing) return;\n installing.addEventListener(\"statechange\", () => {\n // A new worker reaching `installed` WHILE a controller already exists is\n // an update. Reaching `installed` with no controller is the FIRST install\n // — nothing to update, so we stay quiet.\n if (installing.state === \"installed\" && container.controller) {\n markReady(installing);\n }\n });\n };\n\n const wireFocusChecks = (registration: ServiceWorkerRegistration): void => {\n if (!updateOnFocus) return;\n const check = (): void => {\n registration.update().catch(() => {});\n };\n const onFocus = (): void => check();\n const onVisibility = (): void => {\n if (typeof document !== \"undefined\" && document.visibilityState === \"visible\") {\n check();\n }\n };\n if (typeof window !== \"undefined\") window.addEventListener(\"focus\", onFocus);\n if (typeof document !== \"undefined\") {\n document.addEventListener(\"visibilitychange\", onVisibility);\n }\n removeFocusListeners = () => {\n if (typeof window !== \"undefined\") window.removeEventListener(\"focus\", onFocus);\n if (typeof document !== \"undefined\") {\n document.removeEventListener(\"visibilitychange\", onVisibility);\n }\n };\n };\n\n // Manual style registers swUrl; serwist/registerSW style attaches to the\n // registration the app already made via `.ready`.\n const registrationPromise = register ? container.register(swUrl) : container.ready;\n\n registrationPromise\n .then((registration) => {\n if (destroyed) return;\n if (registration.waiting) markReady(registration.waiting);\n registration.addEventListener(\"updatefound\", () => watchInstalling(registration));\n if (pollIntervalMs > 0) {\n pollId = setInterval(() => {\n registration.update().catch(() => {\n // ignore transient update-check network errors\n });\n }, pollIntervalMs);\n }\n wireFocusChecks(registration);\n })\n .catch(() => {\n // registration failed — stay inert rather than throw into the app\n });\n\n return {\n subscribe,\n getState,\n applyUpdate() {\n if (waitingWorker) waitingWorker.postMessage(SKIP_WAITING_MESSAGE);\n },\n destroy() {\n destroyed = true;\n if (pollId !== null) clearInterval(pollId);\n removeFocusListeners?.();\n container.removeEventListener(\"controllerchange\", onControllerChange);\n listeners.clear();\n },\n };\n}\n","/** @jsxImportSource preact */\n\nimport { useEffect, useRef, useState } from \"preact/hooks\";\nimport { createPwaUpdater, type PwaUpdater, type PwaUpdaterOptions } from \"./index.js\";\n\n/**\n * Preact binding for {@link createPwaUpdater} — API-identical to the React\n * adapter, for the Stack B (Bun/Hono/Preact/Vite) apps. Returns whether an\n * update is ready plus the `applyUpdate` action; the controller is created\n * once and torn down on unmount.\n */\nexport function usePwaUpdate(options: PwaUpdaterOptions = {}): {\n updateReady: boolean;\n applyUpdate: () => void;\n} {\n const { swUrl, pollIntervalMs, reloadOnControllerChange, disabled } = options;\n const [updateReady, setUpdateReady] = useState(false);\n const updaterRef = useRef<PwaUpdater | null>(null);\n\n useEffect(() => {\n const updater = createPwaUpdater({\n swUrl,\n pollIntervalMs,\n reloadOnControllerChange,\n disabled,\n });\n updaterRef.current = updater;\n setUpdateReady(updater.getState().updateReady);\n const unsubscribe = updater.subscribe((state) => setUpdateReady(state.updateReady));\n return () => {\n unsubscribe();\n updater.destroy();\n updaterRef.current = null;\n };\n }, [swUrl, pollIntervalMs, reloadOnControllerChange, disabled]);\n\n return {\n updateReady,\n applyUpdate: () => updaterRef.current?.applyUpdate(),\n };\n}\n\nexport interface PwaUpdateBannerLabels {\n title?: string;\n body?: string;\n update?: string;\n dismiss?: string;\n close?: string;\n}\n\nconst DEFAULT_LABELS: Required<PwaUpdateBannerLabels> = {\n title: \"New version available\",\n body: \"Reload to get the latest version.\",\n update: \"Update\",\n dismiss: \"Later\",\n close: \"Close\",\n};\n\nexport interface PwaUpdateBannerProps {\n /** From `usePwaUpdate().updateReady`. Nothing renders when false. */\n updateReady: boolean;\n /** From `usePwaUpdate().applyUpdate`. */\n onUpdate: () => void;\n /** Optional dismiss handler (wired to both \"Later\" and the close ✕). */\n onDismiss?: () => void;\n /** Override any of the built-in strings (i18n). */\n labels?: PwaUpdateBannerLabels;\n /** Your class(es) — the skeleton carries NO colours or design-system styling. */\n className?: string;\n}\n\n/**\n * Unstyled, accessible \"new version available\" banner skeleton for Preact.\n * Structure + a11y + stable testids only; you own the look via `className`\n * and your design tokens. Prefer {@link usePwaUpdate} directly if you'd rather\n * build the UI yourself.\n */\nexport function PwaUpdateBanner({\n updateReady,\n onUpdate,\n onDismiss,\n labels,\n className,\n}: PwaUpdateBannerProps) {\n if (!updateReady) return null;\n const l = { ...DEFAULT_LABELS, ...labels };\n const dismiss = () => onDismiss?.();\n return (\n <div role=\"status\" aria-live=\"polite\" data-testid=\"pwa-update-banner\" className={className}>\n <p data-testid=\"pwa-update-title\">{l.title}</p>\n {l.body ? <p data-testid=\"pwa-update-body\">{l.body}</p> : null}\n <button type=\"button\" data-testid=\"pwa-update-confirm\" onClick={onUpdate}>\n {l.update}\n </button>\n <button type=\"button\" data-testid=\"pwa-update-dismiss\" onClick={dismiss}>\n {l.dismiss}\n </button>\n <button\n type=\"button\"\n data-testid=\"pwa-update-close\"\n aria-label={l.close}\n onClick={dismiss}\n >\n {\"×\"}\n </button>\n </div>\n );\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broberg/pwa",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Fleet PWA primitive: a framework-agnostic service-worker update-detection controller (new-version-available lifecycle) + a React hook and unstyled banner skeleton + a service-worker skip-waiting helper. Zero runtime deps; ship-dark.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,6 +27,16 @@
27
27
  "types": "./dist/sw.d.ts",
28
28
  "import": "./dist/sw.js",
29
29
  "require": "./dist/sw.cjs"
30
+ },
31
+ "./manifest": {
32
+ "types": "./dist/manifest.d.ts",
33
+ "import": "./dist/manifest.js",
34
+ "require": "./dist/manifest.cjs"
35
+ },
36
+ "./preact": {
37
+ "types": "./dist/preact.d.ts",
38
+ "import": "./dist/preact.js",
39
+ "require": "./dist/preact.cjs"
30
40
  }
31
41
  },
32
42
  "scripts": {
@@ -35,17 +45,23 @@
35
45
  "typecheck": "tsc --noEmit"
36
46
  },
37
47
  "peerDependencies": {
38
- "react": ">=18.0.0"
48
+ "react": ">=18.0.0",
49
+ "preact": ">=10.0.0"
39
50
  },
40
51
  "peerDependenciesMeta": {
41
52
  "react": {
42
53
  "optional": true
54
+ },
55
+ "preact": {
56
+ "optional": true
43
57
  }
44
58
  },
45
59
  "devDependencies": {
60
+ "@testing-library/preact": "^3.2.3",
46
61
  "@testing-library/react": "^16.0.0",
47
62
  "@types/react": "^19.0.0",
48
63
  "happy-dom": "^15.0.0",
64
+ "preact": "^10.23.0",
49
65
  "react": "^19.0.0",
50
66
  "react-dom": "^19.0.0",
51
67
  "tsup": "^8.3.0",
@@ -65,6 +81,7 @@
65
81
  "serwist",
66
82
  "workbox",
67
83
  "react",
84
+ "preact",
68
85
  "broberg"
69
86
  ],
70
87
  "repository": {