diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ad863a..46f733f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### API & Frontend Options - API frameworks: Django REST Framework, Strawberry GraphQL, both, or none - Frontend: HTMX + Tailwind CSS, Next.js, or headless +- Frontend bundling for HTMX+Tailwind: Vite (production-ready) or CDN (simple) - Authentication: django-allauth, JWT, or both #### Background Task Processing diff --git a/copier.yml b/copier.yml index 9198a3a..de8d9f6 100644 --- a/copier.yml +++ b/copier.yml @@ -118,6 +118,15 @@ frontend: {%- elif project_type == 'internal-tool' -%}htmx-tailwind {%- else -%}none{%- endif -%} +frontend_bundling: + type: str + help: Frontend asset bundling + when: "{{ frontend == 'htmx-tailwind' }}" + choices: + Vite (Production-ready, bundled assets): vite + CDN (Simple, no build step): cdn + default: "vite" + # Async & Tasks background_tasks: type: str diff --git a/docs/features/frontend-options.md b/docs/features/frontend-options.md index aca9e83..3739b13 100644 --- a/docs/features/frontend-options.md +++ b/docs/features/frontend-options.md @@ -16,6 +16,65 @@ Modern, minimal-JavaScript approach with server-rendered HTML. - **Tailwind CSS** for styling - **Alpine.js** for lightweight JavaScript +#### Asset Bundling Options + +When you select HTMX + Tailwind, you can choose how assets are delivered: + +| Option | Best For | Build Step | External Dependencies | +|--------|----------|------------|----------------------| +| **Vite** (default) | Production apps | Yes | None | +| **CDN** | Prototypes, MVPs | No | Yes (3 CDNs) | + +**Vite (Recommended for Production)** + +- Bundles Tailwind CSS, HTMX, and Alpine.js locally +- No external CDN dependencies in production +- Proper CSS purging for smaller bundles +- Hot Module Replacement (HMR) in development +- Uses `django-vite` for seamless Django integration + +**Development with Docker (Recommended):** +```bash +docker-compose up # Starts Django + Vite dev server together +``` + +**Development without Docker:** +```bash +# Terminal 1: Start Vite dev server +cd frontend +npm install +npm run dev + +# Terminal 2: Start Django +python manage.py runserver +``` + +**Building for Production:** +```bash +# Build optimized assets locally +cd frontend +npm run build # Outputs to static/dist/ + +# Or let Docker handle it +docker build -t myapp . # Multi-stage build includes npm run build +``` + +**Troubleshooting:** + +| Issue | Solution | +|-------|----------| +| Vite HMR not working in Docker | Ensure `VITE_DEV_SERVER_HOST=vite` is set in the Django web service environment | +| Styles not updating | Check Tailwind is scanning `../templates/**/*.html` | +| Assets 404 in production | Run `python manage.py collectstatic` after building | +| `django_vite` template errors | Ensure `DEBUG=False` uses built manifest, not dev server | + +**CDN (Simple, No Build Step)** + +- Assets loaded from external CDNs (tailwindcss, unpkg, jsdelivr) +- Zero configuration needed +- Good for quick prototypes +- Not recommended for production (external dependencies) + ### Next.js Full-featured React framework for building modern web applications. diff --git a/template/.gitignore.jinja b/template/.gitignore.jinja index 606eb46..4f75595 100644 --- a/template/.gitignore.jinja +++ b/template/.gitignore.jinja @@ -31,6 +31,9 @@ db.sqlite3-journal /media /staticfiles /static_root +{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' -%} +/static/dist +{% endif -%} # Environment .env @@ -42,6 +45,11 @@ db.sqlite3-journal .venv/ poetry.lock {% endif -%} +{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' -%} + +# Node +node_modules/ +{% endif -%} # IDEs .vscode/ diff --git a/template/Dockerfile.jinja b/template/Dockerfile.jinja index 5c8c670..984f32f 100644 --- a/template/Dockerfile.jinja +++ b/template/Dockerfile.jinja @@ -1,4 +1,23 @@ # syntax=docker/dockerfile:1 +{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' -%} +# Build frontend assets +FROM node:20-slim AS frontend-builder + +WORKDIR /app/frontend + +# Copy frontend files +COPY frontend/package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy rest of frontend +COPY frontend/ ./ + +# Build assets (outputs to ../static/dist per vite.config.js) +RUN npm run build + +{% endif -%} FROM python:{{ python_version }}-slim as base # Set environment variables @@ -42,6 +61,11 @@ RUN uv sync --no-dev # Copy rest of application COPY . . +{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' -%} +# Copy built frontend assets +COPY --from=frontend-builder /app/static/dist ./static/dist +{% endif -%} + {% else -%} # Install poetry RUN pip install poetry==1.7.0 @@ -61,6 +85,11 @@ RUN poetry config virtualenvs.create false \ # Copy rest of application COPY . . + +{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' -%} +# Copy built frontend assets +COPY --from=frontend-builder /app/static/dist ./static/dist +{% endif -%} {% endif -%} # Change ownership diff --git a/template/config/settings/base.py.jinja b/template/config/settings/base.py.jinja index 8bbbd10..d16bde6 100644 --- a/template/config/settings/base.py.jinja +++ b/template/config/settings/base.py.jinja @@ -44,6 +44,9 @@ DJANGO_APPS = [ ] THIRD_PARTY_APPS = [ +{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' -%} + "django_vite", +{% endif -%} {% if api_style in ['drf', 'both'] -%} "rest_framework", "drf_spectacular", @@ -242,6 +245,19 @@ STORAGES = { MEDIA_URL = "/media/" MEDIA_ROOT = BASE_DIR / "media" +{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' -%} +# Django Vite +DJANGO_VITE = { + "default": { + "dev_mode": DEBUG, + "manifest_path": BASE_DIR / "static" / "dist" / "manifest.json", + "static_url_prefix": "dist/", + "dev_server_host": env("VITE_DEV_SERVER_HOST", default="localhost"), + "dev_server_port": 5173, + } +} +{% endif -%} + {% if media_storage == 'aws-s3' -%} # AWS S3 Storage AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID", default="") diff --git a/template/docker-compose.yml.jinja b/template/docker-compose.yml.jinja index 427cda6..58e1c49 100644 --- a/template/docker-compose.yml.jinja +++ b/template/docker-compose.yml.jinja @@ -26,6 +26,9 @@ services: - EMAIL_PORT=1025 {% if dependency_manager == 'uv' %} - UV_NO_CACHE=1 +{% endif %} +{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %} + - VITE_DEV_SERVER_HOST=vite {% endif %} env_file: - .env @@ -65,6 +68,18 @@ services: ports: - "1025:1025" - "8025:8025" +{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %} + + vite: + image: node:20-slim + working_dir: /app/frontend + volumes: + - ./frontend:/app/frontend + - ./static:/app/static + ports: + - "5173:5173" + command: sh -c "npm ci && npm run dev" +{% endif %} {% if background_tasks in ['temporal', 'both'] %} temporal: diff --git a/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}package.json{% endif %}.jinja b/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}package.json{% endif %}.jinja new file mode 100644 index 0000000..b830f10 --- /dev/null +++ b/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}package.json{% endif %}.jinja @@ -0,0 +1,21 @@ +{ + "name": "{{ project_slug }}-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "htmx.org": "^1.9.10", + "alpinejs": "^3.13.3" + }, + "devDependencies": { + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "vite": "^5.0.10" + } +} diff --git a/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}postcss.config.js{% endif %}.jinja b/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}postcss.config.js{% endif %}.jinja new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}postcss.config.js{% endif %}.jinja @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}src{% endif %}/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}main.js{% endif %}.jinja b/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}src{% endif %}/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}main.js{% endif %}.jinja new file mode 100644 index 0000000..d0392e4 --- /dev/null +++ b/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}src{% endif %}/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}main.js{% endif %}.jinja @@ -0,0 +1,11 @@ +// Import styles +import "./styles.css"; + +// Import HTMX +import htmx from "htmx.org"; +window.htmx = htmx; + +// Import Alpine.js +import Alpine from "alpinejs"; +window.Alpine = Alpine; +Alpine.start(); diff --git a/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}src{% endif %}/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}styles.css{% endif %}.jinja b/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}src{% endif %}/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}styles.css{% endif %}.jinja new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}src{% endif %}/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}styles.css{% endif %}.jinja @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}tailwind.config.js{% endif %}.jinja b/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}tailwind.config.js{% endif %}.jinja new file mode 100644 index 0000000..bf4b3eb --- /dev/null +++ b/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}tailwind.config.js{% endif %}.jinja @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "../templates/**/*.html", + "./src/**/*.js", + ], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}vite.config.js{% endif %}.jinja b/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}vite.config.js{% endif %}.jinja new file mode 100644 index 0000000..ee42010 --- /dev/null +++ b/template/frontend/{% if frontend == 'htmx-tailwind' and frontend_bundling == 'vite' %}vite.config.js{% endif %}.jinja @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; +import { resolve } from "path"; + +export default defineConfig({ + base: "/static/", + build: { + manifest: "manifest.json", + outDir: resolve(__dirname, "../static/dist"), + rollupOptions: { + input: { + main: resolve(__dirname, "src/main.js"), + }, + }, + }, + server: { + host: "0.0.0.0", + port: 5173, + origin: "http://localhost:5173", + }, +}); diff --git a/template/templates/{% if frontend == 'htmx-tailwind' %}base.html{% endif %}.jinja b/template/templates/{% if frontend == 'htmx-tailwind' %}base.html{% endif %}.jinja index 1a033b3..a65761f 100644 --- a/template/templates/{% if frontend == 'htmx-tailwind' %}base.html{% endif %}.jinja +++ b/template/templates/{% if frontend == 'htmx-tailwind' %}base.html{% endif %}.jinja @@ -1,10 +1,15 @@ - +{% if frontend_bundling == 'vite' %}{% raw %}{% load django_vite %}{% endraw %} +{% endif %} {% raw %}{% block title %}{% endraw %}{{ project_name }}{% raw %}{% endblock %}{% endraw %} +{% if frontend_bundling == 'vite' %} + {% raw %}{% vite_hmr_client %}{% endraw %} + {% raw %}{% vite_asset 'src/main.js' %}{% endraw %} +{% else %} @@ -13,6 +18,7 @@ +{% endif %} {% raw %}{% block extra_head %}{% endblock %}{% endraw %} diff --git a/template/{% if dependency_manager == 'poetry' %}pyproject.toml{% endif %}.jinja b/template/{% if dependency_manager == 'poetry' %}pyproject.toml{% endif %}.jinja index 6825453..ba56b54 100644 --- a/template/{% if dependency_manager == 'poetry' %}pyproject.toml{% endif %}.jinja +++ b/template/{% if dependency_manager == 'poetry' %}pyproject.toml{% endif %}.jinja @@ -91,6 +91,9 @@ django-opensearch-dsl = "^0.6.0" {% if use_i18n -%} django-parler = "^2.3.0" {% endif -%} +{% if frontend_bundling == 'vite' -%} +django-vite = "^3.0.0" +{% endif -%} django-extensions = "^3.2.0" python-dotenv = "^1.0.0" django-alive = "^1.3.0" diff --git a/template/{% if dependency_manager == 'uv' %}pyproject.toml{% endif %}.jinja b/template/{% if dependency_manager == 'uv' %}pyproject.toml{% endif %}.jinja index 8fa2b7a..f6b4c4e 100644 --- a/template/{% if dependency_manager == 'uv' %}pyproject.toml{% endif %}.jinja +++ b/template/{% if dependency_manager == 'uv' %}pyproject.toml{% endif %}.jinja @@ -91,6 +91,9 @@ dependencies = [ {% endif -%} {% if use_i18n -%} "django-parler>=2.3.0", +{% endif -%} +{% if frontend_bundling == 'vite' -%} + "django-vite>=3.0.0", {% endif -%} "django-extensions>=3.2.0", "python-dotenv>=1.0.0", diff --git a/tests/test_features.py b/tests/test_features.py index 09a2672..69f9cd1 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -388,6 +388,7 @@ def test_all_features_enabled_generates_successfully(generate): dependency_manager="uv", api_style="both", frontend="htmx-tailwind", + frontend_bundling="vite", # Explicitly test Vite (the default) background_tasks="celery", use_channels=True, auth_backend="both", diff --git a/tests/test_generation.py b/tests/test_generation.py index 2a19132..2850e13 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -204,16 +204,48 @@ def test_no_api_excludes_frameworks(generate): def test_htmx_frontend_templates_generated(generate): - """Test that HTMX frontend generates templates.""" + """Test that HTMX frontend generates templates with Vite (default).""" project = generate(frontend="htmx-tailwind") templates_dir = project / "templates" assert (templates_dir / "base.html").exists() + base_html = (templates_dir / "base.html").read_text() + assert "{% block" in base_html + # Vite is the default, so check for vite tags + assert "django_vite" in base_html + assert "vite_asset" in base_html + + # Vite frontend files should exist + frontend_dir = project / "frontend" + assert (frontend_dir / "package.json").exists() + assert (frontend_dir / "vite.config.js").exists() + assert (frontend_dir / "tailwind.config.js").exists() + + # Check Django settings include django_vite + settings = project / "config" / "settings" / "base.py" + settings_content = settings.read_text() + assert "django_vite" in settings_content + assert "DJANGO_VITE" in settings_content + + +def test_htmx_frontend_cdn_mode(generate): + """Test that HTMX frontend with CDN mode uses CDN links.""" + project = generate(frontend="htmx-tailwind", frontend_bundling="cdn") + + templates_dir = project / "templates" base_html = (templates_dir / "base.html").read_text() assert "{% block" in base_html assert "tailwindcss" in base_html assert "htmx" in base_html + # Should not have vite tags + assert "django_vite" not in base_html + + # CDN mode should NOT create frontend build files + frontend_dir = project / "frontend" + assert not (frontend_dir / "package.json").exists() + assert not (frontend_dir / "vite.config.js").exists() + assert not (frontend_dir / "tailwind.config.js").exists() def test_nextjs_frontend_generated(generate):