# 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 (`
`, 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 `
` 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/
/`. - 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 `...`. 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 `` 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 `