- 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.
12 KiB
name, description
| name | description |
|---|---|
| tibi-hook-authoring | Write and debug server-side hooks for tibi-server (goja Go JS runtime). Covers IIFE structure, HookResponse/HookException types, context.filter Go-object quirk, single-item vs list retrieval, and MongoDB filter patterns. Use when creating or modifying files in api/hooks/. |
tibi-hook-authoring
Use this skill for current tibi-server hook architecture, not just simple CRUD filters. A real website project on this starter typically needs hooks for public filtering, SSR invalidation, action endpoints, validation, and editor safety.
Source of truth
Use these sources when implementing or reviewing hooks:
tibi-server/docs/06-hooks.mdtibi-server/docs/19-actions.mdtibi-server/internal/models/eval_context.gotibi-server/internal/hook/context_*.goapi/hooks/config.jsapi/hooks/filter_public.jsapi/hooks/clear_cache.js.agents/skills/tibi-ssr-caching/SKILL.md
When hook examples and prose ever disagree about how helpers are exposed, trust the current implementation in eval_context.go plus the context_*.go registrations.
First routing decision: collection hook or action
Before writing hook code, decide whether the workflow belongs to CRUD data or to an endpoint.
Use collection hooks when:
- the workflow is about reads or writes on a real collection
- publication filtering belongs to collection reads
- cache invalidation belongs to collection mutations
Use actions when:
- the workflow is endpoint-style business logic
- there is no durable CRUD collection behind it
- validation and side effects matter more than storage
Typical action use cases:
- contact forms
- newsletter signups
- quote or order requests
- webhook receivers
- helper endpoints
Do not implement fake empty collections just to gain a hook surface.
Action hook context.data quirk
In action hooks, the body is NOT parsed before the bind step runs. context.data is only available starting from the validate step. The order is:
bind— runs, butcontext.datais undefined (body not yet parsed)- Body parsing — server parses JSON body into
context.data validate—context.datais now availablehandle—context.dataavailable, this is where main logic goesreturn— final response
Never access context.data in a bind hook — it will be empty. Use handle for data access.
For action config, always use the handle step:
hooks:
post:
handle:
type: javascript
file: hooks/actions/contact/handle.js
Also note: context.data can be an array or object depending on the request. Always guard:
const data = (Array.isArray(context.data) ? {} : context.data) || {}
Hook file structure
Wrap every hook in an IIFE:
;(function () {
/** @type {HookResponse} */
const response = { status: 200 }
// ... hook logic ...
return response
})()
Always return a HookResponse or throw a HookException.
For many hooks, throwing is the normal control flow, especially in SSR hooks where HTML/status are returned via a thrown object.
Type safety
- Use inline JSDoc type casting:
/** @type {TypeName} */ (value). - Reference typed collection entries from
types/global.d.ts. - Avoid
@ts-ignore; use proper casting instead. - Use
constandletinstead ofvar— the goja runtime supports them.
Hook API exposure model
In hook JavaScript, the server injects one top-level object: context.
That means runtime helpers and registered packages are accessed through context, for example:
context.request()context.db.find()context.http.fetch()context.smtp.sendMail()context.debug.dump()context.exec.command()
Do not silently rewrite these to bare request(), db.find(), or http.fetch() when editing docs or examples for hook code.
context.filter — Go object quirk
context.filter is a Go object, not a regular JS object. Even when empty, it is truthy.
Always check with Object.keys():
const requestedFilter =
context.filter &&
typeof context.filter === "object" &&
!Array.isArray(context.filter) &&
Object.keys(context.filter).length > 0
? context.filter
: null
Never use context.filter || null — it is always truthy and produces an empty filter inside $and, which crashes the Go server.
Single-item vs. list retrieval
For GET /:collection/:id, the Go server sets _id automatically from the URL parameter.
GET read hooks should not set their own _id filter for req.param("id"). Only add authorization filters (e.g. { userId: userId }).
Current hook surfaces that matter for website projects
- Collection CRUD hooks under
get,post,put,delete - Bulk hooks for optimized bulk operations
audit.returnhooks for stripping sensitive data from audit outputactions:hook chains for endpoint-like behavior without a backing CRUD collection
For website builds on this starter, do not force everything into collections. Contact forms, newsletter signups, webhook receivers, import jobs, calculators, or other endpoint-style logic often belong into actions: instead.
HookResponse fields (GET hooks)
| Field | Purpose |
|---|---|
filter |
MongoDB filter (list retrieval, or restrict single-item) |
selector |
MongoDB projection ({ field: 0 } exclude, { field: 1 } include) |
offset |
Pagination offset |
limit |
Pagination limit |
sort |
Sort specification |
pipelineMod |
Function to manipulate the aggregation pipeline |
context.data for write hooks
context.datacan be an array for bulk operations — always guard with!Array.isArray(context.data).- For POST hooks,
context.data.idmay contain the new entry ID. - For PUT,
req.param("id")gives the entry ID.
Bulk and optimized paths
- tibi-server supports optimized bulk paths.
- In bulk scenarios,
bindstill runs once at the start. - Per-document validation/update/delete hooks may be skipped depending on the chosen bulk path.
If a website feature depends on per-entry logic, do not assume a bulk update behaves exactly like N single updates. Check whether a dedicated bulk hook exists or whether the optimized path changes the behavior you rely on.
Action hooks
Actions are first-class endpoints and should be part of the skill set for complete website builds.
Typical action steps:
post.bindpost.validatepost.handlepost.returnget.handleget.return
Use actions when the website needs business logic without a CRUD collection.
Typical website use cases:
- contact forms
- newsletter signups
- quote/order requests
- webhook receivers
- utility endpoints
- AI-assisted helper endpoints
Practical hook patterns for this starter family
- public read filtering for
active/publication state - SSR cache invalidation after writes
- route-level SSR validation
- mutation safeguards for readonly/system-managed fields
- custom form/action validation
- audit-output sanitizing for sensitive fields
Public filter and publication contract
For website projects, filter_public.js and publishedFilter are not optional examples. They are part of the public-delivery contract.
Later agents should validate all of these deliberately:
- anonymous public reads see only the intended active/published records
- token-backed or admin-backed reads can still reach records needed for cleanup or operator workflows
- collections that feed navigation or pages do not silently disappear because
active: truewas forgotten
If the public site depends on a collection, a broken public filter is a delivery bug, not only a hook bug.
Mutation-side SSR invalidation
If a mutation can change rendered HTML, the invalidation belongs in hooks.
Typical SSR-critical mutation domains:
- content
- navigation
- medialib or page-critical referenced media
- publication-relevant fields
For these collections, later agents should verify:
- which mutation steps call cache-clearing behavior
- whether post/put/delete are all covered when needed
- whether a representative mutation actually changes the next SSR response
Public filter: token bypass for testdata cleanup
filter_public.js applies publishedFilter (active=true + publication window) to all unauthenticated GET requests. This works well for public traffic but causes a problem: Playwright test cleanup can't see inactive _testdata entries because context.user.auth() returns false even when a static Token: header is present. The filter runs, inactive entries are hidden from the API response, and the cleanup never deletes them. Over multiple test runs, stale entries accumulate in MongoDB.
Fix: check for any auth header in filter_public.js and skip the filter when present:
const req = context.request()
const hasToken =
req.header &&
(req.header("Token") || req.header("X-Admin-Token") || req.header("X-Auth-Token") || req.header("Authorization"))
if (!context.user.auth() && !hasToken) {
// apply publishedFilter
}
This way:
- Anonymous requests → public filter applies (only active entries visible)
- Requests with Token header → no filter → all entries visible → cleanup works
The same fix applies to any collection that uses a public filter hook and also receives testdata writes from Playwright.
Common pitfalls
- Do not assume browser/Node APIs in hooks. The runtime is goja-based server-side JS.
- Do not treat actions as fake collections unless there is a good reason.
- Do not assume bulk hooks run per document.
- Do not build SSR/cache logic into frontend code when the invalidation belongs in hooks.
Verification checklist
After hook-related changes, verify all of these:
- the hook is attached to the right lifecycle step
- actions are used for endpoint workflows instead of fake collections
- anonymous vs token-backed reads behave correctly where public filtering exists
- representative valid and invalid action submissions behave as designed
- representative SSR-critical mutations invalidate or preserve cache as intended
- bulk behavior is understood when the workflow depends on per-document logic
Sending emails from hooks (context.smtp.sendMail())
The sendMail() function is registered on context.smtp (NOT as a global). Always call via:
context.smtp.sendMail({
to: "recipient@example.com", // string or string[]
cc: "cc@example.com", // optional
bcc: "bcc@example.com", // optional
from: "sender@example.com", // required
fromName: "Sender Name", // optional
replyTo: "reply@example.com", // optional
subject: "Subject line",
plain: "Plain text version",
html: "<h1>HTML version</h1>", // optional
})
The SMTP host is configured via:
- Server-level
config.yml:mail.host - Environment variable:
MAIL_HOST(e.g.maildev:1025)
MailDev (dev SMTP server) runs in the Docker stack at maildev:25 (SMTP) with a web UI at :1080.
publishedFilter: active: true erforderlich
Der publishedFilter in api/hooks/config.js filtert nach active: true:
const publishedFilter = {
active: true,
$or: [ ... ]
}
Einträge OHNE active-Feld werden bei öffentlichen API-Calls UNSICHTBAR. Das betrifft besonders:
- Navigationseinträge – werden via
getCachedEntries("navigation", ...)geladen. Fehltactive: true, bleibtnavItemsleer. - Manuell via API/MongoDB angelegte Einträge – das
active-Feld muss explizit gesetzt werden.
Der filter_public.js-Hook überspringt den Filter nur wenn ein Token-Header gesetzt ist. Bei öffentlichen API-Calls (z.B. aus dem SPA ohne Token) greift der Filter immer. Daher: alle Einträge in allen Collections müssen active: true haben, sonst sind sie auf der Website nicht sichtbar.