- Implemented `resolveApiAssetUrl` function to normalize asset URLs based on API base. - Updated `MedialibImage` component to utilize new asset URL resolution and added support for alt text and class properties. - Enhanced image loading behavior with improved width measurement and focal point handling. - Added placeholder image handling and improved accessibility with alt text. - Introduced new test script for auditing broken links in skill documentation. - Expanded seeded test content to include medialib entries and updated related tests for pagebuilder previews. - Improved global setup and teardown logging for clarity on seeded content management.
21 KiB
name, description
| name | description |
|---|---|
| nova-pagebuilder-modeling | Model editor-friendly block systems for tibi-admin-nova. Covers pagebuilder structure, block schemas, preview, drillDown, dependsOn, containerProps.layout, and the required alignment between admin config, frontend block registry, and SSR. |
nova-pagebuilder-modeling
When to use this skill
Use this skill when:
- Building a flexible pagebuilder for pages, landing pages, reusable sections, or site settings
- Designing nested
object[]schemas for blocks in Nova - Deciding how editors should create, scan, reorder, and edit blocks
- Translating website requirements into maintainable block types
- Refactoring a block system that is technically valid but editor-hostile
Goal
The goal is not just to make blocks storable. The goal is to model a block system that is:
- understandable for editors
- safe to extend over time
- easy to preview in Nova
- aligned with frontend rendering and SSR
- structured enough that an LLM can add new blocks without inventing ad-hoc patterns
Source of truth
Use these sources when designing or reviewing the schema:
tibi-admin-nova/types/admin.d.tstibi-admin-nova/docs/collection-config.mdapi/collections/content.ymlfrontend/src/blocks/frontend/src/blocks/BlockRenderer.sveltetypes/global.d.ts
Do not model pagebuilder structures from memory when current Nova types are available.
Core mental model
In this starter family, a pagebuilder is usually an object[] field where each array item represents one block. Each block needs three layers to stay coherent:
- Data model in collection YAML
- Render component in
frontend/src/blocks/ - Type and registry alignment in TypeScript and
BlockRenderer.svelte
If one of these layers is missing, the system is incomplete.
Design rules
1. Prefer a small block vocabulary with strong reuse
Do not create a new block type for every tiny content variation.
Prefer:
herorichTextimageTextctafeatureGridfaqlogostestimonials
Avoid block libraries that mirror every page one-to-one. That produces brittle schemas and weak editor UX.
2. Every block must be recognizable in lists
Editors should understand an entry without opening each block.
Use current Nova preview capabilities on block objects:
meta:
preview:
label: headline
secondary: type
badge: variant
If a block has no single identifying field, use a preview eval that combines multiple fields.
3. Large blocks should open in drill-down editing
If a block contains many fields, nested objects, or repeated items, prefer drill-down editing instead of forcing everything into one long inline form.
Use drillDown when the inline view becomes noisy or error-prone.
4. Use dependsOn to keep block forms focused
Conditional fields are essential in block schemas.
Use dependsOn when:
- a field is only relevant for one
variant - a CTA only appears when
showCtais true - media settings depend on layout choice
- a nested group only matters for one block subtype
Do not dump every optional field into the same visible form.
5. Use containerProps.layout to model editor flow
Block editing should reflect visual and editorial grouping, not raw storage order.
Use containerProps.layout to:
- put related fields side by side
- separate content from appearance controls
- reduce scroll depth
- keep critical fields in the first viewport
6. Keep the block model SSR-safe
If a block is page-critical, it must render correctly in SSR too.
That means:
- the block data must come through the same content-loading path as the page
- the Svelte block component must be importable by the SSR bundle
- the renderer must not rely on browser-only APIs during initial render
7. Model for migrations, not just first delivery
Blocks evolve. Design schemas so fields can be added without breaking every existing entry.
Prefer additive changes and explicit defaults over brittle implicit assumptions.
Recommended modeling workflow
Step 1: Start from editorial jobs, not component names
Define what editors need to do:
- create a page hero
- add structured intro content
- place testimonials
- create CTA sections
- insert FAQs
- reuse site-wide sections
Then derive block types from these jobs.
Step 2: Decide which data belongs at page level and which belongs inside blocks
Keep page-level fields for concerns that apply to the whole page, such as:
- path
- language
- SEO
- publication
- translation linking
Keep block-level fields for modular content slices.
Step 3: Define the block array schema
Typical pagebuilder field:
- name: blocks
type: object[]
meta:
label: { de: "Blöcke", en: "Blocks" }
widget: pagebuilder
pagebuilder:
blockTypeField: type
preview:
label: headline
secondary: type
badge: variant
drillDown: true
subFields:
- name: type
type: string
meta:
widget: select
choices:
- value: hero
label: Hero
- value: richText
label: Rich text
- value: featureGrid
label: Feature grid
- name: headline
type: string
- name: variant
type: string
meta:
dependsOn:
eval: "$parent.type === 'hero'"
The exact shape can vary, but the pattern stays the same: block type first, then a previewable and conditionally focused schema.
Step 3a: Build and wire the block registry
In current Tibi/Nova projects, the pagebuilder registry is typically not implicit. Nova loads it from the admin bundle via meta.pagebuilder.blockRegistry.file.
The concrete chain is:
- define the registry in
frontend/src/admin.ts - export it as
blockRegistry - build the admin bundle with
yarn build - point the collection field or collection meta to the built module
The concrete file names vary by project, but the pattern is the same: registry code lives in the admin bundle and collection config points to the built admin asset.
Typical starter pattern:
const blockRegistry = {
hero: {
label: "Hero",
render(container, row, context) {
return {
update(nextRow, nextContext) {},
destroy() {},
}
},
},
}
export { blockRegistry }
And the collection wiring:
meta:
widget: pagebuilder
pagebuilder:
blockTypeField: type
blockRegistry:
file: /_/assets/dist/admin.mjs?v=${ADMIN_ASSET_VERSION}
Important constraints for this setup:
-
the registry module must be part of the admin bundle, not a random standalone file outside the build pipeline
-
the exported registry keys must match the block type values stored in the collection
-
after registry changes, run
yarn buildsofrontend/dist/admin.mjsis regenerated -
if the registry file path in YAML and the built admin asset diverge, Nova can still render the schema but the pagebuilder preview/picker loses its real block definitions
-
in Nova pagebuilder preview, file fields are already normalized by the admin backend to absolute
http(s)://...URLs when appropriate; preview code must not prependapiBase,projectBase, or other frontend URL helpers when the value is already absolute -
Nova may also pass preview rows with hydrated
_lookupdata for FK-like fields; the registry/block preview should consume that data directly instead of trying to re-fetch or manually hydrate references inside the admin preview -
For medialib-based images, prefer the same shared frontend widget used on the public site rather than preview-only URL logic. The widget/helper must honor
apiBaseOverrideso filter URLs, placeholders, and medialib files keep working inside admin preview. -
Nova's
render(container, row, context)provides API path information.context.projectBasecontains the full project-specific API base including namespace.context.apiBasemay only contain the generic/api/root and is often not sufficient for project-scoped collection endpoints. Blocks that load collection data in preview should therefore use the project-specific base when one is available:import { apiBaseOverride } from "./lib/store" import { get } from "svelte/store" const prev = get(apiBaseOverride) if (context?.projectBase) apiBaseOverride.set(String(context.projectBase)) // mount(BlockRenderer, ...) // in destroy(): apiBaseOverride.set(prev)
When a pagebuilder block renders images from medialib, prefer this pattern:
- request lookup-resolved medialib entries in the data load
- pass the resolved entry into the shared image widget
- let that widget decide filter sizing from explicit
filterorminWidth - rely on
apiBaseOverride/context.projectBasefor admin-safe URL resolution
Do not create a second, preview-only image rendering path that diverges from the public frontend. That usually causes broken placeholders, wrong filter URLs, or SSR/admin mismatches later.
Use collection-level meta.pagebuilder.blockRegistry.file when several pagebuilder fields share the same registry. Override at field level only when one field genuinely needs a different registry.
Step 4: Map each block type to a frontend component
Every allowed type value in the schema must be handled in BlockRenderer.svelte.
Do not leave “temporary” admin-only block types without a renderer unless they are truly non-public and intentionally excluded.
Frontend preparation requirements
For this starter, pagebuilder work is only half done when the collection schema exists. The frontend must be prepared explicitly so block-based rendering stays maintainable.
1. Keep one clear renderer boundary
frontend/src/blocks/BlockRenderer.svelte should remain the central registry that maps block.type to concrete Svelte components.
That means:
- every public block type in the schema gets one renderer branch
- unknown block handling stays explicit
- block selection logic stays centralized instead of being scattered across many unrelated files
Do not distribute block-type branching across the app shell, page components, and nested helpers at the same time.
2. Use a stable component contract
Each block component should receive the block object in a consistent way.
In this starter, the default contract is:
<script lang="ts">
let { block }: { block: ContentBlockEntry } = $props()
</script>
This matters because the block system becomes much easier to extend when every component follows the same top-level prop contract.
If a block needs additional derived data, derive it inside the component or in a small helper, but do not invent a different top-level prop API for every block.
3. Keep ContentBlockEntry aligned with real frontend usage
The frontend preparation is incomplete until types/global.d.ts can express the fields the block components actually read.
Whenever a new block type or field is added, verify alignment between:
- collection YAML subfields
ContentBlockEntry- the block component implementation
BlockRenderer.svelte
If a component reads fields that are only implicit or typed as vague leftovers, the pagebuilder is not ready for reliable future extension.
4. Plan lookup data together with the block model
If blocks reference media or foreign entities, the frontend must be prepared to receive the resolved lookup data through the page-loading path.
For this starter, that usually means checking the lookup strings used when loading content in App.svelte.
For Nova admin previews, treat the incoming row differently from a raw frontend API payload:
_lookupmay already be hydrated by the admin preview pipeline- file/image values may already be absolute URLs
Do not add preview logic that blindly rewrites file URLs or assumes it still has to hydrate foreign references before rendering the admin pagebuilder preview.
Do not add a block that depends on:
- media lookups
- referenced collections
- nested foreign references
without also updating the content-loading layer so the renderer receives the required _lookup data.
5. Treat SSR compatibility as part of frontend preparation
A pagebuilder block is not frontend-ready if it only works after hydration.
Every public block should render safely during SSR:
- no unconditional
window/documentusage at module top level - browser-only behavior guarded inside
typeof window !== "undefined" - meaningful initial markup without waiting for client-only effects
If a block absolutely requires browser APIs, keep the browser-only part small and ensure the surrounding block still renders a stable SSR shell.
6. Unknown block handling should help development without hiding errors
BlockRenderer.svelte should make unknown block types visible enough during development that schema/frontend drift is caught early.
For this starter, the current renderer already has a development-side unknown-block fallback. Keep a mechanism like that in place when the demo renderer is refactored.
Do not silently swallow unknown block types in a way that makes editor-created content disappear with no signal.
7. Keep block components presentation-focused
Pagebuilder block components should mostly render data, not own cross-page application logic.
Prefer:
- block-local formatting and small derived values
- presentational composition
- small helper components inside
frontend/src/blocks/
Avoid pushing these concerns into block components unless there is a strong reason:
- route loading
- global app state orchestration
- unrelated API fetching
- page-level navigation concerns
7a. Admin pagebuilder preview: CSS custom properties in shadow DOM
The Nova pagebuilder renders block previews in an isolated DOM context (shadow DOM or detached subtree). Tailwind 4's @theme directive generates CSS custom properties on :root, but these do not cascade into shadow DOM contexts.
Consequence: Block previews in the admin can have wrong colors (light text instead of dark, missing brand colors) because var(--color-ink) resolves to nothing.
Fix: Add a :host selector in the project's CSS file that redeclares the theme variables for the shadow DOM context. Also set a hardcoded color fallback on [data-admin-preview] since the Nova preview container has this attribute.
:host,
[data-admin-preview] {
--color-ink: #2c3e45;
/* … all theme color variables … */
font-family: "Inter Tight", system-ui, sans-serif;
color: #2c3e45; /* hardcoded fallback, not var() */
}
Verify by checking admin pagebuilder block preview after any CSS theme changes.
7b. Admin pagebuilder preview: API calls from dynamic blocks
Blocks that load data via API (e.g. CategoryGridBlock using getCachedEntries) need the correct API base URL in the admin preview. The Nova pagebuilder's render() callback receives a context object with optional apiBase and namespace, but not all versions provide these.
The admin.mjs is loaded by the pagebuilder via dynamic import() — the URL is resolved relative to the admin page, NOT relative to the API. So import.meta.url contains the admin's page URL (e.g. /_/assets/dist/admin.mjs), not the tibi-server's API URL. Regex extraction from import.meta.url for the pattern /api/_/{namespace}/ does NOT work for this reason.
Reliable approach: use multiple fallbacks in admin.ts, with the admin hostname pattern as the most robust:
context.apiBase(from Nova when available)context.namespace(from Nova)import.meta.urlregex (works when admin serves admin.mjs through its own API proxy)- Hostname extraction: admin URL is
{project}-tibiadmin.{domain}→ extract project name - DOM scan: find any element with
src/hrefcontaining/api/_/{namespace}/
const prevApiBase = get(apiBaseOverride)
let ns: string | null = null
if (context?.apiBase) {
apiBaseOverride.set(String(context.apiBase))
} else {
if (context?.namespace) ns = String(context.namespace)
if (!ns) {
try {
ns = ((import.meta as any).url || "").match(/\/api\/_\/([^/]+)\//)?.[1]
} catch {}
}
// Most reliable: admin hostname is always {namespace}-tibiadmin.{domain}
if (!ns && typeof window !== "undefined") {
const h = window.location.hostname.match(/^(.+?)-tibiadmin\./)
if (h) ns = h[1]
}
// Fallback: scan DOM for API references
if (!ns && typeof document !== "undefined") {
const el = document.querySelector('[src*="/api/_/"], [href*="/api/_/"]')
if (el) {
const a = el.getAttribute("src") || el.getAttribute("href") || ""
ns = a.match(/\/api\/_\/([^/]+)\//)?.[1] || null
}
}
if (ns) apiBaseOverride.set(`/api/_/${ns}/`)
}
Set the apiBaseOverride store BEFORE mounting the block component so API calls inside $effect use the correct base.
The Nova pagebuilder renders block previews in an isolated DOM context (shadow DOM or detached subtree). Tailwind 4's @theme directive generates CSS custom properties on :root, but these do not cascade into shadow DOM contexts.
Consequence: Block previews in the admin can have wrong colors (light text instead of dark, missing brand colors) because var(--color-ink) resolves to nothing.
Fix: Add a :host selector in the project's CSS file that redeclares the theme variables for the shadow DOM context. Also set a hardcoded color fallback on [data-admin-preview] since the Nova preview container has this attribute.
:host,
[data-admin-preview] {
--color-ink: #2c3e45;
--color-ink-2: #3a4d56;
/* … all theme color variables … */
font-family: "Inter Tight", system-ui, sans-serif;
color: #2c3e45; /* hardcoded fallback, not var() */
}
Verify by checking admin pagebuilder block preview after any CSS theme changes.
8. Prepare for styling consistency across blocks
A block system works better when blocks share a few stable layout conventions.
Examples:
- container width choices
- vertical spacing conventions
- anchor/id behavior
- CTA shape and link handling
- media aspect ratio conventions
Do not let every new block invent its own spacing, width, and link semantics from scratch unless the design system really requires it.
When a block is actually ready in the frontend
A new pagebuilder block should only be considered integrated when all of these are true:
- The schema contains the block type and required subfields.
ContentBlockEntryexpresses the fields used by the block.- A dedicated Svelte block component exists in
frontend/src/blocks/. BlockRenderer.svelteroutes the block type to that component.- Any required lookup data is loaded by the app content-loading path.
- The block renders acceptably in SSR and browser navigation.
- Unknown or stale block types remain debuggable.
Step 5: Keep types aligned
Update project types when the block model changes.
In this starter family, block schemas usually affect:
types/global.d.ts- Svelte component props
- block renderer branching
If TypeScript cannot express the new block shape, the schema work is incomplete.
Practical block design patterns
Hero block
Use for top-of-page messaging. Keep the editor form short and obvious.
Typical fields:
- eyebrow
- headline
- subline
- image
- cta
- variant
Use dependsOn for variant-specific media and CTA settings.
Rich text block
Use for long-form body content. Avoid mixing it with too many presentational toggles.
Typical fields:
- headline
- body
- maxWidth
Feature grid block
Use nested repeatable objects for feature items, but make the parent block previewable.
Typical fields:
- headline
- items[]
- columns
- variant
For items[], add its own preview so editors can scan the nested list.
Reusable section reference
If the same content must appear on many pages, consider a dedicated collection plus foreign reference instead of copy-pasting large pagebuilder blocks.
Use foreign previews so editors understand the referenced entity before opening it.
Anti-patterns
- One block type per page template fragment with no reuse
- Giant catch-all block with dozens of unrelated optional fields
- No preview on nested objects
- No drill-down for large objects
- Using array order as the only meaning without labels or previews
- Frontend blocks that exist without matching collection schema
- Collection schema values that have no renderer
Verification checklist
After adding or changing pagebuilder blocks, verify all of these:
- Editors can identify blocks quickly in Nova.
- The block form hides irrelevant fields.
- Reordering works without losing meaning.
BlockRenderer.sveltehandles every public block type.- SSR renders the affected page correctly.
yarn validatestays clean.
What an LLM should inspect first
When asked to extend a pagebuilder on this starter, inspect in this order:
api/collections/content.ymlfrontend/src/blocks/BlockRenderer.svelte- existing files in
frontend/src/blocks/ types/global.d.tstibi-admin-nova/types/admin.d.ts
This order prevents schema-only or frontend-only changes.