# Praxis am Lienhardplatz — Static Rebuild on Zola

**Status:** Approved design, ready for implementation planning
**Date:** 2026-05-20
**Author:** Michael Schimmel (with Claude)
**Repo:** `http://192.168.178.103:3000/Brummel/praxis-page.git` (Gitea, LAN-only)

## Context

The website for the Hausärztliche Gemeinschaftspraxis am Lienhardplatz in Wuppertal-Vohwinkel (`www.praxis-am-lienhardplatz.de`) currently runs on Grav 1.4.3, a flat-file PHP CMS from 2018. The Grav installation has known unpatched CVEs, uses Bootstrap 3 (EOL 2019), and the previous developer's maintenance costs have become prohibitive.

The site is purely informational: ten content pages, regularly-updated news section, image carousel, embedded map. No forms (the contact form defined in Grav was never published). No user accounts, no comments, no search. Single editor (Michael Schimmel); the practice team may need to update certain things (news, schedules) later but does not today.

A read-only snapshot of the current Grav installation lives at `./orig/` (timestamps preserved). It is the source of truth for what must be reproduced.

## Goals

1. Replace the Grav-based site with a static site that produces the same visible result in Phase 1.
2. Eliminate the PHP/CMS attack surface and the maintenance cost of a Grav installation.
3. Establish a stack that can be operated reliably by an LLM, since the user does not intend to write code themselves.
4. Set up the project to evolve incrementally (modernization, live-status widget, team-editable admin UI) in later phases without rewriting from scratch.

## Non-Goals (Phase 1)

- Content rewrites or copy-edits.
- Visual modernization (typography, spacing, mobile-first redesign).
- Accessibility audit (WCAG) — done in Phase 2.
- New features: live "Arzt anwesend" status widget, admin UI, search, newsletter, multi-language, contact form.
- Production cutover. Phase 1 ends with a staging site behind a private URL; the live `praxis-am-lienhardplatz.de` is not touched.

## Stack Choice: Zola

Zola is a single-binary Rust static site generator with Markdown content, Tera templates (Jinja2-like), and built-in image processing, SCSS, and search.

The choice was driven by three criteria:

1. **LLM operability.** Tight conventions and a small surface area reduce the model's mistake budget. Tera syntax is close to Twig (which the current Grav theme uses) and to Jinja2 (well-represented in training data).
2. **Maintenance footprint.** A single 15 MB binary with no Node.js/npm dependency chain ages gracefully. The site builds reproducibly years later.
3. **Output guarantees.** Zola emits plain static HTML/CSS/JS, which is what the Variomedia shared webspace serves natively. No runtime dependency.

Known caveat: Zola has been pre-1.0 since 2017 and the most recent release (v0.22.1, January 2025) is 16 months old at design time, despite ongoing commit activity. The current version is stable for the project's use; the migration risk in a worst-case "Zola is abandoned" scenario is bounded — the output is plain HTML and can be regenerated by any other SSG in a focused weekend of work.

Alternatives considered: Eleventy (better team-handover story, broader plugin ecosystem; rejected because the user is sole operator and prefers single-binary stability), Hugo (similar single-binary philosophy with more releases; rejected because Go templates are harder for an LLM to write correctly than Tera), Astro (modern but Phase-1 overkill).

## Architecture

### Project Layout

```
praxis-page/                       # Gitea repo root
├── config.toml                    # Site config (replaces user/config/*.yaml)
├── content/                       # Markdown content (replaces user/pages/)
│   ├── _index.md                  # Home page (modular composition in [extra])
│   ├── news.md                    # Standalone news page, included by home template
│   ├── services/
│   │   ├── _index.md              # Services list page (section index)
│   │   ├── hv.md
│   │   ├── corona.md
│   │   ├── vorsorge.md
│   │   ├── diagnostik.md
│   │   ├── kardiologie.md
│   │   └── diabetologie.md
│   ├── team/
│   │   ├── _index.md
│   │   ├── doctors.md
│   │   ├── employees.md
│   │   └── jobs.md
│   ├── legal.md
│   └── contact.md
├── templates/                     # Tera templates (replaces user/themes/praxis/templates/)
│   ├── base.html                  # Layout skeleton (was partials/base.html.twig)
│   ├── index.html                 # Home page with inline modular sections
│   ├── section.html               # Section list page (services, team)
│   ├── page.html                  # Single page (legal, contact)
│   ├── partials/
│   │   ├── head.html
│   │   └── navbar.html
│   └── shortcodes/
│       ├── img.html               # Image with resize + lazy-load
│       └── map.html               # Google Maps iframe embed
├── static/                        # Verbatim assets (no processing)
│   ├── images/
│   │   ├── carousel/
│   │   ├── team/
│   │   ├── services/
│   │   └── ...
│   ├── fonts/                     # Montserrat, Roboto, Font Awesome
│   └── js/
│       ├── jquery.min.js
│       └── bootstrap.min.js
├── sass/
│   ├── main.scss                  # Imports the existing theme CSS verbatim
│   └── _praxis_theme.scss         # Lifted from orig/user/themes/praxis/css/
├── docs/
│   ├── specs/                     # This file lives here
│   └── plans/                     # Implementation plans (next step)
└── README.md
```

### Content Migration Rules

- **Frontmatter:** YAML → TOML. Standard Zola keys (`title`, `weight`, `template`) at top level; everything else under `[extra]`.
- **Numbering prefixes:** Grav's `01.`, `02.` directory prefixes drop from filesystem names; ordering moves to `weight = N` in frontmatter.
- **Modular Grav directories** (`_carousel/`, `_about/`, `_services/`, `_team/`, `_notfall/` under `01.home/`): translate to entries in `content/_index.md`'s `[extra]` block. The home template renders these inline. Exception: `_news/` content lives in `content/news.md` as a standalone page so it can be edited (and later admin-editable) independently.
- **Sub-pages of `02.services/` and `03.team/`:** each becomes a standalone Markdown file in the corresponding section directory. The section template iterates the section's pages and renders them as stacked anchored sections (`<section id="HV">`, etc.) on a single long page, matching the current Grav behavior.
- **Image paths:** rewrite from Grav's page-relative references (`/home/_services/HV.jpg`) to flat paths under `/images/` (`/images/services/HV.jpg`).
- **Internal anchor links** (e.g., `/services#HV`): preserved unchanged. The `<section id="HV">` rendering in `section.html` keeps them functional.
- **Inline HTML and Markdown tables:** carried over verbatim.

### Template Migration

The Grav theme is 11 Twig templates, 373 lines total. Migration is largely a syntactic substitution from Twig to Tera:

| Grav/Twig | Zola/Tera |
|---|---|
| `page.header.X` | `page.extra.X` |
| `page.children.visible` | `section.pages` (filtered for `draft = false`) |
| `page.media.images[name].html()` | `{{ img(src="...", width=...) }}` shortcode |
| `loop.index - 1` | `loop.index0` |
| `loop.index == 1` | `loop.first` |
| `{% include "X.html.twig" %}` | `{% include "X.html" %}` |
| `config.plugins.email.X` | removed (no email plugin) |

The complex piece is `partials/navbar.html` (73 lines), which renders a recursive dropdown menu. Its HTML output must be byte-identical to Grav's for Bootstrap 3's mobile burger menu to keep working. Verification approach: HTML diff of the navbar fragment between Grav-served and Zola-served pages.

Modular templates (carousel, about, employees, map, news, text) inline into `templates/index.html` rather than living as separately includable partials. They are only used on the home page and don't benefit from reuse.

### Image Handling

Total image weight in the source is 22 MB across 126 files, with carousel slides at 1-2 MB each (5000+ pixels wide) and team portraits at 4.7 MB. Grav was serving cached resized versions; the static rebuild must replicate this or worse-than-Grav latency results.

Approach:

- Source images in `static/images/<section>/`.
- A Tera shortcode `img` wraps Zola's built-in `resize_image()` function:

  ```tera
  {{ img(src="team/Christian.jpg", width=400, alt="...") }}
  ```

  emits a processed image at the requested width plus `<img width height loading="lazy" alt="...">`. Processed output goes under `processed_images/` with hashed filenames (Zola's default).

- Standard widths per context, hard-coded in the shortcode (no magic numbers in Markdown):
  - Carousel hero: 1920w
  - Team portrait: 400w
  - Service inline image: 600w
  - Default fallback: 800w
- JPEG quality 85. No WebP / AVIF / `srcset` in Phase 1.
- All `<img>` tags carry explicit `width` and `height` attributes to prevent layout shift.

Expected reduction: ~22 MB → ~3-5 MB across the site.

### Bootstrap-3 Preservation

Bootstrap 3.3.6 CSS and JS plus jQuery are lifted verbatim from `orig/user/themes/praxis/bootstrap-3.3.6/dist/` into `static/css/` and `static/js/`. The viewport meta tag, navbar mobile-toggle markup, carousel markup, and Bootstrap responsive utility classes (`hidden-xs`, `col-md-*`, etc.) are reproduced identically in the Tera templates. This preserves mobile responsiveness, carousel behavior, dropdown menus, and the burger menu without any reimplementation.

Modernizing away from Bootstrap 3 is a Phase-2 concern.

### Map Embed

The Grav `google-maps` plugin is replaced by a simple `<iframe>` Google Maps embed inside a `map` shortcode. No API key required, no plugin runtime, identical visual result.

## Build & Deploy

### Local Development

Prerequisites: Zola installed (`pacman -S zola` on Arch/CachyOS — already verified present).

Workflow:

```fish
git clone http://192.168.178.103:3000/Brummel/praxis-page.git
cd praxis-page
zola serve   # local preview at http://127.0.0.1:1111, auto-reload
zola build   # generates ./public/
```

### Staging Deploy

The new site deploys to `~/public/praxis/` on the Variomedia webspace (`mycelium`), which the user has configured as the document root for the subdomain `preview.praxis-am-lienhardplatz.de`. The directory was empty at project start.

Deploy command:

```fish
zola build
rsync -av --delete public/ mycelium:public/praxis/
```

The `--delete` flag removes orphaned files at the destination so the deployed directory exactly matches the local build output.

**Deploy is a gated step.** Claude must obtain explicit confirmation in the current session before executing the rsync, every time, even after prior approvals in the same session. This is to align with the user's invoicing workflow, which depends on production delivery timing being controlled.

### Privacy of the Staging URL

Three layers of protection, none of them strong on their own but adequate combined for sharing the URL selectively:

1. **`noindex` meta tag** on every page (rendered from `base.html`): `<meta name="robots" content="noindex,nofollow">`. Keeps Google and other search engines from listing the staging site.
2. **`robots.txt`** at the staging document root with `User-agent: *` / `Disallow: /`.
3. **Unguessable subdomain.** `preview.praxis-am-lienhardplatz.de` is harder to discover than a public-facing path. Anyone who has the URL can access it; anyone who doesn't, can't easily guess it.

HTTP Basic Auth via `.htaccess` is intentionally not added in Phase 1 because it complicates link sharing for the practice team review. It can be layered on later if needed.

### Production Cutover (Out of Scope for Phase 1)

When the user explicitly authorizes it (as a separate, billed step), the path forward is:

1. Move the current Grav installation from `~/grav/praxis/` to `~/grav/praxis_grav_archive_YYYY-MM-DD/`.
2. Copy or symlink the contents of `~/public/praxis/` (or rebuild directly) into `~/grav/praxis/`. (Apache vhost still points the live domain at the Grav directory, so the docroot path stays the same; only the contents change.)
3. Remove `noindex` meta and `Disallow: /` robots entry.

This is not part of Phase 1 and not designed in detail here.

## Definition of Done (Phase 1)

1. All ten pages reachable under `preview.praxis-am-lienhardplatz.de/[…]`.
2. Visual parity verified at three viewport widths (375, 768, 1280 px) by side-by-side comparison against the live Grav site. No obvious deviations.
3. Interactive elements working: carousel autoplay and click navigation, mobile burger toggle, dropdown menus, Google Maps embed loads.
4. Lighthouse Performance score ≥ Grav baseline (expected to be substantially higher).
5. Total served image weight < 5 MB.
6. `noindex` verified via `curl -I` and `robots.txt` blocks crawlers.
7. Live site `praxis-am-lienhardplatz.de` unchanged from project start.
8. User sign-off.

## Risks & Mitigations

| Risk | Mitigation |
|---|---|
| Subdomain DNS not propagated or vhost misconfigured | First deploy is a single `hello.html` to confirm routing before content migration |
| Navbar HTML drift breaks Bootstrap 3 mobile burger | HTML diff of the navbar fragment between Grav and Zola output is a required check |
| Image resize regression (banding, blurring) | Spot-check 2-3 representative images side-by-side in browser at target widths |
| Tera vs. Twig edge cases (whitespace, filter behavior) | Side-by-side visual verification and HTML diffs catch these |
| "Looks 95% right but something's off" | HTML diff per page surfaces structural deltas quickly |
| Accidentally touching the live site | Deploy commands target `~/public/praxis/` only; production path requires explicit per-step confirmation |

## Out of Scope / Phase 2+

The following are explicitly deferred. None of them block Phase 1 acceptance.

- **Modernization:** typography, spacing, mobile-first redesign, accessibility (WCAG audit), drop Bootstrap 3 dependency.
- **Live "Arzt anwesend" widget:** a `status.json` updated by a separate process plus client-side `fetch` in the page. Stack-independent.
- **Admin UI for team-editable content:** likely Decap CMS or a custom lightweight editor for the news file and a handful of other fields.
- **Image format upgrades:** WebP, AVIF, responsive `srcset`.
- **Auto-deploy:** Gitea webhook triggers a build/deploy pipeline.
- **Search functionality.**
- **Newsletter / mailing list.**
- **Contact form** (was never published in Grav either).
- **Multi-language support.**
- **Content rewrites or copy improvements.**

Each of these gets its own spec when prioritized.

## Open Questions

None blocking. The following are tactical decisions that can be made during implementation without changing the design:

- Exact directory layout under `static/images/` (mirror Grav's `_carousel/`, `_team/` subdirs, or reorganize by semantic category — implementation detail).
- Whether to pull `content/news.md` into the home template via `load_data(format="markdown")` or via `get_page("/news/")` and rendering `page.content` (both work; pick the simpler one when writing the template).
- Exact `weight` numbering scheme (start at 10, 20, 30… or 1, 2, 3…).
