spark-html-manifest 0.1.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 ADDED
@@ -0,0 +1,99 @@
1
+ # ⚡ spark-html-manifest
2
+
3
+ PWA setup for [spark-html](https://www.npmjs.com/package/spark-html) sites
4
+ from a **single config** — a Vite plugin that generates
5
+ `manifest.webmanifest`, resizes your icons from one source image, injects
6
+ the `<head>` tags, and (optionally) emits a minimal offline app-shell
7
+ service worker. No manual icon exports, no copy-paste boilerplate.
8
+
9
+ ```js
10
+ // vite.config.js
11
+ import spark from 'spark-html/vite';
12
+ import prerender from 'spark-prerender/vite';
13
+ import manifest from 'spark-html-manifest/vite';
14
+
15
+ export default {
16
+ plugins: [
17
+ spark(),
18
+ prerender(),
19
+ manifest({
20
+ name: 'My Spark App',
21
+ shortName: 'Spark',
22
+ themeColor: '#ffd24a',
23
+ icon: 'public/icon.png', // one image → 192 + 512 png, resized with sharp
24
+ offline: true, // minimal offline app shell + auto registration
25
+ }),
26
+ ],
27
+ };
28
+ ```
29
+
30
+ That's the whole setup. `npm run build` now produces:
31
+
32
+ - `manifest.webmanifest` — name, colors, display mode, icons
33
+ - `icons/spark-192.png`, `icons/spark-512.png` — resized from your source
34
+ - `<link rel="manifest">`, `<meta name="theme-color">`, apple-touch-icon —
35
+ injected into **every** built page (after `spark-prerender` writes them)
36
+ - with `offline: true`: `spark-manifest-sw.js` + its registration script
37
+
38
+ In dev, the manifest and worker are served straight from config, so
39
+ Lighthouse/devtools "installable" checks pass locally too.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ npm install spark-html-manifest
45
+ ```
46
+
47
+ ## Config
48
+
49
+ ```js
50
+ manifest({
51
+ name: 'My Spark App', // required
52
+ shortName: 'Spark', // home-screen label (default: name)
53
+ description: '…',
54
+ themeColor: '#ffd24a', // default '#ffffff'
55
+ backgroundColor: '#000000', // default: themeColor
56
+ display: 'standalone', // 'standalone' | 'browser' | 'minimal-ui' | 'fullscreen'
57
+ startUrl: '.',
58
+ icon: 'public/icon.png', // source image (≥512px recommended)
59
+ sizes: [192, 512], // generated sizes
60
+ icons: [{ src: '…' }], // OR: explicit icons — skips generation
61
+ filename: 'manifest.webmanifest',
62
+ offline: { shell: ['./'], version: '1' }, // or just `true`
63
+ extra: { shortcuts: [...] }, // merged verbatim into the manifest
64
+ });
65
+ ```
66
+
67
+ ## The offline worker
68
+
69
+ `offline: true` emits a deliberately small service worker:
70
+
71
+ - the **app shell** (`shell` URLs) is precached at install;
72
+ - Vite's hash-named `/assets/…` files are **cache-first** (they're immutable);
73
+ - everything else same-origin is **network-first** with cache fallback — the
74
+ app opens offline but is never a deploy behind while online;
75
+ - offline navigation to any route falls back to the shell.
76
+
77
+ Want offline **URL-imported components** (cross-origin CDN imports) instead
78
+ or as well? That's [spark-html-offline](https://www.npmjs.com/package/spark-html-offline) —
79
+ note a page registers one worker per scope, so pick the one that matches
80
+ your need (this one covers your own origin; spark-html-offline covers CDNs).
81
+
82
+ ## Programmatic API
83
+
84
+ Everything the plugin does is exposed as pure functions:
85
+
86
+ | Export | Meaning |
87
+ |--------|---------|
88
+ | `manifestJson(config)` | The manifest object. |
89
+ | `manifestHtml(config, { href, sw })` | The `<head>` block as a string. |
90
+ | `swSource(options?)` | The app-shell worker source. |
91
+ | `iconPath(config, size)` | Generated icon file name. |
92
+ | `ICON_SIZES` | Default sizes (`[192, 512]`). |
93
+
94
+ `sharp` is imported lazily — if it can't load on your build machine, icons
95
+ are skipped with a warning and everything else still works.
96
+
97
+ ## License
98
+
99
+ MIT
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "spark-html-manifest",
3
+ "version": "0.1.0",
4
+ "description": "PWA manifest + icons + offline app shell for spark-html sites from a single config — a Vite plugin that generates manifest.webmanifest, resizes icons, injects the head tags, and optionally emits a minimal service worker.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "homepage": "https://wilkinnovo.github.io/spark",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "default": "./src/index.js"
13
+ },
14
+ "./vite": {
15
+ "types": "./src/vite.d.ts",
16
+ "default": "./src/vite.js"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "test": "node test/manifest.js"
21
+ },
22
+ "files": [
23
+ "src"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/wilkinnovo/spark.git",
28
+ "directory": "packages/spark-html-manifest"
29
+ },
30
+ "dependencies": {
31
+ "sharp": "^0.34.0"
32
+ },
33
+ "keywords": [
34
+ "spark-html",
35
+ "pwa",
36
+ "manifest",
37
+ "webmanifest",
38
+ "icons",
39
+ "service-worker",
40
+ "vite-plugin"
41
+ ],
42
+ "license": "MIT"
43
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ export interface ManifestIcon {
2
+ src: string;
3
+ sizes?: string;
4
+ type?: string;
5
+ purpose?: string;
6
+ }
7
+
8
+ export interface ManifestConfig {
9
+ /** App name (required). */
10
+ name: string;
11
+ /** Home-screen label (default: name). */
12
+ shortName?: string;
13
+ description?: string;
14
+ /** Default '#ffffff'. */
15
+ themeColor?: string;
16
+ /** Default: themeColor. */
17
+ backgroundColor?: string;
18
+ /** Default 'standalone'. */
19
+ display?: 'standalone' | 'browser' | 'minimal-ui' | 'fullscreen';
20
+ /** Default '.'. */
21
+ startUrl?: string;
22
+ scope?: string;
23
+ lang?: string;
24
+ orientation?: string;
25
+ /** Generated icon sizes in px (default [192, 512]). */
26
+ sizes?: number[];
27
+ /** Explicit icons array — skips generation entirely. */
28
+ icons?: ManifestIcon[];
29
+ /** Merged verbatim into the manifest (shortcuts, screenshots, …). */
30
+ extra?: Record<string, unknown>;
31
+ }
32
+
33
+ export interface AppShellSwOptions {
34
+ /** URLs to precache (default ['./', 'manifest.webmanifest']). */
35
+ shell?: string[];
36
+ /** Bump to invalidate old caches (default '1'). */
37
+ version?: string;
38
+ /** Emitted worker file name (default 'spark-manifest-sw.js'). */
39
+ file?: string;
40
+ }
41
+
42
+ /** Default generated icon sizes. */
43
+ export const ICON_SIZES: number[];
44
+
45
+ /** File name for a generated icon: icons/<slug>-<size>.png */
46
+ export function iconPath(config: ManifestConfig, size: number): string;
47
+
48
+ /** Build the manifest object from one config. */
49
+ export function manifestJson(config: ManifestConfig): Record<string, unknown>;
50
+
51
+ /** The <head> block: manifest link + theme-color meta (+ registration when sw is set). */
52
+ export function manifestHtml(
53
+ config: ManifestConfig,
54
+ opts?: { href?: string; sw?: string },
55
+ ): string;
56
+
57
+ /** Minimal offline app-shell service worker source. */
58
+ export function swSource(options?: AppShellSwOptions): string;
59
+
60
+ declare const _default: {
61
+ manifestJson: typeof manifestJson;
62
+ manifestHtml: typeof manifestHtml;
63
+ swSource: typeof swSource;
64
+ iconPath: typeof iconPath;
65
+ ICON_SIZES: number[];
66
+ };
67
+ export default _default;
package/src/index.js ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * spark-html-manifest — PWA setup from a single config.
3
+ *
4
+ * name, icons, colors, display mode in one place; the vite plugin turns it
5
+ * into manifest.webmanifest + resized icons + the <head> tags + (optionally)
6
+ * a minimal app-shell service worker. No manual icon exports, no copy-paste
7
+ * boilerplate.
8
+ *
9
+ * // vite.config.js
10
+ * import manifest from 'spark-html-manifest/vite';
11
+ * plugins: [spark(), prerender(), manifest({
12
+ * name: 'My App',
13
+ * themeColor: '#ffd24a',
14
+ * icon: 'public/icon.png', // one source image → 192 + 512 (+ maskable)
15
+ * offline: true, // minimal cache-first app-shell worker
16
+ * })]
17
+ *
18
+ * This module is the pure half — config in, JSON/HTML/worker-source out —
19
+ * so it runs anywhere (tests, custom builds, the website playground).
20
+ */
21
+
22
+ /** Default generated icon sizes (px, square). */
23
+ export const ICON_SIZES = [192, 512];
24
+
25
+ const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'app';
26
+
27
+ /** File name for a generated icon: icons/<slug>-192.png */
28
+ export function iconPath(config, size) {
29
+ return `icons/${slug(config.shortName || config.name || 'app')}-${size}.png`;
30
+ }
31
+
32
+ /**
33
+ * Build the manifest object from one config.
34
+ *
35
+ * @param {object} config
36
+ * @param {string} config.name App name (required).
37
+ * @param {string} [config.shortName] Home-screen label (default: name).
38
+ * @param {string} [config.description]
39
+ * @param {string} [config.themeColor='#ffffff']
40
+ * @param {string} [config.backgroundColor=themeColor]
41
+ * @param {string} [config.display='standalone'] 'standalone' | 'browser' | 'minimal-ui' | 'fullscreen'
42
+ * @param {string} [config.startUrl='.']
43
+ * @param {string} [config.scope]
44
+ * @param {string} [config.lang]
45
+ * @param {string} [config.orientation]
46
+ * @param {number[]}[config.sizes] Generated icon sizes (default [192, 512]).
47
+ * @param {object[]}[config.icons] Explicit icons array — skips generation entirely.
48
+ * @param {object} [config.extra] Merged verbatim into the manifest (shortcuts, screenshots, …).
49
+ */
50
+ export function manifestJson(config) {
51
+ if (!config || !config.name) throw new Error('[spark-manifest] config.name is required');
52
+ const theme = config.themeColor || '#ffffff';
53
+ const icons = config.icons || (config.sizes || ICON_SIZES).map((size) => ({
54
+ src: iconPath(config, size),
55
+ sizes: `${size}x${size}`,
56
+ type: 'image/png',
57
+ purpose: 'any',
58
+ }));
59
+ const out = {
60
+ name: config.name,
61
+ short_name: config.shortName || config.name,
62
+ start_url: config.startUrl || '.',
63
+ display: config.display || 'standalone',
64
+ theme_color: theme,
65
+ background_color: config.backgroundColor || theme,
66
+ icons,
67
+ };
68
+ if (config.description) out.description = config.description;
69
+ if (config.scope) out.scope = config.scope;
70
+ if (config.lang) out.lang = config.lang;
71
+ if (config.orientation) out.orientation = config.orientation;
72
+ return { ...out, ...(config.extra || {}) };
73
+ }
74
+
75
+ /**
76
+ * The <head> block: manifest link + theme-color meta (+ apple touch icon
77
+ * when icons exist, + worker registration when offline is on). Everything
78
+ * carries data-spark-manifest so injection stays idempotent.
79
+ *
80
+ * @param {object} config Same config as manifestJson.
81
+ * @param {object} [opts]
82
+ * @param {string} [opts.href='manifest.webmanifest']
83
+ * @param {string} [opts.sw] Worker URL — emits a registration script when set.
84
+ */
85
+ export function manifestHtml(config, opts = {}) {
86
+ const href = opts.href || 'manifest.webmanifest';
87
+ const theme = config.themeColor || '#ffffff';
88
+ const lines = [
89
+ `<link rel="manifest" href="${href}" data-spark-manifest />`,
90
+ `<meta name="theme-color" content="${theme}" data-spark-manifest />`,
91
+ ];
92
+ const icons = config.icons || (config.sizes || ICON_SIZES).map((s) => ({ src: iconPath(config, s), sizes: `${s}x${s}` }));
93
+ const apple = icons.find((i) => /180x180/.test(i.sizes || '')) || icons[icons.length - 1];
94
+ if (apple) lines.push(`<link rel="apple-touch-icon" href="${apple.src}" data-spark-manifest />`);
95
+ if (opts.sw) {
96
+ lines.push(
97
+ `<script data-spark-manifest>if('serviceWorker' in navigator)addEventListener('load',function(){navigator.serviceWorker.register('${opts.sw}')})</script>`,
98
+ );
99
+ }
100
+ return lines.join('\n');
101
+ }
102
+
103
+ /**
104
+ * Minimal app-shell service worker (source string): precaches the shell at
105
+ * install; hash-named assets (immutable) are served cache-first; everything
106
+ * else same-origin is network-first with cache fallback — so the app opens
107
+ * offline but is never a deploy behind while online.
108
+ *
109
+ * @param {object} [options]
110
+ * @param {string[]} [options.shell=['./', 'manifest.webmanifest']] URLs to precache.
111
+ * @param {string} [options.version='1'] Bump to invalidate old caches.
112
+ */
113
+ export function swSource(options = {}) {
114
+ const shell = JSON.stringify(options.shell || ['./', 'manifest.webmanifest']);
115
+ const cache = JSON.stringify(`spark-manifest-v${options.version || '1'}`);
116
+ return `/* generated by spark-html-manifest — offline app shell */
117
+ 'use strict';
118
+ var CACHE = ${cache};
119
+ var SHELL = ${shell};
120
+
121
+ self.addEventListener('install', function (event) {
122
+ event.waitUntil(caches.open(CACHE).then(function (cache) {
123
+ return cache.addAll(SHELL);
124
+ }).then(function () { return self.skipWaiting(); }));
125
+ });
126
+ self.addEventListener('activate', function (event) {
127
+ event.waitUntil(caches.keys().then(function (keys) {
128
+ return Promise.all(keys.filter(function (k) {
129
+ return k !== CACHE && k.indexOf('spark-manifest-') === 0;
130
+ }).map(function (k) { return caches.delete(k); }));
131
+ }).then(function () { return self.clients.claim(); }));
132
+ });
133
+ self.addEventListener('fetch', function (event) {
134
+ var req = event.request;
135
+ if (req.method !== 'GET') return;
136
+ var url;
137
+ try { url = new URL(req.url); } catch (e) { return; }
138
+ if (url.origin !== self.location.origin) return;
139
+ // Vite's hash-named assets are immutable — cache-first is always correct.
140
+ var immutable = /\\/assets\\/[^/]*[-.][A-Za-z0-9_-]{8,}\\.\\w+$/.test(url.pathname);
141
+ event.respondWith(caches.open(CACHE).then(function (cache) {
142
+ return cache.match(req, { ignoreSearch: req.mode === 'navigate' }).then(function (cached) {
143
+ if (cached && immutable) return cached;
144
+ // Network-first keeps pages/components fresh; cache is the offline net.
145
+ return fetch(req).then(function (res) {
146
+ if (res && res.ok) cache.put(req, res.clone());
147
+ return res;
148
+ }).catch(function () {
149
+ if (cached) return cached;
150
+ if (req.mode === 'navigate') return cache.match('./');
151
+ return new Response('', { status: 504, statusText: 'offline' });
152
+ });
153
+ });
154
+ }));
155
+ });
156
+ `;
157
+ }
158
+
159
+ export default { manifestJson, manifestHtml, swSource, iconPath, ICON_SIZES };
package/src/vite.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { ManifestConfig, AppShellSwOptions } from './index.js';
2
+
3
+ export interface ManifestVitePluginOptions extends ManifestConfig {
4
+ /** Source image — resized to every size in `sizes` (requires sharp). */
5
+ icon?: string;
6
+ /** Emitted manifest file name (default 'manifest.webmanifest'). */
7
+ filename?: string;
8
+ /** Emit + register the offline app-shell worker. */
9
+ offline?: boolean | AppShellSwOptions;
10
+ }
11
+
12
+ /**
13
+ * Vite plugin: manifest.webmanifest + resized icons + head tags +
14
+ * (optionally) the app-shell service worker. Put it after prerender().
15
+ */
16
+ export default function sparkManifest(options: ManifestVitePluginOptions): {
17
+ name: string;
18
+ configResolved(config: unknown): void;
19
+ configureServer(server: unknown): void;
20
+ transformIndexHtml(html: string): string;
21
+ generateBundle(): Promise<void>;
22
+ closeBundle: { order: 'post'; handler(): Promise<void> };
23
+ };
package/src/vite.js ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * spark-html-manifest/vite — one config → a full PWA.
3
+ *
4
+ * Build: emits manifest.webmanifest, resizes the source icon into every
5
+ * configured size (sharp, imported lazily), optionally emits the app-shell
6
+ * worker, and injects <link rel="manifest"> + <meta name="theme-color">
7
+ * (+ worker registration) into every built page — runs in closeBundle
8
+ * (order post, after spark-prerender wrote its per-route HTML).
9
+ * Dev: serves the manifest + injects the tags so devtools' "installable"
10
+ * checks work locally too.
11
+ *
12
+ * import manifest from 'spark-html-manifest/vite';
13
+ * plugins: [spark(), prerender(), manifest({
14
+ * name: 'My App', themeColor: '#ffd24a', icon: 'public/icon.png', offline: true,
15
+ * })]
16
+ */
17
+ import { join, resolve } from 'node:path';
18
+ import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
19
+ import { existsSync } from 'node:fs';
20
+ import { manifestJson, manifestHtml, swSource, iconPath, ICON_SIZES } from './index.js';
21
+
22
+ async function htmlFiles(dir) {
23
+ const out = [];
24
+ for (const name of await readdir(dir)) {
25
+ const full = join(dir, name);
26
+ const s = await stat(full);
27
+ if (s.isDirectory()) out.push(...await htmlFiles(full));
28
+ else if (name.endsWith('.html')) out.push(full);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /**
34
+ * @param {object} config Everything manifestJson takes, plus:
35
+ * @param {string} [config.icon] Source image — resized to every size in `sizes`.
36
+ * @param {string} [config.filename='manifest.webmanifest']
37
+ * @param {boolean|object} [config.offline=false] Emit + register the app-shell
38
+ * worker; pass { shell, version, file } to tune it.
39
+ */
40
+ export default function sparkManifest(config = {}) {
41
+ const filename = config.filename || 'manifest.webmanifest';
42
+ const offline = config.offline || false;
43
+ const swFile = (offline && offline.file) || 'spark-manifest-sw.js';
44
+ const headBlock = () =>
45
+ manifestHtml(config, { href: filename, sw: offline ? swFile : undefined });
46
+ let outDir = 'dist';
47
+
48
+ return {
49
+ name: 'spark-html-manifest',
50
+ configResolved(viteConfig) {
51
+ if (viteConfig && viteConfig.build && viteConfig.build.outDir) outDir = viteConfig.build.outDir;
52
+ },
53
+
54
+ // Dev: serve the manifest (and worker) straight from config.
55
+ configureServer(server) {
56
+ server.middlewares.use((req, res, next) => {
57
+ const path = (req.url || '').split('?')[0];
58
+ if (path === `/${filename}`) {
59
+ res.setHeader('Content-Type', 'application/manifest+json');
60
+ res.end(JSON.stringify(manifestJson(config), null, 2));
61
+ } else if (offline && path === `/${swFile}`) {
62
+ res.setHeader('Content-Type', 'text/javascript');
63
+ res.end(swSource(typeof offline === 'object' ? offline : {}));
64
+ } else {
65
+ next();
66
+ }
67
+ });
68
+ },
69
+ transformIndexHtml(html) {
70
+ if (html.includes('data-spark-manifest')) return html;
71
+ return html.replace(/<\/head>/i, `${headBlock()}\n</head>`);
72
+ },
73
+
74
+ // Build: emit manifest + icons + worker.
75
+ async generateBundle() {
76
+ this.emitFile({
77
+ type: 'asset',
78
+ fileName: filename,
79
+ source: JSON.stringify(manifestJson(config), null, 2),
80
+ });
81
+ if (offline) {
82
+ const shellOpts = typeof offline === 'object' ? offline : {};
83
+ this.emitFile({ type: 'asset', fileName: swFile, source: swSource(shellOpts) });
84
+ }
85
+ if (!config.icon || config.icons) return; // explicit icons — nothing to generate
86
+ if (!existsSync(config.icon)) {
87
+ console.warn(`[spark-html-manifest] icon source not found: ${config.icon} — icons skipped`);
88
+ return;
89
+ }
90
+ // sharp is imported lazily so the plugin never breaks a build where
91
+ // native deps can't load — you just generate icons elsewhere.
92
+ let sharp;
93
+ try {
94
+ sharp = (await import('sharp')).default;
95
+ } catch (e) {
96
+ console.warn(`[spark-html-manifest] sharp unavailable — icons skipped (${e.message})`);
97
+ return;
98
+ }
99
+ for (const size of config.sizes || ICON_SIZES) {
100
+ const png = await sharp(config.icon)
101
+ .resize(size, size, { fit: 'cover' })
102
+ .png()
103
+ .toBuffer();
104
+ this.emitFile({ type: 'asset', fileName: iconPath(config, size), source: png });
105
+ }
106
+ console.log(`[spark-html-manifest] ${(config.sizes || ICON_SIZES).length} icon(s) generated from ${config.icon}`);
107
+ },
108
+
109
+ // After prerender wrote its pages: stamp the head tags into every page.
110
+ closeBundle: {
111
+ order: 'post',
112
+ async handler() {
113
+ const root = resolve(outDir);
114
+ if (!existsSync(root)) return;
115
+ const block = headBlock();
116
+ let pages = 0;
117
+ for (const file of await htmlFiles(root)) {
118
+ const html = await readFile(file, 'utf8');
119
+ if (!/<\/head>/i.test(html)) continue; // fragment — skip
120
+ if (html.includes('data-spark-manifest')) continue; // already injected
121
+ await writeFile(file, html.replace(/<\/head>/i, `${block}\n</head>`), 'utf8');
122
+ pages++;
123
+ }
124
+ if (pages) console.log(`[spark-html-manifest] injected PWA tags into ${pages} page(s)`);
125
+ },
126
+ },
127
+ };
128
+ }