---
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, collection YAML authoring, and TypeScript type definitions. 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.

---

## 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: 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 }[]
}
```

### Step 4: 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

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

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

### Step 6: 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
```

### 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
    _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" | "mycollection" | string

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

### 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

---

## 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.
