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:
- Pages are CMS entries in the
contentcollection with apathfield. - Public URLs are typically language-prefixed (
/de/...,/en/...), but the DB entry incontent.pathis stored without that language prefix. App.sveltereacts to URL changes → strips the language prefix → callsgetCachedEntries("content", { lang, path, active: true }).- The same loading path is used for browser navigation and SSR.
- The matching
ContentEntry.blocks[]array is passed toBlockRenderer.svelte. - Each block has a
typefield 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:
- collection YAML in
api/collections/*.yml - type ownership in
types/global.d.ts - typed API mapping in
frontend/src/lib/api.tsviaEntryTypeSwitch - public rendering in
frontend/src/blocks/BlockRenderer.svelte - 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)
- Open the Nova admin at
https://{PROJECT_NAME}-tibiadmin.code.testversion.online/. - Navigate to Inhalte (Content) collection.
- 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:truetranslationKey: Shared key for cross-language linking (e.g.about)blocks: Add content blocks (see below)meta.title/meta.description: SEO metadata
- Save. The page is immediately available at
/{lang}{path}.
Nova authoring guidance:
- Prefer meaningful
meta.previewand fieldpreviewconfigs so entries and nested blocks are understandable in breadcrumbs, foreign-key widgets, and arrays. - Use
containerProps.layout.sizeto keep editors on one screen instead of stacking every field vertically. - Use
dependsOnto 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
ContentEntryper language with the sametranslationKeybut differentlangandpath. - The language switcher is path-based and derives the target URL from the current route plus
ROUTE_TRANSLATIONS. - Add localized route slugs to
ROUTE_TRANSLATIONSinfrontend/src/lib/i18n.tsif URLs should differ per language (e.g./ueber-unsvs/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: ContentBlockEntryas the single prop. - Use
block.anchorIdfor 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.sveltecontrols public renderingfrontend/src/admin.tscontrols 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.previewfor entry and block previewsmeta.drillDown: truefor nested arrays that would otherwise become hard to editcontainerProps.layout.sizefor dense editor layoutsdependsOnfor block-type-specific fields- collection- or field-level
meta.pagebuilderfor 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:
previewfor row/foreign/search display- object-form
singleton sidebargroups instead of ad hoc sidebarspagebuilderdefaults when a collection contains pagebuilder fieldsviewHintpluspreview.tablefor 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
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.jsto enforceactive: truefor unauthenticated users. - Write validation — add method/step hook files such as
api/hooks/mycollection/post_validate.jsorapi/hooks/mycollection/put_validate.js. - Cache invalidation — add your collection to
api/hooks/clear_cache.jsif 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 denvalidate-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""oder0, selbst wennrequired: trueaktiv 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: XundmaxLength: Ypattern: "^[a-zA-Z0-9]+$"(Prüft Regex-Match des kompletten Werts)format: email(oderurl,uuid,slugfür eingebaute Regex-Prüfungen)
- Zahlen (
number,float):min: Xundmax: Y
- Datum/Zeit (
date,datetime,time):minDate: "YYYY-MM-DD"undmaxDate: "YYYY-MM-DD"(Zulässige Zeitgrenzen)
- Listen/Arrays (
string[],object[]):minItems: XundmaxItems: Y
- Dateien/Bilder (
file,file[]):maxFileSize: "50MB"(undminFileSize)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:
globalSetupremoves entries with_testdata: true, then creates new test entriesglobalTeardownremoves entries with_testdata: true- Real editorial content has no
_testdatafield → survives all test runs
Common pitfalls
- Path format: Content paths do NOT include the language prefix. The path
/ueber-unsbecomes/{lang}/ueber-unsvia the i18n layer. - Active flag: Pages with
active: falseare filtered out byfilter_public.jsfor public users. The admin can still see them. - Block
hidefield: Blocks withhide: trueare skipped byBlockRenderer.svelte— useful for draft blocks. - Collection YAML indentation: YAML uses 2-space indentation. Sub-fields under
object[]require asubFieldskey. - After adding a collection: The tibi-server auto-reloads hooks on file change, but a new collection in
config.ymlmay requiremake docker-restart-frontendor a fullmake 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:
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:
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.