import subprocess, shutil
from fasthtml.common import *
from fastcore.utils import *FhVite
Vite plugin for FastHTML - automatic bundling, minifcation and script injection of static assets
Imports
File configuration
# TODO: move this to github and pull from there
_monster_temp = {'vite.config.js': '''import franken from "franken-ui/plugin-vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
franken({
layer: true,
preflight: false,
layerExceptions: ["chart"],
}),
tailwindcss(),
],
css: {
transformer: "lightningcss",
},
build: {
cssMinify: "lightningcss",
manifest: true,
rollupOptions: {
input: "src/index.js",
},
outDir: './dist',
emptyOutDir: true
},
});''',
'package.json': '''{
"name": "fastvite",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"lightningcss": "^1.30.2",
"vite": "^7.1.10"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.14",
"franken-ui": "^2.1.0"
}
}''',
'src/style.css': '''@import "tailwindcss";
@import "franken-ui/css/franken-ui.css";
@source "../../";
@theme {
--color-background: oklch(from hsl(var(--background)) l c h);
--color-foreground: oklch(from hsl(var(--foreground)) l c h);
--color-muted: oklch(from hsl(var(--muted)) l c h);
--color-muted-foreground: oklch(from hsl(var(--muted-foreground)) l c h);
--color-card: oklch(from hsl(var(--card)) l c h);
--color-card-foreground: oklch(from hsl(var(--card-foreground)) l c h);
--color-popover: oklch(from hsl(var(--popover)) l c h);
--color-popover-foreground: oklch(from hsl(var(--popover-foreground)) l c h);
--color-border: oklch(from hsl(var(--border)) l c h / var(--border-alpha, 1));
--color-input: oklch(from hsl(var(--input)) l c h / var(--input-alpha, 1));
--color-primary: oklch(from hsl(var(--primary)) l c h);
--color-primary-foreground: oklch(from hsl(var(--primary-foreground)) l c h);
--color-secondary: oklch(from hsl(var(--secondary)) l c h);
--color-secondary-foreground: oklch(from hsl(var(--secondary-foreground)) l c h);
--color-accent: oklch(from hsl(var(--accent)) l c h);
--color-accent-foreground: oklch(from hsl(var(--accent-foreground)) l c h);
--color-destructive: oklch(from hsl(var(--destructive)) l c h / var(--destructive-alpha, 1));
--color-destructive-foreground: oklch(from hsl(var(--destructive-foreground)) l c h);
--color-ring: oklch(from hsl(var(--ring)) l c h);
}
@layer base {
button,
[role="button"] {
cursor: pointer;
}
body {
@apply antialiased min-h-screen bg-background text-foreground box-border;
}
}''',
'src/index.js': '''import "vite/modulepreload-polyfill";
import "./style.css";
import "franken-ui/js/core.iife";
import "franken-ui/js/icon.iife";'''
}_basecoat_temp = {'vite.config.js': '''import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [tailwindcss()],
css: {
transformer: "lightningcss",
},
build: {
cssMinify: "lightningcss",
manifest: true,
rollupOptions: {
input: "src/index.js",
},
outDir: './dist',
emptyOutDir: true
},
});''',
'package.json': '''{
"name": "fastvite",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"lightningcss": "^1.30.2",
"vite": "^7.1.10"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.14",
"basecoat-css": "^0.3.2"
}
}''',
'src/style.css': '''@import "tailwindcss";
@import "basecoat-css";
@source "../../";
@layer base {
body {
@apply antialiased min-h-screen box-border;
}
}''',
'src/index.js': '''import "vite/modulepreload-polyfill";
import "./style.css";
import "basecoat-css/all";'''
}File init
def setup_files(root_dir, entry_file, use_monster=True):
"Setup Vite files in project, with FastHTML configuration"
n_files = 0
templ = _monster_temp if use_monster else _basecoat_temp
for path,ctx in templ.items():
path = Path(root_dir)/path
path.parent.mkdir(parents=True, exist_ok=True)
if not path.exists():
path.write_text(ctx)
print(f"Created: {path}")
n_files += 1
if n_files or not (Path(root_dir)/'bun.lock').exists():
subprocess.run("pybun install", cwd=root_dir, shell=True, check=True)setup_files(root_dir='vite', entry_file='index.js')Created: vite/vite.config.js
Created: vite/package.json
Created: vite/src/style.css
Created: vite/src/index.js
added 113 packages, and audited 114 packages in 7s
18 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
for path in _monster_temp.keys():
print('#'+path, (Path('vite')/path).read_text()[:100], sep='\n')
print('---')#vite.config.js
import franken from "franken-ui/plugin-vite";
import tailwindcss from "@tailwindcss/vite";
import {
---
#package.json
{
"name": "fastvite",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
---
#src/style.css
@import "tailwindcss";
@import "franken-ui/css/franken-ui.css";
@source "../../";
@theme {
--colo
---
#src/index.js
import "vite/modulepreload-polyfill";
import "./style.css";
---
Create script injections
def _mk_scripts(dirname, entry_file):
"Create necessary vite headers and inject into app"
manifest = loads(Path(dirname).joinpath('dist/.vite/manifest.json').read_text())
entry = f'src/{entry_file}'
def _imported_chunks(name, seen=None):
if seen is None: seen = set()
chunk = manifest[name]
for imp in chunk.get('imports', []):
if imp in seen: continue
seen.add(imp)
yield from _imported_chunks(imp, seen)
yield manifest[imp]
def _url(path): return f"/{dirname}/{path}"
entry_chunk = manifest[entry]
imported = list(_imported_chunks(entry))
hdrs = [Link(rel='stylesheet', href=_url(css))
for css in entry_chunk.get('css', [])]
hdrs += [Link(rel='stylesheet', href=_url(css))
for chunk in imported for css in chunk.get('css', [])]
hdrs.append(Script(type='module', src=_url(entry_chunk['file'])))
hdrs += [Link(rel='modulepreload', href=_url(chunk['file']))
for chunk in imported]
return hdrssubprocess.run('pybun run build', cwd='vite', shell=True, check=True)
> fastvite@0.0.0 build
> vite build
vite v7.0.4 building for production...
transforming...
✓ 3 modules transformed.
rendering chunks...
computing gzip size...
../dist/.vite/manifest.json 0.19 kB │ gzip: 0.14 kB
../dist/assets/index-DwAzhI66.css 215.53 kB │ gzip: 25.35 kB
../dist/assets/index-DSGW5Iaw.js 0.71 kB │ gzip: 0.40 kB
✓ built in 101ms
CompletedProcess(args='npm run build', returncode=0)
print(*(o for o in _mk_scripts(dirname="vite", entry_file='index.js')))<link rel="stylesheet" href="/vite/assets/index-DwAzhI66.css"> <script type="module" src="/vite/assets/index-DSGW5Iaw.js"></script>
App configuration
_monster_head = [
Script("""
const htmlElement = document.documentElement;
const __FRANKEN__ = JSON.parse(
localStorage.getItem("__FRANKEN__") || "{}",
);
if (
__FRANKEN__.mode === "dark" ||
(!__FRANKEN__.mode &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
) {
htmlElement.classList.add("dark");
} else {
htmlElement.classList.remove("dark");
}
htmlElement.classList.add(__FRANKEN__.theme || "uk-theme-zinc");
htmlElement.classList.add(__FRANKEN__.radii || "uk-radii-md");
htmlElement.classList.add(__FRANKEN__.shadows || "uk-shadows-md");
htmlElement.classList.add(__FRANKEN__.font || "uk-font-sm");""")
]The dist mount is served with fasthtml’s StaticImmutable. Vite’s output uses content-hashed filenames, so a changed file always gets a new URL, browsers can safely cache forever, and revalidating would be pure waste. (.vite/manifest.json is served under the same mount without a hashed name, but it’s only read from disk by _mk_scripts, never fetched by browsers.)
def add_vite(app:FastHTML, entry_file='index.js', dirname='frontend', use_monster=True, directory='.', run_build=True):
"Configure app to use vite"
pwd = Path(directory)/dirname
outdir = pwd/'dist'
if not outdir.exists(): outdir.mkdir(parents=True, exist_ok=True)
app.mount(f'/{dirname}', app=StaticImmutable(directory=pwd/'dist'), name=dirname)
@app.on_event('startup')
def _():
setup_files(root_dir=dirname, entry_file=entry_file, use_monster=use_monster)
if run_build: subprocess.run('pybun run build', cwd=dirname, shell=True, check=True)
def_hdrs = [*_mk_scripts(dirname=dirname, entry_file=entry_file)]
app.hdrs[0:0] = def_hdrsfrom fasthtml.common import *app = FastHTML()
app<fasthtml.core.FastHTML>
app = FastHTML(surreal=False)
rt = app.route#app.hdrsadd_vite(app, dirname='vite')
> fastvite@0.0.0 build
> vite build
vite v7.0.4 building for production...
transforming...
✓ 3 modules transformed.
rendering chunks...
computing gzip size...
../dist/.vite/manifest.json 0.19 kB │ gzip: 0.14 kB
../dist/assets/index-DwAzhI66.css 215.53 kB │ gzip: 25.35 kB
../dist/assets/index-DSGW5Iaw.js 0.71 kB │ gzip: 0.40 kB
✓ built in 91ms
app.hdrs[meta((),{'charset': 'utf-8'}),
meta((),{'name': 'viewport', 'content': 'width=device-width, initial-scale=1, viewport-fit=cover'}),
link((),{'rel': 'stylesheet', 'href': '/static/assets/index-DwAzhI66.css'}),
script(('',),{'type': 'module', 'src': '/static/assets/index-DSGW5Iaw.js'}),
link((),{'rel': 'preconnect', 'href': 'https://cdn.jsdelivr.net'}),
script(('\n const htmlElement = document.documentElement;\n\n const __FRANKEN__ = JSON.parse(\n localStorage.getItem("__FRANKEN__") || "{}",\n );\n\n if (\n __FRANKEN__.mode === "dark" ||\n (!__FRANKEN__.mode &&\n window.matchMedia("(prefers-color-scheme: dark)").matches)\n ) {\n htmlElement.classList.add("dark");\n } else {\n htmlElement.classList.remove("dark");\n }\n\n htmlElement.classList.add(__FRANKEN__.theme || "uk-theme-zinc");\n htmlElement.classList.add(__FRANKEN__.radii || "uk-radii-md");\n htmlElement.classList.add(__FRANKEN__.shadows || "uk-shadows-md");\n htmlElement.classList.add(__FRANKEN__.font || "uk-font-sm");',),{}),
script(('',),{'src': 'https://cdn.jsdelivr.net/npm/franken-ui@2.1.0-next.14/dist/js/core.iife.js', 'type': 'module'}),
script(('',),{'src': 'https://cdn.jsdelivr.net/npm/franken-ui@2.1.0-next.14/dist/js/icon.iife.js', 'type': 'module'}),
script((),{'src': 'https://cdn.jsdelivr.net/npm/htmx.org@2.0.4/dist/htmx.min.js'}),
script((),{'src': 'https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js'}),
script(("\n function sendmsg() {\n window.parent.postMessage({height: document.documentElement.offsetHeight}, '*');\n }\n window.onload = function() {\n sendmsg();\n document.body.addEventListener('htmx:afterSettle', sendmsg);\n document.body.addEventListener('htmx:wsAfterMessage', sendmsg);\n };",),{})]
Testing in a running app
server = JupyUvi(app)
Show = partial(HTMX, app=app)@rt
def index():
return Titled('FhVite Test App',
H1('This is a test!'),
Div(Button('Click Me!', cls="uk-btn-primary uk-btn-xs"))
)Show('/')server.stop()!rm -rf vite