Skip to content

Commit 2bfcf70

Browse files
authored
Fix web-artifacts-builder scripts (#39)
* fix: add web artifact builder scripts * fix: address web artifacts builder review feedback
1 parent 64182f3 commit 2bfcf70

3 files changed

Lines changed: 316 additions & 2 deletions

File tree

skills/web-artifacts-builder/SKILL.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,7 @@ This creates a fully configured project with:
3333
- ✅ React + TypeScript (via Vite)
3434
- ✅ Tailwind CSS 3.4.1 with shadcn/ui theming system
3535
- ✅ Path aliases (`@/`) configured
36-
- ✅ 40+ shadcn/ui components pre-installed
37-
- ✅ All Radix UI dependencies included
36+
- ✅ Starter structure for shadcn/ui-style components
3837
- ✅ Parcel configured for bundling (via .parcelrc)
3938
- ✅ Node 18+ compatibility (auto-detects and pins Vite version)
4039

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
if [[ ! -f "index.html" ]]; then
5+
echo "error: run this script from the artifact project root containing index.html" >&2
6+
exit 1
7+
fi
8+
9+
if [[ ! -d "node_modules" ]]; then
10+
echo "error: node_modules not found. Run npm install before bundling." >&2
11+
exit 1
12+
fi
13+
14+
dist_dir="dist-artifact"
15+
rm -rf "$dist_dir"
16+
17+
npx parcel build index.html --dist-dir "$dist_dir" --no-source-maps
18+
19+
node <<'NODE'
20+
import fs from "node:fs";
21+
import path from "node:path";
22+
23+
const distDir = "dist-artifact";
24+
const htmlPath = path.join(distDir, "index.html");
25+
let html = fs.readFileSync(htmlPath, "utf8");
26+
27+
const mimeTypes = {
28+
".avif": "image/avif",
29+
".gif": "image/gif",
30+
".ico": "image/x-icon",
31+
".jpg": "image/jpeg",
32+
".jpeg": "image/jpeg",
33+
".mp3": "audio/mpeg",
34+
".mp4": "video/mp4",
35+
".otf": "font/otf",
36+
".png": "image/png",
37+
".svg": "image/svg+xml",
38+
".ttf": "font/ttf",
39+
".webm": "video/webm",
40+
".webp": "image/webp",
41+
".woff": "font/woff",
42+
".woff2": "font/woff2",
43+
};
44+
45+
const localAssetPattern = /\.(?:avif|gif|ico|jpe?g|mp3|mp4|otf|png|svg|ttf|webm|webp|woff2?)(?:[?#][^"')\s]+)?$/i;
46+
47+
function isExternalReference(ref) {
48+
return /^(?:data:|https?:|mailto:|tel:|#|javascript:)/i.test(ref);
49+
}
50+
51+
function resolveAsset(ref, baseDir) {
52+
if (!ref || isExternalReference(ref) || !localAssetPattern.test(ref)) {
53+
return null;
54+
}
55+
const cleanRef = ref.split(/[?#]/, 1)[0];
56+
const candidates = [
57+
path.join(baseDir, cleanRef),
58+
path.join(distDir, cleanRef.replace(/^\//, "")),
59+
];
60+
return candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile()) ?? null;
61+
}
62+
63+
function toDataUri(filePath) {
64+
const ext = path.extname(filePath).toLowerCase();
65+
const mime = mimeTypes[ext] ?? "application/octet-stream";
66+
const data = fs.readFileSync(filePath).toString("base64");
67+
return `data:${mime};base64,${data}`;
68+
}
69+
70+
function inlineAssetReferences(text, baseDir) {
71+
let output = text.replace(/url\((["']?)([^"')]+)\1\)/g, (match, quote, ref) => {
72+
const filePath = resolveAsset(ref.trim(), baseDir);
73+
if (!filePath) {
74+
return match;
75+
}
76+
return `url(${quote}${toDataUri(filePath)}${quote})`;
77+
});
78+
output = output.replace(
79+
/(["'])(\/?[^"']+\.(?:avif|gif|ico|jpe?g|mp3|mp4|otf|png|svg|ttf|webm|webp|woff2?)(?:[?#][^"']*)?)\1/gi,
80+
(match, quote, ref) => {
81+
const filePath = resolveAsset(ref, baseDir);
82+
if (!filePath) {
83+
return match;
84+
}
85+
return `${quote}${toDataUri(filePath)}${quote}`;
86+
}
87+
);
88+
return output;
89+
}
90+
91+
function inlineHtmlAssetAttributes(text) {
92+
return text.replace(
93+
/\b(src|href)=(")([^"]+\.(?:avif|gif|ico|jpe?g|mp3|mp4|otf|png|svg|ttf|webm|webp|woff2?)(?:[?#][^"]*)?)(")/gi,
94+
(match, attribute, openQuote, ref, closeQuote) => {
95+
const filePath = resolveAsset(ref, distDir);
96+
if (!filePath) {
97+
return match;
98+
}
99+
return `${attribute}=${openQuote}${toDataUri(filePath)}${closeQuote}`;
100+
}
101+
);
102+
}
103+
104+
html = html.replace(/<script([^>]*)src="([^"]+)"([^>]*)><\/script>/g, (_match, before, src, after) => {
105+
const filePath = path.join(distDir, src.replace(/^\//, ""));
106+
const code = inlineAssetReferences(fs.readFileSync(filePath, "utf8"), path.dirname(filePath)).replace(
107+
/<\/script/gi,
108+
"<\\/script"
109+
);
110+
return `<script${before}${after}>${code}</script>`;
111+
});
112+
113+
html = html.replace(/<link([^>]*?)rel="stylesheet"([^>]*?)href="([^"]+)"([^>]*)>/g, (_match, before, middle, href, after) => {
114+
const filePath = path.join(distDir, href.replace(/^\//, ""));
115+
const css = inlineAssetReferences(fs.readFileSync(filePath, "utf8"), path.dirname(filePath));
116+
return `<style${before}${middle}${after}>${css}</style>`;
117+
});
118+
119+
html = inlineHtmlAssetAttributes(html);
120+
121+
fs.writeFileSync("bundle.html", html);
122+
NODE
123+
124+
echo "Created bundle.html"
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
project_name="${1:-}"
5+
if [[ -z "$project_name" ]]; then
6+
echo "usage: bash scripts/init-artifact.sh <project-name>" >&2
7+
exit 2
8+
fi
9+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10+
11+
if [[ -e "$project_name" ]]; then
12+
echo "error: target already exists: $project_name" >&2
13+
exit 1
14+
fi
15+
16+
mkdir -p "$project_name/src/components/ui"
17+
cd "$project_name"
18+
19+
cat > package.json <<'JSON'
20+
{
21+
"type": "module",
22+
"scripts": {
23+
"dev": "vite --host 0.0.0.0",
24+
"build": "vite build",
25+
"bundle": "bash scripts/bundle-artifact.sh"
26+
},
27+
"dependencies": {
28+
"@vitejs/plugin-react": "^4.3.0",
29+
"autoprefixer": "^10.4.19",
30+
"clsx": "^2.1.1",
31+
"lucide-react": "^0.468.0",
32+
"postcss": "^8.4.38",
33+
"react": "^18.2.0",
34+
"react-dom": "^18.2.0",
35+
"tailwind-merge": "^2.3.0",
36+
"tailwindcss": "^3.4.1",
37+
"typescript": "^5.4.5",
38+
"vite": "^5.2.0"
39+
},
40+
"devDependencies": {
41+
"@types/react": "^18.2.66",
42+
"@types/react-dom": "^18.2.22",
43+
"html-inline": "^1.2.0",
44+
"parcel": "^2.12.0"
45+
}
46+
}
47+
JSON
48+
49+
cat > index.html <<'HTML'
50+
<!doctype html>
51+
<html lang="en">
52+
<head>
53+
<meta charset="UTF-8" />
54+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
55+
<title>Claude Artifact</title>
56+
</head>
57+
<body>
58+
<div id="root"></div>
59+
<script type="module" src="/src/main.tsx"></script>
60+
</body>
61+
</html>
62+
HTML
63+
64+
cat > src/main.tsx <<'TSX'
65+
import React from "react";
66+
import { createRoot } from "react-dom/client";
67+
import { App } from "./App";
68+
import "./index.css";
69+
70+
createRoot(document.getElementById("root")!).render(
71+
<React.StrictMode>
72+
<App />
73+
</React.StrictMode>
74+
);
75+
TSX
76+
77+
cat > src/App.tsx <<'TSX'
78+
export function App() {
79+
return (
80+
<main className="min-h-screen bg-zinc-950 text-zinc-50">
81+
<section className="mx-auto flex min-h-screen max-w-5xl flex-col justify-center px-8">
82+
<p className="mb-4 text-sm uppercase tracking-[0.2em] text-cyan-300">Claude Artifact</p>
83+
<h1 className="max-w-3xl text-5xl font-semibold leading-tight">
84+
Replace this starter with the user's actual interactive experience.
85+
</h1>
86+
<p className="mt-6 max-w-2xl text-lg text-zinc-300">
87+
Use React state, focused components, and Tailwind utilities. Keep the final artifact
88+
self-contained by running the bundle script from the project root.
89+
</p>
90+
</section>
91+
</main>
92+
);
93+
}
94+
TSX
95+
96+
cat > src/index.css <<'CSS'
97+
@tailwind base;
98+
@tailwind components;
99+
@tailwind utilities;
100+
101+
* {
102+
box-sizing: border-box;
103+
}
104+
105+
body {
106+
margin: 0;
107+
}
108+
CSS
109+
110+
cat > src/components/ui/README.md <<'MD'
111+
# UI Components
112+
113+
Put small reusable shadcn/ui-style components here. Keep component APIs narrow
114+
and prefer plain Tailwind classes unless a reusable abstraction removes real
115+
duplication.
116+
MD
117+
118+
cat > tsconfig.json <<'JSON'
119+
{
120+
"compilerOptions": {
121+
"target": "ES2020",
122+
"useDefineForClassFields": true,
123+
"lib": ["DOM", "DOM.Iterable", "ES2020"],
124+
"allowJs": false,
125+
"skipLibCheck": true,
126+
"esModuleInterop": true,
127+
"allowSyntheticDefaultImports": true,
128+
"strict": true,
129+
"forceConsistentCasingInFileNames": true,
130+
"module": "ESNext",
131+
"moduleResolution": "Node",
132+
"resolveJsonModule": true,
133+
"isolatedModules": true,
134+
"noEmit": true,
135+
"jsx": "react-jsx",
136+
"baseUrl": ".",
137+
"paths": {
138+
"@/*": ["src/*"]
139+
}
140+
},
141+
"include": ["src"],
142+
"references": []
143+
}
144+
JSON
145+
146+
cat > vite.config.ts <<'TS'
147+
import react from "@vitejs/plugin-react";
148+
import { defineConfig } from "vite";
149+
150+
export default defineConfig({
151+
plugins: [react()],
152+
resolve: {
153+
alias: {
154+
"@": "/src",
155+
},
156+
},
157+
});
158+
TS
159+
160+
cat > tailwind.config.js <<'JS'
161+
/** @type {import('tailwindcss').Config} */
162+
export default {
163+
content: ["./index.html", "./src/**/*.{ts,tsx}"],
164+
theme: {
165+
extend: {},
166+
},
167+
plugins: [],
168+
};
169+
JS
170+
171+
cat > postcss.config.js <<'JS'
172+
export default {
173+
plugins: {
174+
tailwindcss: {},
175+
autoprefixer: {},
176+
},
177+
};
178+
JS
179+
180+
cat > .parcelrc <<'JSON'
181+
{
182+
"extends": "@parcel/config-default"
183+
}
184+
JSON
185+
186+
mkdir -p scripts
187+
cp "$script_dir/bundle-artifact.sh" scripts/bundle-artifact.sh
188+
chmod +x scripts/bundle-artifact.sh
189+
190+
echo "Created artifact project: $project_name"
191+
echo "Next: cd $project_name && npm install && npm run dev"

0 commit comments

Comments
 (0)