@baaqar/artifact-to-pwa 1.0.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,79 @@
1
+ # artifact-to-pwa
2
+
3
+ > Convert any Claude artifact (HTML / React / JSX) or public URL into an installable Progressive Web App — no build step, no Android Studio, no Xcode.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ # From a local file
9
+ npx artifact-to-pwa ./my-app.jsx
10
+
11
+ # From a published Claude artifact URL
12
+ npx artifact-to-pwa https://claude.site/artifacts/abc123
13
+
14
+ # With options
15
+ npx artifact-to-pwa ./app.html --name "My Tool" --color "#ff6b6b" --out ./dist
16
+ ```
17
+
18
+ ## Options
19
+
20
+ | Flag | Description | Default |
21
+ |------|-------------|---------|
22
+ | `-n, --name <name>` | App name | Derived from filename / URL |
23
+ | `-s, --short-name <name>` | Home screen label (max ~12 chars) | First 12 chars of name |
24
+ | `-d, --description <text>` | App description | |
25
+ | `-c, --color <hex>` | Theme / accent color | `#6366f1` |
26
+ | `-b, --bg <hex>` | Splash background color | `#ffffff` |
27
+ | `-o, --out <dir>` | Output directory | `./<slug>-pwa` |
28
+
29
+ ## What it generates
30
+
31
+ ```
32
+ my-app-pwa/
33
+ ├── index.html ← your app, PWA-ready
34
+ ├── manifest.json ← name, icon, colors, display mode
35
+ ├── sw.js ← service worker (offline support)
36
+ ├── icon.svg ← auto-generated app icon
37
+ └── README.md ← install instructions
38
+ ```
39
+
40
+ ## How to install the PWA
41
+
42
+ ### Desktop / Android (Chrome or Edge)
43
+ ```bash
44
+ npx serve my-app-pwa
45
+ ```
46
+ Open the URL → click the **Install** icon in the address bar.
47
+
48
+ ### iPhone / iPad (Safari)
49
+ Deploy the folder anywhere static (see below), open in Safari → **Share** → **Add to Home Screen**.
50
+
51
+ ### One-click deploy options
52
+
53
+ | Host | Command |
54
+ |------|---------|
55
+ | **Netlify** | Drag the folder to [netlify.com/drop](https://netlify.com/drop) |
56
+ | **Vercel** | `npx vercel my-app-pwa` |
57
+ | **GitHub Pages** | Push folder contents, enable Pages in repo settings |
58
+
59
+ ## Supported source formats
60
+
61
+ | Format | Detection |
62
+ |--------|-----------|
63
+ | Full HTML document | `<!DOCTYPE html>` or `<html>` present |
64
+ | HTML fragment | Partial markup without doctype |
65
+ | React / JSX | `import React`, `useState`, `export default function`, JSX syntax |
66
+ | Public URL | Embedded as a full-screen iframe |
67
+
68
+ React artifacts are served using Babel standalone + CDN React — **no bundler needed**.
69
+
70
+ ## Why PWA instead of APK?
71
+
72
+ - **Zero build tooling** — no Android Studio, no Xcode, no Java
73
+ - **Cross-platform** — installs on Android, iOS, Windows, macOS, Linux
74
+ - **Always up to date** — users get the latest version automatically
75
+ - **Offline support** — built-in service worker caches assets
76
+
77
+ ---
78
+
79
+ Made with ❤️ to keep Claude artifacts alive outside the chat.
package/bin/cli.js ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander';
3
+ import { generatePWA } from '../src/generator.js';
4
+ import { readFileSync } from 'fs';
5
+ import { fileURLToPath } from 'url';
6
+ import { join, dirname } from 'path';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
10
+
11
+ program
12
+ .name('artifact-to-pwa')
13
+ .description(
14
+ 'Convert a Claude artifact (HTML/React/JSX) or any public URL into a ready-to-install PWA.\n\n' +
15
+ 'Examples:\n' +
16
+ ' npx artifact-to-pwa ./my-app.jsx\n' +
17
+ ' npx artifact-to-pwa https://claude.site/artifacts/abc123\n' +
18
+ ' npx artifact-to-pwa ./app.html --name "My Tool" --color "#ff6b6b"'
19
+ )
20
+ .version(pkg.version)
21
+ .argument('<source>', 'Local file path (.html, .jsx, .js) or public URL')
22
+ .option('-n, --name <name>', 'App name (default: derived from filename or URL)')
23
+ .option('-s, --short-name <name>', 'Short name shown on home screen (default: first 12 chars of name)')
24
+ .option('-d, --description <text>', 'App description', '')
25
+ .option('-c, --color <hex>', 'Theme / accent color', '#6366f1')
26
+ .option('-b, --bg <hex>', 'Splash screen background color', '#ffffff')
27
+ .option('-o, --out <dir>', 'Output directory (default: ./<slug>-pwa)')
28
+ .action(generatePWA);
29
+
30
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@baaqar/artifact-to-pwa",
3
+ "version": "1.0.0",
4
+ "description": "Convert any Claude artifact (HTML/React/JSX) or public URL into an installable PWA — no build step required",
5
+ "type": "module",
6
+ "bin": {
7
+ "artifact-to-pwa": "bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "README.md"
13
+ ],
14
+ "keywords": [
15
+ "pwa",
16
+ "progressive-web-app",
17
+ "claude",
18
+ "artifact",
19
+ "installable",
20
+ "offline",
21
+ "cli",
22
+ "converter"
23
+ ],
24
+ "author": "",
25
+ "license": "MIT",
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "dependencies": {
30
+ "chalk": "^5.3.0",
31
+ "commander": "^12.1.0"
32
+ }
33
+ }
package/src/detect.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Detects whether a string of source code is:
3
+ * "full-html" — a complete <!DOCTYPE html> document
4
+ * "html-fragment" — partial HTML without doctype/html tags
5
+ * "react" — JSX / React component
6
+ */
7
+ export function detectCodeType(code) {
8
+ const t = code.trim();
9
+
10
+ if (/^<!DOCTYPE\s+html/i.test(t) || /^<html[\s>]/i.test(t)) {
11
+ return 'full-html';
12
+ }
13
+
14
+ const reactSignals = [
15
+ /from ['"]react['"]/,
16
+ /import\s+React/,
17
+ /export\s+default\s+function/,
18
+ /export\s+default\s+class/,
19
+ /useState\s*[(<]/,
20
+ /useEffect\s*\(/,
21
+ /React\.createElement/,
22
+ /return\s*\(\s*</,
23
+ /=>\s*\(/, // arrow fn returning JSX
24
+ /<>\s*</, // fragment shorthand
25
+ ];
26
+
27
+ if (reactSignals.some(re => re.test(t))) return 'react';
28
+
29
+ // Looks like it starts with an HTML tag but has no doctype
30
+ if (/^<[a-zA-Z]/.test(t)) return 'html-fragment';
31
+
32
+ // Default: treat as an HTML fragment
33
+ return 'html-fragment';
34
+ }
35
+
36
+ /**
37
+ * Wraps source code into a complete, PWA-ready index.html.
38
+ * Injects manifest link, theme-color meta, and SW registration.
39
+ */
40
+ export function wrapCode(code, { appName, themeColor }) {
41
+ const type = detectCodeType(code);
42
+
43
+ const headInjects = `
44
+ <meta name="theme-color" content="${themeColor}">
45
+ <meta name="apple-mobile-web-app-capable" content="yes">
46
+ <meta name="apple-mobile-web-app-status-bar-style" content="default">
47
+ <meta name="apple-mobile-web-app-title" content="${appName}">
48
+ <link rel="apple-touch-icon" href="icon.svg">
49
+ <link rel="manifest" href="manifest.json">`.trim();
50
+
51
+ const swScript = `
52
+ <script>
53
+ if ('serviceWorker' in navigator) {
54
+ window.addEventListener('load', () =>
55
+ navigator.serviceWorker.register('sw.js').catch(() => {})
56
+ );
57
+ }
58
+ </script>`.trim();
59
+
60
+ // ── Full HTML document ────────────────────────────────────────────────────
61
+ if (type === 'full-html') {
62
+ let result = code;
63
+
64
+ // Inject into existing <head>
65
+ if (/<\/head>/i.test(result)) {
66
+ result = result.replace(/<\/head>/i, ` ${headInjects}\n</head>`);
67
+ } else {
68
+ // No </head>: inject after <html> or at top
69
+ result = result.replace(/(<html[^>]*>)/i, `$1\n<head>\n ${headInjects}\n</head>`);
70
+ }
71
+
72
+ // Inject SW before </body>
73
+ if (/<\/body>/i.test(result)) {
74
+ result = result.replace(/<\/body>/i, ` ${swScript}\n</body>`);
75
+ } else {
76
+ result += `\n${swScript}`;
77
+ }
78
+
79
+ return result;
80
+ }
81
+
82
+ // ── React / JSX ───────────────────────────────────────────────────────────
83
+ if (type === 'react') {
84
+ // Strip top-level React imports (provided by CDN)
85
+ let cleaned = code
86
+ .replace(/^import\s+React[^;]*;\s*/gm, '')
87
+ .replace(/^import\s*\{[^}]+\}\s*from\s*['"]react['"];\s*/gm, '')
88
+ .trim();
89
+
90
+ // Normalize export default → __App
91
+ cleaned = cleaned
92
+ .replace(/^export\s+default\s+function\s+(\w+)/, 'function __App')
93
+ .replace(/^export\s+default\s+class\s+(\w+)/, 'class __App')
94
+ .replace(/^export\s+default\s+/, 'const __App = ');
95
+
96
+ const hasApp = /\b__App\b/.test(cleaned);
97
+ const renderLine = hasApp
98
+ ? `ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(__App));`
99
+ : `/* Could not auto-detect root component — update the render call below */`;
100
+
101
+ return `<!DOCTYPE html>
102
+ <html lang="en">
103
+ <head>
104
+ <meta charset="UTF-8">
105
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
106
+ <title>${appName}</title>
107
+ ${headInjects}
108
+ <!-- React + Babel (no build step) -->
109
+ <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
110
+ <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
111
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
112
+ <script src="https://cdn.tailwindcss.com"></script>
113
+ <style>html,body,#root{height:100%;margin:0;padding:0;}</style>
114
+ ${swScript}
115
+ </head>
116
+ <body>
117
+ <div id="root"></div>
118
+ <script type="text/babel">
119
+ const {
120
+ useState, useEffect, useRef, useCallback,
121
+ useMemo, useReducer, useContext, createContext
122
+ } = React;
123
+
124
+ ${cleaned}
125
+
126
+ ${renderLine}
127
+ </script>
128
+ </body>
129
+ </html>`;
130
+ }
131
+
132
+ // ── HTML fragment ─────────────────────────────────────────────────────────
133
+ return `<!DOCTYPE html>
134
+ <html lang="en">
135
+ <head>
136
+ <meta charset="UTF-8">
137
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
138
+ <title>${appName}</title>
139
+ ${headInjects}
140
+ ${swScript}
141
+ </head>
142
+ <body>
143
+ ${code}
144
+ </body>
145
+ </html>`;
146
+ }
@@ -0,0 +1,123 @@
1
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
2
+ import { join, basename, extname } from 'path';
3
+ import { detectCodeType } from './detect.js';
4
+ import { generateSVGIcon } from './icons.js';
5
+ import { buildIndexHTML, buildManifest, buildServiceWorker, buildReadme } from './templates.js';
6
+
7
+ // ── Helpers ───────────────────────────────────────────────────────────────────
8
+
9
+ const isURL = s => /^https?:\/\//i.test(s.trim());
10
+
11
+ /** Turn an arbitrary string into a lowercase URL slug. */
12
+ const slugify = s =>
13
+ s.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-');
14
+
15
+ /** Derive a human-readable app name from a path or URL. */
16
+ function nameFromSource(source) {
17
+ if (isURL(source)) {
18
+ try {
19
+ const host = new URL(source).hostname.replace(/^www\./, '');
20
+ const part = host.split('.')[0];
21
+ return part.charAt(0).toUpperCase() + part.slice(1);
22
+ } catch {
23
+ return 'My App';
24
+ }
25
+ }
26
+ const base = basename(source, extname(source));
27
+ return base
28
+ .replace(/[-_]+/g, ' ')
29
+ .replace(/\b\w/g, c => c.toUpperCase());
30
+ }
31
+
32
+ // ── Main export ───────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Orchestrates PWA generation from a CLI invocation.
36
+ *
37
+ * @param {string} source - File path or URL
38
+ * @param {object} options - Commander option values
39
+ */
40
+ export async function generatePWA(source, options) {
41
+ // ── Lazy-load chalk (ESM-only package) ──────────────────────────────────
42
+ const { default: chalk } = await import('chalk');
43
+
44
+ console.log('\n' + chalk.bold.cyan(' artifact-to-pwa') + chalk.gray(' — PWA generator\n'));
45
+
46
+ // ── Resolve config ──────────────────────────────────────────────────────
47
+ const appName = (options.name || nameFromSource(source)).trim();
48
+ const shortName = (options.shortName || appName).slice(0, 12).trim();
49
+ const slug = slugify(appName) || 'my-pwa';
50
+ const outDir = options.out || `./${slug}-pwa`;
51
+ const themeColor = /^#[0-9a-f]{3,6}$/i.test(options.color || '') ? options.color : '#6366f1';
52
+ const bgColor = /^#[0-9a-f]{3,6}$/i.test(options.bg || '') ? options.bg : '#ffffff';
53
+
54
+ // ── Print plan ──────────────────────────────────────────────────────────
55
+ console.log(chalk.gray(' App name ') + chalk.white(appName));
56
+ console.log(chalk.gray(' Output ') + chalk.white(outDir));
57
+ console.log(chalk.gray(' Color ') + chalk.white(themeColor));
58
+ console.log(chalk.gray(' Source ') + chalk.white(source));
59
+ console.log();
60
+
61
+ // ── Resolve source content ──────────────────────────────────────────────
62
+ let mode, code;
63
+
64
+ if (isURL(source)) {
65
+ mode = 'url';
66
+ code = null;
67
+ console.log(chalk.gray(' ↳ URL mode: artifact will be embedded via iframe'));
68
+ console.log(chalk.gray(' (For offline support, paste the source code instead)\n'));
69
+ } else {
70
+ if (!existsSync(source)) {
71
+ console.error(chalk.red(`\n ✗ File not found: ${source}\n`));
72
+ process.exit(1);
73
+ }
74
+ mode = 'code';
75
+ code = readFileSync(source, 'utf8');
76
+ const detectedType = detectCodeType(code);
77
+ console.log(chalk.gray(` ↳ Detected: `) + chalk.yellow(detectedType));
78
+ console.log();
79
+ }
80
+
81
+ // ── Build config object ─────────────────────────────────────────────────
82
+ const config = {
83
+ appName,
84
+ shortName,
85
+ description: options.description || '',
86
+ themeColor,
87
+ bgColor,
88
+ slug,
89
+ mode,
90
+ code,
91
+ url: source,
92
+ };
93
+
94
+ // ── Create output directory ─────────────────────────────────────────────
95
+ mkdirSync(outDir, { recursive: true });
96
+
97
+ // ── Write files ─────────────────────────────────────────────────────────
98
+ const files = [
99
+ ['index.html', buildIndexHTML(config)],
100
+ ['manifest.json', buildManifest(config)],
101
+ ['sw.js', buildServiceWorker(config)],
102
+ ['icon.svg', generateSVGIcon(themeColor, appName[0] || 'A')],
103
+ ['README.md', buildReadme(config)],
104
+ ];
105
+
106
+ for (const [filename, content] of files) {
107
+ writeFileSync(join(outDir, filename), content, 'utf8');
108
+ console.log(chalk.green(' ✓ ') + filename);
109
+ }
110
+
111
+ // ── Done ─────────────────────────────────────────────────────────────────
112
+ console.log(
113
+ '\n' +
114
+ chalk.bold.white(' All done!\n') +
115
+ chalk.gray('\n Test locally:\n') +
116
+ chalk.white(` npx serve ${outDir}\n`) +
117
+ chalk.gray('\n Or drag ') +
118
+ chalk.white(`'${outDir}/'`) +
119
+ chalk.gray(' to ') +
120
+ chalk.cyan('netlify.com/drop') +
121
+ chalk.gray(' for instant hosting + mobile install.\n')
122
+ );
123
+ }
package/src/icons.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Generates a rounded-square SVG icon suitable for PWA manifests.
3
+ * SVG icons are supported by all modern browsers and require no
4
+ * native image-processing dependencies.
5
+ *
6
+ * @param {string} color - Background hex color, e.g. "#6366f1"
7
+ * @param {string} letter - Single character to display
8
+ * @returns {string} SVG markup
9
+ */
10
+ export function generateSVGIcon(color, letter = '?') {
11
+ const char = String(letter).charAt(0).toUpperCase();
12
+
13
+ // Derive a slightly lighter tint for a subtle inner glow
14
+ const tint = lighten(color, 0.15);
15
+
16
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
17
+ <defs>
18
+ <radialGradient id="bg" cx="40%" cy="35%" r="65%">
19
+ <stop offset="0%" stop-color="${tint}"/>
20
+ <stop offset="100%" stop-color="${color}"/>
21
+ </radialGradient>
22
+ </defs>
23
+ <!-- Rounded square background -->
24
+ <rect width="512" height="512" rx="112" ry="112" fill="url(#bg)"/>
25
+ <!-- Centred letter -->
26
+ <text
27
+ x="256" y="340"
28
+ font-family="system-ui, -apple-system, sans-serif"
29
+ font-weight="700"
30
+ font-size="280"
31
+ text-anchor="middle"
32
+ fill="rgba(255,255,255,0.95)"
33
+ >${char}</text>
34
+ </svg>`;
35
+ }
36
+
37
+ /**
38
+ * Naively lightens a hex color by mixing it toward white.
39
+ * @param {string} hex - e.g. "#6366f1"
40
+ * @param {number} ratio - 0..1, how much to lighten
41
+ */
42
+ function lighten(hex, ratio) {
43
+ const clean = hex.replace('#', '');
44
+ const r = parseInt(clean.slice(0, 2), 16);
45
+ const g = parseInt(clean.slice(2, 4), 16);
46
+ const b = parseInt(clean.slice(4, 6), 16);
47
+ const mix = v => Math.round(v + (255 - v) * ratio);
48
+ return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`;
49
+ }
@@ -0,0 +1,175 @@
1
+ import { wrapCode } from './detect.js';
2
+
3
+ /**
4
+ * Builds the main index.html.
5
+ * In URL mode the artifact is embedded in a full-screen iframe.
6
+ * In code mode the source is compiled/wrapped via detect.js.
7
+ */
8
+ export function buildIndexHTML(config) {
9
+ const { mode, code, url, appName, themeColor } = config;
10
+
11
+ const headInjects = `
12
+ <meta name="theme-color" content="${themeColor}">
13
+ <meta name="apple-mobile-web-app-capable" content="yes">
14
+ <meta name="apple-mobile-web-app-status-bar-style" content="default">
15
+ <meta name="apple-mobile-web-app-title" content="${appName}">
16
+ <link rel="apple-touch-icon" href="icon.svg">
17
+ <link rel="manifest" href="manifest.json">`.trim();
18
+
19
+ const swScript = `
20
+ <script>
21
+ if ('serviceWorker' in navigator) {
22
+ window.addEventListener('load', () =>
23
+ navigator.serviceWorker.register('sw.js').catch(() => {})
24
+ );
25
+ }
26
+ </script>`.trim();
27
+
28
+ if (mode === 'url') {
29
+ return `<!DOCTYPE html>
30
+ <html lang="en">
31
+ <head>
32
+ <meta charset="UTF-8">
33
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
34
+ <title>${appName}</title>
35
+ ${headInjects}
36
+ <style>
37
+ * { margin: 0; padding: 0; box-sizing: border-box; }
38
+ html,
39
+ body { width: 100%; height: 100%; overflow: hidden; }
40
+ iframe { width: 100%; height: 100%; border: none; display: block; }
41
+ </style>
42
+ ${swScript}
43
+ </head>
44
+ <body>
45
+ <iframe
46
+ src="${url}"
47
+ allow="fullscreen; clipboard-write; clipboard-read"
48
+ sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
49
+ ></iframe>
50
+ </body>
51
+ </html>`;
52
+ }
53
+
54
+ return wrapCode(code, { appName, themeColor });
55
+ }
56
+
57
+ /**
58
+ * Builds a Web App Manifest (manifest.json).
59
+ */
60
+ export function buildManifest({ appName, shortName, description, themeColor, bgColor }) {
61
+ const manifest = {
62
+ name: appName,
63
+ short_name: shortName || appName.slice(0, 12),
64
+ description: description || '',
65
+ start_url: './',
66
+ scope: './',
67
+ display: 'standalone',
68
+ orientation: 'any',
69
+ theme_color: themeColor,
70
+ background_color: bgColor,
71
+ icons: [
72
+ {
73
+ src: 'icon.svg',
74
+ sizes: 'any',
75
+ type: 'image/svg+xml',
76
+ purpose: 'any maskable',
77
+ },
78
+ ],
79
+ };
80
+ return JSON.stringify(manifest, null, 2);
81
+ }
82
+
83
+ /**
84
+ * Builds a network-first service worker with offline fallback.
85
+ */
86
+ export function buildServiceWorker({ slug }) {
87
+ const CACHE = `${slug}-v1`;
88
+ return `// Service worker generated by artifact-to-pwa
89
+ const CACHE = '${CACHE}';
90
+ const ASSETS = ['./', './index.html', './manifest.json', './icon.svg'];
91
+
92
+ // ── Install: pre-cache core assets ───────────────────────────────────────────
93
+ self.addEventListener('install', event => {
94
+ event.waitUntil(
95
+ caches.open(CACHE)
96
+ .then(cache => cache.addAll(ASSETS))
97
+ .catch(() => { /* non-fatal if assets are missing at install time */ })
98
+ );
99
+ self.skipWaiting();
100
+ });
101
+
102
+ // ── Activate: remove stale caches ────────────────────────────────────────────
103
+ self.addEventListener('activate', event => {
104
+ event.waitUntil(
105
+ caches.keys().then(keys =>
106
+ Promise.all(
107
+ keys
108
+ .filter(key => key !== CACHE)
109
+ .map(key => caches.delete(key))
110
+ )
111
+ )
112
+ );
113
+ self.clients.claim();
114
+ });
115
+
116
+ // ── Fetch: network-first, cache fallback ─────────────────────────────────────
117
+ self.addEventListener('fetch', event => {
118
+ if (event.request.method !== 'GET') return;
119
+
120
+ event.respondWith(
121
+ fetch(event.request)
122
+ .then(response => {
123
+ // Cache a copy of successful responses
124
+ if (response.ok) {
125
+ const clone = response.clone();
126
+ caches.open(CACHE).then(cache => cache.put(event.request, clone));
127
+ }
128
+ return response;
129
+ })
130
+ .catch(() => caches.match(event.request))
131
+ );
132
+ });
133
+ `;
134
+ }
135
+
136
+ /**
137
+ * Builds a README.md for the generated PWA project.
138
+ */
139
+ export function buildReadme({ appName, slug }) {
140
+ return `# ${appName}
141
+
142
+ > Generated by [artifact-to-pwa](https://github.com/yourusername/artifact-to-pwa)
143
+
144
+ ## Install
145
+
146
+ ### Quickest (local)
147
+ \`\`\`bash
148
+ npx serve .
149
+ \`\`\`
150
+ Open the printed URL in Chrome/Edge → click the **Install** icon in the address bar.
151
+
152
+ ### Android (Chrome)
153
+ Deploy to any static host, then visit on Android → ⋮ → **Add to Home Screen**.
154
+
155
+ ### iPhone / iPad (Safari)
156
+ Deploy → open in Safari → **Share** → **Add to Home Screen**.
157
+
158
+ ## One-click deploy
159
+
160
+ | Host | Steps |
161
+ |------|-------|
162
+ | **Netlify** | Drag this folder to [netlify.com/drop](https://netlify.com/drop) |
163
+ | **Vercel** | \`npx vercel .\` |
164
+ | **GitHub Pages** | Push to a repo, enable Pages in Settings |
165
+
166
+ ## Files
167
+
168
+ | File | Purpose |
169
+ |------|---------|
170
+ | \`index.html\` | App entry point |
171
+ | \`manifest.json\` | PWA metadata (name, icons, colors) |
172
+ | \`sw.js\` | Service worker — enables offline use |
173
+ | \`icon.svg\` | App icon (scales to any size) |
174
+ `;
175
+ }