---
name: content-authoring
description: Add new pages, content blocks, and collections to a tibi project. Covers the content-based routing model, block registration in BlockRenderer and frontend/src/admin.ts, lookup-aware reference modeling, collection YAML authoring, and TypeScript type ownership. Use when creating new pages, block types, or collections.
---

# content-authoring

## When to use this skill

Use this skill when:

- Adding a new page to the website
- Creating a new content block type (e.g. testimonials, pricing table, gallery)
- Adding a new collection to the CMS (e.g. products, events, team members)
- Understanding how content is structured and rendered

## Key concept: content-based routing

This project does **NOT** use file-based routing (no SvelteKit router). Instead:

1. Pages are **CMS entries** in the `content` collection with a `path` field.
2. Public URLs are typically language-prefixed (`/de/...`, `/en/...`), but the DB entry in `content.path` is stored **without** that language prefix.
3. `App.svelte` reacts to URL changes → strips the language prefix → calls `getCachedEntries("content", { lang, path, active: true })`.
4. The same loading path is used for browser navigation and SSR.
5. The matching `ContentEntry.blocks[]` array is passed to `BlockRenderer.svelte`.
6. Each block has a `type` field that maps to a Svelte component.

**Implication:** To add a new page, you create a content entry (via Admin UI or API) — no new Svelte file or route config is needed.

**Important:** When adding new page types, inspect both the frontend route/i18n layer and `api/hooks/config.js` (SSR route validation). A page can exist in the DB and still fail under SSR if the public URL shape and `content.path` mapping are not aligned.

## Cross-surface ownership rule

For real project work, treat content authoring as a multi-surface contract.

When you add or change blocks, pages, or collections, check these surfaces together:

1. collection YAML in `api/collections/*.yml`
2. type ownership in `types/global.d.ts`
3. typed API mapping in `frontend/src/lib/api.ts` via `EntryTypeSwitch`
4. public rendering in `frontend/src/blocks/BlockRenderer.svelte`
5. admin pagebuilder preview in `frontend/src/admin.ts`

If one of these surfaces is skipped, the project often still looks half-correct until SSR, admin preview, or typed API usage exposes the mismatch.

---

## Adding a new page

### Option A: Via Admin UI (preferred for content editors)

1. Open the Nova admin at `https://{PROJECT_NAME}-tibiadmin.code.testversion.online/`.
2. Navigate to **Inhalte** (Content) collection.
3. Click **New** and fill in:
    - `name`: Display name (e.g. "Über uns")
    - `path`: URL path without language prefix (e.g. `/ueber-uns`)
    - `lang`: Language code (e.g. `de`)
    - `active`: `true`
    - `translationKey`: Shared key for cross-language linking (e.g. `about`)
    - `blocks`: Add content blocks (see below)
    - `meta.title` / `meta.description`: SEO metadata
4. Save. The page is immediately available at `/{lang}{path}`.

**Nova authoring guidance:**

- Prefer meaningful `meta.preview` and field `preview` configs so entries and nested blocks are understandable in breadcrumbs, foreign-key widgets, and arrays.
- Use `containerProps.layout.size` to keep editors on one screen instead of stacking every field vertically.
- Use `dependsOn` to hide block-specific fields until the relevant block type is selected.
- Prefer drill-down editing for larger `object[]` structures instead of flat, folded arrays.

### Option B: Via API

```sh
curl -X POST "$CODING_URL/api/content" \
  -H "Token: $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "active": true,
    "lang": "de",
    "name": "Über uns",
    "path": "/ueber-uns",
    "translationKey": "about",
    "blocks": [
      { "type": "hero", "headline": "Über uns", "subline": "Unser Team" }
    ],
    "meta": { "title": "Über uns", "description": "Erfahre mehr über unser Team." }
  }'
```

### Option C: Via mock data (for MOCK=1 mode)

Add the entry to `frontend/mocking/content.json` — the mock engine supports MongoDB-style filtering.

### Adding to navigation

To make the page appear in the header/footer menu, edit the corresponding `navigation` entry:

```sh
# Get existing header nav
curl "$CODING_URL/api/navigation?filter[type]=header&filter[language]=de" -H "Token: $ADMIN_TOKEN"

# Look up the content entry ID for your page
curl "$CODING_URL/api/content?filter[path]=/ueber-uns&filter[lang]=de" -H "Token: $ADMIN_TOKEN"

# PUT to update elements array (add your page by FK id)
curl -X PUT "$CODING_URL/api/navigation/<id>" \
  -H "Token: $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "elements": [ ...existing, { "name": "Über uns", "page": "<content-id>" } ] }'
```

If navigation drives the public website shell, treat navigation as page-critical SSR data. A page is not fully SSR-ready if only the main content entry exists but header/footer navigation is missing.

### Multi-language pages

- Create one `ContentEntry` per language with the **same `translationKey`** but different `lang` and `path`.
- The language switcher is path-based and derives the target URL from the current route plus `ROUTE_TRANSLATIONS`.
- Add localized route slugs to `ROUTE_TRANSLATIONS` in `frontend/src/lib/i18n.ts` if URLs should differ per language (e.g. `/ueber-uns` vs `/about`).

---

## Adding a new content block type

### Step 1: Create the Svelte component

Create `frontend/src/blocks/MyNewBlock.svelte`:

```svelte
<script lang="ts">
    let { block }: { block: ContentBlockEntry } = $props()
</script>

<section class="py-16 sm:py-24" id={block.anchorId || undefined}>
    <div class="max-w-6xl mx-auto px-6">
        {#if block.headline}
            <h2 class="text-3xl font-bold mb-6">{block.headline}</h2>
        {/if}
        <!-- Block-specific content here -->
    </div>
</section>
```

**Conventions:**

- Accept `block: ContentBlockEntry` as the single prop.
- Use `block.anchorId` for scroll anchoring.
- Respect `block.containerWidth` (`""` = default, `"wide"`, `"full"`).
- Guard browser-only code with `typeof window !== "undefined"` (SSR safety).

### Step 2: Register in BlockRenderer

Edit `frontend/src/blocks/BlockRenderer.svelte`:

```svelte
<!-- Add import at the top -->
import MyNewBlock from "./MyNewBlock.svelte"

<!-- Add case in the {#each} block -->
{:else if block.type === "my-new-block"}
    <MyNewBlock {block} />
```

If block types become numerous, plan for grouping and registry discipline early. A real website built on this starter should not keep extending a demo-style renderer forever without structure.

### Step 3: Register in the admin block registry

If the block is authored through a pagebuilder field, also register it in `frontend/src/admin.ts`.

Example:

```ts
const blockRegistry = {
    hero: createContentBlockDefinition({ label: "Hero", icon: "image", color: "#1d4ed8" }),
    "my-new-block": createContentBlockDefinition({
        label: "My New Block",
        icon: "view_compact",
        color: "#0f766e",
    }),
}
```

Important:

- `BlockRenderer.svelte` controls public rendering
- `frontend/src/admin.ts` controls Nova pagebuilder preview availability
- both should point at the same block contract instead of drifting into separate preview-only logic

### Step 4: Extend TypeScript types (if new fields are needed)

Edit `types/global.d.ts` — add fields to `ContentBlockEntry`:

```typescript
interface ContentBlockEntry {
    // ... existing fields ...
    // my-new-block fields
    myCustomField?: string
    myItems?: { title: string; description: string }[]
}
```

If the change also introduces a new collection or new API usage surface, update the corresponding entry interfaces in the same change instead of leaving `Record<string, unknown>` as a long-term placeholder.

### Step 5: Extend collection YAML (if new fields need admin editing)

Edit `api/collections/content.yml` — add subFields under `blocks`:

```yaml
- name: blocks
  type: object[]
  subFields:
      # ... existing subFields ...
      - name: myCustomField
        type: string
      - name: myItems
        type: object[]
        meta:
            drillDown: true
            preview: title
        subFields:
            - name: title
              type: string
            - name: description
              type: string
```

Use current Nova patterns when extending block schemas:

- `meta.preview` for entry and block previews
- `meta.drillDown: true` for nested arrays that would otherwise become hard to edit
- `containerProps.layout.size` for dense editor layouts
- `dependsOn` for block-type-specific fields
- collection- or field-level `meta.pagebuilder` for registry/default viewport settings

When blocks contain foreign references such as medialib images, model the reference path deliberately so later loaders can request the needed `lookup` data.

### Step 6: Update mock data (if using MOCK=1)

Add a block with your new type to `frontend/mocking/content.json`.

### Step 7: Verify

```sh
yarn validate   # TypeScript check — must be warning-free
```

For blocks that appear on SSR pages, also verify:

```sh
yarn build:server
# then request the SSR endpoint directly and check that the block content appears in HTML
```

For blocks that are authored in pagebuilder and use images or foreign references, also verify:

- the block appears in the admin chooser
- the preview renders in Nova
- image/reference data is present through the intended lookup path

### Existing block types for reference

| Type           | Component                 | Purpose                                   |
| -------------- | ------------------------- | ----------------------------------------- |
| `hero`         | `HeroBlock.svelte`        | Full-width hero with image, headline, CTA |
| `richtext`     | `RichtextBlock.svelte`    | Rich text with optional image             |
| `accordion`    | `AccordionBlock.svelte`   | Expandable FAQ/accordion items            |
| `contact-form` | `ContactFormBlock.svelte` | Contact form                              |

---

## Adding a new collection

### Step 1: Create collection YAML

Create `api/collections/mycollection.yml`. Use `content.yml`, `navigation.yml`, or a current `tibi-admin-nova` example config as a template:

```yaml
########################################################################
# MyCollection — description of what this collection stores
########################################################################

name: mycollection
meta:
    label: { de: "Meine Sammlung", en: "My Collection" }
    muiIcon: category # Material UI icon name
    viewHint: table
    preview:
        label: name
        table:
            - name
            - source: active
              label: Active

permissions:
    public:
        methods:
            get: true # Public read access
    user:
        methods:
            get: true
            post: true
            put: true
            delete: true

fields:
    - name: active
      type: boolean
      meta:
          label: { de: "Aktiv", en: "Active" }
    - name: name
      type: string
      meta:
          label: { de: "Name", en: "Name" }
    # Add more fields as needed
```

Use current Nova config:

- `preview` for row/foreign/search display
- object-form `singleton`
- `sidebar` groups instead of ad hoc sidebars
- `pagebuilder` defaults when a collection contains pagebuilder fields
- `viewHint` plus `preview.table` for better admin ergonomics

**Field types:** `string`, `number`, `boolean`, `object`, `object[]`, `string[]`, `file`, `file[]`.

For the full schema reference: `tibi-types/schemas/config/collection.schema.json`.

### Step 2: Include in config.yml

Edit `api/config.yml`:

```yaml
collections:
    - !include collections/content.yml
    - !include collections/navigation.yml
    - !include collections/ssr.yml
    - !include collections/mycollection.yml # ← add this line
```

### Step 3: Add TypeScript types

Edit `types/global.d.ts`:

```typescript
interface MyCollectionEntry {
    id?: string
    active?: boolean
    name?: string
    // ... fields matching your YAML
}
```

### Step 4: Configure API layer (optional)

If you need typed helpers, extend the `EntryTypeSwitch` in `frontend/src/lib/api.ts`:

```typescript
type CollectionNameT = "medialib" | "content" | "navigation" | "mycollection" | string

type EntryTypeSwitch<T extends string> = T extends "medialib"
    ? MedialibEntry
    : T extends "content"
      ? ContentEntry
      : T extends "navigation"
        ? NavigationEntry
        : T extends "mycollection"
          ? MyCollectionEntry
          : Record<string, unknown>
```

Do not treat `EntryTypeSwitch` as optional cleanup. If the frontend or tests consume the collection in a typed way, update this mapping in the same change.

### Step 5: Add hooks (optional)

Common hook patterns:

- **Public filter** — reuse `filter_public.js` to enforce `active: true` for unauthenticated users.
- **Write validation** — add method/step hook files such as `api/hooks/mycollection/post_validate.js` or `api/hooks/mycollection/put_validate.js`.
- **Cache invalidation** — add your collection to `api/hooks/clear_cache.js` if it affects rendered pages.
- **Action endpoints** — prefer `actions:` instead of fake collections when you need forms, newsletters, calculators, imports, or other endpoint-like behavior without CRUD storage.

Reference hook in YAML:

```yaml
hooks:
    get:
        read:
            type: javascript
            file: hooks/filter_public.js
    put:
        update:
            type: javascript
            file: hooks/clear_cache.js
    post:
        create:
            type: javascript
            file: hooks/clear_cache.js
    delete:
        delete:
            type: javascript
            file: hooks/clear_cache.js
```

### Step 6: Add mock data (if using MOCK=1)

Create `frontend/mocking/mycollection.json`:

```json
[{ "_id": "1", "active": true, "name": "Example Entry" }]
```

### Step 7: Verify

```sh
yarn validate   # TypeScript check
# If Docker is running, the tibi-server auto-reloads the collection config
```

For collections intended for rich editorial usage, also verify in Nova:

- list/table/card previews are readable
- nested arrays are editable with drill-down where needed
- sidebar groups and layout are usable without scrolling through one long form
- foreign-key displays use meaningful previews
- pagebuilder fields render previews and screenshots correctly

If the collection feeds public pages or admin block previews, also verify that the typed API helpers and runtime components agree on the same data shape.

---

## Collection Validators

Validatoren definieren Sicherheitsregeln und Typ-Constraints, indem sie als `validator`-Key innerhalb der `fields`-Definitionen einer Collection-YAML (`api/collections/*.yml`) konfiguriert werden.

**Unterschied Client- vs. Serverseitige Validatoren:**

- **Serverseite (`tibi-server`)**: Validatoren werden zentral im Go-Backend bei jedem Datensatz-Schreibvorgang (`POST` / `PUT`) ausgeführt (nach den `validate`-Hooks). Wenn Daten nicht den Constraints entsprechen, erfolgt ein Abbruch (`400 Bad Request`).
- **Clientseite (`tibi-admin-nova`)**: Das CMS-Admin-Interface liest diese Validator-Regeln automatisch über das OpenAPI-Schema ein und wendet sie instant als Client-Side-Validierung in den Formularen an (Rote Markierungen und Check vor dem eigentlichen API-Call). **Validatoren müssen daher nur 1x zentral in der YAML definiert werden.**

**Häufige Validator-Optionen je Feldtyp:**

- **Generell**:
    - `required: true` (Zwingendes Pflichtfeld)
    - `allowZero: true` (Erlaubt die explizite Eingabe von `""` oder `0`, selbst wenn `required: true` aktiv ist)
    - `in: ["wert1", "wert2"]` (Nur dieser exakte Pool an primitiven Werten ist erlaubt)
    - `eval: "$this.length >= 3 && $this.length <= 100"` (Serverseitige Javascript-Evaluation für Custom-Logik)
- **Einfache Texte (`string`)**:
    - `minLength: X` und `maxLength: Y`
    - `pattern: "^[a-zA-Z0-9]+$"` (Prüft Regex-Match des kompletten Werts)
    - `format: email` (oder `url`, `uuid`, `slug` für eingebaute Regex-Prüfungen)
- **Zahlen (`number`, `float`)**:
    - `min: X` und `max: Y`
- **Datum/Zeit (`date`, `datetime`, `time`)**:
    - `minDate: "YYYY-MM-DD"` und `maxDate: "YYYY-MM-DD"` (Zulässige Zeitgrenzen)
- **Listen/Arrays (`string[]`, `object[]`)**:
    - `minItems: X` und `maxItems: Y`
- **Dateien/Bilder (`file`, `file[]`)**:
    - `maxFileSize: "50MB"` (und `minFileSize`)
    - `accept: ["image/png", "image/webp"]` (Erlaubte MIME-Types)
    - Constraints für Bildabmessungen konfigurierbar via Sub-Objekt:
        ```yaml
        image:
            minWidth: 800
            maxWidth: 2400
            minHeight: 600
            maxHeight: 1800
        ```

**Beispiel für die Einbindung in einer Collection:**

```yaml
fields:
    - name: internalName
      type: string
      validator:
          required: true
          maxLength: 100
      meta:
          label: { de: "Interner Name", en: "Internal Name" }

    - name: externalLink
      type: string
      validator:
          format: url
      meta:
          label: Externe URL

    - name: document
      type: file
      validator:
          maxFileSize: "20MB"
          accept: ["application/pdf"]
```

## Seed data pattern (Playwright)

Test seed data uses `_testdata: true` as a hidden marker field. **Real content must NEVER use this flag** — otherwise test teardown will delete it.

```yaml
# Last field in every collection schema
- name: _testdata
  type: boolean
  meta:
      hide: true
```

Test setup:

1. `globalSetup` removes entries with `_testdata: true`, then creates new test entries
2. `globalTeardown` removes entries with `_testdata: true`
3. Real editorial content has no `_testdata` field → survives all test runs

## Common pitfalls

- **Path format**: Content paths do NOT include the language prefix. The path `/ueber-uns` becomes `/{lang}/ueber-uns` via the i18n layer.
- **Active flag**: Pages with `active: false` are filtered out by `filter_public.js` for public users. The admin can still see them.
- **Block `hide` field**: Blocks with `hide: true` are skipped by `BlockRenderer.svelte` — useful for draft blocks.
- **Collection YAML indentation**: YAML uses 2-space indentation. Sub-fields under `object[]` require a `subFields` key.
- **After adding a collection**: The tibi-server auto-reloads hooks on file change, but a new collection in `config.yml` may require `make docker-restart-frontend` or a full `make docker-up`.
- **Do not fake forms as collections** if they are really endpoint logic. Use `actions:` when no CRUD collection is needed.
- **Do not overfit to demo blocks**. Real projects should shape block schemas and admin ergonomics around actual editor workflows.

## API lookup für aufgelöste Referenzen

Beim Laden von Collections können Fremdschlüssel via `lookup`-Parameter automatisch aufgelöst werden. Der `lookup`-Parameter wird einfach im Optionen-Objekt an `getCachedEntries` übergeben:

```ts
const products = await getCachedEntries<"machines">("machines", {
    filter: { active: true, category: catId },
    sort: "sortOrder",
    lookup: "images:medialib", // lookup: "feld:collection"
})
```

Das Format ist `"feldname:zielcollection"` (z.B. `"images:medialib"`). Die aufgelösten Daten landen in `entry._lookup.feldname` als Array der Ziel-Collection-Objekte. Ohne lookup bleiben `string[]`-Felder reine ID-Arrays.

Wichtig: der `lookup`-Parameter muss auch in `getDBEntries` und `apiRequest` durchgereicht werden (siehe `api.ts`).

Für blockbasierte Inhalte ist der Lookup-Pfad oft verschachtelt, nicht flach. Beispiel:

```ts
const entries = await getCachedEntries<"content">(
    "content",
    { active: true, path: "/preview-page" },
    "sort",
    undefined,
    1,
    undefined,
    undefined,
    "blocks.heroImage.image:medialib"
)
```

Merke:

- flache Relationen nutzen Pfade wie `images:medialib`
- block- oder objektverschachtelte Relationen nutzen Dot-Paths wie `blocks.heroImage.image:medialib`
- ohne den passenden Lookup fehlen Admin-Preview, SSR oder Frontend-Rendern oft erst zur Laufzeit

Treat public rendering, SSR rendering, and admin preview as the same reference contract whenever possible. If a block renders a medialib image in the site, the admin preview should usually depend on the same resolved media assumption instead of inventing a separate preview-only data path.
