Content Rendering¶
Audience: Developers extending the rendering surface for course bodies, blog posts, marketing pages, or new entry types.
Scope: How Portable Text and the shared widget renderers turn CMS payloads into LMS DOM. Authoritative behaviour lives in
packages/portable-text,packages/widget-renderers, andpackages/widget-wire-schemas.
Pipeline¶
CMS payload (JSON)
→ Zod parse (apps/lms/src/schemas)
→ @portabletext/react serialiser (packages/portable-text)
→ Widget React component (packages/widget-renderers)
→ DOM
Both apps share the same packages so the rendered output of a Portable Text block is identical in CMS preview and the LMS public site.
Portable Text¶
- Library:
@portabletext/react, wrapped by the shared kernel inpackages/portable-text. - Block-level types include
block,image,code, and any custom widget container types declared in the wire schemas. - Inline marks include the standard set (
strong,em,code,link) plus any project-specific marks declared by the CMS. - The kernel is host-agnostic; LMS and CMS each provide an adapter that wires the kernel to host components (
Link,Image, etc.). - All
rawHtmlblocks are sanitized server-side incmsFetchviasrc/lib/cms/sanitize-cms-html.tsbefore payloads reach client renderers.
Widget Renderers¶
- React components live in
packages/widget-renderers. - Wire shapes live in
packages/widget-wire-schemas(Zod). Both apps validate against the same schemas so the contract cannot drift. - A widget renderer is a pure function of its (validated) payload — no side effects, no direct DB access. State (e.g. quiz attempts) is owned by the host route and passed in.
- The package exposes a
/metadatasub-entry for server-safe metadata access (titles, types) without pulling client component bundles.
Cross-widget appearance properties¶
- Shared rendering chrome options belong in
widget.properties.appearanceon the wire schema, not in per-widgetcontent. appearance.bordercontrols whetherWidgetShellrenders the card border. Missing/truekeeps default card chrome;falseapplies borderless styling.appearance.backgroundcontrols whetherWidgetShellrenders an opaque card background. Missing/truekeeps default card chrome;falseappliesbg-transparentand removes shadow chrome.appearance.paddingcontrols whetherWidgetShell(and text admonition variants) use default card padding. Missing/truekeepspy-6/px-6chrome;falseappliespy-0 gap-0on the card andpx-0on header/content (admonitions userichtext-widget--trim-paddingin design tokens).appearance.showTitlecontrols whether the widget title fromwidget.titleis shown in preview/LMS. Missing/truekeeps the title when non-empty;falsehides the defaultWidgetShelltitle and the Free Text admonition banner label. Custom-header widgets (casestudy,udl-content) are unaffected; the CMS editor omits this toggle for those types only.- Keep
appearanceoptional and additive so older payloads continue to parse safely. - Use the same pattern for future cross-widget rendering options (for example spacing or elevation), then map them centrally in
WidgetShell. CmsAuthoredPagePreviewBodywraps widget blocks in a borderlesscms-authored-preview-viewportwithbg-background(same as CMSworkarea-preview-viewport/ full preview page), notbg-card, soappearance.background === falseshows the page surface. Marketing horizontal inset:SiteShell(w-[90%]+px-6) is the sole page gutter on public CMS routes rendered viaCmsPageSurface. Widgets keep their authoredappearancebehavior (default padded whenappearance.paddingis missing/true, flush only when explicitlyfalse), so LMS rendering matches CMS preview. The preview viewport does not add horizontal padding;displayTitle/showPreviewHeadingcontrol only the hero<h1>(text-4xl font-semibold tracking-tight md:text-5xl). Header-to-content vertical gap is owned byPageContent(pt-2).- Text widgets with non-
defaultadmonition variants share the sameappearancemapping viaresolveWidgetAppearance(Tailwind on the card plusrichtext-widget--hide-border,richtext-widget--hide-background, andrichtext-widget--trim-paddingmodifiers in design tokens).
Adapters¶
apps/lms/src/lib/cms/rewrite-asset-urls.tsrewrites CMS/api/assets/{uuid}paths to same-origin proxies (/api/cms/site/assets/{uuid}for site pages,/api/cms/courses/{slug}/assets/{uuid}for course payloads).adaptPageDetailResponseaccepts a rewrite mode: site pages use the site proxy; course entry bodies pass{ kind: "course", courseSlug }so widget images resolve to the course proxy.- Course asset proxies are public for published courses only (404 for unknown or unpublished slugs), which keeps
next/imageoptimization working without session cookies on the optimizer fetch. - This rewrite runs server-side before the payload is passed to the renderer so client bundles never carry CMS host references.
Leading image LCP contract¶
- LMS computes the first image URL in page render order with
findLeadingMediaSrcfrom the already rewrittenpage.blockspayload. CmsAuthoredPagePreviewBodypasses that URL intoLeadingMediaPriorityProvider, so every nested image renderer (PortableTextImage,LmsWidgetImage) shares one deterministic match rule:src === leadingMediaSrc.- The matched image uses
loading="eager"withfetchPriority="high"; all other images default toloading="lazy". - This applies to site pages (
/api/cms/site/assets/{uuid}) and course pages (/api/cms/courses/{slug}/assets/{uuid}) without route-specific wiring.
LMS Components (embeddable sections)¶
Three CMS widgets embed live LMS sections onto any CMS-authored page block:
| Widget type | LMS section component | Route hardcoded fallback |
|---|---|---|
lms-featured-courses |
apps/lms/src/components/sections/featured-courses-section |
none |
lms-blog-listing |
apps/lms/src/components/sections/blog-listing-section |
none |
lms-course-catalog |
apps/lms/src/components/sections/course-catalog-section |
none |
These widgets are zero-config (empty content schema; no author settings). Rendering is driven by surface:
- CMS surfaces (
cms-preview): the sharedLmsComponentPlaceholderWidgetrenders a grey block with the centered widget title. No data is fetched. - LMS (
lms-learner): live, server-data-backed sections.
Server data resolution¶
Because renderers are pure functions of their payload but these sections need tenant-scoped data, the LMS resolves data on the server and injects it into the client tree:
- A route that renders a CMS body (home, learn index, blog index,
\[slug\], blog post, lesson entry) callspageHasLmsWidgets(blocks). Only when an LMS widget is present does itawait searchParamsand callresolveLmsWidgetData(blocks, { locale, search })(apps/lms/src/lib/cms/resolve-lms-widget-data.ts). Pages without LMS widgets stay statically rendered. resolveLmsWidgetDatascans blocks, resolves each distinct widget type at most once (tenant context +db+ CMS client, reusinggetFeaturedCourses/getCatalogCourses/getPageList), and returnsRecord<widgetId, ResolvedLmsWidgetData>. Public/unknown hosts andCmsIntegrationErrordegrade to empty results.- The map threads through
CmsAuthoredPagePreviewBody→CmsAuthoredPagePreviewViewport, which wraps the tree inResolvedLmsWidgetDataProvider. SharedWidgetViewregisters LMS override renderers (LmsFeaturedCoursesWidget,LmsBlogListingWidget,LmsCourseCatalogWidget) for the three types. Each reads its data viauseResolvedLmsWidgetData(widget.id)and renders the matching section, or the grey placeholder when no data is resolved.
Search, pagination, and config¶
- Blog and Catalog widgets share the hosting page's
?search=/?page=URL contract. CMS enforces a single-instance group for these two widgets (lms-section), so a page can include at most one of {Blog Listing, Course Catalog}. TheWidgetPaginatorbuilds links from the currentusePathname()so pagination works regardless of which page hosts the widget. - Tunables are LMS env vars (no author config):
LMS_FEATURED_COURSES_LIMIT(featured card count, default 6). Blog/catalog page size uses sharedDEFAULT_PAGE_SIZEfrom@open-learning-hub/platform-config, with optional override viaNEXT_PUBLIC_PAGE_SIZE(default 20; clamped to 1-100). - Routes hosting an LMS widget become dynamic for that request (tenant context reads
headers()).
Adding a New Widget Type¶
When the CMS introduces a new widget:
- Add the wire shape (Zod) to
packages/widget-wire-schemas. - Add the renderer to
packages/widget-renderers. - Update the registry that maps
_typeto renderer. - Add component tests next to the renderer (every renderer ships with at least one).
- Update the LMS / CMS consumer routes only if they need to inject host context (e.g. asset rewrite, quiz answer state).
Adding a New Entry Type¶
Entry types live in the LMS schemas under apps/lms/src/schemas:
- Add the discriminated-union variant (e.g.
{ _type: 'video', ... }). - Update
getEntryAccess()insrc/lib/course/gating.tsonly if the access semantics differ from lesson/quiz. - Add a renderer under
apps/lms/src/components/course/keyed off_type. - Add Vitest specs for the renderer (light + dark, key interactions).
- Update
reference/hierarchy-structure.md.
Accessibility¶
- Widget renderers MUST meet WCAG 2.1 AA: visible focus, programmatic labels, captions/alt text, keyboard support.
- Decorative images use
alt=""oraria-hidden="true". - Long-running content (videos, audio) MUST NOT autoplay (WCAG 1.4.2).
CMS integration failures¶
The LMS fetches CMS content through cmsFetch in apps/lms/src/lib/cms/client.ts. Failures are classified by CmsErrorKind in apps/lms/src/schemas/cms/errors.ts:
| Kind | Typical cause | Log event | Severity |
|---|---|---|---|
unauthorized |
Invalid or expired CMS_API_UUID (401/403) |
cms.fetch.unauthorized |
error |
unavailable |
Network error or CMS 5xx (status 0 or ≥500) | cms.fetch.unavailable |
error |
invalid_response |
Non-JSON body or Zod schema mismatch | cms.fetch.schema_mismatch (validation detail) |
error |
bad_request |
CMS rejected the request (400) | cms.fetch.non_ok |
warn |
unknown |
Other non-OK HTTP status | cms.fetch.non_ok |
warn |
Auth and infrastructure failures log at error level with structured context (path, status, kind). For unauthorized, logs include a keyPrefix (first eight characters of CMS_API_UUID only — never the full key) and a hint to rotate the key and clear ISR cache.
In development, CmsDevDiagnostic renders an accessible banner on CMS-authored routes (blog index, generic CMS pages) when a CmsIntegrationError is caught, so an expired API key is visible instead of silently serving stale ISR content.
All CMS integration errors flow through reportCmsError in client.ts — the seam that reports hard failures to PostHog via serverAnalytics().captureException (see admin/observability.md).
Operator runbook: expired API key¶
- Recognize: server logs show
cms.fetch.unauthorized; dev UI shows "CMS API key invalid or expired". - Verify: call
GET {CMS_API_URL}/mewith the currentx-api-key— expect 401 when expired. - Fix: generate a new CMS API key (Admin → API keys), update
CMS_API_UUIDinapps/lms/.env.local, restart the LMS dev server. - Clear stale cache: delete
.next/cacheor restart so ISR does not keep serving content fetched before the key expired.
CMS → LMS revalidation webhook (CMS_WEBHOOK_SECRET)¶
The LMS exposes a cache-invalidation receiver at POST /api/cms/revalidate (route.ts). It lets the CMS push fresh content immediately instead of waiting for the 60-second ISR window.
Receiver contract¶
- Auth: shared secret via
Authorization: Bearer <CMS_WEBHOOK_SECRET>orx-cms-webhook-secretheader. Missing or mismatched secrets return401. - Payload: JSON with exactly one of
{ "tag": "course:slug" }or{ "tags": ["course:slug", "entry:slug:entry"] }. - Allowed tag prefixes:
course:,entry:,site:,cms:page-hierarchy,progress:(see.cursor/rules/050-apis.mdc). - Rate limit: 60 requests per minute per IP.
- Response:
{ "revalidated": ["…"] }on success.
Shared-secret handshake¶
Both apps must hold the same secret value:
| App | Variable | Role |
|---|---|---|
| LMS | CMS_WEBHOOK_SECRET |
Validates incoming webhook requests |
| CMS (future) | LMS_WEBHOOK_SECRET or equivalent |
Sends the secret when POSTing to the LMS |
On publish/unpublish, the CMS sender would POST to {LMS_BASE_URL}/api/cms/revalidate with the secret and tags for affected content. Rotate the secret in both apps together; old secrets stop working immediately on the LMS side.
Example (manual test):
curl -X POST "http://demo.lvh.me:3001/api/cms/revalidate" \
-H "Authorization: Bearer $CMS_WEBHOOK_SECRET" \
-H "Content-Type: application/json" \
-d '{"tags":["cms:page-hierarchy"]}'
Known gaps (future work)¶
- No CMS sender yet — nothing in
apps/cmsPOSTs to the LMS revalidate endpoint today; content updates rely on the 60s ISR window (or fail entirely whencmsFetcherrors). - Tag allow-list mismatch —
cmsFetchtags fetches with identifiers the webhook does not accept, for examplepage:<id>,cms:site-project-theme, andcms:coursesinclient.ts. A future CMS sender must reconcile tags with the allow-list (or extend the allow-list) so targeted page revalidation works end-to-end.
Related References¶
- Hierarchy Structure — Course → Module → Entry shape.
- Quiz System — quiz wire shape and grading contract.
- Entry Pagination System — gating / preview / progress.
packages/widget-renderers/AGENTS.md.packages/portable-text/AGENTS.md.packages/widget-wire-schemas/AGENTS.md.