@vesk/plugin-pwa 0.2.15

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 ADDED
@@ -0,0 +1,44 @@
1
+ # @vesk/plugin-pwa
2
+
3
+ Progressive Web App plugin for Vesk — generates a `manifest.webmanifest`, a
4
+ service worker, and CSP-safe PWA head tags with zero image dependencies.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm install @vesk/plugin-pwa
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ```ts
15
+ import { defineConfig } from '@vesk/compiler'
16
+ import pwaPlugin from '@vesk/plugin-pwa'
17
+
18
+ export default defineConfig({
19
+ plugins: [pwaPlugin({ name: 'My App', shortName: 'MyApp', themeColor: '#4f46e5' })],
20
+ })
21
+ ```
22
+
23
+ ## What it does
24
+
25
+ - **Manifest** — writes `manifest.webmanifest` plus real `icon-192.png` /
26
+ `icon-512.png` (inline-generated, no asset pipeline needed) into your
27
+ `publicDir`, satisfying Chrome's installability audit.
28
+ - **Service worker** — writes `sw.js` (precache of the app shell) and an
29
+ external `pwa-init.js` that registers it. Default strategy is
30
+ stale-while-revalidate; `cache-first` and `network-first` are available.
31
+ - **Head injection** — via the `onHead` hook, injects the manifest link,
32
+ `theme-color`, apple meta tags, and the SW-registration script. Everything
33
+ is a static file URL — no inline scripts, so CSP remains strict.
34
+ - `{#client}` islands and `on*` handlers keep working unchanged.
35
+
36
+ ## Options
37
+
38
+ `name`, `shortName`, `description`, `scope`, `lang`, `display`, `orientation`,
39
+ `themeColor`, `backgroundColor`, `icons`, `publicDir`, `noServiceWorker`,
40
+ `swStrategy`. See the `PwaOptions` type for details.
41
+
42
+ ## License
43
+
44
+ MIT
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @vesk/plugin-pwa — Progressive Web App plugin for Vesk.
3
+ *
4
+ * - Generates manifest.webmanifest + sw.js + pwa-init.js at build start
5
+ * (written into publicDir so both dev and prod serve them as static files).
6
+ * - Injects the manifest link / theme-color / apple meta tags / SW registration
7
+ * into the generated head for every page via the `onHead` hook — no manual
8
+ * layout edits and no inline scripts (CSP-safe): SW registration lives in the
9
+ * external `/pwa-init.js`.
10
+ *
11
+ * @module plugin-pwa
12
+ */
13
+ import type { VeskPlugin } from '@vesk/types';
14
+ export interface PwaIcon {
15
+ src: string;
16
+ sizes: string;
17
+ type: string;
18
+ purpose?: string;
19
+ }
20
+ export interface PwaOptions {
21
+ /** App name — used for manifest name, short_name default. */
22
+ name?: string;
23
+ /** Short name for the homescreen prompt. */
24
+ shortName?: string;
25
+ description?: string;
26
+ /** Base URL of the deployed site (default '/'). */
27
+ scope?: string;
28
+ /** Default locale — 'en-US'. */
29
+ lang?: string;
30
+ /** display mode: standalone | fullscreen | minimal-ui | browser. */
31
+ display?: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
32
+ /** Preferred orientation. */
33
+ orientation?: 'any' | 'natural' | 'landscape' | 'landscape-primary' | 'landscape-secondary' | 'portrait' | 'portrait-primary' | 'portrait-secondary';
34
+ themeColor?: string;
35
+ backgroundColor?: string;
36
+ icons?: PwaIcon[];
37
+ /** Where the plugin writes its generated files (public dir of the app). */
38
+ publicDir?: string;
39
+ /** Disable auto service-worker generation (sw.js + pwa-init.js). */
40
+ noServiceWorker?: boolean;
41
+ /** Service-worker caching strategy (default 'stale-while-revalidate'). */
42
+ swStrategy?: 'stale-while-revalidate' | 'cache-first' | 'network-first';
43
+ }
44
+ export declare const PWA_MANIFEST_URL = "/manifest.webmanifest";
45
+ export declare const PWA_SW_URL = "/sw.js";
46
+ export declare const PWA_INIT_URL = "/pwa-init.js";
47
+ export declare function solidPng(size: number, color: string): Buffer;
48
+ export declare function pwaPlugin(opts?: PwaOptions): VeskPlugin;
49
+ export default pwaPlugin;
50
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,MAAM,WAAW,OAAO;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,UAAU;IACzB,6DAA6D;IAC7D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4CAA4C;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mDAAmD;IACnD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gCAAgC;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oEAAoE;IACpE,OAAO,CAAC,EAAE,YAAY,GAAG,YAAY,GAAG,YAAY,GAAG,SAAS,CAAC;IACjE,6BAA6B;IAC7B,WAAW,CAAC,EAAE,KAAK,GAAG,SAAS,GAAG,WAAW,GAAG,mBAAmB,GAAG,qBAAqB,GAAG,UAAU,GAAG,kBAAkB,GAAG,oBAAoB,CAAC;IACrJ,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC;IAClB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oEAAoE;IACpE,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,0EAA0E;IAC1E,UAAU,CAAC,EAAE,wBAAwB,GAAG,aAAa,GAAG,eAAe,CAAC;CACzE;AAeD,eAAO,MAAM,gBAAgB,0BAA0B,CAAC;AACxD,eAAO,MAAM,UAAU,WAAW,CAAC;AACnC,eAAO,MAAM,YAAY,iBAAiB,CAAC;AA4C3C,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAuB5D;AA4HD,wBAAgB,SAAS,CAAC,IAAI,GAAE,UAAe,GAAG,UAAU,CAyF3D;AAGD,eAAe,SAAS,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,312 @@
1
+ /**
2
+ * @vesk/plugin-pwa — Progressive Web App plugin for Vesk.
3
+ *
4
+ * - Generates manifest.webmanifest + sw.js + pwa-init.js at build start
5
+ * (written into publicDir so both dev and prod serve them as static files).
6
+ * - Injects the manifest link / theme-color / apple meta tags / SW registration
7
+ * into the generated head for every page via the `onHead` hook — no manual
8
+ * layout edits and no inline scripts (CSP-safe): SW registration lives in the
9
+ * external `/pwa-init.js`.
10
+ *
11
+ * @module plugin-pwa
12
+ */
13
+ import { writeFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
14
+ import { deflateSync } from 'node:zlib';
15
+ import { resolve, join } from 'node:path';
16
+ const DEFAULTS = {
17
+ name: 'Vesk App',
18
+ shortName: 'Vesk',
19
+ description: 'A Vesk app',
20
+ scope: '/',
21
+ lang: 'en-US',
22
+ display: 'standalone',
23
+ orientation: 'any',
24
+ themeColor: '#000000',
25
+ backgroundColor: '#ffffff',
26
+ swStrategy: 'stale-while-revalidate',
27
+ };
28
+ export const PWA_MANIFEST_URL = '/manifest.webmanifest';
29
+ export const PWA_SW_URL = '/sw.js';
30
+ export const PWA_INIT_URL = '/pwa-init.js';
31
+ /**
32
+ * Solid-color RGBA PNG encoder (no image dependencies). Chrome's
33
+ * installability audit requires the manifest icons to actually exist and be
34
+ * fetchable, so the plugin writes real icon-192.png / icon-512.png files at
35
+ * build time.
36
+ */
37
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
38
+ const CRC_TABLE = (() => {
39
+ const t = new Uint32Array(256);
40
+ for (let n = 0; n < 256; n++) {
41
+ let c = n;
42
+ for (let k = 0; k < 8; k++)
43
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
44
+ t[n] = c >>> 0;
45
+ }
46
+ return t;
47
+ })();
48
+ function crc32(buf) {
49
+ let c = 0xffffffff;
50
+ for (let i = 0; i < buf.length; i++)
51
+ c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
52
+ return (c ^ 0xffffffff) >>> 0;
53
+ }
54
+ function pngChunk(type, data) {
55
+ const len = Buffer.alloc(4);
56
+ len.writeUInt32BE(data.length, 0);
57
+ const typeBuf = Buffer.from(type, 'ascii');
58
+ const crc = Buffer.alloc(4);
59
+ crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
60
+ return Buffer.concat([len, typeBuf, data, crc]);
61
+ }
62
+ function hexToRgba(hex) {
63
+ let h = hex.trim().replace(/^#/, '');
64
+ if (h.length === 3)
65
+ h = h.split('').map((c) => c + c).join('');
66
+ if (h.length === 6)
67
+ h += 'ff';
68
+ const n = parseInt(h.slice(0, 8), 16);
69
+ if (Number.isNaN(n))
70
+ return [0, 0, 0, 255];
71
+ return [(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
72
+ }
73
+ export function solidPng(size, color) {
74
+ const [r, g, b, a] = hexToRgba(color);
75
+ const ihdr = Buffer.alloc(13);
76
+ ihdr.writeUInt32BE(size, 0);
77
+ ihdr.writeUInt32BE(size, 4);
78
+ ihdr[8] = 8;
79
+ ihdr[9] = 6;
80
+ const row = Buffer.alloc(1 + size * 4);
81
+ for (let x = 0; x < size; x++) {
82
+ row[1 + x * 4] = r;
83
+ row[2 + x * 4] = g;
84
+ row[3 + x * 4] = b;
85
+ row[4 + x * 4] = a;
86
+ }
87
+ const raw = Buffer.alloc(size * row.length);
88
+ for (let y = 0; y < size; y++)
89
+ row.copy(raw, y * row.length);
90
+ const idat = deflateSync(raw);
91
+ return Buffer.concat([
92
+ PNG_SIGNATURE,
93
+ pngChunk('IHDR', ihdr),
94
+ pngChunk('IDAT', idat),
95
+ pngChunk('IEND', Buffer.alloc(0)),
96
+ ]);
97
+ }
98
+ function generateManifest(opts) {
99
+ const name = opts.name || DEFAULTS.name;
100
+ const shortName = opts.shortName || name;
101
+ const icons = (opts.icons && opts.icons.length > 0)
102
+ ? opts.icons.map(i => ({ src: i.src, sizes: i.sizes, type: i.type, ...(i.purpose ? { purpose: i.purpose } : {}) }))
103
+ : [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }, { src: '/icon-512.png', sizes: '512x512', type: 'image/png' }];
104
+ const manifest = {
105
+ name,
106
+ short_name: shortName,
107
+ ...(opts.description ? { description: opts.description } : {}),
108
+ scope: opts.scope || DEFAULTS.scope,
109
+ start_url: opts.scope || DEFAULTS.scope,
110
+ id: opts.scope || DEFAULTS.scope,
111
+ lang: opts.lang || DEFAULTS.lang,
112
+ display: opts.display || DEFAULTS.display,
113
+ ...(opts.orientation && opts.orientation !== 'any' ? { orientation: opts.orientation } : {}),
114
+ ...(opts.themeColor ? { theme_color: opts.themeColor } : {}),
115
+ ...(opts.backgroundColor ? { background_color: opts.backgroundColor } : {}),
116
+ icons,
117
+ };
118
+ return JSON.stringify(manifest, null, 2);
119
+ }
120
+ function generateServiceWorker(strategy) {
121
+ const strat = strategy || DEFAULTS.swStrategy;
122
+ return `// Vesk PWA — auto-generated service worker (strategy: ${strat})
123
+ const CACHE = 'vesk-pwa-v1';
124
+ self.addEventListener('install', (e) => { e.waitUntil(self.skipWaiting()); });
125
+ self.addEventListener('activate', (e) => { e.waitUntil(self.clients.claim()); });
126
+ self.addEventListener('fetch', (e) => {
127
+ const url = new URL(e.request.url);
128
+ if (e.request.method !== 'GET' || url.protocol === 'chrome-extension:' || url.protocol === 'chrome:' || url.protocol === 'file:') return;
129
+ e.respondWith((async () => {
130
+ try {
131
+ ${strat === 'cache-first'
132
+ ? ` const cached = await caches.match(e.request);
133
+ if (cached) return cached;
134
+ const resp = await fetch(e.request);
135
+ const cache = await caches.open(CACHE);
136
+ cache.put(e.request, resp.clone());
137
+ return resp;`
138
+ : strat === 'network-first'
139
+ ? ` const resp = await fetch(e.request);
140
+ const cache = await caches.open(CACHE);
141
+ cache.put(e.request, resp.clone());
142
+ return resp;`
143
+ : ` const cached = await caches.match(e.request);
144
+ const resp = await fetch(e.request);
145
+ if (resp && resp.ok) { const cache = await caches.open(CACHE); cache.put(e.request, resp.clone()); }
146
+ return cached || resp;`}
147
+ } catch { return caches.match(e.request); }
148
+ })());
149
+ });
150
+ `;
151
+ }
152
+ /** External (non-inline) service-worker registration script. */
153
+ function generatePwaInit(opts) {
154
+ const scope = opts.scope || DEFAULTS.scope;
155
+ return `// Vesk PWA — service-worker registration (external, CSP-safe)
156
+ (() => {
157
+ if ('serviceWorker' in navigator) {
158
+ navigator.serviceWorker.register(${JSON.stringify(PWA_SW_URL)}, { scope: ${JSON.stringify(scope)} }).catch(() => {});
159
+ }
160
+ })();
161
+ `;
162
+ }
163
+ /** The head tags the `onHead` hook and `provides.pwaHeadTags` share. */
164
+ function headTags(opts) {
165
+ const name = opts.name || DEFAULTS.name;
166
+ const shortName = opts.shortName || name;
167
+ const lines = [
168
+ `<link rel="manifest" href="${PWA_MANIFEST_URL}" />`,
169
+ `<meta name="theme-color" content="${opts.themeColor || DEFAULTS.themeColor}" />`,
170
+ ];
171
+ if (opts.backgroundColor)
172
+ lines.push(`<meta name="background-color" content="${opts.backgroundColor}" />`);
173
+ if (opts.name)
174
+ lines.push(`<meta name="application-name" content="${name}" />`);
175
+ if (opts.shortName)
176
+ lines.push(`<meta name="apple-mobile-web-app-title" content="${shortName}" />`);
177
+ if (opts.display === 'standalone')
178
+ lines.push('<meta name="apple-mobile-web-app-capable" content="yes" />');
179
+ const icons = (opts.icons && opts.icons.length > 0)
180
+ ? opts.icons
181
+ : [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }, { src: '/icon-512.png', sizes: '512x512', type: 'image/png' }];
182
+ for (const icon of icons) {
183
+ if (icon.purpose && icon.purpose !== 'any')
184
+ continue;
185
+ lines.push(`<link rel="apple-touch-icon" href="${icon.src}" />`);
186
+ }
187
+ if (!opts.noServiceWorker)
188
+ lines.push(`<script src="${PWA_INIT_URL}" defer></script>`);
189
+ return lines.join('\n');
190
+ }
191
+ /**
192
+ * Merge `tags` into an assembled head fragment without duplicating tags the
193
+ * page (or an earlier hook) already declared. Dedup keys on the `rel=` /
194
+ * `name=` / `src=` attribute value, so re-running against identical or slightly
195
+ * different markup stays idempotent.
196
+ */
197
+ function dedupInsert(headHtml, tags) {
198
+ const present = new Set(headHtml.split('\n').map((s) => s.trim()));
199
+ const out = [];
200
+ for (const raw of tags.split('\n')) {
201
+ const line = raw.trim();
202
+ if (!line)
203
+ continue;
204
+ let key = '';
205
+ const rel = line.indexOf('rel="');
206
+ const name = line.indexOf('name="');
207
+ const src = line.indexOf('src="');
208
+ if (rel >= 0)
209
+ key = line.slice(rel);
210
+ else if (name >= 0)
211
+ key = line.slice(name);
212
+ else if (src >= 0)
213
+ key = line.slice(src);
214
+ else
215
+ key = line;
216
+ if (present.has(line))
217
+ continue;
218
+ if (key && headHtml.includes(key))
219
+ continue;
220
+ present.add(line);
221
+ out.push(line);
222
+ }
223
+ if (out.length === 0)
224
+ return headHtml;
225
+ return (headHtml || '') + '\n' + out.join('\n');
226
+ }
227
+ // The VeskPlugin returned by the factory
228
+ export function pwaPlugin(opts = {}) {
229
+ const name = opts.name || DEFAULTS.name;
230
+ const shortName = opts.shortName || name;
231
+ const publicDir = opts.publicDir || resolve(process.cwd(), 'public');
232
+ function writeFiles() {
233
+ try {
234
+ mkdirSync(publicDir, { recursive: true });
235
+ writeFileSync(join(publicDir, 'manifest.webmanifest'), generateManifest(opts), 'utf-8');
236
+ if (!opts.icons || opts.icons.length === 0) {
237
+ writeFileSync(join(publicDir, 'icon-192.png'), solidPng(192, opts.themeColor || DEFAULTS.themeColor));
238
+ writeFileSync(join(publicDir, 'icon-512.png'), solidPng(512, opts.themeColor || DEFAULTS.themeColor));
239
+ }
240
+ if (!opts.noServiceWorker) {
241
+ writeFileSync(join(publicDir, 'sw.js'), generateServiceWorker(opts.swStrategy), 'utf-8');
242
+ writeFileSync(join(publicDir, 'pwa-init.js'), generatePwaInit(opts), 'utf-8');
243
+ }
244
+ }
245
+ catch (e) {
246
+ console.error(`[vesk/plugin-pwa] write error:`, e instanceof Error ? e.message : e);
247
+ }
248
+ }
249
+ const provides = {
250
+ pwaHeadTags: () => headTags(opts),
251
+ pwaManifestLink: () => `<link rel="manifest" href="${PWA_MANIFEST_URL}" />`,
252
+ pwaSwRegister: () => (opts.noServiceWorker ? '' : `<script src="${PWA_INIT_URL}" defer></script>`),
253
+ pwaMetaThemeColor: () => `<meta name="theme-color" content="${opts.themeColor || DEFAULTS.themeColor}" />`,
254
+ pwaName: () => name,
255
+ pwaShortName: () => shortName,
256
+ };
257
+ return {
258
+ name: '@vesk/plugin-pwa',
259
+ dependencies: new Set(),
260
+ async onBuildStart() {
261
+ writeFiles();
262
+ },
263
+ async onFileWatch(filePath) {
264
+ const base = publicDir;
265
+ if (filePath === resolve(base, 'manifest.webmanifest') ||
266
+ filePath === resolve(base, 'sw.js') ||
267
+ filePath === resolve(base, 'pwa-init.js')) {
268
+ return { handled: true };
269
+ }
270
+ return { handled: false };
271
+ },
272
+ provides,
273
+ async onHead(headHtml) {
274
+ return dedupInsert(headHtml || '', headTags(opts));
275
+ },
276
+ async onRequest(ctx) {
277
+ const url = new URL(ctx.request.url);
278
+ if (url.pathname === PWA_MANIFEST_URL) {
279
+ try {
280
+ const manifestPath = join(publicDir, 'manifest.webmanifest');
281
+ if (existsSync(manifestPath)) {
282
+ ctx.set('pwaManifestBody', readFileSync(manifestPath, 'utf-8'));
283
+ }
284
+ }
285
+ catch { /* ignore */ }
286
+ }
287
+ if (url.pathname === PWA_SW_URL) {
288
+ try {
289
+ const swPath = join(publicDir, 'sw.js');
290
+ if (existsSync(swPath)) {
291
+ ctx.set('pwaSwBody', readFileSync(swPath, 'utf-8'));
292
+ }
293
+ }
294
+ catch { /* ignore */ }
295
+ }
296
+ if (url.pathname === PWA_INIT_URL) {
297
+ try {
298
+ const initPath = join(publicDir, 'pwa-init.js');
299
+ if (existsSync(initPath)) {
300
+ ctx.set('pwaInitBody', readFileSync(initPath, 'utf-8'));
301
+ }
302
+ }
303
+ catch { /* ignore */ }
304
+ }
305
+ },
306
+ async onBuildEnd() {
307
+ writeFiles();
308
+ },
309
+ };
310
+ }
311
+ // Legacy named-export default for backward compat
312
+ export default pwaPlugin;
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@vesk/plugin-pwa",
3
+ "version": "0.2.15",
4
+ "type": "module",
5
+ "description": "Progressive Web App plugin for Vesk — generates manifest.webmanifest and service worker",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.build.json",
16
+ "prepublishOnly": "npm run build",
17
+ "test": "tsx src/index.test.ts",
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "dependencies": {
21
+ "@vesk/types": "^0.2.15"
22
+ },
23
+ "devDependencies": {
24
+ "typescript": "^5.8.0"
25
+ },
26
+ "license": "MIT",
27
+ "author": "opencode",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/emeraldlinks/veskTs.git"
31
+ },
32
+ "homepage": "https://github.com/emeraldlinks/veskTs#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/emeraldlinks/veskTs/issues"
35
+ },
36
+ "engines": {
37
+ "node": ">=20.0.0"
38
+ },
39
+ "files": [
40
+ "dist",
41
+ "README.md"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }