Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
9ebc382
feat: add minimal preview base layout for page type previews
alexluckett May 13, 2026
e187021
feat: add generate-page-previews module with renderPage and writePage…
alexluckett May 13, 2026
f63c8d4
feat: export generatePageMd and add previewSlug support for page type…
alexluckett May 13, 2026
460974b
docs: add missing JSDoc @param for previewSlug in generatePageMd
alexluckett May 13, 2026
365b447
feat: add page-preview-fixtures with static context for all 6 page types
alexluckett May 13, 2026
8782715
fix: access formComponent.model in test to trigger lazy getter evalua…
alexluckett May 13, 2026
779640d
fix: remove silent fallback in componentViewModel, strengthen test as…
alexluckett May 13, 2026
a38e147
feat: wire page type previews into docs generation pipeline
alexluckett May 13, 2026
936cb21
fix: update generatePagesIndex links from .md to .mdx
alexluckett May 13, 2026
4b7516d
feat: replace preview-layout with GOV.UK div-based page shell
alexluckett May 13, 2026
1049229
feat: PageController preview — two variants (single question, multipl…
alexluckett May 13, 2026
1e17318
fix: custom preview-layout classes, disable form submission, remove p…
alexluckett May 13, 2026
7cca73f
feat: include FileUploadField component in FileUploadPageController J…
alexluckett May 13, 2026
7bbee4d
fix: remove GOV.UK logo, example tag, and footer content from preview…
alexluckett May 13, 2026
7950fc6
chore: remove unused app-page-preview__logo and __tag CSS rules
alexluckett May 13, 2026
354cced
fix: bold service name text in page preview header
alexluckett May 13, 2026
a28b7f1
fix: include FileUploadField in components array so file list renders…
alexluckett May 13, 2026
3d926f0
fix: RepeatPageController preview — single context with three items
alexluckett May 13, 2026
0da154f
fix: repeat page title, add page.allowContinue to file upload fixture…
alexluckett May 13, 2026
b635555
refactor: move exampleComponents from component-metadata.json to page…
alexluckett May 14, 2026
750b987
docs: simplify componentViewModel JSDoc
alexluckett May 14, 2026
3c34464
refactor: componentViewModel returns {type, model} — callers no longe…
alexluckett May 14, 2026
d6d0a49
refactor: use real page controllers via getViewModel for PageControll…
alexluckett May 14, 2026
4775986
chore: type mock request as FormContextRequest in pageViewContext
alexluckett May 14, 2026
e68010b
fix: engine V2, summary page, force access in pageViewContext — resto…
alexluckett May 14, 2026
e7bcc10
refactor: derive viewName from pageViewContext — remove hardcoded index
alexluckett May 14, 2026
3274a12
refactor: viewName moves into context.page — renderPage reads from co…
alexluckett May 14, 2026
659e5e1
refactor: all 6 page controllers use pageViewContext — drop component…
alexluckett May 15, 2026
f277b2b
fix: isForceAccess false restores Change/Remove links; patch listSumm…
alexluckett May 15, 2026
4675b6b
fix: remove force query param so FileUploadField shows Remove links
alexluckett May 15, 2026
3821093
feat: neutralise all hrefs in page previews to prevent navigation
alexluckett May 15, 2026
17d1501
docs: explain interactive element neutralisation in renderPage
alexluckett May 15, 2026
b747bba
rename invoke
alexluckett May 15, 2026
0bd7c4b
cleanup
alexluckett May 15, 2026
28c83e4
more changes
alexluckett May 15, 2026
40f4a7f
improve docs
alexluckett May 15, 2026
3016a52
move preview template into doc scripts dir
alexluckett May 15, 2026
69b5469
Merge branch 'main' into docs/page-previews
alexluckett May 15, 2026
c88a33a
fix types
alexluckett May 15, 2026
07eb3ba
Merge branch 'docs/page-previews' of github.com:DEFRA/forms-engine-pl…
alexluckett May 15, 2026
a6f67be
format fix
alexluckett May 15, 2026
146eb53
remove title prop for html component
alexluckett May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/assets/css/docusaurus.scss
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,39 @@
color: #505a5f;
}

.app-page-preview__header {
background: #1d70b8;
padding: 10px 20px;
display: flex;
align-items: center;
margin-bottom: 0;
}

.app-page-preview__service-name {
color: #ffffff;
font-weight: 700;
font-size: 16px;
font-family: sans-serif;
}

.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;
Expand Down
54 changes: 46 additions & 8 deletions scripts/generate-component-docs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -863,12 +865,14 @@ export function controllerSlug(controllerKey) {
* @param {string} controllerKey
* @param {Array<{name: string, type: string, optional: boolean}>} uniqueProps
* @param {string} [examplePath]
* @param {Array<object>|null} [exampleComponents]
* @returns {Record<string, unknown>}
*/
export function generatePageExample(
controllerKey,
uniqueProps,
examplePath = '/page-path'
examplePath = '/page-path',
exampleComponents = null
) {
const controllerValue =
controllerKey === 'PageController' ? null : controllerKey
Expand All @@ -886,6 +890,8 @@ export function generatePageExample(
setNestedValue(example, prop.name, placeholderForType(prop.type))
}

if (exampleComponents) example.components = exampleComponents

return example
}

Expand All @@ -894,12 +900,16 @@ export function generatePageExample(
* @param {Array<{name: string, type: string, optional: boolean}>} uniqueProps
* @param {string} examplePath
* @param {number} sidebarPosition
* @param {string|null} [previewSlug]
* @param {Array<object>|null} [exampleComponents]
*/
function generatePageMd(
export function generatePageMd(
controllerKey,
uniqueProps,
examplePath,
sidebarPosition
sidebarPosition,
previewSlug = null,
exampleComponents = null
) {
const description = metadata.pages[controllerKey]
if (!description) return null
Expand All @@ -908,11 +918,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}`,
``,
Expand All @@ -933,12 +948,21 @@ function generatePageMd(
lines.push(`**Controller value:** \`"${controllerKey}"\``, ``)
}

if (previewSlug) {
lines.push(`## Preview`, ``, `<Preview />`, ``)
}

lines.push(
`## JSON definition`,
``,
'```json',
JSON.stringify(
generatePageExample(controllerKey, uniqueProps, examplePath),
generatePageExample(
controllerKey,
uniqueProps,
examplePath,
exampleComponents
),
null,
2
),
Expand Down Expand Up @@ -1032,7 +1056,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(``)
Expand Down Expand Up @@ -1092,6 +1116,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)
Expand Down Expand Up @@ -1180,13 +1206,25 @@ function main() {
}
const { props: uniqueProps = [], examplePath = '/page-path' } =
pageInterfaces[key] ?? {}
const content = generatePageMd(key, uniqueProps, examplePath, i + 1)
const fixture = pageFixtures[key]
const sidebarPosition = i + 1
const content = generatePageMd(
key,
uniqueProps,
examplePath,
sidebarPosition,
fixture ? slug : null,
fixture?.exampleComponents ?? 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.`
Expand Down
88 changes: 87 additions & 1 deletion scripts/generate-component-docs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -39,7 +47,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 ''
}),
Expand All @@ -55,6 +63,7 @@ import {
generateComponentMd,
generateExample,
generatePageExample,
generatePageMd,
placeholderForType,
setNestedValue,
simplifyType,
Expand Down Expand Up @@ -287,6 +296,83 @@ describe('Component Documentation Generator', () => {
expect(result.repeat.options.name).toBe('')
expect(result.repeat).not.toHaveProperty('schema')
})

it('injects exampleComponents into the example when provided', () => {
const components = [
{
type: 'FileUploadField',
name: 'upload',
title: 'Upload a document',
options: {},
schema: {}
}
]
const result = generatePageExample(
'FileUploadPageController',
[],
'/file-upload',
components
)
expect(result.components).toEqual(components)
})

it('does not add components when exampleComponents is null', () => {
const result = generatePageExample(
'PageController',
[],
'/page-path',
null
)
expect(result).not.toHaveProperty('components')
})
})

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 <Preview /> when previewSlug is provided', () => {
const result = generatePageMd(
'PageController',
[],
'/page-path',
1,
'page-controller'
)
expect(result).toContain('## Preview')
expect(result).toContain('<Preview />')
})

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 <Preview /> 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('<Preview />')
})
})

describe('controllerLabel', () => {
Expand Down
5 changes: 3 additions & 2 deletions scripts/generate-component-previews.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, '\\${')
Expand All @@ -67,7 +68,7 @@ export function buildPartialMdx(renders) {
const labelLine = safeLabel
? `<h3 className="govuk-heading-s">${safeLabel}</h3>\n`
: ''
return `${labelLine}<div className="component-preview">\n <div dangerouslySetInnerHTML={{ __html: \`${escaped}\` }} />\n</div>`
return `${labelLine}<div className="${wrapperClass}">\n <div dangerouslySetInnerHTML={{ __html: \`${escaped}\` }} />\n</div>`
})
.join('\n\n')
}
Expand Down
11 changes: 11 additions & 0 deletions scripts/generate-component-previews.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<input>' }],
'component-preview component-preview--page'
)
expect(result).toContain(
'className="component-preview component-preview--page"'
)
expect(result).not.toContain('className="component-preview"')
})
})

describe('renderComponent', () => {
Expand Down
61 changes: 61 additions & 0 deletions scripts/generate-page-previews.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'

import { buildPartialMdx } from './generate-component-previews.js'

import { environment } from '~/src/server/plugins/nunjucks/environment.js'

// Make preview-layout.html discoverable by name within the Nunjucks environment.
const scriptsDir = fileURLToPath(new URL('.', import.meta.url))
for (const loader of /** @type {any} */ (environment).loaders ?? []) {
if (loader.searchPaths) loader.searchPaths.push(scriptsDir)
}

/**
* Render a single page fixture context to an HTML string.
* Reads the view name from context.page.viewName — set automatically by the
* real page controller via getViewModel, or manually on the page stub for
* fixtures that don't use pageViewContext.
* Passes baseLayoutPath to strip the GOV.UK page wrapper.
* @param {PageViewModelBase} context
* @returns {string}
*/
export function renderPage(context) {
const html = environment.render(`${context.page.viewName}.html`, {
...context,
baseLayoutPath: 'preview-layout.html'
})
// Neutralise interactive elements — this is documentation, not a working service.
return html
.replace(/<form\b[^>]*>/g, '<div class="app-page-preview__form">')
.replace(/<\/form>/g, '</div>')
.replace(/href="[^"]*"/g, 'href="#"')
}

/**
* Renders all variants (or single context) for a page fixture and writes the
* MDX partial to previewsDir/<slug>.mdx.
* @param {string} previewsDir
* @param {string} slug
* @param {{ context?: PageViewModelBase, variants?: Array<{label: string, context: PageViewModelBase}> }} 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)
}))
: [{ html: renderPage(/** @type {PageViewModelBase} */ (fixture.context)) }]

fs.writeFileSync(
path.join(previewsDir, `${slug}.mdx`),
buildPartialMdx(renders, 'component-preview component-preview--page')
)
}

/**
* @typedef {import('~/src/server/plugins/engine/types.js').PageViewModelBase} PageViewModelBase
*/
Loading
Loading