Skip to content

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, and packages/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 in packages/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 rawHtml blocks are sanitized server-side in cmsFetch via src/lib/cms/sanitize-cms-html.ts before 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 /metadata sub-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.appearance on the wire schema, not in per-widget content.
  • appearance.border controls whether WidgetShell renders the card border. Missing/true keeps default card chrome; false applies borderless styling.
  • appearance.background controls whether WidgetShell renders an opaque card background. Missing/true keeps default card chrome; false applies bg-transparent and removes shadow chrome.
  • appearance.padding controls whether WidgetShell (and text admonition variants) use default card padding. Missing/true keeps py-6 / px-6 chrome; false applies py-0 gap-0 on the card and px-0 on header/content (admonitions use richtext-widget--trim-padding in design tokens).
  • appearance.showTitle controls whether the widget title from widget.title is shown in preview/LMS. Missing/true keeps the title when non-empty; false hides the default WidgetShell title 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 appearance optional 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.
  • CmsAuthoredPagePreviewBody wraps widget blocks in a borderless cms-authored-preview-viewport with bg-background (same as CMS workarea-preview-viewport / full preview page), not bg-card, so appearance.background === false shows the page surface. Marketing horizontal inset: SiteShell (w-[90%] + px-6) is the sole page gutter on public CMS routes rendered via CmsPageSurface. Widgets keep their authored appearance behavior (default padded when appearance.padding is missing/true, flush only when explicitly false), so LMS rendering matches CMS preview. The preview viewport does not add horizontal padding; displayTitle / showPreviewHeading control only the hero <h1> (text-4xl font-semibold tracking-tight md:text-5xl). Header-to-content vertical gap is owned by PageContent (pt-2).
  • Text widgets with non-default admonition variants share the same appearance mapping via resolveWidgetAppearance (Tailwind on the card plus richtext-widget--hide-border, richtext-widget--hide-background, and richtext-widget--trim-padding modifiers in design tokens).

Adapters

  • apps/lms/src/lib/cms/rewrite-asset-urls.ts rewrites 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).
  • adaptPageDetailResponse accepts 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/image optimization 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 findLeadingMediaSrc from the already rewritten page.blocks payload.
  • CmsAuthoredPagePreviewBody passes that URL into LeadingMediaPriorityProvider, so every nested image renderer (PortableTextImage, LmsWidgetImage) shares one deterministic match rule: src === leadingMediaSrc.
  • The matched image uses loading="eager" with fetchPriority="high"; all other images default to loading="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 shared LmsComponentPlaceholderWidget renders 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:

  1. A route that renders a CMS body (home, learn index, blog index, \[slug\], blog post, lesson entry) calls pageHasLmsWidgets(blocks). Only when an LMS widget is present does it await searchParams and call resolveLmsWidgetData(blocks, { locale, search }) (apps/lms/src/lib/cms/resolve-lms-widget-data.ts). Pages without LMS widgets stay statically rendered.
  2. resolveLmsWidgetData scans blocks, resolves each distinct widget type at most once (tenant context + db + CMS client, reusing getFeaturedCourses / getCatalogCourses / getPageList), and returns Record<widgetId, ResolvedLmsWidgetData>. Public/unknown hosts and CmsIntegrationError degrade to empty results.
  3. The map threads through CmsAuthoredPagePreviewBodyCmsAuthoredPagePreviewViewport, which wraps the tree in ResolvedLmsWidgetDataProvider.
  4. SharedWidgetView registers LMS override renderers (LmsFeaturedCoursesWidget, LmsBlogListingWidget, LmsCourseCatalogWidget) for the three types. Each reads its data via useResolvedLmsWidgetData(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}. The WidgetPaginator builds links from the current usePathname() 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 shared DEFAULT_PAGE_SIZE from @open-learning-hub/platform-config, with optional override via NEXT_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:

  1. Add the wire shape (Zod) to packages/widget-wire-schemas.
  2. Add the renderer to packages/widget-renderers.
  3. Update the registry that maps _type to renderer.
  4. Add component tests next to the renderer (every renderer ships with at least one).
  5. 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:

  1. Add the discriminated-union variant (e.g. { _type: 'video', ... }).
  2. Update getEntryAccess() in src/lib/course/gating.ts only if the access semantics differ from lesson/quiz.
  3. Add a renderer under apps/lms/src/components/course/ keyed off _type.
  4. Add Vitest specs for the renderer (light + dark, key interactions).
  5. 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="" or aria-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

  1. Recognize: server logs show cms.fetch.unauthorized; dev UI shows "CMS API key invalid or expired".
  2. Verify: call GET {CMS_API_URL}/me with the current x-api-key — expect 401 when expired.
  3. Fix: generate a new CMS API key (Admin → API keys), update CMS_API_UUID in apps/lms/.env.local, restart the LMS dev server.
  4. Clear stale cache: delete .next/cache or 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> or x-cms-webhook-secret header. Missing or mismatched secrets return 401.
  • 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)

  1. No CMS sender yet — nothing in apps/cms POSTs to the LMS revalidate endpoint today; content updates rely on the 60s ISR window (or fail entirely when cmsFetch errors).
  2. Tag allow-list mismatchcmsFetch tags fetches with identifiers the webhook does not accept, for example page:<id>, cms:site-project-theme, and cms:courses in client.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.