FhVite

Vite plugin for FastHTML - automatic bundling, minifcation and script injection of static assets

Imports

import subprocess, shutil
from fasthtml.common import *
from fastcore.utils import *

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 hdrs
subprocess.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_hdrs
from fasthtml.common import *
app = FastHTML()
app
<fasthtml.core.FastHTML>
app = FastHTML(surreal=False)
rt = app.route
#app.hdrs
add_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