Check Performance Data - Guidance pages and content staging (`288673_guidance-landing-page` branch)
Two features ship together on this branch:
-
Guidance pages (288673) — a
/guidancelanding page and the long/guidance/2026-ks4-june-checking-exercisepage, both built from CMS content blocks against a section manifest in code. - Content staging (289182) — export/import of wiki pages and content blocks between environments as a schema-versioned JSON bundle, with GUID identity and an import preview.
Plus supporting work: content-block search, custom SEO slugs, and two additive migrations.
What's on the branch
Guidance pages
-
Two MVC pages composed from CMS content blocks, not wiki pages.
GuidanceControllerexposesGET /guidance(Index(), no view model) andGET /guidance/2026-ks4-june-checking-exercise(Ks4June2026(), passes the staticGuidancePage.Ks4June2026). Chrome/header/footer are the shared_Layout— untouched. -
A section manifest in code is the single source of truth.
GuidancePage.Ks4June2026is aGuidancePagerecord holding the page's block keys plus an orderedIReadOnlyList<GuidanceSection>. The manifest — not the CMS — owns the page's structure: section order, anchors, heading levels, and which content-block key fills each slot. Editors change the content of each block; they cannot change the page's skeleton. -
Headings and body are separate editable blocks. Each section is rendered as a
<section id="{Anchor}">wrapper containing anEditableTitle(the heading block) and anEditableContent(the body block). The template owns theidattribute so the HTML sanitiser never sees it — anchors can never be stripped or rewritten by an editor. -
The contents nav is one editable block, not auto-generated at runtime.
GuidancePage.NavBlockKeypoints at a single block holding the side-nav markup (canonical MoJ side-navigation, with H3 sub-sections nested under their H2). It is rendered raw. The nav block's HTML is generated offline from the same section list (bootstrap tooling, outside the repo) and seeded like any other block, so the nav and the manifest stay in lockstep without a per-request build. -
Landing page (
Index.cshtml) is similarly composed fromguidance-landing-*blocks: search box, sign-in, email-alerts, and card grids whose cards deep-link into KS4-page sections.
Content staging (CMS export/import)
-
Schema-versioned JSON bundle.
ContentBundlecarries$schema = "cpd-content-v1"(ContentBundle.CurrentSchema) andSchemaVersion = 1(CurrentSchemaVersion), plus optionalExportedAtUtc/ExportedBymetadata and theWikiPages/ContentBlockscollections. Serialised camelCase, indented (diff-friendly), enums as strings, nulls omitted (ContentStagingJson.Options). -
GUID identity, decoupled from slug/key. Every wiki page and content block carries a stable
ContentIdGUID (new columns — see migrations). Import matches by GUID, never by slug or key, so a page renamed or re-slugged in one environment still updates the right row in another. An unknown id (orGuid.Empty) means "create new". -
Selective or whole-environment export.
GET /admin/content-staging/exportdownloads everything;GET …/selectlists theContentCatalog, andPOST …/exportexports the ticked pages/blocks plus all ancestor pages (so a child never exports without its parent chain). -
Import preview with per-collision decisions.
POST …/previewparses an uploaded bundle and returns a dry-runContentImportPreview(per-itemExists/ExistingDescription/ParentMissing, plusNewCount/CollisionCount/BlockedCount). The reviewer picks an import mode and can override it per item, thenPOST …/importapplies it. -
Three import modes (
ContentImportMode):Skip(add missing only, leave existing untouched),Replace(overwrite existing — blocks record a new version),Fail(abort the whole import if any collision remains unresolved). The service guards up front and throwsContentImportConflictExceptionif aFail-mode collision is left without a per-itemSkip/Replace.
Content-block search
-
ContentBlockSearchService.SearchAsync(wired into/help/search) searches content blocks alongside wiki pages. It rejects terms under two characters, over-fetches to de-duplicate by destination URL, builds a<mark>-highlighted snippet (everything HTML-encoded except the mark tag), and resolves each hit to a clickable, anchored URL. -
ContentBlockLocations.Resolve(key)is the static block-key → page-URL + anchor map that makes deep-linking work:home-*→/,guidance-landing-*→/guidance,guidance-ks4-2026-<section>→/guidance/2026-ks4-june-checking-exercise#<section>(a-headingsuffix resolves to the same anchor). Unmapped keys return null and never surface in search.
The KS4 page manifest
GuidancePage (record):
| Field | Purpose |
|---|---|
Title |
Page heading text |
TitleBlockKey |
Editable Title block for the main heading |
IntroBlockKey |
Editable lede/intro block |
PublishedBlockKey |
Editable "published / last reviewed" blue callout |
NavBlockKey |
The single editable block holding the side-nav HTML |
Sections |
Ordered GuidanceSection list — the page skeleton |
GuidanceSection (record):
| Field | Purpose |
|---|---|
Anchor |
Stable, slug-safe id; rendered as the <section id> (template-owned, never sanitised) |
NavTitle |
Heading text, also the nav link label |
Level |
2 top-level, 3 nested (default 2) |
HeadingBlockKey |
Editable Title block for the section heading |
BlockKey |
Editable content block for the section body |
HeadingCssClass (computed)
|
govuk-heading-l (L2) / govuk-heading-m (L3) |
HeadingElement (computed)
|
h2 (L2) / h3 (L3) |
The KS4 manifest is 31 sections in the guidance-ks4-2026- namespace: 17 level-2 sections and 14 level-3 sections nested under "Pupil removal reason". Block keys are derived from the anchor: {namespace}{anchor} for the body, {namespace}{anchor}-heading for the heading — so adding a section is a one-line manifest edit plus seeding the two new blocks.
Database
Two additive migrations (apply clean on a fresh DB and over current main):
-
20260626070144_ContentId_PagesAndBlocks— adds aContentIduuidcolumn toWikiPagesandContentBlocks, each defaulting togen_random_uuid()with a unique index (IX_WikiPages_ContentId,IX_ContentBlocks_ContentId). This is the cross-environment identity content staging matches on. -
20260626084300_ContentBlock_LastSeenPath— adds nullableLastSeenPath(text) andLastSeenAt(timestamptz) toContentBlocks. The editable view components record the request path each time a block is rendered, so the content-blocks admin page can show which page uses this block — essential for dynamically-keyed blocks that a code scan can't find.
Content is CMS data, not migrations
The guidance pages render their structure from the manifest, but their content lives in CMS content blocks, which are not part of any migration or auto-seeder. A fresh environment (including a PR review app) renders the page skeleton with "Content to be added" placeholders until the blocks are seeded — this is expected, not a bug. Seeding is done out-of-band via the controller surface (POST /content-block/save), the same path content staging import uses.
(Separately, the admin rules editor gets its data from the rules-config blobs, which the web app self-seeds on startup — see E2E-Playwright.md under "Test data isolation".)
Check the data directory
For an example file to import to populate the CMS
Testing
- Unit — guidance section/manifest mapping, content-block search (HTML-encoding +
<mark>safety), slug generation, content-block service/controller. - Integration (Testcontainers Postgres) — content-staging export/import round-trip and both migrations on a real database.
- E2E — guidance landing + KS4 structure and the content-staging admin pages (export / select / content-blocks), including anonymous-redirect guards.