Skip to content

Commit 4e1e533

Browse files
authored
Merge branch 'main' into docs/page-elements
2 parents 589459f + 9c52146 commit 4e1e533

10 files changed

Lines changed: 799 additions & 11 deletions

docs/assets/css/docusaurus.scss

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,39 @@
8080
color: #505a5f;
8181
}
8282

83+
.app-page-preview__header {
84+
background: #1d70b8;
85+
padding: 10px 20px;
86+
display: flex;
87+
align-items: center;
88+
margin-bottom: 0;
89+
}
90+
91+
.app-page-preview__service-name {
92+
color: #ffffff;
93+
font-weight: 700;
94+
font-size: 16px;
95+
font-family: sans-serif;
96+
}
97+
98+
.app-page-preview__main {
99+
padding: 20px;
100+
background: #ffffff;
101+
}
102+
103+
.app-page-preview__footer {
104+
background: #f3f2f1;
105+
border-top: 1px solid #b1b4b6;
106+
padding: 10px 20px;
107+
font-size: 14px;
108+
color: #505a5f;
109+
font-family: sans-serif;
110+
}
111+
112+
.component-preview--page {
113+
padding: 0;
114+
}
115+
83116
@media (min-width: 48.125em) {
84117
.govuk-template--rebranded .app-masthead .govuk-grid-row {
85118
display: flex;

scripts/generate-component-docs.js

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import ts from 'typescript'
77

88
import { fixtures } from './component-preview-fixtures.js'
99
import { writePreviewPartial } from './generate-component-previews.js'
10+
import { writePagePreviewPartial } from './generate-page-previews.js'
11+
import { pageFixtures } from './page-preview-fixtures.js'
1012

1113
const __dirname = path.dirname(fileURLToPath(import.meta.url))
1214

@@ -863,12 +865,14 @@ export function controllerSlug(controllerKey) {
863865
* @param {string} controllerKey
864866
* @param {Array<{name: string, type: string, optional: boolean}>} uniqueProps
865867
* @param {string} [examplePath]
868+
* @param {Array<object>|null} [exampleComponents]
866869
* @returns {Record<string, unknown>}
867870
*/
868871
export function generatePageExample(
869872
controllerKey,
870873
uniqueProps,
871-
examplePath = '/page-path'
874+
examplePath = '/page-path',
875+
exampleComponents = null
872876
) {
873877
const controllerValue =
874878
controllerKey === 'PageController' ? null : controllerKey
@@ -886,6 +890,8 @@ export function generatePageExample(
886890
setNestedValue(example, prop.name, placeholderForType(prop.type))
887891
}
888892

893+
if (exampleComponents) example.components = exampleComponents
894+
889895
return example
890896
}
891897

@@ -894,12 +900,16 @@ export function generatePageExample(
894900
* @param {Array<{name: string, type: string, optional: boolean}>} uniqueProps
895901
* @param {string} examplePath
896902
* @param {number} sidebarPosition
903+
* @param {string|null} [previewSlug]
904+
* @param {Array<object>|null} [exampleComponents]
897905
*/
898-
function generatePageMd(
906+
export function generatePageMd(
899907
controllerKey,
900908
uniqueProps,
901909
examplePath,
902-
sidebarPosition
910+
sidebarPosition,
911+
previewSlug = null,
912+
exampleComponents = null
903913
) {
904914
const description = metadata.pages[controllerKey]
905915
if (!description) return null
@@ -908,11 +918,16 @@ function generatePageMd(
908918
const isDefault = controllerKey === 'PageController'
909919
const links = metadata.pageLinks?.[controllerKey] ?? []
910920

921+
const previewImport = previewSlug
922+
? [``, `import Preview from './_previews/${previewSlug}.mdx'`]
923+
: []
924+
911925
const lines = [
912926
`---`,
913927
`sidebar_label: "${label}"`,
914928
`sidebar_position: ${sidebarPosition}`,
915929
`---`,
930+
...previewImport,
916931
``,
917932
`# ${label}`,
918933
``,
@@ -933,12 +948,21 @@ function generatePageMd(
933948
lines.push(`**Controller value:** \`"${controllerKey}"\``, ``)
934949
}
935950

951+
if (previewSlug) {
952+
lines.push(`## Preview`, ``, `<Preview />`, ``)
953+
}
954+
936955
lines.push(
937956
`## JSON definition`,
938957
``,
939958
'```json',
940959
JSON.stringify(
941-
generatePageExample(controllerKey, uniqueProps, examplePath),
960+
generatePageExample(
961+
controllerKey,
962+
uniqueProps,
963+
examplePath,
964+
exampleComponents
965+
),
942966
null,
943967
2
944968
),
@@ -1032,7 +1056,7 @@ function generatePagesIndex() {
10321056
for (const [key, description] of Object.entries(metadata.pages)) {
10331057
const label = controllerLabel(key)
10341058
const slug = controllerSlug(key)
1035-
lines.push(`- [**${label}**](./${slug}.md) — ${description}`)
1059+
lines.push(`- [**${label}**](./${slug}.mdx) — ${description}`)
10361060
}
10371061

10381062
lines.push(``)
@@ -1092,6 +1116,8 @@ function main() {
10921116
fs.rmSync(pagesOutputDir, { recursive: true, force: true })
10931117
}
10941118
fs.mkdirSync(pagesOutputDir, { recursive: true })
1119+
const pagePreviewsDir = path.resolve(pagesOutputDir, '_previews')
1120+
fs.mkdirSync(pagePreviewsDir, { recursive: true })
10951121

10961122
// Parse sources
10971123
const interfaces = parseComponentInterfaces(componentsDtsPath)
@@ -1180,13 +1206,25 @@ function main() {
11801206
}
11811207
const { props: uniqueProps = [], examplePath = '/page-path' } =
11821208
pageInterfaces[key] ?? {}
1183-
const content = generatePageMd(key, uniqueProps, examplePath, i + 1)
1209+
const fixture = pageFixtures[key]
1210+
const sidebarPosition = i + 1
1211+
const content = generatePageMd(
1212+
key,
1213+
uniqueProps,
1214+
examplePath,
1215+
sidebarPosition,
1216+
fixture ? slug : null,
1217+
fixture?.exampleComponents ?? null
1218+
)
11841219
if (content) {
1185-
fs.writeFileSync(path.join(pagesOutputDir, `${slug}.md`), content)
1220+
fs.writeFileSync(path.join(pagesOutputDir, `${slug}.mdx`), content)
1221+
}
1222+
if (fixture) {
1223+
writePagePreviewPartial(pagePreviewsDir, slug, fixture)
11861224
}
11871225
}
11881226

1189-
fs.writeFileSync(path.join(pagesOutputDir, 'index.md'), generatePagesIndex())
1227+
fs.writeFileSync(path.join(pagesOutputDir, 'index.mdx'), generatePagesIndex())
11901228

11911229
console.log(
11921230
`Generated ${componentOrder.length} component pages and ${Object.keys(metadata.pages).length} page type pages.`

scripts/generate-component-docs.test.js

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ jest.mock('./component-preview-fixtures.js', () => ({
3030
fixtures: {}
3131
}))
3232

33+
jest.mock('./generate-page-previews.js', () => ({
34+
writePagePreviewPartial: jest.fn()
35+
}))
36+
37+
jest.mock('./page-preview-fixtures.js', () => ({
38+
pageFixtures: {}
39+
}))
40+
3341
// jest.mock factories are hoisted before variable declarations, so the
3442
// component-metadata.json payload must be inlined rather than referenced.
3543
jest.mock('fs', () => ({
@@ -39,7 +47,7 @@ jest.mock('fs', () => ({
3947
readdirSync: jest.fn(),
4048
readFileSync: jest.fn().mockImplementation((filePath) => {
4149
if (String(filePath ?? '').includes('component-metadata.json')) {
42-
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."}}'
50+
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."}}'
4351
}
4452
return ''
4553
}),
@@ -55,6 +63,7 @@ import {
5563
generateComponentMd,
5664
generateExample,
5765
generatePageExample,
66+
generatePageMd,
5867
placeholderForType,
5968
setNestedValue,
6069
simplifyType,
@@ -287,6 +296,83 @@ describe('Component Documentation Generator', () => {
287296
expect(result.repeat.options.name).toBe('')
288297
expect(result.repeat).not.toHaveProperty('schema')
289298
})
299+
300+
it('injects exampleComponents into the example when provided', () => {
301+
const components = [
302+
{
303+
type: 'FileUploadField',
304+
name: 'upload',
305+
title: 'Upload a document',
306+
options: {},
307+
schema: {}
308+
}
309+
]
310+
const result = generatePageExample(
311+
'FileUploadPageController',
312+
[],
313+
'/file-upload',
314+
components
315+
)
316+
expect(result.components).toEqual(components)
317+
})
318+
319+
it('does not add components when exampleComponents is null', () => {
320+
const result = generatePageExample(
321+
'PageController',
322+
[],
323+
'/page-path',
324+
null
325+
)
326+
expect(result).not.toHaveProperty('components')
327+
})
328+
})
329+
330+
describe('generatePageMd with preview', () => {
331+
it('includes preview import when previewSlug is provided', () => {
332+
const result = generatePageMd(
333+
'PageController',
334+
[],
335+
'/page-path',
336+
1,
337+
'page-controller'
338+
)
339+
expect(result).toContain(
340+
"import Preview from './_previews/page-controller.mdx'"
341+
)
342+
})
343+
344+
it('includes ## Preview section and <Preview /> when previewSlug is provided', () => {
345+
const result = generatePageMd(
346+
'PageController',
347+
[],
348+
'/page-path',
349+
1,
350+
'page-controller'
351+
)
352+
expect(result).toContain('## Preview')
353+
expect(result).toContain('<Preview />')
354+
})
355+
356+
it('places ## Preview before ## JSON definition', () => {
357+
const result = generatePageMd(
358+
'SummaryPageController',
359+
[],
360+
'/summary',
361+
1,
362+
'summary-page-controller'
363+
)
364+
const previewIdx = result.indexOf('## Preview')
365+
const jsonIdx = result.indexOf('## JSON definition')
366+
expect(previewIdx).toBeGreaterThan(-1)
367+
expect(previewIdx).toBeLessThan(jsonIdx)
368+
})
369+
370+
it('omits import, ## Preview, and <Preview /> when previewSlug is absent', () => {
371+
const result = generatePageMd('PageController', [], '/page-path', 1)
372+
expect(result).not.toContain('import Preview')
373+
expect(result).not.toContain('## Preview')
374+
expect(result).not.toContain('<Preview />')
375+
})
290376
})
291377

292378
describe('controllerLabel', () => {

scripts/generate-component-previews.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,10 @@ export function renderComponent(fixture) {
5050
/**
5151
* Build the MDX partial content from one or more rendered HTML strings.
5252
* @param {Array<{ label?: string, html: string }>} renders
53+
* @param {string} [wrapperClass]
5354
* @returns {string}
5455
*/
55-
export function buildPartialMdx(renders) {
56+
export function buildPartialMdx(renders, wrapperClass = 'component-preview') {
5657
return renders
5758
.map(({ label, html }) => {
5859
const escaped = html.replace(/`/g, '\\`').replace(/\$\{/g, '\\${')
@@ -67,7 +68,7 @@ export function buildPartialMdx(renders) {
6768
const labelLine = safeLabel
6869
? `<h3 className="govuk-heading-s">${safeLabel}</h3>\n`
6970
: ''
70-
return `${labelLine}<div className="component-preview">\n <div dangerouslySetInnerHTML={{ __html: \`${escaped}\` }} />\n</div>`
71+
return `${labelLine}<div className="${wrapperClass}">\n <div dangerouslySetInnerHTML={{ __html: \`${escaped}\` }} />\n</div>`
7172
})
7273
.join('\n\n')
7374
}

scripts/generate-component-previews.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,17 @@ describe('buildPartialMdx', () => {
116116
expect(result).toContain('\\`backtick\\`')
117117
expect(result).toContain('\\' + dollarBrace + 'expr}')
118118
})
119+
120+
it('uses custom wrapperClass when provided', () => {
121+
const result = buildPartialMdx(
122+
[{ html: '<input>' }],
123+
'component-preview component-preview--page'
124+
)
125+
expect(result).toContain(
126+
'className="component-preview component-preview--page"'
127+
)
128+
expect(result).not.toContain('className="component-preview"')
129+
})
119130
})
120131

121132
describe('renderComponent', () => {

scripts/generate-page-previews.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
import { fileURLToPath } from 'url'
4+
5+
import { buildPartialMdx } from './generate-component-previews.js'
6+
7+
import { environment } from '~/src/server/plugins/nunjucks/environment.js'
8+
9+
// Make preview-layout.html discoverable by name within the Nunjucks environment.
10+
const scriptsDir = fileURLToPath(new URL('.', import.meta.url))
11+
for (const loader of /** @type {any} */ (environment).loaders ?? []) {
12+
if (loader.searchPaths) loader.searchPaths.push(scriptsDir)
13+
}
14+
15+
/**
16+
* Render a single page fixture context to an HTML string.
17+
* Reads the view name from context.page.viewName — set automatically by the
18+
* real page controller via getViewModel, or manually on the page stub for
19+
* fixtures that don't use pageViewContext.
20+
* Passes baseLayoutPath to strip the GOV.UK page wrapper.
21+
* @param {PageViewModelBase} context
22+
* @returns {string}
23+
*/
24+
export function renderPage(context) {
25+
const html = environment.render(`${context.page.viewName}.html`, {
26+
...context,
27+
baseLayoutPath: 'preview-layout.html'
28+
})
29+
// Neutralise interactive elements — this is documentation, not a working service.
30+
return html
31+
.replace(/<form\b[^>]*>/g, '<div class="app-page-preview__form">')
32+
.replace(/<\/form>/g, '</div>')
33+
.replace(/href="[^"]*"/g, 'href="#"')
34+
}
35+
36+
/**
37+
* Renders all variants (or single context) for a page fixture and writes the
38+
* MDX partial to previewsDir/<slug>.mdx.
39+
* @param {string} previewsDir
40+
* @param {string} slug
41+
* @param {{ context?: PageViewModelBase, variants?: Array<{label: string, context: PageViewModelBase}> }} fixture
42+
*/
43+
export function writePagePreviewPartial(previewsDir, slug, fixture) {
44+
fs.mkdirSync(previewsDir, { recursive: true })
45+
46+
const renders = fixture.variants
47+
? fixture.variants.map(({ label, context }) => ({
48+
label,
49+
html: renderPage(context)
50+
}))
51+
: [{ html: renderPage(/** @type {PageViewModelBase} */ (fixture.context)) }]
52+
53+
fs.writeFileSync(
54+
path.join(previewsDir, `${slug}.mdx`),
55+
buildPartialMdx(renders, 'component-preview component-preview--page')
56+
)
57+
}
58+
59+
/**
60+
* @typedef {import('~/src/server/plugins/engine/types.js').PageViewModelBase} PageViewModelBase
61+
*/

0 commit comments

Comments
 (0)