---
name: Publii CMS – Schnellstart-Memory für Claude Code
description: Vollständiges Wissen über Publii-Dateistruktur, SQLite-Schema, Config-Formate, Bildverwaltung und häufige Fehler – für alle Claude Code-Nutzer mit Publii
type: project
---

# Publii CMS – Wissen für Claude Code

Quelle: https://www.logies.de/claude-code-memory-fuer-publii/
Erstellt: 2026-03-13

## Publii-Datenpfade je Betriebssystem

- **Linux:** `~/Documents/Publii/` oder `~/Dokumente/Publii/`
- **macOS:** `~/Library/Application Support/Publii/`
- **Windows:** `%APPDATA%\Publii\`

Starte Claude Code **im `sites/`-Unterverzeichnis**, z.B.:
`cd ~/Documents/Publii/sites && claude`

## Site-Verzeichnisstruktur (Publii v0.40+)

```
sites/SITE-NAME/
├── input/
│   ├── db.sqlite              ← SQLite-Datenbank (alle Inhalte)
│   ├── config/
│   │   ├── site.config.json   ← UUID, Name, Theme, RSS-Einstellungen
│   │   ├── menu.config.json   ← Menü-Definitionen (JSON-Array)
│   │   └── theme.config.json  ← Theme-Einstellungen (Logo, Farben, Fonts)
│   ├── media/
│   │   ├── posts/{id}/        ← Bilder je Post (id = numerische DB-ID)
│   │   │   └── responsive/    ← Responsive-Varianten (6 Breakpoints, PFLICHT!)
│   │   ├── website/           ← Logo, Hintergrundbild
│   │   └── files/             ← Downloadbare Dateien (z.B. PDFs)
│   ├── root-files/            ← Dateien im Web-Root (robots.txt, Verification-HTML)
│   └── themes/THEME-NAME/     ← Lokale Theme-Kopie (nur wenn Anpassungen nötig)
├── output/                    ← Generierte Website – NIE manuell bearbeiten!
└── preview/                   ← Preview-Kopie – spiegelt input/media/ (PFLICHT!)
```

Publii erkennt Sites automatisch: Jedes Verzeichnis mit `input/config/site.config.json`
wird beim nächsten Publii-Start als Site erkannt – kein manuelles Hinzufügen nötig.

## SQLite-Schema

Publii nutzt NUR diese Tabellen (keine `menus`- oder `media`-Tabellen!):

```sql
posts:
  id INTEGER PRIMARY KEY,
  title TEXT,
  authors TEXT,           -- enthält author_id als String (z.B. "1")
  slug TEXT,              -- URL-Pfad ohne Slashes
  text TEXT,              -- HTML-Inhalt
  featured_image_id INTEGER,
  created_at,             -- MUSS Millisekunden-Integer sein (s.u.!)
  modified_at,            -- MUSS Millisekunden-Integer sein (s.u.!)
  status TEXT,            -- s. Status-Tabelle unten
  template TEXT           -- leer für Standard-Template

posts_additional_data:
  id INTEGER PRIMARY KEY,
  post_id INTEGER,
  key TEXT,               -- '_core' oder 'postViewSettings'
  value TEXT              -- JSON-String

posts_images:
  id INTEGER PRIMARY KEY,
  post_id INTEGER,
  url TEXT, title TEXT, caption TEXT, additional_data TEXT

tags: id, name, slug, description, additional_data
posts_tags: tag_id, post_id  (beide zusammen PRIMARY KEY)
authors: id, name, username, password, config, additional_data
```

### Status-Werte für `posts.status`

| Wert | Bedeutung |
|------|-----------|
| `published` | Blog-Post, öffentlich sichtbar |
| `draft` | Blog-Post-Entwurf |
| `published is-page` | Statische Seite, öffentlich sichtbar |
| `published,is-page` | Statische Seite (älteres Format, beide funktionieren) |
| `draft is-page` | Statische Seiten-Entwurf |

**KRITISCH: `published is-page` erscheint NICHT in Tag-Listings!**
Nur `status='published'` (Blog-Post) erscheint in Tag-Listings, Autoren-Seiten und im Feed.
Neue Seiten per `INSERT` mit `'published is-page'` (Leerzeichen) anlegen; Komma-Variante
taucht in migrierten Sites auf und wird von Publii ebenfalls akzeptiert.

## KRITISCH: Datums-Format für Blog-Posts

`created_at` und `modified_at` **müssen als Millisekunden-Integer** gespeichert werden!

```python
from datetime import datetime, timezone
ts_ms = int(datetime(2026, 3, 13, 10, 0, 0, tzinfo=timezone.utc).timestamp() * 1000)
# Ergibt z.B.: 1773396000000
```

**Warum:** Publii's `contexts/post.js` führt `parseInt(createdAt, 10)` aus.
Bei einem ISO8601-String wie `'2026-03-13T10:00:00.000Z'` liefert `parseInt()` nur `2026`
(= 2 Sekunden nach Epoch) → Datum erscheint als „1. Januar 1970".

## Bildverwaltung

### Bildpfade im HTML-Text (posts.text)

```html
<!-- Klickbares Bild – Klick öffnet Originaldatei: -->
<p>
  <a href="#DOMAIN_NAME#bild-orig.jpg">
    <img class="post-img" title="Beschreibung" src="#DOMAIN_NAME#bild.jpg"
         alt="Alt-Text" loading="lazy">
  </a>
</p>

<!-- Klickbares Vorschaubild – Klick öffnet PDF oder GIF: -->
<p>
  <a href="#DOMAIN_NAME#dokument.pdf">
    <img src="#DOMAIN_NAME#vorschau.jpg" style="max-width:450px; width:100%;">
  </a>
</p>
```

`#DOMAIN_NAME#` wird von Publii aufgelöst:
- **Preview:** `file:///…/sites/SITE/preview/media/posts/{id}/`
- **Produktion:** `https://domain.de/media/posts/{id}/`

**KRITISCH – nur Dateiname angeben:** `#DOMAIN_NAME#datei.jpg` (korrekt).
Nie einen Pfad voranstellen: `#DOMAIN_NAME#media/posts/33/datei.jpg` → doppelter Pfad!

### KRITISCH: Alle verlinkten Dateien in media/posts/{id}/ ablegen

Downloadbare Dateien (PDF, GIF, ZIP …), die im Post-Text via `#DOMAIN_NAME#` verlinkt
werden, **müssen** in `input/media/posts/{id}/` liegen – **nicht** in `media/files/`!

Dateien in `media/files/` erhalten von Publii das Attribut `data-is-external-image="true"`
→ werden im Browser nicht angezeigt, Links führen in `file://`-Preview ins Leere.

**Faustregel:**
- `media/files/` → nur für Dateien, die **direkt als `/media/files/datei`-URL** verlinkt werden (HTML-`<a href="/media/files/…">`), ohne `#DOMAIN_NAME#`
- `media/posts/{id}/` → alles, was mit `#DOMAIN_NAME#` referenziert wird

### Preview-HTML ist nicht persistent

Publii überschreibt `preview/*.html` bei jedem Sync/Preview vollständig aus dem DB-Text.
Manuelle Änderungen an preview-HTML-Dateien gehen beim nächsten Rendering verloren.
**Nur Änderungen an `posts.text` in der SQLite-DB sind dauerhaft.**

Wenn preview-HTML nach dem Rendering per Script nachbearbeitet werden muss (z.B. für
Pfad-Korrekturen im file://-Kontext), immer Regex statt Literal-Suche verwenden – Publii
fügt automatisch `loading="lazy"`, `srcset`, `data-is-external-image` u.a. hinzu:
```python
import re
html = open('preview/post.html').read()
match = re.search(r'<img[^>]*dateiname\.jpg[^>]*>', html)
```

### Responsive-Varianten (PFLICHT für korrekte Browser-Darstellung)

Für jedes Bild `{name}.jpg` in `input/media/posts/{id}/`:

```
input/media/posts/{id}/responsive/
    {name}-xs.jpg    ← max. 640 px (Breite oder Höhe bei Hochformat)
    {name}-sm.jpg    ← max. 768 px
    {name}-md.jpg    ← max. 1024 px
    {name}-lg.jpg    ← max. 1366 px
    {name}-xl.jpg    ← max. 1600 px
    {name}-2xl.jpg   ← max. 1920 px
```

**Skalierung:** Mit LANCZOS aus dem Original skalieren. Ist das Original kleiner als der
Breakpoint, Original-Größe verwenden. Hochformat-Bilder: Höhe als Schranke nutzen.

**Dateinamensregel:** Der responsive Name leitet sich vom `src`-Dateinamen ab (dem
Thumbnail), nicht vom Original. Beispiel: `src="#DOMAIN_NAME#foto.jpg"` →
Responsive-Dateien: `foto-xs.jpg`, `foto-sm.jpg` usw.

**Originals auf max. 1200 px skalieren:** Damit beim Klick auf einem Mobilgerät kein
4080-px-Foto geladen wird:
```python
from PIL import Image
img = Image.open("foto-orig.jpg")
img.thumbnail((1200, 1200), Image.LANCZOS)
img.save("foto-orig.jpg", quality=85)
```

### preview/-Verzeichnis synchron halten

`preview/media/posts/{id}/` muss identisch mit `input/media/posts/{id}/` sein:
```python
import shutil
shutil.copytree(f"input/media/posts/{post_id}", f"preview/media/posts/{post_id}",
                dirs_exist_ok=True)
```

## Config-Dateien

### site.config.json
```json
{
  "uuid": "uuid-{timestamp_ms}-{random}",
  "name": "site-verzeichnisname",
  "description": "Kurzbeschreibung",
  "displayName": "Anzeigename der Site",
  "logo": {"icon": "web-medicine", "color": 3},
  "theme": "simple",
  "feed": {
    "enableRss": 1,
    "updatedDateType": "createdAt"
  }
}
```

### menu.config.json
```json
[
  {
    "name": "Hauptmenü",
    "position": "mainMenu",
    "items": [
      {
        "id": 1001,
        "label": "Startseite",
        "title": "",
        "type": "page",
        "link": 1,
        "target": "_self",
        "cssClass": "",
        "rel": "",
        "isHidden": false,
        "items": []
      },
      {
        "id": 1002,
        "label": "Externe Seite",
        "title": "",
        "type": "external",
        "link": "https://beispiel.de/",
        "target": "_blank",
        "cssClass": "",
        "rel": "",
        "isHidden": false,
        "items": []
      }
    ]
  }
]
```

**Häufige Fehler im Menü:**
- `position` fehlt → `TypeError: Cannot read properties of undefined (reading 'split')`
- Slug statt numerischer DB-ID als `link` bei `type: "page"` → Eintrag gilt als ungültig
- Relative URL bei `type: "url"` → Publii rendert den Link nicht
- Absolute URL bei `type: "url"` → funktioniert **nicht** in der Preview, nur nach Sync

**Richtige Verlinkungstypen für Preview + Produktion:**
- `type: "page"`, `link: <DB-ID>` → statische Seite (Preview + Produktion ✓)
- `type: "tag"`, `link: <tag-id>` → Tag-Seite (Preview + Produktion ✓)
- `type: "author"`, `link: "<slug>"` → Autor-Listing-Seite (Preview + Produktion ✓)
- `type: "external"`, `link: "https://..."` → externer Link ✓
- `type: "internal"`, `link: "/pfad/"` → interner Link ✓
- `type: "url"` → **UNGÜLTIG** (GetPublii/Publii#2540), führt zu TypeError im Rendering

**"Alle Blog-Posts"-Seite:** Publii erzeugt keine globale Blog-Liste wenn `usePageAsFrontpage: true`.
Lösung: Alle Blog-Posts mit einem gemeinsamen Tag versehen und `type: "tag"` im Menü nutzen.
Die Tag-Seite `/tags/{slug}/` listet alle Posts mit diesem Tag – funktioniert in Preview und Produktion.

### theme.config.json
```json
{
  "config": {
    "logo": "media/website/logo.jpg"
  },
  "customConfig": {
    "primaryColor": "#1B6CA8",
    "fontBody": "lora"
  }
}
```

## Menüpositionen je Theme

Jedes Theme definiert eigene Menüpositionen. Im Theme `simple`:
- `mainMenu` → Hauptnavigation
- `footerMenu` → Footer-Links

Die verfügbaren Positionen stehen in der Theme-Konfigurationsdatei
(`themes/THEME/config.json` → `menus`-Array).

## Neue Site anlegen – Schnellstart

1. Publii öffnen → „Add new site" → Name + Theme wählen → Schließen
2. Verzeichnis `sites/SITE-NAME/input/` ist angelegt
3. Claude Code im `sites/`-Verzeichnis starten: `cd ~/Documents/Publii/sites && claude`
4. In Claude Code: `Lies die Struktur von SITE-NAME und lege Inhalte an`

## Häufige Fehler und ihre Lösung

| Symptom | Ursache | Lösung |
|---------|---------|--------|
| Datum = 1. Jan. 1970 | ISO8601-String statt ms-Integer | `int(datetime(..., tzinfo=timezone.utc).timestamp() * 1000)` |
| Bilder fehlen in Preview | `preview/media/` nicht befüllt | `shutil.copytree` von `input/media/` nach `preview/media/` |
| Bilder matschig/winzig | Responsive aus Thumbnail skaliert | Aus Original skalieren |
| Menü-TypeError | `position` fehlt | `"position": "mainMenu"` ergänzen |
| Menüeintrag grau/ungültig | Slug statt DB-ID | Numerische ID aus Datenbank verwenden |
| Blog-Post unsichtbar | Status falsch | Genau `'published'` (ohne Leerzeichen) |
| RSS-Datum = Epoch | ms-Integer falsch | `modified_at` auch als ms-Integer |
| **Menü + Posts kaputt (Rendering-Crash)** | Neuer Autor mit leerem `config`/`additional_data` | Vollständige JSON-Struktur eintragen (s. Autoren-Abschnitt unten) |
| Post fehlt in Tag-Listing/Feed | Status `published is-page` statt `published` | `UPDATE posts SET status='published'` |
| Datei unsichtbar / Link ins Leere (Preview) | Datei liegt in `media/files/` statt `media/posts/{id}/` | Datei nach `media/posts/{id}/` + `preview/media/posts/{id}/` kopieren; `#DOMAIN_NAME#dateiname` im DB-Text |
| Bild/Datei mit doppeltem Pfad (`…/media/posts/33/media/posts/33/…`) | `#DOMAIN_NAME#media/posts/33/datei` im DB-Text | Nur Dateinamen: `#DOMAIN_NAME#datei.ext` (ohne Pfad!) |
| Preview-HTML-Patch geht beim nächsten Sync verloren | preview-HTML direkt bearbeitet statt DB-Text | Änderung in `posts.text` (SQLite) vornehmen, nicht in preview/*.html |
| Feed-Vorschau zu breit (Mobile) | Langer URL-Text als Link-Inhalt | CSS: `.feed__item p { overflow-wrap: break-word; word-break: break-word; }` |
| postViewSettings zeigt nichts an | Publii-UI setzt kein `"value": "enabled"` | Alle gewuenschten Felder manuell mit `"value":"enabled"` setzen |
| Post-ID-Konflikt beim INSERT | Publii vergibt IDs beim Preview/Sync selbst | Vorher `SELECT MAX(id) FROM posts` pruefen, nie annehmen MAX+1 ist frei |
| Aenderungsdatum veraltet nach Datei-Update | `modified_at` des verlinkenden Posts nicht aktualisiert | Immer `UPDATE posts SET modified_at=<now_ms>` fuer alle Posts, die auf die geaenderte Datei verlinken; Checksummen im Post-Text ebenfalls erneuern |

## posts_additional_data – Pflichtfelder

Für jeden Post (Seite oder Blog-Post) müssen diese Einträge vorhanden sein:

```python
# key='_core' (PFLICHT)
core_value = json.dumps({
    "metaTitle": "",
    "metaDesc": "",
    "metaRobots": "index, follow",
    "canonicalUrl": "",
    "editor": "tinymce",
    "mainTag": ""
})

# key='postViewSettings' (für Blog-Posts empfohlen)
view_value = json.dumps({
    "displayDate": {"type": "select", "value": "enabled"},
    "displayAuthor": {"type": "select"},
    "displayLastUpdatedDate": {"type": "select", "value": "enabled"},
    "displayTags": {"type": "select", "value": "enabled"},
    "displayShareButtons": {"type": "select"},
    "displayAuthorBio": {"type": "select"},
    "displayPostNavigation": {"type": "select"},
    "displayRelatedPosts": {"type": "select"},
    "displayComments": {"type": "select"}
})
```

**SEO-Felder im `_core`-Objekt:**
- `metaTitle`: Wenn befüllt, ersetzt dieser Wert den gesamten `<title>`-Inhalt der Seite
  (ignoriert die `postMetaTitle`-Vorlage aus `site.config.json`). Nützlich, wenn der
  Post-Titel zu lang fuer den `<title>`-Tag ist (Richtwert: max. 60 Zeichen).
- `metaDesc`: Wird als `<meta name="description">` ausgegeben. Leer lassen = Publii
  nutzt den Seiten-Textanfang als Fallback (kann zu identischen Descriptions fuehren!).
  Empfehlung: immer individuell befuellen (max. 160 Zeichen).

**ROUTINE – Metadaten bei jeder neuen Seite/Post sofort setzen:**
Niemals leer lassen. Direkt beim Anlegen in `posts_additional_data` (key `_core`) befuellen:
```json
{
  "metaTitle": "Aussagekraeftiger Titel – Praxisname (max. 60 Zeichen)",
  "metaDesc": "1–2 Saetze zum Seiteninhalt fuer Suchmaschinen (max. 160 Zeichen).",
  "metaRobots": "index, follow",
  "canonicalUrl": "",
  "editor": "tinymce",
  "mainTag": ""
}
```

**SEO-Felder im Autor-`config`-Objekt (authors-Tabelle):**
- `metaTitle`: `<title>` der Autor-Listing-Seite (`/authors/{username}/`)
- `metaDescription`: `<meta name="description">` der Autor-Listing-Seite

## Autoren anlegen – KRITISCH: vollständige Struktur erforderlich

Neuen Autor IMMER mit vollständiger `config`- und `additional_data`-Struktur anlegen.
Ein leeres `{}` führt zu einem Publii-Rendering-Crash, der das gesamte Menü und alle
Posts des Autors kaputt macht.

```python
config = json.dumps({
    "email": "",
    "website": "",
    "avatar": "",
    "useGravatar": False,
    "description": "",
    "metaTitle": "",
    "metaDescription": "",
    "template": ""
})
additional_data = json.dumps({
    "viewConfig": {},
    "featuredImage": "",
    "featuredImageAlt": "",
    "featuredImageCaption": "",
    "featuredImageCredits": "",
    "metaRobots": "",
    "canonicalUrl": ""
})
cursor.execute("""
    INSERT INTO authors (name, username, password, config, additional_data)
    VALUES (?, ?, '', ?, ?)
""", (name, slug, config, additional_data))
```

**Blog-Listing-URL je Autor:** `/authors/{username}/`
Wenn Posts verschiedenen Autoren zugeordnet sind, hat jeder Autor eine eigene Listing-Seite.
Es gibt KEINE gemeinsame "alle Posts"-Seite; Menü-Links müssen auf die richtige Autor-Seite zeigen.

## WCAG-Kontrast-Hinweis

Beim Simple-Theme: `primaryColor` muss WCAG AA bestehen (min. 4.5:1 auf Weiß).
- `#0D9488` (Teal) → 3.74:1 → **FAIL**
- `#0C706A` (dunkles Teal) → 5.93:1 → **PASS**
- `#1B6CA8` (Blau) → passt gut für medizinische Sites
Prüfen mit: https://webaim.org/resources/contrastchecker/

## GUI-Abgleich: theme.config.json nach Template-Änderungen

**Regel:** Nach jeder Änderung an `*.hbs`-Templates prüfen, ob `theme.config.json`
noch das tatsächliche Verhalten widerspiegelt. Sonst sieht der Nutzer in der GUI
falsche Einstellungen.

### Abgleich-Checkliste

| theme.config.json-Feld | Prüfung |
|---|---|
| `feedDate` | Wird Datum in tag.hbs / author.hbs / index.hbs angezeigt? → `true` / `false` |
| `feedAuthor` | Wird Autoren-Link im Feed gezeigt? |
| `feedAvatar` | Wird Autoren-Avatar im Feed gezeigt? |
| `feedFeaturedImage` | Werden Featured Images in Feed-Listen gezeigt? |
| `feedtReadMore` | Gibt es einen „Weiterlesen"-Link? |
| `backToTopButton` | Ist Back-to-top-Button sichtbar? |
| `searchFeature` | Ist Suche aktiv? |
| `socialButtons` | Sind Social-Links eingetragen? (sonst → `false`) |
| `formatDate` | Welches Datumsformat nutzen die Templates tatsächlich? |

### Sonderfall: hardcoded vs. konfigurationsgesteuert

Wenn ein Template-Feature **immer** sichtbar ist (hardcoded, kein `{{#if @config.custom.X}}`):
- Empfohlen: `{{#if @config.custom.X}}`-Wrapper ergänzen – dann steuert die GUI das Feature
- Alternativ: GUI-Wert auf `true` setzen + im Memory dokumentieren

### Typisches Problem

`feedDate: false` in GUI, aber Datum wird trotzdem hardcoded angezeigt.
Dann: entweder `feedDate: true` setzen UND den Standard-`feedDate`-Block im Template
entfernen (um doppelte Anzeige zu vermeiden), oder den eigenen Span in
`{{#if @config.custom.feedDate}}` einschließen.

```handlebars
{{! Datum-Span – gesteuert durch feedDate-Toggle in der GUI: }}
{{#if @config.custom.feedDate}}
  <span class="feed__title-dates">
    ({{date createdAt 'DD.MM.YYYY'}} / {{date modifiedAt 'DD.MM.YYYY'}})
  </span>
{{/if}}
```
