From 9ebc3828ba2c0266338a26484903b2d815dc18ee Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 13 May 2026 16:37:40 +0100 Subject: [PATCH 01/40] feat: add minimal preview base layout for page type previews --- src/server/plugins/engine/views/preview-layout.html | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/server/plugins/engine/views/preview-layout.html diff --git a/src/server/plugins/engine/views/preview-layout.html b/src/server/plugins/engine/views/preview-layout.html new file mode 100644 index 000000000..cb0dbe444 --- /dev/null +++ b/src/server/plugins/engine/views/preview-layout.html @@ -0,0 +1 @@ +{% block content %}{% endblock %} From e187021f669f064492035a259ea6d128f1e2bda7 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 13 May 2026 16:39:47 +0100 Subject: [PATCH 02/40] feat: add generate-page-previews module with renderPage and writePagePreviewPartial --- scripts/generate-page-previews.js | 44 ++++++++ scripts/generate-page-previews.test.js | 139 +++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 scripts/generate-page-previews.js create mode 100644 scripts/generate-page-previews.test.js diff --git a/scripts/generate-page-previews.js b/scripts/generate-page-previews.js new file mode 100644 index 000000000..63c5bb135 --- /dev/null +++ b/scripts/generate-page-previews.js @@ -0,0 +1,44 @@ +import fs from 'fs' +import path from 'path' + +import { buildPartialMdx } from './generate-component-previews.js' + +import { environment } from '~/src/server/plugins/nunjucks/environment.js' + +/** + * Render a single page fixture context to an HTML string. + * Passes baseLayoutPath: 'preview-layout.html' to strip the GOV.UK page wrapper, + * leaving only the {% block content %} output from the page template. + * @param {object} context + * @param {string} viewName + * @returns {string} + */ +export function renderPage(context, viewName) { + return environment.render(`${viewName}.html`, { + ...context, + baseLayoutPath: 'preview-layout.html' + }) +} + +/** + * Renders all variants (or single context) for a page fixture and writes the + * MDX partial to previewsDir/.mdx. + * @param {string} previewsDir + * @param {string} slug + * @param {{ viewName: string, context?: object, variants?: Array<{label: string, context: object}> }} fixture + */ +export function writePagePreviewPartial(previewsDir, slug, fixture) { + fs.mkdirSync(previewsDir, { recursive: true }) + + const renders = fixture.variants + ? fixture.variants.map(({ label, context }) => ({ + label, + html: renderPage(context, fixture.viewName) + })) + : [{ html: renderPage(fixture.context, fixture.viewName) }] + + fs.writeFileSync( + path.join(previewsDir, `${slug}.mdx`), + buildPartialMdx(renders) + ) +} diff --git a/scripts/generate-page-previews.test.js b/scripts/generate-page-previews.test.js new file mode 100644 index 000000000..9a257b2c2 --- /dev/null +++ b/scripts/generate-page-previews.test.js @@ -0,0 +1,139 @@ +// @ts-nocheck + +jest.mock('fs', () => ({ + mkdirSync: jest.fn(), + writeFileSync: jest.fn() +})) + +jest.mock('~/src/server/plugins/nunjucks/environment.js', () => ({ + environment: { render: jest.fn() } +})) + +jest.mock('./generate-component-previews.js', () => ({ + buildPartialMdx: jest.fn().mockReturnValue('') +})) + +import { mkdirSync, writeFileSync } from 'fs' + +import { buildPartialMdx } from './generate-component-previews.js' +import { + renderPage, + writePagePreviewPartial +} from './generate-page-previews.js' + +import { environment } from '~/src/server/plugins/nunjucks/environment.js' + +describe('renderPage', () => { + beforeEach(() => { + environment.render.mockReturnValue('

Page

') + }) + + it('calls environment.render with the correct template name', () => { + renderPage({ pageTitle: 'Test' }, 'index') + expect(environment.render).toHaveBeenCalledWith( + 'index.html', + expect.objectContaining({ pageTitle: 'Test' }) + ) + }) + + it('overrides baseLayoutPath with preview-layout.html', () => { + renderPage( + { pageTitle: 'Test', baseLayoutPath: 'something-else.html' }, + 'summary' + ) + expect(environment.render).toHaveBeenCalledWith( + 'summary.html', + expect.objectContaining({ baseLayoutPath: 'preview-layout.html' }) + ) + }) + + it('returns the rendered HTML string', () => { + environment.render.mockReturnValue('

Check your answers

') + const result = renderPage({ pageTitle: 'Check your answers' }, 'summary') + expect(result).toBe('

Check your answers

') + }) +}) + +describe('writePagePreviewPartial', () => { + beforeEach(() => { + environment.render.mockReturnValue('
page html
') + buildPartialMdx.mockReturnValue('') + }) + + it('creates the output directory', () => { + writePagePreviewPartial('/out/_previews', 'page-controller', { + viewName: 'index', + context: { pageTitle: 'Question' } + }) + expect(mkdirSync).toHaveBeenCalledWith('/out/_previews', { + recursive: true + }) + }) + + it('writes MDX to the correct path', () => { + writePagePreviewPartial('/out/_previews', 'summary-page-controller', { + viewName: 'summary', + context: { pageTitle: 'Check your answers' } + }) + expect(writeFileSync).toHaveBeenCalledWith( + '/out/_previews/summary-page-controller.mdx', + '' + ) + }) + + it('passes a single unlabelled render to buildPartialMdx for a flat fixture', () => { + writePagePreviewPartial('/out/_previews', 'page-controller', { + viewName: 'index', + context: { pageTitle: 'Question' } + }) + expect(buildPartialMdx).toHaveBeenCalledWith([ + { html: '
page html
' } + ]) + }) + + it('passes labelled renders to buildPartialMdx for a variant fixture', () => { + writePagePreviewPartial('/out/_previews', 'file-upload-page-controller', { + viewName: 'file-upload', + variants: [ + { label: 'No files uploaded', context: { pageTitle: 'Upload' } }, + { label: 'With files uploaded', context: { pageTitle: 'Upload' } } + ] + }) + expect(buildPartialMdx).toHaveBeenCalledWith([ + { label: 'No files uploaded', html: '
page html
' }, + { label: 'With files uploaded', html: '
page html
' } + ]) + }) + + it('renders each variant context independently', () => { + environment.render + .mockReturnValueOnce('
empty
') + .mockReturnValueOnce('
with files
') + + writePagePreviewPartial('/out/_previews', 'file-upload-page-controller', { + viewName: 'file-upload', + variants: [ + { + label: 'No files uploaded', + context: { pageTitle: 'Upload', formAction: null } + }, + { + label: 'With files uploaded', + context: { pageTitle: 'Upload', formAction: 'preview' } + } + ] + }) + + expect(environment.render).toHaveBeenCalledTimes(2) + expect(environment.render).toHaveBeenNthCalledWith( + 1, + 'file-upload.html', + expect.objectContaining({ formAction: null }) + ) + expect(environment.render).toHaveBeenNthCalledWith( + 2, + 'file-upload.html', + expect.objectContaining({ formAction: 'preview' }) + ) + }) +}) From f63c8d4b6cbe4cf7cd762bb543706469f32578c0 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 13 May 2026 16:57:08 +0100 Subject: [PATCH 03/40] feat: export generatePageMd and add previewSlug support for page type previews --- scripts/generate-component-docs.js | 14 ++++++- scripts/generate-component-docs.test.js | 51 ++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 81458ec01..84838f918 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -895,11 +895,12 @@ export function generatePageExample( * @param {string} examplePath * @param {number} sidebarPosition */ -function generatePageMd( +export function generatePageMd( controllerKey, uniqueProps, examplePath, - sidebarPosition + sidebarPosition, + previewSlug = null ) { const description = metadata.pages[controllerKey] if (!description) return null @@ -908,11 +909,16 @@ function generatePageMd( const isDefault = controllerKey === 'PageController' const links = metadata.pageLinks?.[controllerKey] ?? [] + const previewImport = previewSlug + ? [``, `import Preview from './_previews/${previewSlug}.mdx'`] + : [] + const lines = [ `---`, `sidebar_label: "${label}"`, `sidebar_position: ${sidebarPosition}`, `---`, + ...previewImport, ``, `# ${label}`, ``, @@ -933,6 +939,10 @@ function generatePageMd( lines.push(`**Controller value:** \`"${controllerKey}"\``, ``) } + if (previewSlug) { + lines.push(`## Preview`, ``, ``, ``) + } + lines.push( `## JSON definition`, ``, diff --git a/scripts/generate-component-docs.test.js b/scripts/generate-component-docs.test.js index 74823e0b3..48031b662 100644 --- a/scripts/generate-component-docs.test.js +++ b/scripts/generate-component-docs.test.js @@ -39,7 +39,7 @@ jest.mock('fs', () => ({ readdirSync: jest.fn(), readFileSync: jest.fn().mockImplementation((filePath) => { if (String(filePath ?? '').includes('component-metadata.json')) { - return '{"components":{"TextField":"Single-line text input."},"pages":{"PageController":"The default page type.","RepeatPageController":"Allows repeated answers."},"properties":{"rows":"Number of rows for the textarea."},"pageProperties":{"repeat.options.name":"Identifier for the repeatable section."}}' + return '{"components":{"TextField":"Single-line text input."},"pages":{"PageController":"The default page type.","RepeatPageController":"Allows repeated answers.","SummaryPageController":"Summary page type."},"properties":{"rows":"Number of rows for the textarea."},"pageProperties":{"repeat.options.name":"Identifier for the repeatable section."}}' } return '' }), @@ -55,6 +55,7 @@ import { generateComponentMd, generateExample, generatePageExample, + generatePageMd, placeholderForType, setNestedValue, simplifyType, @@ -289,6 +290,54 @@ describe('Component Documentation Generator', () => { }) }) + describe('generatePageMd with preview', () => { + it('includes preview import when previewSlug is provided', () => { + const result = generatePageMd( + 'PageController', + [], + '/page-path', + 1, + 'page-controller' + ) + expect(result).toContain( + "import Preview from './_previews/page-controller.mdx'" + ) + }) + + it('includes ## Preview section and when previewSlug is provided', () => { + const result = generatePageMd( + 'PageController', + [], + '/page-path', + 1, + 'page-controller' + ) + expect(result).toContain('## Preview') + expect(result).toContain('') + }) + + it('places ## Preview before ## JSON definition', () => { + const result = generatePageMd( + 'SummaryPageController', + [], + '/summary', + 1, + 'summary-page-controller' + ) + const previewIdx = result.indexOf('## Preview') + const jsonIdx = result.indexOf('## JSON definition') + expect(previewIdx).toBeGreaterThan(-1) + expect(previewIdx).toBeLessThan(jsonIdx) + }) + + it('omits import, ## Preview, and when previewSlug is absent', () => { + const result = generatePageMd('PageController', [], '/page-path', 1) + expect(result).not.toContain('import Preview') + expect(result).not.toContain('## Preview') + expect(result).not.toContain('') + }) + }) + describe('controllerLabel', () => { it('strips Controller suffix and formats words', () => { expect(controllerLabel('RepeatPageController')).toBe('Repeat Page') From 460974ba708cacc8b4d1b07e1dcb4d4174b24864 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 13 May 2026 17:03:05 +0100 Subject: [PATCH 04/40] docs: add missing JSDoc @param for previewSlug in generatePageMd --- scripts/generate-component-docs.js | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 84838f918..2f7c527e4 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -894,6 +894,7 @@ export function generatePageExample( * @param {Array<{name: string, type: string, optional: boolean}>} uniqueProps * @param {string} examplePath * @param {number} sidebarPosition + * @param {string|null} [previewSlug] */ export function generatePageMd( controllerKey, From 365b4478ab3e103abd378a8d3e414be7eb396117 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 13 May 2026 17:22:28 +0100 Subject: [PATCH 05/40] feat: add page-preview-fixtures with static context for all 6 page types --- scripts/page-preview-fixtures.js | 190 ++++++++++++++++++++++++++ scripts/page-preview-fixtures.test.js | 70 ++++++++++ 2 files changed, 260 insertions(+) create mode 100644 scripts/page-preview-fixtures.js create mode 100644 scripts/page-preview-fixtures.test.js diff --git a/scripts/page-preview-fixtures.js b/scripts/page-preview-fixtures.js new file mode 100644 index 000000000..83d4e552b --- /dev/null +++ b/scripts/page-preview-fixtures.js @@ -0,0 +1,190 @@ +import { ComponentType } from '@defra/forms-model' + +import { fixtures as componentFixtures } from './component-preview-fixtures.js' + +import { createComponent } from '~/src/server/plugins/engine/components/helpers/components.js' + +/** + * Derives a GOV.UK Frontend view model from the canonical component fixture. + * Handles both flat fixtures (def/model/payload at top level) and variant + * fixtures (variants array). When variantLabel is provided, finds the matching + * variant; otherwise uses the first variant (or the flat fixture itself). + * @param {string} name - Component fixture key, e.g. 'TextField' + * @param {string} [variantLabel] - Variant label, e.g. 'No files uploaded' + * @returns {object} + */ +function componentViewModel(name, variantLabel) { + const fixture = componentFixtures[name] + const variant = fixture.variants + ? (fixture.variants.find((v) => v.label === variantLabel) ?? + fixture.variants[0]) + : fixture + const component = createComponent(variant.def, { model: variant.model }) + return component?.getViewModel(variant.payload, []) ?? {} +} + +/** @type {Record }>} */ +export const pageFixtures = { + PageController: { + viewName: 'index', + context: { + pageTitle: 'What is your full name?', + showTitle: true, + page: { allowContinue: true }, + allowSaveAndExit: false, + components: [ + { + type: ComponentType.TextField, + model: componentViewModel('TextField') + } + ] + } + }, + + StartPageController: { + viewName: 'index', + context: { + pageTitle: 'Apply for a licence', + showTitle: true, + isStartPage: true, + page: { allowContinue: true }, + allowSaveAndExit: false, + components: [] + } + }, + + TerminalPageController: { + viewName: 'index', + context: { + pageTitle: 'You are not eligible', + showTitle: true, + page: { allowContinue: false }, + components: [ + { + type: ComponentType.Html, + model: { + content: + '

You do not meet the eligibility criteria for this service.

' + } + } + ] + } + }, + + RepeatPageController: { + viewName: 'repeat-list-summary', + variants: [ + { + label: 'No items', + context: { + pageTitle: 'People', + showTitle: true, + checkAnswers: [], + allowSaveAndExit: false + } + }, + { + label: 'One item', + context: { + pageTitle: 'People', + showTitle: true, + allowSaveAndExit: false, + checkAnswers: [ + { + summaryList: { + rows: [ + { + key: { text: 'Full name' }, + value: { text: 'Sarah Phillips' }, + actions: { items: [{ href: '#', text: 'Remove' }] } + } + ] + } + } + ] + } + } + ] + }, + + FileUploadPageController: { + viewName: 'file-upload', + get variants() { + return [ + { + label: 'No files uploaded', + context: { + pageTitle: 'Upload a document', + showTitle: true, + formAction: 'preview', + componentsBefore: [], + components: [], + formComponent: { + type: ComponentType.FileUploadField, + model: componentViewModel('FileUploadField', 'No files uploaded') + } + } + }, + { + label: 'With files uploaded', + context: { + pageTitle: 'Upload a document', + showTitle: true, + formAction: 'preview', + componentsBefore: [], + components: [], + formComponent: { + type: ComponentType.FileUploadField, + model: componentViewModel( + 'FileUploadField', + 'With files uploaded' + ) + } + } + } + ] + } + }, + + SummaryPageController: { + viewName: 'summary', + context: { + pageTitle: 'Check your answers', + allowSaveAndExit: false, + checkAnswers: [ + { + summaryList: { + rows: [ + { + key: { text: 'Full name' }, + value: { text: 'Sarah Phillips' }, + actions: { + items: [ + { + href: '#', + text: 'Change', + visuallyHiddenText: 'full name' + } + ] + } + }, + { + key: { text: 'Email address' }, + value: { text: 'sarah@example.gov.uk' }, + actions: { + items: [ + { + href: '#', + text: 'Change', + visuallyHiddenText: 'email address' + } + ] + } + } + ] + } + } + ] + } + } +} diff --git a/scripts/page-preview-fixtures.test.js b/scripts/page-preview-fixtures.test.js new file mode 100644 index 000000000..b0bebb196 --- /dev/null +++ b/scripts/page-preview-fixtures.test.js @@ -0,0 +1,70 @@ +// @ts-nocheck + +jest.mock( + '~/src/server/plugins/engine/components/helpers/components.ts', + () => ({ createComponent: jest.fn() }) +) + +import { pageFixtures } from './page-preview-fixtures.js' + +import { createComponent } from '~/src/server/plugins/engine/components/helpers/components.ts' + +beforeEach(() => { + createComponent.mockReturnValue({ + getViewModel: jest.fn().mockReturnValue({ id: 'field', name: 'field' }) + }) +}) + +describe('page-preview-fixtures', () => { + it('all 6 expected page types are present', () => { + const expected = [ + 'PageController', + 'StartPageController', + 'TerminalPageController', + 'RepeatPageController', + 'FileUploadPageController', + 'SummaryPageController' + ] + for (const key of expected) { + expect(pageFixtures[key]).toBeDefined() + } + }) + + it('every fixture has a viewName string', () => { + for (const [_key, fixture] of Object.entries(pageFixtures)) { + expect(typeof fixture.viewName).toBe('string') + } + }) + + it('every fixture has either context or variants, not neither', () => { + for (const [_key, fixture] of Object.entries(pageFixtures)) { + const hasContext = !!fixture.context + const hasVariants = !!fixture.variants + expect(hasContext || hasVariants).toBe(true) + } + }) + + it('variant fixtures have label and context on every variant', () => { + const variantFixtures = Object.entries(pageFixtures).filter( + ([, fixture]) => fixture.variants + ) + expect(variantFixtures.length).toBeGreaterThan(0) + for (const [, fixture] of variantFixtures) { + for (const variant of fixture.variants) { + expect(typeof variant.label).toBe('string') + expect(variant.context).toBeDefined() + } + } + }) + + it('FileUploadPageController and RepeatPageController have exactly 2 variants', () => { + expect(pageFixtures.FileUploadPageController.variants).toHaveLength(2) + expect(pageFixtures.RepeatPageController.variants).toHaveLength(2) + }) + + it('FileUploadPageController variants reference the FileUploadField component fixture', () => { + // createComponent should have been called when the module was loaded + // (context objects are evaluated at module load time) + expect(createComponent).toHaveBeenCalled() + }) +}) From 878271590d4ad64bd965cecabbf3b99ad02d885f Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 13 May 2026 17:24:15 +0100 Subject: [PATCH 06/40] fix: access formComponent.model in test to trigger lazy getter evaluation --- scripts/page-preview-fixtures.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/page-preview-fixtures.test.js b/scripts/page-preview-fixtures.test.js index b0bebb196..10d0ac22c 100644 --- a/scripts/page-preview-fixtures.test.js +++ b/scripts/page-preview-fixtures.test.js @@ -63,8 +63,9 @@ describe('page-preview-fixtures', () => { }) it('FileUploadPageController variants reference the FileUploadField component fixture', () => { - // createComponent should have been called when the module was loaded - // (context objects are evaluated at module load time) + for (const variant of pageFixtures.FileUploadPageController.variants) { + const _model = variant.context.formComponent.model // trigger lazy getter + } expect(createComponent).toHaveBeenCalled() }) }) From 779640d6840d19f1382d730a38de6a045597c23e Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 13 May 2026 17:32:53 +0100 Subject: [PATCH 07/40] fix: remove silent fallback in componentViewModel, strengthen test assertion --- scripts/page-preview-fixtures.js | 2 +- scripts/page-preview-fixtures.test.js | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/scripts/page-preview-fixtures.js b/scripts/page-preview-fixtures.js index 83d4e552b..a6b540524 100644 --- a/scripts/page-preview-fixtures.js +++ b/scripts/page-preview-fixtures.js @@ -20,7 +20,7 @@ function componentViewModel(name, variantLabel) { fixture.variants[0]) : fixture const component = createComponent(variant.def, { model: variant.model }) - return component?.getViewModel(variant.payload, []) ?? {} + return component.getViewModel(variant.payload, []) } /** @type {Record }>} */ diff --git a/scripts/page-preview-fixtures.test.js b/scripts/page-preview-fixtures.test.js index 10d0ac22c..079ef1f9b 100644 --- a/scripts/page-preview-fixtures.test.js +++ b/scripts/page-preview-fixtures.test.js @@ -2,9 +2,15 @@ jest.mock( '~/src/server/plugins/engine/components/helpers/components.ts', - () => ({ createComponent: jest.fn() }) + () => ({ + createComponent: jest.fn().mockReturnValue({ + getViewModel: jest.fn().mockReturnValue({ id: 'field', name: 'field' }) + }) + }) ) +import { ComponentType } from '@defra/forms-model' + import { pageFixtures } from './page-preview-fixtures.js' import { createComponent } from '~/src/server/plugins/engine/components/helpers/components.ts' @@ -66,6 +72,9 @@ describe('page-preview-fixtures', () => { for (const variant of pageFixtures.FileUploadPageController.variants) { const _model = variant.context.formComponent.model // trigger lazy getter } - expect(createComponent).toHaveBeenCalled() + expect(createComponent).toHaveBeenCalledWith( + expect.objectContaining({ type: ComponentType.FileUploadField }), + expect.anything() + ) }) }) From a38e1475a03ec5bc6cd0d5b746c98090e9b47833 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 13 May 2026 18:25:32 +0100 Subject: [PATCH 08/40] feat: wire page type previews into docs generation pipeline --- scripts/generate-component-docs.js | 20 +++++++++++++++++--- scripts/generate-component-docs.test.js | 8 ++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 2f7c527e4..eabb48e51 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -7,6 +7,8 @@ import ts from 'typescript' import { fixtures } from './component-preview-fixtures.js' import { writePreviewPartial } from './generate-component-previews.js' +import { writePagePreviewPartial } from './generate-page-previews.js' +import { pageFixtures } from './page-preview-fixtures.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -1103,6 +1105,8 @@ function main() { fs.rmSync(pagesOutputDir, { recursive: true, force: true }) } fs.mkdirSync(pagesOutputDir, { recursive: true }) + const pagePreviewsDir = path.resolve(pagesOutputDir, '_previews') + fs.mkdirSync(pagePreviewsDir, { recursive: true }) // Parse sources const interfaces = parseComponentInterfaces(componentsDtsPath) @@ -1191,13 +1195,23 @@ function main() { } const { props: uniqueProps = [], examplePath = '/page-path' } = pageInterfaces[key] ?? {} - const content = generatePageMd(key, uniqueProps, examplePath, i + 1) + const fixture = pageFixtures[key] + const content = generatePageMd( + key, + uniqueProps, + examplePath, + i + 1, + fixture ? slug : null + ) if (content) { - fs.writeFileSync(path.join(pagesOutputDir, `${slug}.md`), content) + fs.writeFileSync(path.join(pagesOutputDir, `${slug}.mdx`), content) + } + if (fixture) { + writePagePreviewPartial(pagePreviewsDir, slug, fixture) } } - fs.writeFileSync(path.join(pagesOutputDir, 'index.md'), generatePagesIndex()) + fs.writeFileSync(path.join(pagesOutputDir, 'index.mdx'), generatePagesIndex()) console.log( `Generated ${componentOrder.length} component pages and ${Object.keys(metadata.pages).length} page type pages.` diff --git a/scripts/generate-component-docs.test.js b/scripts/generate-component-docs.test.js index 48031b662..9bb042cb2 100644 --- a/scripts/generate-component-docs.test.js +++ b/scripts/generate-component-docs.test.js @@ -30,6 +30,14 @@ jest.mock('./component-preview-fixtures.js', () => ({ fixtures: {} })) +jest.mock('./generate-page-previews.js', () => ({ + writePagePreviewPartial: jest.fn() +})) + +jest.mock('./page-preview-fixtures.js', () => ({ + pageFixtures: {} +})) + // jest.mock factories are hoisted before variable declarations, so the // component-metadata.json payload must be inlined rather than referenced. jest.mock('fs', () => ({ From 936cb21139e5e1e520c62e6945f3a6163f870bc2 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 13 May 2026 20:41:08 +0100 Subject: [PATCH 09/40] fix: update generatePagesIndex links from .md to .mdx --- scripts/generate-component-docs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index eabb48e51..b35738358 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -1045,7 +1045,7 @@ function generatePagesIndex() { for (const [key, description] of Object.entries(metadata.pages)) { const label = controllerLabel(key) const slug = controllerSlug(key) - lines.push(`- [**${label}**](./${slug}.md) — ${description}`) + lines.push(`- [**${label}**](./${slug}.mdx) — ${description}`) } lines.push(``) From 4b7516d3ed2f40cf23bf94e785051c0816e16d03 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 14 May 2026 00:21:25 +0100 Subject: [PATCH 10/40] feat: replace preview-layout with GOV.UK div-based page shell --- .../plugins/engine/views/preview-layout.html | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/views/preview-layout.html b/src/server/plugins/engine/views/preview-layout.html index cb0dbe444..250902a31 100644 --- a/src/server/plugins/engine/views/preview-layout.html +++ b/src/server/plugins/engine/views/preview-layout.html @@ -1 +1,40 @@ -{% block content %}{% endblock %} +{% from "govuk/components/phase-banner/macro.njk" import govukPhaseBanner %} + +
+
+ +
+ + Example service + + Example layout + + +
+
+
+ +
+ {{ govukPhaseBanner({ tag: { text: 'Beta' }, html: 'This is a new service.' }) }} +
+ {% block content %}{% endblock %} +
+
+ + From 1049229d8765ef8e5f1ab2d53db0bc8a0e530faa Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 14 May 2026 00:24:58 +0100 Subject: [PATCH 11/40] =?UTF-8?q?feat:=20PageController=20preview=20?= =?UTF-8?q?=E2=80=94=20two=20variants=20(single=20question,=20multiple=20q?= =?UTF-8?q?uestions)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/page-preview-fixtures.js | 47 ++++++++++++++++++++------- scripts/page-preview-fixtures.test.js | 39 ++++++++++++++++++++++ 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/scripts/page-preview-fixtures.js b/scripts/page-preview-fixtures.js index a6b540524..009eceba8 100644 --- a/scripts/page-preview-fixtures.js +++ b/scripts/page-preview-fixtures.js @@ -27,18 +27,43 @@ function componentViewModel(name, variantLabel) { export const pageFixtures = { PageController: { viewName: 'index', - context: { - pageTitle: 'What is your full name?', - showTitle: true, - page: { allowContinue: true }, - allowSaveAndExit: false, - components: [ - { - type: ComponentType.TextField, - model: componentViewModel('TextField') + variants: [ + { + label: 'Single question', + context: { + showTitle: false, + page: { allowContinue: true }, + allowSaveAndExit: false, + get components() { + const model = componentViewModel('TextField') + model.label.isPageHeading = true + model.label.classes = 'govuk-label--l' + return [{ type: ComponentType.TextField, model }] + } } - ] - } + }, + { + label: 'Multiple questions', + context: { + pageTitle: 'Tell us about yourself', + showTitle: true, + page: { allowContinue: true }, + allowSaveAndExit: false, + get components() { + return [ + { + type: ComponentType.TextField, + model: componentViewModel('TextField') + }, + { + type: ComponentType.DatePartsField, + model: componentViewModel('DatePartsField') + } + ] + } + } + } + ] }, StartPageController: { diff --git a/scripts/page-preview-fixtures.test.js b/scripts/page-preview-fixtures.test.js index 079ef1f9b..0d2f18a5f 100644 --- a/scripts/page-preview-fixtures.test.js +++ b/scripts/page-preview-fixtures.test.js @@ -77,4 +77,43 @@ describe('page-preview-fixtures', () => { expect.anything() ) }) + + it('PageController has exactly 2 variants', () => { + expect(pageFixtures.PageController.variants).toHaveLength(2) + }) + + it('PageController single question variant sets isPageHeading and govuk-label--l on the TextField label', () => { + const mockLabel = { + text: 'What is your full name?', + classes: 'govuk-label--s' + } + createComponent.mockReturnValue({ + getViewModel: jest + .fn() + .mockReturnValue({ + id: 'full-name', + name: 'full-name', + label: mockLabel + }) + }) + const singleVariant = pageFixtures.PageController.variants[0] + const [component] = singleVariant.context.components + expect(component.model.label.isPageHeading).toBe(true) + expect(component.model.label.classes).toBe('govuk-label--l') + }) + + it('PageController single question variant has showTitle false', () => { + expect(pageFixtures.PageController.variants[0].context.showTitle).toBe( + false + ) + }) + + it('PageController multiple questions variant has showTitle true', () => { + expect(pageFixtures.PageController.variants[1].context.showTitle).toBe(true) + }) + + it('PageController multiple questions variant has two components', () => { + const multiVariant = pageFixtures.PageController.variants[1] + expect(multiVariant.context.components).toHaveLength(2) + }) }) From 1e1731800f856c4a8cc824f57f73676ecb3ad104 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 14 May 2026 00:33:21 +0100 Subject: [PATCH 12/40] fix: custom preview-layout classes, disable form submission, remove padding and phase banner --- docs/assets/css/docusaurus.scss | 52 +++++++++++++++++++ scripts/generate-component-previews.js | 5 +- scripts/generate-component-previews.test.js | 11 ++++ scripts/generate-page-previews.js | 7 ++- scripts/generate-page-previews.test.js | 29 ++++++++--- .../plugins/engine/views/preview-layout.html | 47 ++++------------- 6 files changed, 103 insertions(+), 48 deletions(-) diff --git a/docs/assets/css/docusaurus.scss b/docs/assets/css/docusaurus.scss index 97f2eaa48..81c291578 100644 --- a/docs/assets/css/docusaurus.scss +++ b/docs/assets/css/docusaurus.scss @@ -80,6 +80,58 @@ color: #505a5f; } +.app-page-preview__header { + background: #1d70b8; + padding: 10px 20px; + display: flex; + align-items: center; + gap: 20px; + margin-bottom: 0; +} + +.app-page-preview__logo { + color: #ffffff; + font-weight: 700; + font-size: 18px; + font-family: sans-serif; +} + +.app-page-preview__service-name { + color: #ffffff; + font-weight: 400; + font-size: 16px; + font-family: sans-serif; +} + +.app-page-preview__tag { + background: #fd0; + color: #0b0c0c; + font-size: 12px; + font-weight: 700; + padding: 2px 8px; + margin-left: 8px; + display: inline-block; + vertical-align: middle; +} + +.app-page-preview__main { + padding: 20px; + background: #ffffff; +} + +.app-page-preview__footer { + background: #f3f2f1; + border-top: 1px solid #b1b4b6; + padding: 10px 20px; + font-size: 14px; + color: #505a5f; + font-family: sans-serif; +} + +.component-preview--page { + padding: 0; +} + @media (min-width: 48.125em) { .govuk-template--rebranded .app-masthead .govuk-grid-row { display: flex; diff --git a/scripts/generate-component-previews.js b/scripts/generate-component-previews.js index 53ac39f3f..7fcf546b7 100644 --- a/scripts/generate-component-previews.js +++ b/scripts/generate-component-previews.js @@ -50,9 +50,10 @@ export function renderComponent(fixture) { /** * Build the MDX partial content from one or more rendered HTML strings. * @param {Array<{ label?: string, html: string }>} renders + * @param {string} [wrapperClass] * @returns {string} */ -export function buildPartialMdx(renders) { +export function buildPartialMdx(renders, wrapperClass = 'component-preview') { return renders .map(({ label, html }) => { const escaped = html.replace(/`/g, '\\`').replace(/\$\{/g, '\\${') @@ -67,7 +68,7 @@ export function buildPartialMdx(renders) { const labelLine = safeLabel ? `

${safeLabel}

\n` : '' - return `${labelLine}
\n
\n
` + return `${labelLine}
\n
\n
` }) .join('\n\n') } diff --git a/scripts/generate-component-previews.test.js b/scripts/generate-component-previews.test.js index 09d7aa6ec..52413f536 100644 --- a/scripts/generate-component-previews.test.js +++ b/scripts/generate-component-previews.test.js @@ -116,6 +116,17 @@ describe('buildPartialMdx', () => { expect(result).toContain('\\`backtick\\`') expect(result).toContain('\\' + dollarBrace + 'expr}') }) + + it('uses custom wrapperClass when provided', () => { + const result = buildPartialMdx( + [{ html: '' }], + 'component-preview component-preview--page' + ) + expect(result).toContain( + 'className="component-preview component-preview--page"' + ) + expect(result).not.toContain('className="component-preview"') + }) }) describe('renderComponent', () => { diff --git a/scripts/generate-page-previews.js b/scripts/generate-page-previews.js index 63c5bb135..13de5a76d 100644 --- a/scripts/generate-page-previews.js +++ b/scripts/generate-page-previews.js @@ -14,10 +14,13 @@ import { environment } from '~/src/server/plugins/nunjucks/environment.js' * @returns {string} */ export function renderPage(context, viewName) { - return environment.render(`${viewName}.html`, { + const html = environment.render(`${viewName}.html`, { ...context, baseLayoutPath: 'preview-layout.html' }) + return html + .replace(/]*>/g, '
') + .replace(/<\/form>/g, '
') } /** @@ -39,6 +42,6 @@ export function writePagePreviewPartial(previewsDir, slug, fixture) { fs.writeFileSync( path.join(previewsDir, `${slug}.mdx`), - buildPartialMdx(renders) + buildPartialMdx(renders, 'component-preview component-preview--page') ) } diff --git a/scripts/generate-page-previews.test.js b/scripts/generate-page-previews.test.js index 9a257b2c2..187e3d6fd 100644 --- a/scripts/generate-page-previews.test.js +++ b/scripts/generate-page-previews.test.js @@ -52,6 +52,17 @@ describe('renderPage', () => { const result = renderPage({ pageTitle: 'Check your answers' }, 'summary') expect(result).toBe('

Check your answers

') }) + + it('replaces
tags with divs to prevent form submission', () => { + environment.render.mockReturnValue( + '
' + ) + const result = renderPage({ pageTitle: 'Test' }, 'index') + expect(result).not.toContain('') + expect(result).toContain('
') + expect(result).toContain('
') + }) }) describe('writePagePreviewPartial', () => { @@ -86,9 +97,10 @@ describe('writePagePreviewPartial', () => { viewName: 'index', context: { pageTitle: 'Question' } }) - expect(buildPartialMdx).toHaveBeenCalledWith([ - { html: '
page html
' } - ]) + expect(buildPartialMdx).toHaveBeenCalledWith( + [{ html: '
page html
' }], + 'component-preview component-preview--page' + ) }) it('passes labelled renders to buildPartialMdx for a variant fixture', () => { @@ -99,10 +111,13 @@ describe('writePagePreviewPartial', () => { { label: 'With files uploaded', context: { pageTitle: 'Upload' } } ] }) - expect(buildPartialMdx).toHaveBeenCalledWith([ - { label: 'No files uploaded', html: '
page html
' }, - { label: 'With files uploaded', html: '
page html
' } - ]) + expect(buildPartialMdx).toHaveBeenCalledWith( + [ + { label: 'No files uploaded', html: '
page html
' }, + { label: 'With files uploaded', html: '
page html
' } + ], + 'component-preview component-preview--page' + ) }) it('renders each variant context independently', () => { diff --git a/src/server/plugins/engine/views/preview-layout.html b/src/server/plugins/engine/views/preview-layout.html index 250902a31..f174776d5 100644 --- a/src/server/plugins/engine/views/preview-layout.html +++ b/src/server/plugins/engine/views/preview-layout.html @@ -1,40 +1,13 @@ -{% from "govuk/components/phase-banner/macro.njk" import govukPhaseBanner %} - -
-
- -
- - Example service - - Example layout - - -
-
+
+ + + Example service + Example layout +
- -
- {{ govukPhaseBanner({ tag: { text: 'Beta' }, html: 'This is a new service.' }) }} -
- {% block content %}{% endblock %} -
+
+ {% block content %}{% endblock %}
- -