Files
tibi-svelte-starter/.agents/skills/content-authoring/SKILL.md
Sebastian Frank a9a13a6b5b feat: add admin-ui-config, content-authoring, and frontend-architecture skills documentation
- Introduced `admin-ui-config` skill for configuring admin UI for collections.
- Added `content-authoring` skill detailing page and block creation in the CMS.
- Included `frontend-architecture` skill explaining custom SPA routing and state management.
- Updated `AGENTS.md` to reference new skills and provide infrastructure prerequisites.
- Enhanced `frontend/AGENTS.md` with routing details and SPA navigation information.
2026-03-07 16:16:19 +00:00

9.8 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, 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. App.svelte reacts to URL changes → calls getCachedEntries("content", { lang, path, active: true }).
  3. The matching ContentEntry.blocks[] array is passed to BlockRenderer.svelte.
  4. 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.


Adding a new page

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

  1. Open the tibi-admin at CODING_URL/_/admin/.
  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}.

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"

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

Multi-language pages

  • Create one ContentEntry per language with the same translationKey but different lang and path.
  • The language switcher in App.svelte uses currentContentEntry.translationKey to find the equivalent page.
  • 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} />

Step 3: 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 }[]
}

Step 4: 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[]
        subFields:
            - name: title
              type: string
            - name: description
              type: string

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

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

Step 6: Verify

yarn validate   # TypeScript check — must be warning-free

Existing block types for reference

Type Component Purpose
hero HeroBlock.svelte Full-width hero with image, headline, CTA
features FeaturesBlock.svelte Feature grid with icons
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 or navigation.yml 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
    rowIdentTpl: { twig: "{{ name }}" } # Row display in admin list

    views:
        - type: simpleList
          mediaQuery: "(max-width: 600px)"
          primaryText: name
        - type: table
          columns:
              - name
              - source: active
                filter: true

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

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

For the full schema reference: tibi-types/schemas/api-config/collection.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" | "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.
  • Before-save validation — create api/hooks/mycollection_validate.js.
  • Cache invalidation — add your collection to api/hooks/clear_cache.js if it affects rendered pages.

Reference hook in YAML:

hooks:
    beforeRead: |
        !include hooks/filter_public.js
    afterWrite: |
        !include 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

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.