@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 +79 -0
- package/bin/cli.js +30 -0
- package/package.json +33 -0
- package/src/detect.js +146 -0
- package/src/generator.js +123 -0
- package/src/icons.js +49 -0
- package/src/templates.js +175 -0
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
|
+
}
|
package/src/generator.js
ADDED
|
@@ -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
|
+
}
|
package/src/templates.js
ADDED
|
@@ -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
|
+
}
|