Files
tibi-svelte-starter/.agents/skills/content-authoring/SKILL.md
T

20 KiB

name, description
name description
content-authoring 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

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:

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

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

<!-- 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:

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:

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:

- 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

yarn validate   # TypeScript check — must be warning-free

For blocks that appear on SSR pages, also verify:

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:

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

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:

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:

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:

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:

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

Step 7: Verify

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:
      image:
          minWidth: 800
          maxWidth: 2400
          minHeight: 600
          maxHeight: 1800
      

Beispiel für die Einbindung in einer Collection:

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.

# 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 als 8. Argument an getCachedEntries übergeben:

const products = await getCachedEntries<"machines">(
    "machines",
    { active: true, category: catId },
    "sortOrder",
    undefined,
    undefined,
    undefined,
    undefined,
    "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:

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.