@broberg/pwa 0.4.0 → 0.5.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 +104 -4
- package/dist/bin.cjs +123 -0
- package/dist/bin.cjs.map +1 -0
- package/dist/bin.d.cts +1 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +100 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-JWKQNVNE.js +25 -0
- package/dist/chunk-JWKQNVNE.js.map +1 -0
- package/dist/index.cjs +18 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +18 -6
- package/dist/index.js.map +1 -1
- package/dist/manifest.cjs.map +1 -1
- package/dist/manifest.d.cts +2 -3
- package/dist/manifest.d.ts +2 -3
- package/dist/manifest.js.map +1 -1
- package/dist/preact.cjs +18 -6
- package/dist/preact.cjs.map +1 -1
- package/dist/preact.js +18 -6
- package/dist/preact.js.map +1 -1
- package/dist/raster.cjs +28 -0
- package/dist/raster.cjs.map +1 -0
- package/dist/raster.d.cts +56 -0
- package/dist/raster.d.ts +56 -0
- package/dist/raster.js +3 -0
- package/dist/raster.js.map +1 -0
- package/dist/react.cjs +18 -6
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +18 -6
- package/dist/react.js.map +1 -1
- package/package.json +18 -4
package/README.md
CHANGED
|
@@ -8,6 +8,26 @@ as one small, framework- and bundler-agnostic package instead of a fifth copy.
|
|
|
8
8
|
npm i @broberg/pwa
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
## 0.5.0 — PNG icons, so «Add to Home Screen» on an iPhone shows the logo (F021.9)
|
|
12
|
+
|
|
13
|
+
New `pwa-icons` bin + `@broberg/pwa/raster` — see «PNG icons» below. Additive:
|
|
14
|
+
nothing existing changed output, and the core entries are still dependency-free.
|
|
15
|
+
|
|
16
|
+
## 0.4.1 — «Update now» now always updates (F054.9)
|
|
17
|
+
|
|
18
|
+
**Take 0.4.1 if you show an update button.** In 0.4.0 a press could do nothing,
|
|
19
|
+
forever: on a page opened WITHOUT an active service worker (a shift-reload, or
|
|
20
|
+
the first page after registration) while a new worker was waiting, the guard
|
|
21
|
+
that stops a first install from reloading a new visitor also swallowed the
|
|
22
|
+
takeover the press caused. No reload, and every later press went to a worker
|
|
23
|
+
that was already active. Reported by the owner after 30 presses in helpdesk.
|
|
24
|
+
|
|
25
|
+
Now: after `applyUpdate()` the next takeover always reloads; a press whose
|
|
26
|
+
worker already activated reloads directly; and an SW without `clientsClaim`
|
|
27
|
+
reloads when the worker reaches `activated`. One reload, never two.
|
|
28
|
+
`reloadOnControllerChange: false` still means you reload yourself. No API
|
|
29
|
+
change; a first install still never reloads on its own.
|
|
30
|
+
|
|
11
31
|
## 0.4.0 — READ, don't remember (F054.8)
|
|
12
32
|
|
|
13
33
|
**Take 0.4.0.** Before it, `updateReady` could only ever go **up**, and only from
|
|
@@ -40,6 +60,56 @@ const { updateReady, applyUpdate, snooze } = usePwaUpdate();
|
|
|
40
60
|
// "Update" → applyUpdate() "Later" → snooze(), NOT a local setState(false)
|
|
41
61
|
```
|
|
42
62
|
|
|
63
|
+
**The pattern to look for in your own code**, named by cardmem when they read
|
|
64
|
+
this report against their adoption — it is not hypothetical, it is what a
|
|
65
|
+
consumer writes when the flag can only ever rise:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// ⚠️ the 0.3.x shape: a local mirror, reset only on dismiss
|
|
69
|
+
const [needRefresh, setNeedRefresh] = useState(false);
|
|
70
|
+
useEffect(() => { if (updateReady) setNeedRefresh(true); }, [updateReady]);
|
|
71
|
+
const onDismiss = () => setNeedRefresh(false); // ← the only way down
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
That mirror cannot fall when `updateReady` does, and dismissing it is permanent
|
|
75
|
+
— which is the original defect, reproduced one layer up. Drop the mirror and
|
|
76
|
+
render from `updateReady` directly; wire "Later" to `snooze()`.
|
|
77
|
+
|
|
78
|
+
So an upgrade here is a small **migration**, not a free version bump. It is the
|
|
79
|
+
one place 0.4.0 asks anything of you.
|
|
80
|
+
|
|
81
|
+
### Upgrading does NOT fix this on its own if you have your own dismissal
|
|
82
|
+
|
|
83
|
+
fd-sundhed found this by reading their own installed code rather than this
|
|
84
|
+
report, and it is the half that would otherwise produce a green claim and an
|
|
85
|
+
unchanged user. Their banner carries a second dismissal on top of the hook:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const [dismissed] = useState(() => sessionStorage.getItem(KEY) === "1");
|
|
89
|
+
if (!updateReady || dismissed) return null; // "Later" hides it for the whole tab
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The defect is **doubled** — ours in the hook, theirs in the component — and only
|
|
93
|
+
ours goes away with a `pnpm up`. Upgrade, report it fixed, and the person still
|
|
94
|
+
never sees the banner again.
|
|
95
|
+
|
|
96
|
+
**So before you upgrade, grep your own component for a second way the banner can
|
|
97
|
+
be hidden**: a `sessionStorage`/`localStorage` flag, a `dismissed` state, a
|
|
98
|
+
`useRef` that latches. Whatever holds it down has to become the snooze, or the
|
|
99
|
+
snooze is decorative.
|
|
100
|
+
|
|
101
|
+
### If you need something stricter than 30 minutes
|
|
102
|
+
|
|
103
|
+
`snoozeMs` is a plain number, so a large one is available and this package will
|
|
104
|
+
not stop you. The honest statement is therefore narrower than "a mute is
|
|
105
|
+
impossible": there is no OPTION named mute, and the default comes back.
|
|
106
|
+
|
|
107
|
+
If the policy you actually want is *"gone for the rest of this tab"*, express it
|
|
108
|
+
as scope rather than duration — `snoozeStorage: sessionStorage` with a long
|
|
109
|
+
`snoozeMs`. It dies with the tab instead of pretending to be permanent, so a
|
|
110
|
+
person who closes and reopens the app is asked again, and nobody has to reason
|
|
111
|
+
about a timestamp in the year 2255.
|
|
112
|
+
|
|
43
113
|
`snooze()` is deliberately not a mute, and there is no option to make it one: a
|
|
44
114
|
banner that can be silenced forever is the defect above, made official. The
|
|
45
115
|
snooze is persisted (`localStorage` by default, `snoozeStorage: null` for
|
|
@@ -161,8 +231,8 @@ new Serwist({ /* … */, skipWaiting: false, clientsClaim: true });
|
|
|
161
231
|
The *other* half of a PWA: the `manifest.webmanifest`, the icon set, and the
|
|
162
232
|
apple-touch `<meta>` tags — all as **pure, zero-dep** factories so you stop
|
|
163
233
|
hand-rolling `app/manifest.ts`, a `gen-pwa-icons.cjs` script, and a wall of
|
|
164
|
-
`<meta>` tags. Icons are emitted as self-contained **SVG
|
|
165
|
-
|
|
234
|
+
`<meta>` tags. Icons are emitted as self-contained **SVG**; for the PNGs an
|
|
235
|
+
iPhone needs, see «PNG icons» below (0.5.0).
|
|
166
236
|
|
|
167
237
|
```ts
|
|
168
238
|
import { defineManifest, serializeManifest, buildIconSet, pwaMetaTags } from "@broberg/pwa/manifest";
|
|
@@ -186,8 +256,38 @@ pwaMetaTags({ themeColor: "#141969", title: "AK" });
|
|
|
186
256
|
apple-touch `<link>`, not the manifest `icons[]`.
|
|
187
257
|
- **Everything is a pure return value** — you own the filesystem write and the head
|
|
188
258
|
render. Runs in a build script, a Route Handler, or the browser.
|
|
189
|
-
-
|
|
190
|
-
|
|
259
|
+
- **iOS needs PNG.** Safari does not use an SVG `apple-touch-icon`: with only SVG
|
|
260
|
+
icons, «Add to Home Screen» on an iPhone shows a screenshot of the page instead
|
|
261
|
+
of the logo. (This README said the opposite until 0.5.0 — it was wrong.)
|
|
262
|
+
|
|
263
|
+
## PNG icons — `pwa-icons` and `@broberg/pwa/raster` (0.5.0, F021.9)
|
|
264
|
+
|
|
265
|
+
One command, run at build time; no sharp script per app:
|
|
266
|
+
|
|
267
|
+
```sh
|
|
268
|
+
npm i -D @resvg/resvg-js # the renderer — an optional peer, prebuilt binaries
|
|
269
|
+
npx pwa-icons --svg logo.svg --background "#141969" --out public/icons
|
|
270
|
+
# or: --monogram AK --color "#fff" (also --base-path /icons)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
It writes `icon-180/192/512.{svg,png}` + `icon-maskable-512.{svg,png}` and prints
|
|
274
|
+
the manifest `icons[]` (PNG) and `appleTouchIcon`. Point the head tags at the PNG:
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
pwaMetaTags({ themeColor: "#141969", appleTouchIcon: "/icons/icon-180.png" });
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Or in code:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
import { buildIconSet } from "@broberg/pwa/manifest";
|
|
284
|
+
import { rasterizeIconSet } from "@broberg/pwa/raster";
|
|
285
|
+
const { files, icons, appleTouchIcon } = await rasterizeIconSet(buildIconSet({ svg, background: "#141969" }));
|
|
286
|
+
// files → [{ path: "/icons/icon-180.png", content: Uint8Array, … }] — you write them
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
`@broberg/pwa` and `/manifest` stay dependency-free; only `/raster` and the bin
|
|
290
|
+
need `@resvg/resvg-js`, and without it they fail with the install command.
|
|
191
291
|
|
|
192
292
|
## Gotchas (baked into this package so you don't rediscover them)
|
|
193
293
|
|
package/dist/bin.cjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var fs = require('fs');
|
|
5
|
+
var path = require('path');
|
|
6
|
+
|
|
7
|
+
// src/manifest.ts
|
|
8
|
+
var DEFAULT_SIZES = [180, 192, 512];
|
|
9
|
+
function escapeXml(s) {
|
|
10
|
+
return s.replace(
|
|
11
|
+
/[<>&"']/g,
|
|
12
|
+
(c) => c === "<" ? "<" : c === ">" ? ">" : c === "&" ? "&" : c === '"' ? """ : "'"
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
function toBase64(s) {
|
|
16
|
+
const g = globalThis;
|
|
17
|
+
if (typeof g.Buffer !== "undefined") return g.Buffer.from(s, "utf-8").toString("base64");
|
|
18
|
+
const bytes = new TextEncoder().encode(s);
|
|
19
|
+
let bin = "";
|
|
20
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
21
|
+
return g.btoa(bin);
|
|
22
|
+
}
|
|
23
|
+
function renderIcon(size, inset, opts) {
|
|
24
|
+
const bg = opts.background ?? "#111827";
|
|
25
|
+
const fg = opts.color ?? "#ffffff";
|
|
26
|
+
const pad = Math.round(size * inset);
|
|
27
|
+
const inner = size - pad * 2;
|
|
28
|
+
let art;
|
|
29
|
+
if (opts.svg) {
|
|
30
|
+
const href = `data:image/svg+xml;base64,${toBase64(opts.svg)}`;
|
|
31
|
+
art = `<image x="${pad}" y="${pad}" width="${inner}" height="${inner}" href="${href}" preserveAspectRatio="xMidYMid meet"/>`;
|
|
32
|
+
} else {
|
|
33
|
+
const text = escapeXml((opts.monogram ?? "").slice(0, 3).toUpperCase());
|
|
34
|
+
const fontSize = Math.round(inner * (text.length >= 3 ? 0.42 : 0.52));
|
|
35
|
+
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>`;
|
|
36
|
+
}
|
|
37
|
+
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>`;
|
|
38
|
+
}
|
|
39
|
+
function buildIconSet(opts) {
|
|
40
|
+
if (!opts?.svg && !opts?.monogram) {
|
|
41
|
+
throw new Error("buildIconSet: provide either `svg` or `monogram`");
|
|
42
|
+
}
|
|
43
|
+
const base = (opts.basePath ?? "/icons").replace(/\/$/, "");
|
|
44
|
+
const sizes = opts.sizes ?? DEFAULT_SIZES;
|
|
45
|
+
const files = [];
|
|
46
|
+
const icons = [];
|
|
47
|
+
for (const size of sizes) {
|
|
48
|
+
const path = `${base}/icon-${size}.svg`;
|
|
49
|
+
files.push({ path, content: renderIcon(size, 0, opts), type: "image/svg+xml", size, purpose: "any" });
|
|
50
|
+
if (size !== 180) {
|
|
51
|
+
icons.push({ src: path, sizes: `${size}x${size}`, type: "image/svg+xml", purpose: "any" });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (opts.maskable !== false) {
|
|
55
|
+
const path = `${base}/icon-maskable-512.svg`;
|
|
56
|
+
files.push({ path, content: renderIcon(512, 0.1, opts), type: "image/svg+xml", size: 512, purpose: "maskable" });
|
|
57
|
+
icons.push({ src: path, sizes: "512x512", type: "image/svg+xml", purpose: "maskable" });
|
|
58
|
+
}
|
|
59
|
+
return { files, icons };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/raster.ts
|
|
63
|
+
var RESVG_MISSING = "rasterizeIconSet needs @resvg/resvg-js \u2014 install it as a dev dependency: npm i -D @resvg/resvg-js";
|
|
64
|
+
var toPng = (p) => p.replace(/\.svg$/, ".png");
|
|
65
|
+
async function rasterizeIconSet(set, opts = {}) {
|
|
66
|
+
let Resvg;
|
|
67
|
+
try {
|
|
68
|
+
({ Resvg } = await import('@resvg/resvg-js'));
|
|
69
|
+
} catch {
|
|
70
|
+
throw new Error(RESVG_MISSING);
|
|
71
|
+
}
|
|
72
|
+
const files = set.files.map((f) => {
|
|
73
|
+
const png = new Resvg(f.content, {
|
|
74
|
+
fitTo: { mode: "width", value: f.size },
|
|
75
|
+
font: { loadSystemFonts: true, fontFiles: opts.fontFiles ?? [] }
|
|
76
|
+
}).render().asPng();
|
|
77
|
+
return { path: toPng(f.path), content: new Uint8Array(png), type: "image/png", size: f.size, purpose: f.purpose };
|
|
78
|
+
});
|
|
79
|
+
const icons = set.icons.map((i) => ({ ...i, src: toPng(i.src), type: "image/png" }));
|
|
80
|
+
const apple = files.find((f) => f.size === 180 && f.purpose === "any");
|
|
81
|
+
return { files, icons, appleTouchIcon: apple?.path };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/cli.ts
|
|
85
|
+
var USAGE = "usage: pwa-icons (--svg <file> | --monogram <text>) [--background <hex>] [--color <hex>] [--out public/icons] [--base-path /icons]";
|
|
86
|
+
async function main(argv) {
|
|
87
|
+
const arg = (name) => {
|
|
88
|
+
const i = argv.indexOf(`--${name}`);
|
|
89
|
+
return i >= 0 ? argv[i + 1] : void 0;
|
|
90
|
+
};
|
|
91
|
+
const svgFile = arg("svg");
|
|
92
|
+
const monogram = arg("monogram");
|
|
93
|
+
if (!svgFile && !monogram) {
|
|
94
|
+
console.error(`pwa-icons: give --svg <file> or --monogram <text>
|
|
95
|
+
${USAGE}`);
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
const out = arg("out") ?? "public/icons";
|
|
99
|
+
const set = buildIconSet({
|
|
100
|
+
svg: svgFile ? fs.readFileSync(svgFile, "utf8") : void 0,
|
|
101
|
+
monogram,
|
|
102
|
+
background: arg("background"),
|
|
103
|
+
color: arg("color"),
|
|
104
|
+
basePath: arg("base-path") ?? "/icons"
|
|
105
|
+
});
|
|
106
|
+
let raster;
|
|
107
|
+
try {
|
|
108
|
+
raster = await rasterizeIconSet(set);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
console.error(`pwa-icons: ${e.message}`);
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
fs.mkdirSync(out, { recursive: true });
|
|
114
|
+
for (const f of set.files) fs.writeFileSync(path.join(out, path.basename(f.path)), f.content);
|
|
115
|
+
for (const f of raster.files) fs.writeFileSync(path.join(out, path.basename(f.path)), f.content);
|
|
116
|
+
console.log(JSON.stringify({ icons: raster.icons, appleTouchIcon: raster.appleTouchIcon }, null, 2));
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/bin.ts
|
|
121
|
+
main(process.argv.slice(2)).then((code) => process.exit(code));
|
|
122
|
+
//# sourceMappingURL=bin.cjs.map
|
|
123
|
+
//# sourceMappingURL=bin.cjs.map
|
package/dist/bin.cjs.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/manifest.ts","../src/raster.ts","../src/cli.ts","../src/bin.ts"],"names":["readFileSync","mkdirSync","writeFileSync","join","basename"],"mappings":"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","file":"bin.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. iOS does NOT use an SVG apple-touch-icon —\n * rasterise with `@broberg/pwa/raster` / the `pwa-icons` bin (F021.9).\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 === \"<\" ? \"<\" : c === \">\" ? \">\" : c === \"&\" ? \"&\" : c === '\"' ? \""\" : \"'\",\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","/**\n * @broberg/pwa/raster — F021.9: PNG icons, because iOS does not use an SVG\n * apple-touch-icon. «Add to Home Screen» on an iPhone with only SVG icons shows\n * a screenshot of the page instead of the logo (measured by sales, 10/10).\n *\n * Kept OUT of the core entries so `@broberg/pwa` and `/manifest` stay\n * dependency-free: the renderer, @resvg/resvg-js (prebuilt binaries, no system\n * libraries), is an optional peer loaded only when this runs.\n */\nimport type { IconSet, ManifestIcon } from \"./manifest.js\";\n\nexport interface RasterIcon {\n /** URL path, e.g. `/icons/icon-180.png`. */\n path: string;\n content: Uint8Array;\n type: \"image/png\";\n size: number;\n purpose: \"any\" | \"maskable\";\n}\n\nexport interface RasterIconSet {\n files: RasterIcon[];\n /** Manifest `icons[]` pointing at the PNGs. */\n icons: ManifestIcon[];\n /** Href for `<link rel=\"apple-touch-icon\">` — pass it to `pwaMetaTags({ appleTouchIcon })`. Undefined when 180 was not generated. */\n appleTouchIcon?: string;\n}\n\nexport interface RasterizeOptions {\n /** Extra font files for monogram text (system fonts are loaded too). */\n fontFiles?: string[];\n}\n\nexport const RESVG_MISSING =\n \"rasterizeIconSet needs @resvg/resvg-js — install it as a dev dependency: npm i -D @resvg/resvg-js\";\n\nconst toPng = (p: string) => p.replace(/\\.svg$/, \".png\");\n\n/** Rasterise every SVG from `buildIconSet` to a PNG of the same size. */\nexport async function rasterizeIconSet(set: IconSet, opts: RasterizeOptions = {}): Promise<RasterIconSet> {\n let Resvg: typeof import(\"@resvg/resvg-js\").Resvg;\n try {\n ({ Resvg } = await import(\"@resvg/resvg-js\"));\n } catch {\n throw new Error(RESVG_MISSING);\n }\n const files: RasterIcon[] = set.files.map((f) => {\n const png = new Resvg(f.content, {\n fitTo: { mode: \"width\", value: f.size },\n font: { loadSystemFonts: true, fontFiles: opts.fontFiles ?? [] },\n })\n .render()\n .asPng();\n return { path: toPng(f.path), content: new Uint8Array(png), type: \"image/png\", size: f.size, purpose: f.purpose };\n });\n const icons = set.icons.map((i) => ({ ...i, src: toPng(i.src), type: \"image/png\" }));\n const apple = files.find((f) => f.size === 180 && f.purpose === \"any\");\n return { files, icons, appleTouchIcon: apple?.path };\n}\n","/// <reference types=\"node\" />\n// F021.9 — `pwa-icons`: SVG + PNG icon set in one command, so no app keeps its\n// own sharp script. Prints the manifest icons[] and the apple-touch href.\nimport { mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { basename, join } from \"node:path\";\nimport { buildIconSet } from \"./manifest.js\";\nimport { rasterizeIconSet } from \"./raster.js\";\n\nconst USAGE =\n \"usage: pwa-icons (--svg <file> | --monogram <text>) [--background <hex>] [--color <hex>] [--out public/icons] [--base-path /icons]\";\n\nexport async function main(argv: string[]): Promise<number> {\n const arg = (name: string) => {\n const i = argv.indexOf(`--${name}`);\n return i >= 0 ? argv[i + 1] : undefined;\n };\n const svgFile = arg(\"svg\");\n const monogram = arg(\"monogram\");\n if (!svgFile && !monogram) {\n console.error(`pwa-icons: give --svg <file> or --monogram <text>\\n${USAGE}`);\n return 1;\n }\n const out = arg(\"out\") ?? \"public/icons\";\n const set = buildIconSet({\n svg: svgFile ? readFileSync(svgFile, \"utf8\") : undefined,\n monogram,\n background: arg(\"background\"),\n color: arg(\"color\"),\n basePath: arg(\"base-path\") ?? \"/icons\",\n });\n let raster;\n try {\n raster = await rasterizeIconSet(set);\n } catch (e) {\n console.error(`pwa-icons: ${(e as Error).message}`);\n return 1;\n }\n mkdirSync(out, { recursive: true });\n for (const f of set.files) writeFileSync(join(out, basename(f.path)), f.content);\n for (const f of raster.files) writeFileSync(join(out, basename(f.path)), f.content);\n console.log(JSON.stringify({ icons: raster.icons, appleTouchIcon: raster.appleTouchIcon }, null, 2));\n return 0;\n}\n\n","#!/usr/bin/env node\n/// <reference types=\"node\" />\n// F021.9 — the `pwa-icons` entry point; the logic lives in cli.ts so tests can call it.\nimport { main } from \"./cli.js\";\n\nmain(process.argv.slice(2)).then((code) => process.exit(code));\n"]}
|
package/dist/bin.d.cts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/bin.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { rasterizeIconSet } from './chunk-JWKQNVNE.js';
|
|
3
|
+
import { readFileSync, mkdirSync, writeFileSync } from 'fs';
|
|
4
|
+
import { join, basename } from 'path';
|
|
5
|
+
|
|
6
|
+
// src/manifest.ts
|
|
7
|
+
var DEFAULT_SIZES = [180, 192, 512];
|
|
8
|
+
function escapeXml(s) {
|
|
9
|
+
return s.replace(
|
|
10
|
+
/[<>&"']/g,
|
|
11
|
+
(c) => c === "<" ? "<" : c === ">" ? ">" : c === "&" ? "&" : c === '"' ? """ : "'"
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
function toBase64(s) {
|
|
15
|
+
const g = globalThis;
|
|
16
|
+
if (typeof g.Buffer !== "undefined") return g.Buffer.from(s, "utf-8").toString("base64");
|
|
17
|
+
const bytes = new TextEncoder().encode(s);
|
|
18
|
+
let bin = "";
|
|
19
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
20
|
+
return g.btoa(bin);
|
|
21
|
+
}
|
|
22
|
+
function renderIcon(size, inset, opts) {
|
|
23
|
+
const bg = opts.background ?? "#111827";
|
|
24
|
+
const fg = opts.color ?? "#ffffff";
|
|
25
|
+
const pad = Math.round(size * inset);
|
|
26
|
+
const inner = size - pad * 2;
|
|
27
|
+
let art;
|
|
28
|
+
if (opts.svg) {
|
|
29
|
+
const href = `data:image/svg+xml;base64,${toBase64(opts.svg)}`;
|
|
30
|
+
art = `<image x="${pad}" y="${pad}" width="${inner}" height="${inner}" href="${href}" preserveAspectRatio="xMidYMid meet"/>`;
|
|
31
|
+
} else {
|
|
32
|
+
const text = escapeXml((opts.monogram ?? "").slice(0, 3).toUpperCase());
|
|
33
|
+
const fontSize = Math.round(inner * (text.length >= 3 ? 0.42 : 0.52));
|
|
34
|
+
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>`;
|
|
35
|
+
}
|
|
36
|
+
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>`;
|
|
37
|
+
}
|
|
38
|
+
function buildIconSet(opts) {
|
|
39
|
+
if (!opts?.svg && !opts?.monogram) {
|
|
40
|
+
throw new Error("buildIconSet: provide either `svg` or `monogram`");
|
|
41
|
+
}
|
|
42
|
+
const base = (opts.basePath ?? "/icons").replace(/\/$/, "");
|
|
43
|
+
const sizes = opts.sizes ?? DEFAULT_SIZES;
|
|
44
|
+
const files = [];
|
|
45
|
+
const icons = [];
|
|
46
|
+
for (const size of sizes) {
|
|
47
|
+
const path = `${base}/icon-${size}.svg`;
|
|
48
|
+
files.push({ path, content: renderIcon(size, 0, opts), type: "image/svg+xml", size, purpose: "any" });
|
|
49
|
+
if (size !== 180) {
|
|
50
|
+
icons.push({ src: path, sizes: `${size}x${size}`, type: "image/svg+xml", purpose: "any" });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (opts.maskable !== false) {
|
|
54
|
+
const path = `${base}/icon-maskable-512.svg`;
|
|
55
|
+
files.push({ path, content: renderIcon(512, 0.1, opts), type: "image/svg+xml", size: 512, purpose: "maskable" });
|
|
56
|
+
icons.push({ src: path, sizes: "512x512", type: "image/svg+xml", purpose: "maskable" });
|
|
57
|
+
}
|
|
58
|
+
return { files, icons };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/cli.ts
|
|
62
|
+
var USAGE = "usage: pwa-icons (--svg <file> | --monogram <text>) [--background <hex>] [--color <hex>] [--out public/icons] [--base-path /icons]";
|
|
63
|
+
async function main(argv) {
|
|
64
|
+
const arg = (name) => {
|
|
65
|
+
const i = argv.indexOf(`--${name}`);
|
|
66
|
+
return i >= 0 ? argv[i + 1] : void 0;
|
|
67
|
+
};
|
|
68
|
+
const svgFile = arg("svg");
|
|
69
|
+
const monogram = arg("monogram");
|
|
70
|
+
if (!svgFile && !monogram) {
|
|
71
|
+
console.error(`pwa-icons: give --svg <file> or --monogram <text>
|
|
72
|
+
${USAGE}`);
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
const out = arg("out") ?? "public/icons";
|
|
76
|
+
const set = buildIconSet({
|
|
77
|
+
svg: svgFile ? readFileSync(svgFile, "utf8") : void 0,
|
|
78
|
+
monogram,
|
|
79
|
+
background: arg("background"),
|
|
80
|
+
color: arg("color"),
|
|
81
|
+
basePath: arg("base-path") ?? "/icons"
|
|
82
|
+
});
|
|
83
|
+
let raster;
|
|
84
|
+
try {
|
|
85
|
+
raster = await rasterizeIconSet(set);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.error(`pwa-icons: ${e.message}`);
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
mkdirSync(out, { recursive: true });
|
|
91
|
+
for (const f of set.files) writeFileSync(join(out, basename(f.path)), f.content);
|
|
92
|
+
for (const f of raster.files) writeFileSync(join(out, basename(f.path)), f.content);
|
|
93
|
+
console.log(JSON.stringify({ icons: raster.icons, appleTouchIcon: raster.appleTouchIcon }, null, 2));
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/bin.ts
|
|
98
|
+
main(process.argv.slice(2)).then((code) => process.exit(code));
|
|
99
|
+
//# sourceMappingURL=bin.js.map
|
|
100
|
+
//# sourceMappingURL=bin.js.map
|
package/dist/bin.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/manifest.ts","../src/cli.ts","../src/bin.ts"],"names":[],"mappings":"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","file":"bin.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. iOS does NOT use an SVG apple-touch-icon —\n * rasterise with `@broberg/pwa/raster` / the `pwa-icons` bin (F021.9).\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 === \"<\" ? \"<\" : c === \">\" ? \">\" : c === \"&\" ? \"&\" : c === '\"' ? \""\" : \"'\",\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","/// <reference types=\"node\" />\n// F021.9 — `pwa-icons`: SVG + PNG icon set in one command, so no app keeps its\n// own sharp script. Prints the manifest icons[] and the apple-touch href.\nimport { mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { basename, join } from \"node:path\";\nimport { buildIconSet } from \"./manifest.js\";\nimport { rasterizeIconSet } from \"./raster.js\";\n\nconst USAGE =\n \"usage: pwa-icons (--svg <file> | --monogram <text>) [--background <hex>] [--color <hex>] [--out public/icons] [--base-path /icons]\";\n\nexport async function main(argv: string[]): Promise<number> {\n const arg = (name: string) => {\n const i = argv.indexOf(`--${name}`);\n return i >= 0 ? argv[i + 1] : undefined;\n };\n const svgFile = arg(\"svg\");\n const monogram = arg(\"monogram\");\n if (!svgFile && !monogram) {\n console.error(`pwa-icons: give --svg <file> or --monogram <text>\\n${USAGE}`);\n return 1;\n }\n const out = arg(\"out\") ?? \"public/icons\";\n const set = buildIconSet({\n svg: svgFile ? readFileSync(svgFile, \"utf8\") : undefined,\n monogram,\n background: arg(\"background\"),\n color: arg(\"color\"),\n basePath: arg(\"base-path\") ?? \"/icons\",\n });\n let raster;\n try {\n raster = await rasterizeIconSet(set);\n } catch (e) {\n console.error(`pwa-icons: ${(e as Error).message}`);\n return 1;\n }\n mkdirSync(out, { recursive: true });\n for (const f of set.files) writeFileSync(join(out, basename(f.path)), f.content);\n for (const f of raster.files) writeFileSync(join(out, basename(f.path)), f.content);\n console.log(JSON.stringify({ icons: raster.icons, appleTouchIcon: raster.appleTouchIcon }, null, 2));\n return 0;\n}\n\n","#!/usr/bin/env node\n/// <reference types=\"node\" />\n// F021.9 — the `pwa-icons` entry point; the logic lives in cli.ts so tests can call it.\nimport { main } from \"./cli.js\";\n\nmain(process.argv.slice(2)).then((code) => process.exit(code));\n"]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// src/raster.ts
|
|
2
|
+
var RESVG_MISSING = "rasterizeIconSet needs @resvg/resvg-js \u2014 install it as a dev dependency: npm i -D @resvg/resvg-js";
|
|
3
|
+
var toPng = (p) => p.replace(/\.svg$/, ".png");
|
|
4
|
+
async function rasterizeIconSet(set, opts = {}) {
|
|
5
|
+
let Resvg;
|
|
6
|
+
try {
|
|
7
|
+
({ Resvg } = await import('@resvg/resvg-js'));
|
|
8
|
+
} catch {
|
|
9
|
+
throw new Error(RESVG_MISSING);
|
|
10
|
+
}
|
|
11
|
+
const files = set.files.map((f) => {
|
|
12
|
+
const png = new Resvg(f.content, {
|
|
13
|
+
fitTo: { mode: "width", value: f.size },
|
|
14
|
+
font: { loadSystemFonts: true, fontFiles: opts.fontFiles ?? [] }
|
|
15
|
+
}).render().asPng();
|
|
16
|
+
return { path: toPng(f.path), content: new Uint8Array(png), type: "image/png", size: f.size, purpose: f.purpose };
|
|
17
|
+
});
|
|
18
|
+
const icons = set.icons.map((i) => ({ ...i, src: toPng(i.src), type: "image/png" }));
|
|
19
|
+
const apple = files.find((f) => f.size === 180 && f.purpose === "any");
|
|
20
|
+
return { files, icons, appleTouchIcon: apple?.path };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { RESVG_MISSING, rasterizeIconSet };
|
|
24
|
+
//# sourceMappingURL=chunk-JWKQNVNE.js.map
|
|
25
|
+
//# sourceMappingURL=chunk-JWKQNVNE.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/raster.ts"],"names":[],"mappings":";AAiCO,IAAM,aAAA,GACX;AAEF,IAAM,QAAQ,CAAC,CAAA,KAAc,CAAA,CAAE,OAAA,CAAQ,UAAU,MAAM,CAAA;AAGvD,eAAsB,gBAAA,CAAiB,GAAA,EAAc,IAAA,GAAyB,EAAC,EAA2B;AACxG,EAAA,IAAI,KAAA;AACJ,EAAA,IAAI;AACF,IAAA,CAAC,EAAE,KAAA,EAAM,GAAI,MAAM,OAAO,iBAAiB,CAAA;AAAA,EAC7C,CAAA,CAAA,MAAQ;AACN,IAAA,MAAM,IAAI,MAAM,aAAa,CAAA;AAAA,EAC/B;AACA,EAAA,MAAM,KAAA,GAAsB,GAAA,CAAI,KAAA,CAAM,GAAA,CAAI,CAAC,CAAA,KAAM;AAC/C,IAAA,MAAM,GAAA,GAAM,IAAI,KAAA,CAAM,CAAA,CAAE,OAAA,EAAS;AAAA,MAC/B,OAAO,EAAE,IAAA,EAAM,OAAA,EAAS,KAAA,EAAO,EAAE,IAAA,EAAK;AAAA,MACtC,IAAA,EAAM,EAAE,eAAA,EAAiB,IAAA,EAAM,WAAW,IAAA,CAAK,SAAA,IAAa,EAAC;AAAE,KAChE,CAAA,CACE,MAAA,EAAO,CACP,KAAA,EAAM;AACT,IAAA,OAAO,EAAE,IAAA,EAAM,KAAA,CAAM,EAAE,IAAI,CAAA,EAAG,SAAS,IAAI,UAAA,CAAW,GAAG,CAAA,EAAG,MAAM,WAAA,EAAa,IAAA,EAAM,EAAE,IAAA,EAAM,OAAA,EAAS,EAAE,OAAA,EAAQ;AAAA,EAClH,CAAC,CAAA;AACD,EAAA,MAAM,QAAQ,GAAA,CAAI,KAAA,CAAM,GAAA,CAAI,CAAC,OAAO,EAAE,GAAG,CAAA,EAAG,GAAA,EAAK,MAAM,CAAA,CAAE,GAAG,CAAA,EAAG,IAAA,EAAM,aAAY,CAAE,CAAA;AACnF,EAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,IAAA,CAAK,CAAC,CAAA,KAAM,EAAE,IAAA,KAAS,GAAA,IAAO,CAAA,CAAE,OAAA,KAAY,KAAK,CAAA;AACrE,EAAA,OAAO,EAAE,KAAA,EAAO,KAAA,EAAO,cAAA,EAAgB,OAAO,IAAA,EAAK;AACrD","file":"chunk-JWKQNVNE.js","sourcesContent":["/**\n * @broberg/pwa/raster — F021.9: PNG icons, because iOS does not use an SVG\n * apple-touch-icon. «Add to Home Screen» on an iPhone with only SVG icons shows\n * a screenshot of the page instead of the logo (measured by sales, 10/10).\n *\n * Kept OUT of the core entries so `@broberg/pwa` and `/manifest` stay\n * dependency-free: the renderer, @resvg/resvg-js (prebuilt binaries, no system\n * libraries), is an optional peer loaded only when this runs.\n */\nimport type { IconSet, ManifestIcon } from \"./manifest.js\";\n\nexport interface RasterIcon {\n /** URL path, e.g. `/icons/icon-180.png`. */\n path: string;\n content: Uint8Array;\n type: \"image/png\";\n size: number;\n purpose: \"any\" | \"maskable\";\n}\n\nexport interface RasterIconSet {\n files: RasterIcon[];\n /** Manifest `icons[]` pointing at the PNGs. */\n icons: ManifestIcon[];\n /** Href for `<link rel=\"apple-touch-icon\">` — pass it to `pwaMetaTags({ appleTouchIcon })`. Undefined when 180 was not generated. */\n appleTouchIcon?: string;\n}\n\nexport interface RasterizeOptions {\n /** Extra font files for monogram text (system fonts are loaded too). */\n fontFiles?: string[];\n}\n\nexport const RESVG_MISSING =\n \"rasterizeIconSet needs @resvg/resvg-js — install it as a dev dependency: npm i -D @resvg/resvg-js\";\n\nconst toPng = (p: string) => p.replace(/\\.svg$/, \".png\");\n\n/** Rasterise every SVG from `buildIconSet` to a PNG of the same size. */\nexport async function rasterizeIconSet(set: IconSet, opts: RasterizeOptions = {}): Promise<RasterIconSet> {\n let Resvg: typeof import(\"@resvg/resvg-js\").Resvg;\n try {\n ({ Resvg } = await import(\"@resvg/resvg-js\"));\n } catch {\n throw new Error(RESVG_MISSING);\n }\n const files: RasterIcon[] = set.files.map((f) => {\n const png = new Resvg(f.content, {\n fitTo: { mode: \"width\", value: f.size },\n font: { loadSystemFonts: true, fontFiles: opts.fontFiles ?? [] },\n })\n .render()\n .asPng();\n return { path: toPng(f.path), content: new Uint8Array(png), type: \"image/png\", size: f.size, purpose: f.purpose };\n });\n const icons = set.icons.map((i) => ({ ...i, src: toPng(i.src), type: \"image/png\" }));\n const apple = files.find((f) => f.size === 180 && f.purpose === \"any\");\n return { files, icons, appleTouchIcon: apple?.path };\n}\n"]}
|
package/dist/index.cjs
CHANGED
|
@@ -27,7 +27,6 @@ function createPwaUpdater(options = {}) {
|
|
|
27
27
|
} = options;
|
|
28
28
|
const listeners = /* @__PURE__ */ new Set();
|
|
29
29
|
let updateReady = false;
|
|
30
|
-
let waitingWorker = null;
|
|
31
30
|
const getState = () => ({ updateReady });
|
|
32
31
|
const emit = () => {
|
|
33
32
|
const state = getState();
|
|
@@ -83,7 +82,6 @@ function createPwaUpdater(options = {}) {
|
|
|
83
82
|
};
|
|
84
83
|
const check = () => {
|
|
85
84
|
const waiting = registrationRef?.waiting ?? null;
|
|
86
|
-
waitingWorker = waiting;
|
|
87
85
|
const next = shouldOfferUpdate({
|
|
88
86
|
waiting,
|
|
89
87
|
snoozedUntil: readSnooze(),
|
|
@@ -95,14 +93,19 @@ function createPwaUpdater(options = {}) {
|
|
|
95
93
|
}
|
|
96
94
|
};
|
|
97
95
|
let hadController = !!container.controller;
|
|
96
|
+
let applied = false;
|
|
97
|
+
const reloadOnce = () => {
|
|
98
|
+
if (!reloadOnControllerChange || reloading) return;
|
|
99
|
+
reloading = true;
|
|
100
|
+
if (typeof location !== "undefined") location.reload();
|
|
101
|
+
};
|
|
98
102
|
const onControllerChange = () => {
|
|
99
103
|
if (!reloadOnControllerChange || reloading) return;
|
|
100
|
-
if (!hadController) {
|
|
104
|
+
if (!hadController && !applied) {
|
|
101
105
|
hadController = true;
|
|
102
106
|
return;
|
|
103
107
|
}
|
|
104
|
-
|
|
105
|
-
if (typeof location !== "undefined") location.reload();
|
|
108
|
+
reloadOnce();
|
|
106
109
|
};
|
|
107
110
|
container.addEventListener("controllerchange", onControllerChange);
|
|
108
111
|
const watchInstalling = (registration) => {
|
|
@@ -156,7 +159,16 @@ function createPwaUpdater(options = {}) {
|
|
|
156
159
|
subscribe,
|
|
157
160
|
getState,
|
|
158
161
|
applyUpdate() {
|
|
159
|
-
|
|
162
|
+
const waiting = registrationRef?.waiting ?? null;
|
|
163
|
+
if (waiting) {
|
|
164
|
+
applied = true;
|
|
165
|
+
waiting.addEventListener("statechange", () => {
|
|
166
|
+
if (waiting.state === "activated") reloadOnce();
|
|
167
|
+
});
|
|
168
|
+
waiting.postMessage(SKIP_WAITING_MESSAGE);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (updateReady) reloadOnce();
|
|
160
172
|
},
|
|
161
173
|
snooze() {
|
|
162
174
|
writeSnooze(Date.now() + snoozeMs);
|