From cd26718fe0fe00f5f5e94862570883ce986c2bd0 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 15:59:58 +0100 Subject: [PATCH 01/41] chore(docs): add component/page doc dirs to gitignore, update build script --- .gitignore | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a60b52269..6c5a64ee7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ package .env tsconfig.tsbuildinfo docs/schemas +docs/features/components +docs/features/pages temp-schemas # Docusaurus diff --git a/package.json b/package.json index f013196c0..757ee1ea7 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "format:check": "prettier --cache --cache-location .cache/prettier --cache-strategy content --check \"**/*.{cjs,js,json,md,mjs,scss,ts}\"", "docs:dev": "BROWSERSLIST_ENV=javascripts docusaurus start --host 0.0.0.0", "docs:build": "BROWSERSLIST_ENV=javascripts docusaurus build", - "docs:build:all": "node scripts/generate-schema-docs.js && npm run docs:build", + "docs:build:all": "node scripts/generate-schema-docs.js && node scripts/generate-component-docs.js && npm run docs:build", "docs:serve": "docusaurus serve --host 0.0.0.0", "docs:clear": "docusaurus clear", "generate-schema-docs": "node scripts/generate-schema-docs.js", From cbc3efd4866119c3df1f698b7a381d7e144899ca Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 16:02:54 +0100 Subject: [PATCH 02/41] docs: add component and page metadata for doc generation --- scripts/component-metadata.json | 502 ++++++++++++++++++++++++++++++++ 1 file changed, 502 insertions(+) create mode 100644 scripts/component-metadata.json diff --git a/scripts/component-metadata.json b/scripts/component-metadata.json new file mode 100644 index 000000000..c87c72620 --- /dev/null +++ b/scripts/component-metadata.json @@ -0,0 +1,502 @@ +{ + "components": { + "TextField": { + "description": "Single-line text input for collecting short answers.", + "category": "input", + "sidebarPosition": 1, + "example": { + "type": "TextField", + "name": "fullName", + "title": "What is your full name?", + "hint": "As it appears on your passport", + "options": {}, + "schema": {} + } + }, + "MultilineTextField": { + "description": "Multi-line text area for collecting longer text answers.", + "category": "input", + "sidebarPosition": 2, + "example": { + "type": "MultilineTextField", + "name": "description", + "title": "Describe the issue", + "options": { "rows": 5 }, + "schema": { "max": 500 } + } + }, + "NumberField": { + "description": "Numeric input for collecting whole or decimal numbers.", + "category": "input", + "sidebarPosition": 3, + "example": { + "type": "NumberField", + "name": "age", + "title": "What is your age?", + "options": {}, + "schema": { "min": 0, "max": 150 } + } + }, + "EmailAddressField": { + "description": "Text input validated as an email address.", + "category": "input", + "sidebarPosition": 4, + "example": { + "type": "EmailAddressField", + "name": "email", + "title": "What is your email address?", + "options": {} + } + }, + "TelephoneNumberField": { + "description": "Text input for collecting telephone numbers.", + "category": "input", + "sidebarPosition": 5, + "example": { + "type": "TelephoneNumberField", + "name": "phone", + "title": "What is your telephone number?", + "options": {} + } + }, + "DatePartsField": { + "description": "Three separate inputs (day, month, year) for collecting full dates.", + "category": "input", + "sidebarPosition": 6, + "example": { + "type": "DatePartsField", + "name": "dob", + "title": "What is your date of birth?", + "hint": "For example, 27 3 1990", + "options": {} + } + }, + "MonthYearField": { + "description": "Two inputs (month, year) for collecting partial dates.", + "category": "input", + "sidebarPosition": 7, + "example": { + "type": "MonthYearField", + "name": "startDate", + "title": "When did you start?", + "hint": "For example, 3 1990", + "options": {} + } + }, + "YesNoField": { + "description": "A pair of radio buttons offering a yes/no choice.", + "category": "input", + "sidebarPosition": 8, + "example": { + "type": "YesNoField", + "name": "hasAddress", + "title": "Do you have a UK address?", + "options": {} + } + }, + "RadiosField": { + "description": "Radio buttons allowing the user to select one option from a named list.", + "category": "selection", + "sidebarPosition": 9, + "example": { + "type": "RadiosField", + "name": "colour", + "title": "Which colour do you prefer?", + "list": "colours", + "options": {} + } + }, + "CheckboxesField": { + "description": "Checkboxes allowing the user to select one or more options from a named list.", + "category": "selection", + "sidebarPosition": 10, + "example": { + "type": "CheckboxesField", + "name": "interests", + "title": "What are your interests?", + "list": "interests", + "options": {} + } + }, + "SelectField": { + "description": "A dropdown select element for choosing one option from a named list.", + "category": "selection", + "sidebarPosition": 11, + "example": { + "type": "SelectField", + "name": "country", + "title": "Which country do you live in?", + "list": "countries", + "options": {} + } + }, + "AutocompleteField": { + "description": "Text input with autocomplete suggestions sourced from a named list.", + "category": "selection", + "sidebarPosition": 12, + "example": { + "type": "AutocompleteField", + "name": "nationality", + "title": "What is your nationality?", + "list": "nationalities", + "options": {} + } + }, + "UkAddressField": { + "description": "Structured address input for collecting UK postal addresses.", + "category": "input", + "sidebarPosition": 13, + "example": { + "type": "UkAddressField", + "name": "address", + "title": "What is your home address?", + "options": {} + } + }, + "FileUploadField": { + "description": "File upload control integrated with the CDP file upload service.", + "category": "input", + "sidebarPosition": 14, + "example": { + "type": "FileUploadField", + "name": "evidence", + "title": "Upload your evidence", + "options": {}, + "schema": {} + } + }, + "DeclarationField": { + "description": "A checkbox the user must tick to confirm a declaration before proceeding.", + "category": "input", + "sidebarPosition": 15, + "example": { + "type": "DeclarationField", + "name": "declaration", + "title": "Declaration", + "content": "By submitting this form you confirm the information is correct.", + "options": {} + } + }, + "HiddenField": { + "description": "A non-visible field that stores a fixed value in form state without user input.", + "category": "input", + "sidebarPosition": 16, + "example": { + "type": "HiddenField", + "name": "source", + "title": "Source", + "options": {} + } + }, + "PaymentField": { + "description": "Redirects the user to GOV.UK Pay to collect a payment before proceeding.", + "category": "payment", + "sidebarPosition": 17, + "example": { + "type": "PaymentField", + "name": "payment", + "title": "Payment", + "options": { + "amount": 2000, + "description": "Application fee" + } + } + }, + "Html": { + "description": "Display static HTML content on a page without collecting user input.", + "category": "content", + "sidebarPosition": 18, + "example": { + "type": "Html", + "name": "introHtml", + "title": "Introduction", + "content": "

This is some HTML content.

", + "options": {} + } + }, + "Markdown": { + "description": "Display content authored in Markdown, rendered to HTML at runtime.", + "category": "content", + "sidebarPosition": 19, + "example": { + "type": "Markdown", + "name": "introMarkdown", + "title": "Introduction", + "content": "## Heading\n\nSome **bold** text.", + "options": {} + } + }, + "InsetText": { + "description": "Display highlighted inset text using the GOV.UK inset text component.", + "category": "content", + "sidebarPosition": 20, + "example": { + "type": "InsetText", + "name": "warningText", + "title": "Important", + "content": "You must provide accurate information." + } + }, + "Details": { + "description": "Display content inside a collapsible disclosure element.", + "category": "content", + "sidebarPosition": 21, + "example": { + "type": "Details", + "name": "helpDetails", + "title": "Help with this question", + "content": "More information about what we need.", + "options": {} + } + }, + "List": { + "description": "Display a GOV.UK-styled list sourced from a named list definition.", + "category": "content", + "sidebarPosition": 22, + "example": { + "type": "List", + "name": "nextSteps", + "title": "Next steps", + "list": "nextStepsList", + "options": {} + } + }, + "EastingNorthingField": { + "description": "Paired numeric inputs for collecting British National Grid easting and northing coordinates.", + "category": "geospatial", + "sidebarPosition": 23, + "example": { + "type": "EastingNorthingField", + "name": "location", + "title": "Enter the grid coordinates", + "options": {} + } + }, + "OsGridRefField": { + "description": "Text input for collecting an Ordnance Survey grid reference.", + "category": "geospatial", + "sidebarPosition": 24, + "example": { + "type": "OsGridRefField", + "name": "gridRef", + "title": "Enter the OS grid reference", + "options": {} + } + }, + "NationalGridFieldNumberField": { + "description": "Numeric input for a single National Grid coordinate component.", + "category": "geospatial", + "sidebarPosition": 25, + "example": { + "type": "NationalGridFieldNumberField", + "name": "gridNumber", + "title": "Enter the National Grid number", + "options": {} + } + }, + "LatLongField": { + "description": "Paired decimal inputs for collecting WGS84 latitude and longitude coordinates.", + "category": "geospatial", + "sidebarPosition": 26, + "example": { + "type": "LatLongField", + "name": "coordinates", + "title": "Enter the coordinates", + "options": {} + } + }, + "GeospatialField": { + "description": "Composite location picker supporting multiple geospatial coordinate formats.", + "category": "geospatial", + "sidebarPosition": 27, + "example": { + "type": "GeospatialField", + "name": "location", + "title": "Where is the location?", + "options": {} + } + } + }, + "pages": { + "PageController": { + "label": "Question Page", + "description": "The default page type. Displays a set of components and advances to the next page on submission. Omit the `controller` property to use this page type.", + "controllerValue": null, + "sidebarPosition": 1, + "uniqueProperties": [], + "example": { + "path": "/your-name", + "title": "Your name", + "next": [{ "path": "/next-page" }], + "components": [] + } + }, + "StartPageController": { + "label": "Start Page", + "description": "The entry page of the form. Initialises the session and redirects to the first question. Must use the path `/start`.", + "controllerValue": "StartPageController", + "sidebarPosition": 2, + "uniqueProperties": [], + "example": { + "path": "/start", + "controller": "StartPageController", + "title": "Apply for something", + "next": [{ "path": "/first-question" }], + "components": [] + } + }, + "TerminalPageController": { + "label": "Terminal Page", + "description": "A dead-end page that does not route the user to another page. Use this for outcomes where the journey ends without proceeding to the summary — for example, an ineligibility screen.", + "controllerValue": "TerminalPageController", + "sidebarPosition": 3, + "uniqueProperties": [], + "example": { + "path": "/ineligible", + "controller": "TerminalPageController", + "title": "You are not eligible", + "components": [] + } + }, + "RepeatPageController": { + "label": "Repeat Page", + "description": "Allows the user to add multiple sets of answers to the same group of questions. Answers are stored as an array under the key defined by `repeat.options.name`.", + "controllerValue": "RepeatPageController", + "sidebarPosition": 4, + "uniqueProperties": [ + { + "name": "repeat.options.name", + "type": "string", + "required": true, + "description": "Identifier for the repeatable section, used as the key in form state." + }, + { + "name": "repeat.options.title", + "type": "string", + "required": true, + "description": "Label displayed per repeated item in the list summary." + }, + { + "name": "repeat.schema.min", + "type": "number", + "required": true, + "description": "Minimum number of items the user must add." + }, + { + "name": "repeat.schema.max", + "type": "number", + "required": true, + "description": "Maximum number of items the user can add. Cannot exceed 200." + } + ], + "example": { + "path": "/people", + "controller": "RepeatPageController", + "title": "Add a person", + "repeat": { + "options": { "name": "person", "title": "Person" }, + "schema": { "min": 1, "max": 25 } + }, + "next": [{ "path": "/summary" }], + "components": [] + } + }, + "FileUploadPageController": { + "label": "File Upload Page", + "description": "A question page that handles file upload via the CDP file upload service. Must contain a `FileUploadField` component.", + "controllerValue": "FileUploadPageController", + "sidebarPosition": 5, + "uniqueProperties": [], + "example": { + "path": "/upload-evidence", + "controller": "FileUploadPageController", + "title": "Upload your evidence", + "next": [{ "path": "/next-page" }], + "components": [ + { + "type": "FileUploadField", + "name": "evidence", + "title": "Upload a file", + "options": {}, + "schema": {} + } + ] + } + }, + "SummaryPageController": { + "label": "Summary Page", + "description": "Displays a check-your-answers summary of all form responses before submission. Must use the path `/summary`.", + "controllerValue": "SummaryPageController", + "sidebarPosition": 6, + "uniqueProperties": [], + "example": { + "path": "/summary", + "controller": "SummaryPageController", + "title": "Check your answers" + } + }, + "SummaryPageWithConfirmationEmailController": { + "label": "Summary Page with Confirmation Email", + "description": "Summary page that sends a confirmation email to an address collected in the form. Requires an `EmailAddressField` in the form and `emailField` configured on the `PaymentField` or via plugin options.", + "controllerValue": "SummaryPageWithConfirmationEmailController", + "sidebarPosition": 7, + "uniqueProperties": [], + "example": { + "path": "/summary", + "controller": "SummaryPageWithConfirmationEmailController", + "title": "Check your answers" + } + }, + "StatusPageController": { + "label": "Status Page", + "description": "The confirmation page shown after successful form submission. Must use the path `/status`.", + "controllerValue": "StatusPageController", + "sidebarPosition": 8, + "uniqueProperties": [], + "example": { + "path": "/status", + "controller": "StatusPageController", + "title": "Application submitted" + } + } + }, + "properties": { + "required": "Whether the field must be filled in. Defaults to `true`.", + "optionalText": "When `true`, appends '(optional)' to the field label.", + "classes": "Additional CSS classes applied to the component.", + "customValidationMessage": "A single custom message shown for any validation error on this field.", + "customValidationMessages": "A map of Joi error codes to custom error messages.", + "instructionText": "Alternative text read by screen readers in place of the field title.", + "condition": "Name of a condition that controls whether this component is shown.", + "autocomplete": "Value for the HTML `autocomplete` attribute (e.g. `'given-name'`, `'email'`).", + "rows": "Number of rows for the textarea. Defaults to 5.", + "maxWords": "Maximum number of words permitted in the text area.", + "prefix": "Text displayed before the input (e.g. `'£'`).", + "suffix": "Text displayed after the input (e.g. `'kg'`).", + "precision": "Number of decimal places allowed.", + "minPrecision": "Minimum number of decimal places.", + "minLength": "Minimum number of characters.", + "maxLength": "Maximum number of characters.", + "hideTitle": "When `true`, hides the component or section title.", + "usePostcodeLookup": "When `true`, enables a postcode lookup integration.", + "maxDaysInPast": "Maximum number of days in the past the entered date can be.", + "maxDaysInFuture": "Maximum number of days in the future the entered date can be.", + "bold": "When `true`, displays option labels in bold.", + "type": "Display style for the list: `'bulleted'` or `'numbered'`.", + "accept": "Comma-separated list of accepted MIME types (e.g. `'image/png,application/pdf'`).", + "declarationConfirmationLabel": "Custom label text for the declaration checkbox.", + "amount": "Fixed payment amount in pence.", + "description": "Description shown on the GOV.UK Pay payment page.", + "conditionalAmounts": "Condition-based payment amounts. Each entry requires a `condition` name and `amount` in pence.", + "emailField": "Name of the form field containing the user's email address for the confirmation email.", + "min": "Minimum value, character length, word count, or file count.", + "max": "Maximum value, character length, word count, or file count.", + "length": "Exact character length required.", + "regex": "Regular expression pattern the value must match.", + "easting": "Easting coordinate constraints (`min`, `max`).", + "northing": "Northing coordinate constraints (`min`, `max`).", + "latitude": "Latitude constraints (`min`, `max`).", + "longitude": "Longitude constraints (`min`, `max`).", + "content": "HTML or Markdown content to display." + } +} From 807aa9626f261768bb6e0f49ab1f2012e86c09d9 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 16:07:10 +0100 Subject: [PATCH 03/41] feat(docs): add component and page documentation generator script --- scripts/generate-component-docs.js | 384 +++++++++++++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 scripts/generate-component-docs.js diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js new file mode 100644 index 000000000..827fdd7a0 --- /dev/null +++ b/scripts/generate-component-docs.js @@ -0,0 +1,384 @@ +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +import ts from 'typescript' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const formsModelTypesDir = path.resolve( + __dirname, + '../node_modules/@defra/forms-model/dist/types' +) +const componentsOutputDir = path.resolve( + __dirname, + '../docs/features/components' +) +const pagesOutputDir = path.resolve(__dirname, '../docs/features/pages') +const metadata = JSON.parse( + fs.readFileSync(path.resolve(__dirname, 'component-metadata.json'), 'utf-8') +) + +/** + * Convert PascalCase to kebab-case. + * "TextField" -> "text-field", "Html" -> "html" + */ +function toKebabCase(str) { + return str.replace( + /([A-Z])/g, + (match, letter, offset) => (offset > 0 ? '-' : '') + letter.toLowerCase() + ) +} + +/** + * Convert PascalCase to Title Case with spaces. + * "TextField" -> "Text Field" + */ +function toLabel(name) { + return name + .replace( + /([A-Z])/g, + (match, letter, offset) => (offset > 0 ? ' ' : '') + letter + ) + .trim() +} + +/** + * Simplify complex TypeScript type strings for display in docs tables. + */ +function simplifyType(rawType) { + if (!rawType) return 'unknown' + const t = rawType.replace(/\s+/g, ' ').trim() + if (t.startsWith('{') || t.includes('LanguageMessages')) return 'object' + if (t.includes('ListTypeContent') || t.includes('ListTypeOption')) + return 'string' + if (t.endsWith('[]')) return simplifyType(t.slice(0, -2)) + '[]' + return t +} + +/** + * Extract properties from a TypeLiteralNode. + */ +function extractTypeLiteralProps(typeNode, sourceFile) { + const props = [] + if (!ts.isTypeLiteralNode(typeNode)) return props + for (const member of typeNode.members) { + if (!ts.isPropertySignature(member)) continue + const name = member.name.getText(sourceFile) + const optional = !!member.questionToken + const rawType = member.type ? member.type.getText(sourceFile) : 'unknown' + props.push({ name, optional, type: simplifyType(rawType) }) + } + return props +} + +/** + * Extract component-specific options properties. + * options type is: BaseOptions & { specific?: type } — we want the & { } part. + */ +function extractOptionsProps(typeNode, sourceFile) { + if (ts.isIntersectionTypeNode(typeNode)) { + const lastType = typeNode.types[typeNode.types.length - 1] + if (ts.isTypeLiteralNode(lastType)) { + return extractTypeLiteralProps(lastType, sourceFile) + } + } + if (ts.isTypeLiteralNode(typeNode)) { + return extractTypeLiteralProps(typeNode, sourceFile) + } + return [] +} + +/** + * Parse all exported interfaces from a .d.ts file. + * Returns a map of interface name -> { options: [], schema: [] }. + */ +function parseComponentInterfaces(dtsPath) { + const content = fs.readFileSync(dtsPath, 'utf-8') + const sourceFile = ts.createSourceFile( + dtsPath, + content, + ts.ScriptTarget.Latest, + true + ) + + const result = {} + + function visit(node) { + if ( + ts.isInterfaceDeclaration(node) && + node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + ) { + const name = node.name.text + const options = [] + const schema = [] + + for (const member of node.members) { + if (!ts.isPropertySignature(member)) continue + const propName = member.name.getText(sourceFile) + + if (propName === 'options' && member.type) { + options.push(...extractOptionsProps(member.type, sourceFile)) + } + if (propName === 'schema' && member.type) { + schema.push(...extractTypeLiteralProps(member.type, sourceFile)) + } + } + + if (options.length > 0 || schema.length > 0) { + result[name] = { options, schema } + } + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return result +} + +/** + * Generate markdown content for a single component page. + */ +function generateComponentMd(componentName, interfaceData) { + const meta = metadata.components[componentName] || {} + const description = meta.description || '' + const example = meta.example || { + type: componentName, + name: 'fieldName', + title: 'Field title', + options: {} + } + const label = meta.label || toLabel(componentName) + const { options = [], schema = [] } = interfaceData || {} + + const lines = [ + `---`, + `sidebar_label: ${label}`, + `sidebar_position: ${meta.sidebarPosition ?? 99}`, + `---`, + ``, + `# ${label}`, + ``, + description, + ``, + `## JSON definition`, + ``, + '```json', + JSON.stringify(example, null, 2), + '```', + `` + ] + + if (options.length > 0) { + lines.push(`## Options`, ``) + lines.push(`| Property | Type | Required | Description |`) + lines.push(`|----------|------|----------|-------------|`) + for (const prop of options) { + const desc = metadata.properties[prop.name] ?? '' + const required = prop.optional ? 'No' : 'Yes' + lines.push( + `| \`${prop.name}\` | \`${prop.type}\` | ${required} | ${desc} |` + ) + } + lines.push(``) + } + + if (schema.length > 0) { + lines.push(`## Schema constraints`, ``) + lines.push(`| Property | Type | Description |`) + lines.push(`|----------|------|-------------|`) + for (const prop of schema) { + const desc = metadata.properties[prop.name] ?? '' + lines.push(`| \`${prop.name}\` | \`${prop.type}\` | ${desc} |`) + } + lines.push(``) + } + + return lines.join('\n') +} + +/** + * Generate markdown content for a single page controller page. + */ +function generatePageMd(controllerKey) { + const meta = metadata.pages[controllerKey] + if (!meta) return null + + const { label, description, controllerValue, uniqueProperties, example } = + meta + + const lines = [ + `---`, + `sidebar_label: ${label}`, + `sidebar_position: ${meta.sidebarPosition ?? 99}`, + `---`, + ``, + `# ${label}`, + ``, + description, + `` + ] + + if (controllerValue) { + lines.push(`**Controller value:** \`"${controllerValue}"\``, ``) + } else { + lines.push( + `**Controller value:** omit the \`controller\` property, or use \`"PageController"\``, + `` + ) + } + + lines.push( + `## JSON definition`, + ``, + '```json', + JSON.stringify(example, null, 2), + '```', + `` + ) + + if (uniqueProperties && uniqueProperties.length > 0) { + lines.push(`## Configuration`, ``) + lines.push(`| Property | Type | Required | Description |`) + lines.push(`|----------|------|----------|-------------|`) + for (const prop of uniqueProperties) { + lines.push( + `| \`${prop.name}\` | \`${prop.type}\` | ${prop.required ? 'Yes' : 'No'} | ${prop.description} |` + ) + } + lines.push(``) + } + + return lines.join('\n') +} + +/** + * Generate the components index page listing all components by category. + */ +function generateComponentsIndex(componentNames) { + const categories = { + input: { label: 'Input fields', items: [] }, + selection: { label: 'Selection fields', items: [] }, + content: { label: 'Content components', items: [] }, + payment: { label: 'Payment', items: [] }, + geospatial: { label: 'Geospatial fields', items: [] } + } + + for (const name of componentNames) { + const meta = metadata.components[name] + if (!meta) continue + const category = meta.category || 'input' + const label = meta.label || toLabel(name) + const slug = toKebabCase(name) + if (categories[category]) { + categories[category].items.push({ + label, + slug, + description: meta.description + }) + } + } + + const lines = [ + `---`, + `sidebar_position: 1`, + `---`, + ``, + `# Components`, + ``, + `Built-in components available for use in your form definitions. Add a component to a page by specifying its \`type\` in the \`components\` array.`, + `` + ] + + for (const [, cat] of Object.entries(categories)) { + if (cat.items.length === 0) continue + lines.push(`## ${cat.label}`, ``) + for (const item of cat.items) { + lines.push(`- [**${item.label}**](./${item.slug}) — ${item.description}`) + } + lines.push(``) + } + + return lines.join('\n') +} + +/** + * Generate the pages index page listing all page types. + */ +function generatePagesIndex() { + const lines = [ + `---`, + `sidebar_position: 1`, + `---`, + ``, + `# Page Types`, + ``, + `Built-in page controllers that define how a page behaves. Set the \`controller\` property on a page definition to use a specific page type.`, + `` + ] + + for (const [, meta] of Object.entries(metadata.pages)) { + const slug = meta.label.toLowerCase().replace(/\s+/g, '-') + lines.push(`- [**${meta.label}**](./${slug}) — ${meta.description}`) + } + + lines.push(``) + return lines.join('\n') +} + +function main() { + // Set up output directories + if (fs.existsSync(componentsOutputDir)) { + fs.rmSync(componentsOutputDir, { recursive: true, force: true }) + } + fs.mkdirSync(componentsOutputDir, { recursive: true }) + + if (fs.existsSync(pagesOutputDir)) { + fs.rmSync(pagesOutputDir, { recursive: true, force: true }) + } + fs.mkdirSync(pagesOutputDir, { recursive: true }) + + // Parse component interfaces + const componentDtsPath = path.join( + formsModelTypesDir, + 'components/types.d.ts' + ) + const interfaces = parseComponentInterfaces(componentDtsPath) + + // Generate component pages + const componentNames = Object.keys(metadata.components) + for (const name of componentNames) { + const slug = toKebabCase(name) + // Interface names in types.d.ts use a "Component" suffix (e.g. TextFieldComponent) + const interfaceData = interfaces[`${name}Component`] ?? interfaces[name] + const content = generateComponentMd(name, interfaceData) + fs.writeFileSync(path.join(componentsOutputDir, `${slug}.md`), content) + } + + // Generate components index + fs.writeFileSync( + path.join(componentsOutputDir, 'index.md'), + generateComponentsIndex(componentNames) + ) + + // Generate page type pages + for (const key of Object.keys(metadata.pages)) { + const meta = metadata.pages[key] + const slug = meta.label.toLowerCase().replace(/\s+/g, '-') + const content = generatePageMd(key) + if (content) { + fs.writeFileSync(path.join(pagesOutputDir, `${slug}.md`), content) + } + } + + // Generate pages index + fs.writeFileSync(path.join(pagesOutputDir, 'index.md'), generatePagesIndex()) + + const componentCount = componentNames.length + const pageCount = Object.keys(metadata.pages).length + console.log( + `Generated ${componentCount} component pages and ${pageCount} page type pages.` + ) +} + +main() From e9f6dd0f7599a69dc66436a68ce2c0f1344ae44e Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 16:34:53 +0100 Subject: [PATCH 04/41] fix(docs): add missing date field options, warning for missing interfaces, YAML quoting --- scripts/component-metadata.json | 8 ++++++++ scripts/generate-component-docs.js | 19 ++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/scripts/component-metadata.json b/scripts/component-metadata.json index c87c72620..aa24dd032 100644 --- a/scripts/component-metadata.json +++ b/scripts/component-metadata.json @@ -63,6 +63,10 @@ "description": "Three separate inputs (day, month, year) for collecting full dates.", "category": "input", "sidebarPosition": 6, + "extraOptions": [ + { "name": "maxDaysInPast", "optional": true, "type": "number" }, + { "name": "maxDaysInFuture", "optional": true, "type": "number" } + ], "example": { "type": "DatePartsField", "name": "dob", @@ -75,6 +79,10 @@ "description": "Two inputs (month, year) for collecting partial dates.", "category": "input", "sidebarPosition": 7, + "extraOptions": [ + { "name": "maxDaysInPast", "optional": true, "type": "number" }, + { "name": "maxDaysInFuture", "optional": true, "type": "number" } + ], "example": { "type": "MonthYearField", "name": "startDate", diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 827fdd7a0..4d0c71381 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -149,11 +149,12 @@ function generateComponentMd(componentName, interfaceData) { options: {} } const label = meta.label || toLabel(componentName) - const { options = [], schema = [] } = interfaceData || {} + const { options: parsedOptions = [], schema = [] } = interfaceData || {} + const options = [...parsedOptions, ...(meta.extraOptions ?? [])] const lines = [ `---`, - `sidebar_label: ${label}`, + `sidebar_label: "${label}"`, `sidebar_position: ${meta.sidebarPosition ?? 99}`, `---`, ``, @@ -209,7 +210,7 @@ function generatePageMd(controllerKey) { const lines = [ `---`, - `sidebar_label: ${label}`, + `sidebar_label: "${label}"`, `sidebar_position: ${meta.sidebarPosition ?? 99}`, `---`, ``, @@ -343,6 +344,12 @@ function main() { formsModelTypesDir, 'components/types.d.ts' ) + if (!fs.existsSync(componentDtsPath)) { + console.error( + `Error: cannot find @defra/forms-model types at:\n ${componentDtsPath}\nIs the package installed?` + ) + process.exit(1) + } const interfaces = parseComponentInterfaces(componentDtsPath) // Generate component pages @@ -351,6 +358,12 @@ function main() { const slug = toKebabCase(name) // Interface names in types.d.ts use a "Component" suffix (e.g. TextFieldComponent) const interfaceData = interfaces[`${name}Component`] ?? interfaces[name] + const meta = metadata.components[name] + if (!interfaceData && meta?.category !== 'content') { + console.warn( + `Warning: no interface data found for ${name} (tried ${name}Component and ${name})` + ) + } const content = generateComponentMd(name, interfaceData) fs.writeFileSync(path.join(componentsOutputDir, `${slug}.md`), content) } From 003046688764822a3098e218cdd27374b10bb198 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 16:36:47 +0100 Subject: [PATCH 05/41] feat(docs): add Components and Page Types nav sections, mark advanced sections --- docusaurus.config.cjs | 128 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 2 deletions(-) diff --git a/docusaurus.config.cjs b/docusaurus.config.cjs index 2d5fa4f8c..aa59cea02 100644 --- a/docusaurus.config.cjs +++ b/docusaurus.config.cjs @@ -77,7 +77,131 @@ const config = { sidebar: [ { text: 'Overview', href: '/features' }, { - text: 'Configuration-based', + text: 'Components', + href: '/features/components', + items: [ + { text: 'Overview', href: '/features/components' }, + { text: 'Text Field', href: '/features/components/text-field' }, + { + text: 'Multiline Text Field', + href: '/features/components/multiline-text-field' + }, + { + text: 'Number Field', + href: '/features/components/number-field' + }, + { + text: 'Email Address Field', + href: '/features/components/email-address-field' + }, + { + text: 'Telephone Number Field', + href: '/features/components/telephone-number-field' + }, + { + text: 'Date Parts Field', + href: '/features/components/date-parts-field' + }, + { + text: 'Month Year Field', + href: '/features/components/month-year-field' + }, + { + text: 'Yes No Field', + href: '/features/components/yes-no-field' + }, + { + text: 'Radios Field', + href: '/features/components/radios-field' + }, + { + text: 'Checkboxes Field', + href: '/features/components/checkboxes-field' + }, + { + text: 'Select Field', + href: '/features/components/select-field' + }, + { + text: 'Autocomplete Field', + href: '/features/components/autocomplete-field' + }, + { + text: 'UK Address Field', + href: '/features/components/uk-address-field' + }, + { + text: 'File Upload Field', + href: '/features/components/file-upload-field' + }, + { + text: 'Declaration Field', + href: '/features/components/declaration-field' + }, + { + text: 'Hidden Field', + href: '/features/components/hidden-field' + }, + { + text: 'Payment Field', + href: '/features/components/payment-field' + }, + { text: 'HTML', href: '/features/components/html' }, + { text: 'Markdown', href: '/features/components/markdown' }, + { text: 'Inset Text', href: '/features/components/inset-text' }, + { text: 'Details', href: '/features/components/details' }, + { text: 'List', href: '/features/components/list' }, + { + text: 'Easting Northing Field', + href: '/features/components/easting-northing-field' + }, + { + text: 'OS Grid Ref Field', + href: '/features/components/os-grid-ref-field' + }, + { + text: 'National Grid Field Number Field', + href: '/features/components/national-grid-field-number-field' + }, + { + text: 'Lat Long Field', + href: '/features/components/lat-long-field' + }, + { + text: 'Geospatial Field', + href: '/features/components/geospatial-field' + } + ] + }, + { + text: 'Page Types', + href: '/features/pages', + items: [ + { text: 'Overview', href: '/features/pages' }, + { + text: 'Question Page', + href: '/features/pages/question-page' + }, + { text: 'Start Page', href: '/features/pages/start-page' }, + { + text: 'Terminal Page', + href: '/features/pages/terminal-page' + }, + { text: 'Repeat Page', href: '/features/pages/repeat-page' }, + { + text: 'File Upload Page', + href: '/features/pages/file-upload-page' + }, + { text: 'Summary Page', href: '/features/pages/summary-page' }, + { + text: 'Summary Page with Confirmation Email', + href: '/features/pages/summary-page-with-confirmation-email' + }, + { text: 'Status Page', href: '/features/pages/status-page' } + ] + }, + { + text: 'Configuration-based (Advanced)', href: '/features/configuration-based', items: [ { @@ -91,7 +215,7 @@ const config = { ] }, { - text: 'Code-based', + text: 'Code-based (Advanced)', href: '/features/code-based', items: [ { text: 'Components', href: '/features/code-based/components' }, From 43199ac9c791094757bc4a7c0a4e9e22ac09057a Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 16:37:58 +0100 Subject: [PATCH 06/41] docs: update features index, add Advanced callouts to configuration-based and code-based --- docs/features/code-based/index.md | 4 ++++ docs/features/configuration-based/index.md | 4 ++++ docs/features/index.md | 10 +++++++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/features/code-based/index.md b/docs/features/code-based/index.md index 2dabf6e81..8daf47fb9 100644 --- a/docs/features/code-based/index.md +++ b/docs/features/code-based/index.md @@ -1,5 +1,9 @@ # Code-based Features +:::note Advanced +Code-based features require writing TypeScript or JavaScript. Use these when configuration-based features and built-in components do not meet your requirements. +::: + Code-based features let you extend forms-engine-plugin with custom TypeScript or JavaScript. Use them when configuration-based options aren't sufficient for your requirements — for example, to build a bespoke component, override a service, or add highly specialised page logic. > Only introduce code-based customisations where there is genuine business need. Custom code becomes your team's responsibility to test, maintain and keep accessible. diff --git a/docs/features/configuration-based/index.md b/docs/features/configuration-based/index.md index a469f1ba4..f949f072b 100644 --- a/docs/features/configuration-based/index.md +++ b/docs/features/configuration-based/index.md @@ -1,5 +1,9 @@ # Configuration-based Features +:::note Advanced +Configuration-based features require familiarity with the form definition format. Most use cases are covered by the built-in [components](../components) and [page types](../pages). +::: + Configuration-based features let you drive advanced behaviour entirely through your form definition — no custom code required. They are implemented in the JSON/YAML form definition and processed by forms-engine-plugin at runtime. When developing with forms-engine-plugin, prefer configuration-based features over code-based ones wherever possible. They require less effort to maintain and benefit from the same testing and accessibility assurance as core forms-engine-plugin. diff --git a/docs/features/index.md b/docs/features/index.md index e976a9492..e6c75e94e 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -1,6 +1,14 @@ # Features -forms-engine-plugin provides two categories of features to help you extend and customise your form journeys beyond the out-of-the-box behaviour. +forms-engine-plugin provides built-in components and page types you can use immediately in your form definitions, as well as advanced features for driving dynamic behaviour or writing custom code. + +## [Components](./features/components) + +A library of built-in form components — text fields, date inputs, radio buttons, file upload, payment, geospatial fields, and more. Add them to your form definition by name. + +## [Page Types](./features/pages) + +Built-in page controllers that define how a page behaves — question pages, repeating groups, file upload pages, summary and confirmation pages. ## [Configuration-based Features](./features/configuration-based) From f5c28ee3330a327a9691d22426467ccee608557a Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 16:07:57 +0100 Subject: [PATCH 07/41] fix(docs): simplify redundant nested titles in schema docs generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @adobe/jsonschema2md tool slugifies JSON schema titles into filenames. The @defra/forms-model v2 schema has deeply nested titles that compound on each other (e.g. "Components (array)" → "Components (array) Item" → "Components (array) Item (object)"), producing filenames of ~252 chars. As .md files they fit within the 255-byte OS limit, but Docusaurus writing them as .html pushes them to 257+ bytes, causing ENAMETOOLONG on Linux CI. Add simplifyNestedTitles() which strips the parent title prefix from child titles before jsonschema2md runs (e.g. "Components (array) Item" → "Item"). The original title is passed down the hierarchy so multi-level stripping works correctly. Whitespace is normalised when matching to handle the double-space present in some schema titles. --- scripts/generate-schema-docs.js | 59 +++++++++++++++++ scripts/generate-schema-docs.test.js | 98 +++++++++++++++++++++++++++- 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/scripts/generate-schema-docs.js b/scripts/generate-schema-docs.js index 15ecb50f5..ed1bb3dc9 100644 --- a/scripts/generate-schema-docs.js +++ b/scripts/generate-schema-docs.js @@ -53,6 +53,64 @@ export function getSchemaFiles() { .sort((a, b) => a.localeCompare(b)) } +/** + * Recursively simplifies titles in nested schemas by stripping redundant parent + * prefixes. For example, if parent title is "Components (array)" and child title + * is "Components (array) Item", the child title is simplified to "Item". + * + * This prevents jsonschema2md from generating excessively long filenames that + * exceed the OS 255-byte filename limit when Docusaurus renders them as .html. + * + * The original (pre-simplification) title is passed to child schemas for + * matching, so the chain of prefix-stripping works correctly across all levels. + * @param {JsonSchema} schema - The schema to simplify in place + * @param {string} [originalParentTitle] - Original title of the parent schema + */ +export function simplifyNestedTitles(schema, originalParentTitle = '') { + if (!schema || typeof schema !== 'object') return + + const originalTitle = schema.title ?? '' + + if (originalTitle && originalParentTitle) { + const normalizedTitle = originalTitle.replace(/\s+/g, ' ').trim() + const normalizedParent = originalParentTitle.replace(/\s+/g, ' ').trim() + + if (normalizedTitle.startsWith(normalizedParent)) { + const stripped = normalizedTitle.slice(normalizedParent.length).trim() + if (stripped) { + schema.title = stripped + } + } + } + + // Pass the ORIGINAL title to children so multi-level stripping works correctly + const nextParent = originalTitle || originalParentTitle + + for (const keyword of /** @type {const} */ (['anyOf', 'oneOf', 'allOf'])) { + if (Array.isArray(schema[keyword])) { + for (const sub of schema[keyword]) { + simplifyNestedTitles(/** @type {JsonSchema} */ (sub), nextParent) + } + } + } + + if (schema.items) { + if (Array.isArray(schema.items)) { + for (const item of schema.items) { + simplifyNestedTitles(/** @type {JsonSchema} */ (item), nextParent) + } + } else { + simplifyNestedTitles(schema.items, nextParent) + } + } + + if (schema.properties) { + for (const propSchema of Object.values(schema.properties)) { + simplifyNestedTitles(/** @type {JsonSchema} */ (propSchema), nextParent) + } + } +} + /** * Process schema content by adding ID if missing and building title map * @param {JsonSchema} schema - Schema content to process @@ -65,6 +123,7 @@ export function processSchemaContent(schema, filename, schemaTitleMap) { schema.$id = `@defra/forms-model/schemas/${filename}` } + simplifyNestedTitles(schema) buildTitleMap(schema, filename.replace('.json', ''), schemaTitleMap) return schema } diff --git a/scripts/generate-schema-docs.test.js b/scripts/generate-schema-docs.test.js index abc5b18de..fc38d56ea 100644 --- a/scripts/generate-schema-docs.test.js +++ b/scripts/generate-schema-docs.test.js @@ -25,7 +25,8 @@ import { processStandardMarkdownFiles, readSchemaFile, runJsonSchema2Md, - setupDirectories + setupDirectories, + simplifyNestedTitles } from './generate-schema-docs.js' jest.mock('fs', () => ({ @@ -591,6 +592,101 @@ describe('Schema Documentation Generator', () => { }) }) + describe('simplifyNestedTitles', () => { + it('strips parent prefix from child title', () => { + const schema = { + title: 'Components', + anyOf: [{ title: 'Components (array)' }] + } + simplifyNestedTitles(schema) + expect(schema.anyOf[0].title).toBe('(array)') + }) + + it('strips prefix through multiple nesting levels using original titles', () => { + const schema = { + title: 'Components', + anyOf: [ + { + title: 'Components (array)', + items: { + title: 'Components (array) Item', + anyOf: [{ title: 'Components (array) Item (object)' }] + } + } + ] + } + simplifyNestedTitles(schema) + expect(schema.anyOf[0].title).toBe('(array)') + expect(schema.anyOf[0].items.title).toBe('Item') + expect(schema.anyOf[0].items.anyOf[0].title).toBe('(object)') + }) + + it('normalizes extra whitespace when comparing', () => { + const schema = { + title: 'Foo Bar', + items: { title: 'Foo Bar Baz' } + } + simplifyNestedTitles(schema) + expect(schema.items.title).toBe('Baz') + }) + + it('does not strip when title does not start with parent prefix', () => { + const schema = { + title: 'Pages', + properties: { + components: { title: 'Components' } + } + } + simplifyNestedTitles(schema) + expect(schema.properties.components.title).toBe('Components') + }) + + it('does not blank a title when stripped result would be empty', () => { + const schema = { + title: 'Item', + items: { title: 'Item' } + } + simplifyNestedTitles(schema) + expect(schema.items.title).toBe('Item') + }) + + it('leaves top-level title unchanged when no parent provided', () => { + const schema = { title: 'Top Level' } + simplifyNestedTitles(schema) + expect(schema.title).toBe('Top Level') + }) + + it('handles oneOf and allOf in addition to anyOf', () => { + const schema = { + title: 'Regex', + oneOf: [{ title: 'Regex (string)' }, { title: 'Regex (string)' }] + } + simplifyNestedTitles(schema) + expect(schema.oneOf[0].title).toBe('(string)') + expect(schema.oneOf[1].title).toBe('(string)') + }) + + it('handles array items', () => { + const schema = { + title: 'Conditional Amounts', + items: { title: 'Conditional Amounts Item' } + } + simplifyNestedTitles(schema) + expect(schema.items.title).toBe('Item') + }) + + it('handles schemas without titles gracefully', () => { + const schema = { + title: 'Parent', + properties: { + noTitle: { type: 'string' } + } + } + expect(() => simplifyNestedTitles(schema)).not.toThrow() + expect(schema.properties.noTitle.title).toBeUndefined() + }) + }) + describe('buildTitleMap', () => { it('builds map of schema paths to titles', () => { const schema = { ...mockSchema } From 1f25e4c46dd9bd8bbd643a69148d4f692eaa9822 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 16:48:31 +0100 Subject: [PATCH 08/41] fix(docs): remove component/page sub-items from Features sidebar --- docusaurus.config.cjs | 126 +----------------------------------------- 1 file changed, 2 insertions(+), 124 deletions(-) diff --git a/docusaurus.config.cjs b/docusaurus.config.cjs index aa59cea02..8677221a6 100644 --- a/docusaurus.config.cjs +++ b/docusaurus.config.cjs @@ -76,130 +76,8 @@ const config = { href: '/features', sidebar: [ { text: 'Overview', href: '/features' }, - { - text: 'Components', - href: '/features/components', - items: [ - { text: 'Overview', href: '/features/components' }, - { text: 'Text Field', href: '/features/components/text-field' }, - { - text: 'Multiline Text Field', - href: '/features/components/multiline-text-field' - }, - { - text: 'Number Field', - href: '/features/components/number-field' - }, - { - text: 'Email Address Field', - href: '/features/components/email-address-field' - }, - { - text: 'Telephone Number Field', - href: '/features/components/telephone-number-field' - }, - { - text: 'Date Parts Field', - href: '/features/components/date-parts-field' - }, - { - text: 'Month Year Field', - href: '/features/components/month-year-field' - }, - { - text: 'Yes No Field', - href: '/features/components/yes-no-field' - }, - { - text: 'Radios Field', - href: '/features/components/radios-field' - }, - { - text: 'Checkboxes Field', - href: '/features/components/checkboxes-field' - }, - { - text: 'Select Field', - href: '/features/components/select-field' - }, - { - text: 'Autocomplete Field', - href: '/features/components/autocomplete-field' - }, - { - text: 'UK Address Field', - href: '/features/components/uk-address-field' - }, - { - text: 'File Upload Field', - href: '/features/components/file-upload-field' - }, - { - text: 'Declaration Field', - href: '/features/components/declaration-field' - }, - { - text: 'Hidden Field', - href: '/features/components/hidden-field' - }, - { - text: 'Payment Field', - href: '/features/components/payment-field' - }, - { text: 'HTML', href: '/features/components/html' }, - { text: 'Markdown', href: '/features/components/markdown' }, - { text: 'Inset Text', href: '/features/components/inset-text' }, - { text: 'Details', href: '/features/components/details' }, - { text: 'List', href: '/features/components/list' }, - { - text: 'Easting Northing Field', - href: '/features/components/easting-northing-field' - }, - { - text: 'OS Grid Ref Field', - href: '/features/components/os-grid-ref-field' - }, - { - text: 'National Grid Field Number Field', - href: '/features/components/national-grid-field-number-field' - }, - { - text: 'Lat Long Field', - href: '/features/components/lat-long-field' - }, - { - text: 'Geospatial Field', - href: '/features/components/geospatial-field' - } - ] - }, - { - text: 'Page Types', - href: '/features/pages', - items: [ - { text: 'Overview', href: '/features/pages' }, - { - text: 'Question Page', - href: '/features/pages/question-page' - }, - { text: 'Start Page', href: '/features/pages/start-page' }, - { - text: 'Terminal Page', - href: '/features/pages/terminal-page' - }, - { text: 'Repeat Page', href: '/features/pages/repeat-page' }, - { - text: 'File Upload Page', - href: '/features/pages/file-upload-page' - }, - { text: 'Summary Page', href: '/features/pages/summary-page' }, - { - text: 'Summary Page with Confirmation Email', - href: '/features/pages/summary-page-with-confirmation-email' - }, - { text: 'Status Page', href: '/features/pages/status-page' } - ] - }, + { text: 'Components', href: '/features/components' }, + { text: 'Page Types', href: '/features/pages' }, { text: 'Configuration-based (Advanced)', href: '/features/configuration-based', From c1a2731c3b6cffc2be05beb6d76108616eba2ac2 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 16:50:37 +0100 Subject: [PATCH 09/41] chore(docs): run component generator before docs:dev --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 757ee1ea7..24c7f73ec 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "dev:debug": "concurrently \"npm run client:watch\" \"npm run server:watch:debug\" --kill-others --names \"client,server\" --prefix-colors \"red.dim,blue.dim\"", "format": "npm run format:check -- --write", "format:check": "prettier --cache --cache-location .cache/prettier --cache-strategy content --check \"**/*.{cjs,js,json,md,mjs,scss,ts}\"", + "predocs:dev": "node scripts/generate-component-docs.js", "docs:dev": "BROWSERSLIST_ENV=javascripts docusaurus start --host 0.0.0.0", "docs:build": "BROWSERSLIST_ENV=javascripts docusaurus build", "docs:build:all": "node scripts/generate-schema-docs.js && node scripts/generate-component-docs.js && npm run docs:build", From e59786d7fcec06d51aa9d005e40a8c4a17ff99be Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 17:00:23 +0100 Subject: [PATCH 10/41] fix(docs): use .md extensions in index links so Docusaurus resolves them correctly --- scripts/generate-component-docs.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 4d0c71381..57a644eab 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -295,7 +295,9 @@ function generateComponentsIndex(componentNames) { if (cat.items.length === 0) continue lines.push(`## ${cat.label}`, ``) for (const item of cat.items) { - lines.push(`- [**${item.label}**](./${item.slug}) — ${item.description}`) + lines.push( + `- [**${item.label}**](./${item.slug}.md) — ${item.description}` + ) } lines.push(``) } @@ -320,7 +322,7 @@ function generatePagesIndex() { for (const [, meta] of Object.entries(metadata.pages)) { const slug = meta.label.toLowerCase().replace(/\s+/g, '-') - lines.push(`- [**${meta.label}**](./${slug}) — ${meta.description}`) + lines.push(`- [**${meta.label}**](./${slug}.md) — ${meta.description}`) } lines.push(``) From 3547d1c86e38699484c005050a575c871ccfd562 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 17:05:07 +0100 Subject: [PATCH 11/41] docs: remove SummaryPageWithConfirmationEmail and Status page types --- scripts/component-metadata.json | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/scripts/component-metadata.json b/scripts/component-metadata.json index aa24dd032..e7b29bbeb 100644 --- a/scripts/component-metadata.json +++ b/scripts/component-metadata.json @@ -442,30 +442,6 @@ "controller": "SummaryPageController", "title": "Check your answers" } - }, - "SummaryPageWithConfirmationEmailController": { - "label": "Summary Page with Confirmation Email", - "description": "Summary page that sends a confirmation email to an address collected in the form. Requires an `EmailAddressField` in the form and `emailField` configured on the `PaymentField` or via plugin options.", - "controllerValue": "SummaryPageWithConfirmationEmailController", - "sidebarPosition": 7, - "uniqueProperties": [], - "example": { - "path": "/summary", - "controller": "SummaryPageWithConfirmationEmailController", - "title": "Check your answers" - } - }, - "StatusPageController": { - "label": "Status Page", - "description": "The confirmation page shown after successful form submission. Must use the path `/status`.", - "controllerValue": "StatusPageController", - "sidebarPosition": 8, - "uniqueProperties": [], - "example": { - "path": "/status", - "controller": "StatusPageController", - "title": "Application submitted" - } } }, "properties": { From 178890addf9562d104026b82dc187d563ceb5aa9 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 17:08:58 +0100 Subject: [PATCH 12/41] docs: add Advanced heading to features page and sidebar --- docs/features/index.md | 6 ++++-- docusaurus.config.cjs | 35 +++++------------------------------ 2 files changed, 9 insertions(+), 32 deletions(-) diff --git a/docs/features/index.md b/docs/features/index.md index e6c75e94e..7c9964d41 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -10,10 +10,12 @@ A library of built-in form components — text fields, date inputs, radio button Built-in page controllers that define how a page behaves — question pages, repeating groups, file upload pages, summary and confirmation pages. -## [Configuration-based Features](./features/configuration-based) +## Advanced + +### [Configuration-based Features](./features/configuration-based) Drive advanced functionality — such as calling APIs and rendering dynamic content — entirely through form definitions, with no custom code required. -## [Code-based Features](./features/code-based) +### [Code-based Features](./features/code-based) Implement highly tailored behaviour by writing custom TypeScript/JavaScript that integrates with forms-engine-plugin's extension points. diff --git a/docusaurus.config.cjs b/docusaurus.config.cjs index 8677221a6..d4020c551 100644 --- a/docusaurus.config.cjs +++ b/docusaurus.config.cjs @@ -79,40 +79,15 @@ const config = { { text: 'Components', href: '/features/components' }, { text: 'Page Types', href: '/features/pages' }, { - text: 'Configuration-based (Advanced)', - href: '/features/configuration-based', + text: 'Advanced', items: [ { - text: 'Page Events', - href: '/features/configuration-based/page-events' + text: 'Configuration-based', + href: '/features/configuration-based' }, { - text: 'Page Templates', - href: '/features/configuration-based/page-templates' - } - ] - }, - { - text: 'Code-based (Advanced)', - href: '/features/code-based', - items: [ - { text: 'Components', href: '/features/code-based/components' }, - { - text: 'Custom Services', - href: '/features/code-based/custom-services' - }, - { - text: 'File Upload', - href: '/features/code-based/file-upload' - }, - { text: 'Page Views', href: '/features/code-based/page-views' }, - { - text: 'Pre-populate State', - href: '/features/code-based/pre-populate-state' - }, - { - text: 'Save and Exit', - href: '/features/code-based/save-and-exit' + text: 'Code-based', + href: '/features/code-based' } ] } From ccaa03610438e1c92f6da10a08d336368e073ff1 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 17:09:27 +0100 Subject: [PATCH 13/41] docs: remove TypeScript mention from code-based advanced callout --- docs/features/code-based/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/code-based/index.md b/docs/features/code-based/index.md index 8daf47fb9..36ad69445 100644 --- a/docs/features/code-based/index.md +++ b/docs/features/code-based/index.md @@ -1,7 +1,7 @@ # Code-based Features :::note Advanced -Code-based features require writing TypeScript or JavaScript. Use these when configuration-based features and built-in components do not meet your requirements. +Code-based features require writing JavaScript. Use these when configuration-based features and built-in components do not meet your requirements. ::: Code-based features let you extend forms-engine-plugin with custom TypeScript or JavaScript. Use them when configuration-based options aren't sufficient for your requirements — for example, to build a bespoke component, override a service, or add highly specialised page logic. From addccf45c52561292b9212ad2d9a6cf0f9d0986c Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 17:09:57 +0100 Subject: [PATCH 14/41] docs: remove Advanced callouts from configuration-based and code-based pages --- docs/features/code-based/index.md | 4 ---- docs/features/configuration-based/index.md | 4 ---- 2 files changed, 8 deletions(-) diff --git a/docs/features/code-based/index.md b/docs/features/code-based/index.md index 36ad69445..2dabf6e81 100644 --- a/docs/features/code-based/index.md +++ b/docs/features/code-based/index.md @@ -1,9 +1,5 @@ # Code-based Features -:::note Advanced -Code-based features require writing JavaScript. Use these when configuration-based features and built-in components do not meet your requirements. -::: - Code-based features let you extend forms-engine-plugin with custom TypeScript or JavaScript. Use them when configuration-based options aren't sufficient for your requirements — for example, to build a bespoke component, override a service, or add highly specialised page logic. > Only introduce code-based customisations where there is genuine business need. Custom code becomes your team's responsibility to test, maintain and keep accessible. diff --git a/docs/features/configuration-based/index.md b/docs/features/configuration-based/index.md index f949f072b..a469f1ba4 100644 --- a/docs/features/configuration-based/index.md +++ b/docs/features/configuration-based/index.md @@ -1,9 +1,5 @@ # Configuration-based Features -:::note Advanced -Configuration-based features require familiarity with the form definition format. Most use cases are covered by the built-in [components](../components) and [page types](../pages). -::: - Configuration-based features let you drive advanced behaviour entirely through your form definition — no custom code required. They are implemented in the JSON/YAML form definition and processed by forms-engine-plugin at runtime. When developing with forms-engine-plugin, prefer configuration-based features over code-based ones wherever possible. They require less effort to maintain and benefit from the same testing and accessibility assurance as core forms-engine-plugin. From 42c93f8a8f210c607d84ff09c8efabcd236d475f Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 17:11:51 +0100 Subject: [PATCH 15/41] docs: move payment/geospatial under Input fields as subheadings, fix UK/OS labels --- scripts/component-metadata.json | 2 ++ scripts/generate-component-docs.js | 28 +++++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/scripts/component-metadata.json b/scripts/component-metadata.json index e7b29bbeb..e88fae2c2 100644 --- a/scripts/component-metadata.json +++ b/scripts/component-metadata.json @@ -151,6 +151,7 @@ } }, "UkAddressField": { + "label": "UK Address Field", "description": "Structured address input for collecting UK postal addresses.", "category": "input", "sidebarPosition": 13, @@ -281,6 +282,7 @@ } }, "OsGridRefField": { + "label": "OS Grid Ref Field", "description": "Text input for collecting an Ordnance Survey grid reference.", "category": "geospatial", "sidebarPosition": 24, diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 57a644eab..9753d8c51 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -261,8 +261,8 @@ function generateComponentsIndex(componentNames) { input: { label: 'Input fields', items: [] }, selection: { label: 'Selection fields', items: [] }, content: { label: 'Content components', items: [] }, - payment: { label: 'Payment', items: [] }, - geospatial: { label: 'Geospatial fields', items: [] } + payment: { label: 'Payment', items: [], subheading: true }, + geospatial: { label: 'Geospatial fields', items: [], subheading: true } } for (const name of componentNames) { @@ -291,7 +291,29 @@ function generateComponentsIndex(componentNames) { `` ] - for (const [, cat] of Object.entries(categories)) { + // Render input fields first, then payment and geospatial as subheadings within it + const subheadings = ['payment', 'geospatial'] + + lines.push(`## ${categories.input.label}`, ``) + for (const item of categories.input.items) { + lines.push(`- [**${item.label}**](./${item.slug}.md) — ${item.description}`) + } + lines.push(``) + + for (const key of subheadings) { + const cat = categories[key] + if (cat.items.length === 0) continue + lines.push(`### ${cat.label}`, ``) + for (const item of cat.items) { + lines.push( + `- [**${item.label}**](./${item.slug}.md) — ${item.description}` + ) + } + lines.push(``) + } + + for (const key of ['selection', 'content']) { + const cat = categories[key] if (cat.items.length === 0) continue lines.push(`## ${cat.label}`, ``) for (const item of cat.items) { From cc55ce2d5bbb258ed87c123d5d113d83133da198 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 17:26:54 +0100 Subject: [PATCH 16/41] refactor(docs): generate all component data from forms-model types, metadata is descriptions only --- scripts/component-metadata.json | 353 ++---------------------- scripts/generate-component-docs.js | 427 ++++++++++++++++++++--------- 2 files changed, 317 insertions(+), 463 deletions(-) diff --git a/scripts/component-metadata.json b/scripts/component-metadata.json index e88fae2c2..8e6aa9fed 100644 --- a/scripts/component-metadata.json +++ b/scripts/component-metadata.json @@ -1,331 +1,32 @@ { "components": { - "TextField": { - "description": "Single-line text input for collecting short answers.", - "category": "input", - "sidebarPosition": 1, - "example": { - "type": "TextField", - "name": "fullName", - "title": "What is your full name?", - "hint": "As it appears on your passport", - "options": {}, - "schema": {} - } - }, - "MultilineTextField": { - "description": "Multi-line text area for collecting longer text answers.", - "category": "input", - "sidebarPosition": 2, - "example": { - "type": "MultilineTextField", - "name": "description", - "title": "Describe the issue", - "options": { "rows": 5 }, - "schema": { "max": 500 } - } - }, - "NumberField": { - "description": "Numeric input for collecting whole or decimal numbers.", - "category": "input", - "sidebarPosition": 3, - "example": { - "type": "NumberField", - "name": "age", - "title": "What is your age?", - "options": {}, - "schema": { "min": 0, "max": 150 } - } - }, - "EmailAddressField": { - "description": "Text input validated as an email address.", - "category": "input", - "sidebarPosition": 4, - "example": { - "type": "EmailAddressField", - "name": "email", - "title": "What is your email address?", - "options": {} - } - }, - "TelephoneNumberField": { - "description": "Text input for collecting telephone numbers.", - "category": "input", - "sidebarPosition": 5, - "example": { - "type": "TelephoneNumberField", - "name": "phone", - "title": "What is your telephone number?", - "options": {} - } - }, - "DatePartsField": { - "description": "Three separate inputs (day, month, year) for collecting full dates.", - "category": "input", - "sidebarPosition": 6, - "extraOptions": [ - { "name": "maxDaysInPast", "optional": true, "type": "number" }, - { "name": "maxDaysInFuture", "optional": true, "type": "number" } - ], - "example": { - "type": "DatePartsField", - "name": "dob", - "title": "What is your date of birth?", - "hint": "For example, 27 3 1990", - "options": {} - } - }, - "MonthYearField": { - "description": "Two inputs (month, year) for collecting partial dates.", - "category": "input", - "sidebarPosition": 7, - "extraOptions": [ - { "name": "maxDaysInPast", "optional": true, "type": "number" }, - { "name": "maxDaysInFuture", "optional": true, "type": "number" } - ], - "example": { - "type": "MonthYearField", - "name": "startDate", - "title": "When did you start?", - "hint": "For example, 3 1990", - "options": {} - } - }, - "YesNoField": { - "description": "A pair of radio buttons offering a yes/no choice.", - "category": "input", - "sidebarPosition": 8, - "example": { - "type": "YesNoField", - "name": "hasAddress", - "title": "Do you have a UK address?", - "options": {} - } - }, - "RadiosField": { - "description": "Radio buttons allowing the user to select one option from a named list.", - "category": "selection", - "sidebarPosition": 9, - "example": { - "type": "RadiosField", - "name": "colour", - "title": "Which colour do you prefer?", - "list": "colours", - "options": {} - } - }, - "CheckboxesField": { - "description": "Checkboxes allowing the user to select one or more options from a named list.", - "category": "selection", - "sidebarPosition": 10, - "example": { - "type": "CheckboxesField", - "name": "interests", - "title": "What are your interests?", - "list": "interests", - "options": {} - } - }, - "SelectField": { - "description": "A dropdown select element for choosing one option from a named list.", - "category": "selection", - "sidebarPosition": 11, - "example": { - "type": "SelectField", - "name": "country", - "title": "Which country do you live in?", - "list": "countries", - "options": {} - } - }, - "AutocompleteField": { - "description": "Text input with autocomplete suggestions sourced from a named list.", - "category": "selection", - "sidebarPosition": 12, - "example": { - "type": "AutocompleteField", - "name": "nationality", - "title": "What is your nationality?", - "list": "nationalities", - "options": {} - } - }, - "UkAddressField": { - "label": "UK Address Field", - "description": "Structured address input for collecting UK postal addresses.", - "category": "input", - "sidebarPosition": 13, - "example": { - "type": "UkAddressField", - "name": "address", - "title": "What is your home address?", - "options": {} - } - }, - "FileUploadField": { - "description": "File upload control integrated with the CDP file upload service.", - "category": "input", - "sidebarPosition": 14, - "example": { - "type": "FileUploadField", - "name": "evidence", - "title": "Upload your evidence", - "options": {}, - "schema": {} - } - }, - "DeclarationField": { - "description": "A checkbox the user must tick to confirm a declaration before proceeding.", - "category": "input", - "sidebarPosition": 15, - "example": { - "type": "DeclarationField", - "name": "declaration", - "title": "Declaration", - "content": "By submitting this form you confirm the information is correct.", - "options": {} - } - }, - "HiddenField": { - "description": "A non-visible field that stores a fixed value in form state without user input.", - "category": "input", - "sidebarPosition": 16, - "example": { - "type": "HiddenField", - "name": "source", - "title": "Source", - "options": {} - } - }, - "PaymentField": { - "description": "Redirects the user to GOV.UK Pay to collect a payment before proceeding.", - "category": "payment", - "sidebarPosition": 17, - "example": { - "type": "PaymentField", - "name": "payment", - "title": "Payment", - "options": { - "amount": 2000, - "description": "Application fee" - } - } - }, - "Html": { - "description": "Display static HTML content on a page without collecting user input.", - "category": "content", - "sidebarPosition": 18, - "example": { - "type": "Html", - "name": "introHtml", - "title": "Introduction", - "content": "

This is some HTML content.

", - "options": {} - } - }, - "Markdown": { - "description": "Display content authored in Markdown, rendered to HTML at runtime.", - "category": "content", - "sidebarPosition": 19, - "example": { - "type": "Markdown", - "name": "introMarkdown", - "title": "Introduction", - "content": "## Heading\n\nSome **bold** text.", - "options": {} - } - }, - "InsetText": { - "description": "Display highlighted inset text using the GOV.UK inset text component.", - "category": "content", - "sidebarPosition": 20, - "example": { - "type": "InsetText", - "name": "warningText", - "title": "Important", - "content": "You must provide accurate information." - } - }, - "Details": { - "description": "Display content inside a collapsible disclosure element.", - "category": "content", - "sidebarPosition": 21, - "example": { - "type": "Details", - "name": "helpDetails", - "title": "Help with this question", - "content": "More information about what we need.", - "options": {} - } - }, - "List": { - "description": "Display a GOV.UK-styled list sourced from a named list definition.", - "category": "content", - "sidebarPosition": 22, - "example": { - "type": "List", - "name": "nextSteps", - "title": "Next steps", - "list": "nextStepsList", - "options": {} - } - }, - "EastingNorthingField": { - "description": "Paired numeric inputs for collecting British National Grid easting and northing coordinates.", - "category": "geospatial", - "sidebarPosition": 23, - "example": { - "type": "EastingNorthingField", - "name": "location", - "title": "Enter the grid coordinates", - "options": {} - } - }, - "OsGridRefField": { - "label": "OS Grid Ref Field", - "description": "Text input for collecting an Ordnance Survey grid reference.", - "category": "geospatial", - "sidebarPosition": 24, - "example": { - "type": "OsGridRefField", - "name": "gridRef", - "title": "Enter the OS grid reference", - "options": {} - } - }, - "NationalGridFieldNumberField": { - "description": "Numeric input for a single National Grid coordinate component.", - "category": "geospatial", - "sidebarPosition": 25, - "example": { - "type": "NationalGridFieldNumberField", - "name": "gridNumber", - "title": "Enter the National Grid number", - "options": {} - } - }, - "LatLongField": { - "description": "Paired decimal inputs for collecting WGS84 latitude and longitude coordinates.", - "category": "geospatial", - "sidebarPosition": 26, - "example": { - "type": "LatLongField", - "name": "coordinates", - "title": "Enter the coordinates", - "options": {} - } - }, - "GeospatialField": { - "description": "Composite location picker supporting multiple geospatial coordinate formats.", - "category": "geospatial", - "sidebarPosition": 27, - "example": { - "type": "GeospatialField", - "name": "location", - "title": "Where is the location?", - "options": {} - } - } + "TextField": "Single-line text input for collecting short answers.", + "MultilineTextField": "Multi-line text area for collecting longer text answers.", + "NumberField": "Numeric input for collecting whole or decimal numbers.", + "EmailAddressField": "Text input validated as an email address.", + "TelephoneNumberField": "Text input for collecting telephone numbers.", + "DatePartsField": "Three separate inputs (day, month, year) for collecting full dates.", + "MonthYearField": "Two inputs (month, year) for collecting partial dates.", + "YesNoField": "A pair of radio buttons offering a yes/no choice.", + "RadiosField": "Radio buttons allowing the user to select one option from a named list.", + "CheckboxesField": "Checkboxes allowing the user to select one or more options from a named list.", + "SelectField": "A dropdown select element for choosing one option from a named list.", + "AutocompleteField": "Text input with autocomplete suggestions sourced from a named list.", + "UkAddressField": "Structured address input for collecting UK postal addresses.", + "FileUploadField": "File upload control integrated with the CDP file upload service.", + "DeclarationField": "A checkbox the user must tick to confirm a declaration before proceeding.", + "HiddenField": "A non-visible field that stores a fixed value in form state without user input.", + "PaymentField": "Redirects the user to GOV.UK Pay to collect a payment before proceeding.", + "Html": "Display static HTML content on a page without collecting user input.", + "Markdown": "Display content authored in Markdown, rendered to HTML at runtime.", + "InsetText": "Display highlighted inset text using the GOV.UK inset text component.", + "Details": "Display content inside a collapsible disclosure element.", + "List": "Display a GOV.UK-styled list sourced from a named list definition.", + "EastingNorthingField": "Paired numeric inputs for collecting British National Grid easting and northing coordinates.", + "OsGridRefField": "Text input for collecting an Ordnance Survey grid reference.", + "NationalGridFieldNumberField": "Numeric input for a single National Grid coordinate component.", + "LatLongField": "Paired decimal inputs for collecting WGS84 latitude and longitude coordinates.", + "GeospatialField": "Composite location picker supporting multiple geospatial coordinate formats." }, "pages": { "PageController": { diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 9753d8c51..bf432025b 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -15,14 +15,33 @@ const componentsOutputDir = path.resolve( '../docs/features/components' ) const pagesOutputDir = path.resolve(__dirname, '../docs/features/pages') + const metadata = JSON.parse( fs.readFileSync(path.resolve(__dirname, 'component-metadata.json'), 'utf-8') ) -/** - * Convert PascalCase to kebab-case. - * "TextField" -> "text-field", "Html" -> "html" - */ +// Properties from FormFieldBase['options'] that apply to every form component. +// These are excluded from per-component option tables since they're universal. +const BASE_OPTION_PROPS = new Set([ + 'required', + 'optionalText', + 'classes', + 'customValidationMessages', + 'instructionText' +]) + +// Known acronyms for label generation +const ACRONYMS = { Uk: 'UK', Os: 'OS', Html: 'HTML' } + +// Name fragments that identify geospatial components +const GEOSPATIAL_NAMES = [ + 'EastingNorthing', + 'OsGridRef', + 'NationalGrid', + 'LatLong', + 'Geospatial' +] + function toKebabCase(str) { return str.replace( /([A-Z])/g, @@ -30,35 +49,28 @@ function toKebabCase(str) { ) } -/** - * Convert PascalCase to Title Case with spaces. - * "TextField" -> "Text Field" - */ function toLabel(name) { - return name - .replace( - /([A-Z])/g, - (match, letter, offset) => (offset > 0 ? ' ' : '') + letter - ) + const words = name + .replace(/([A-Z])/g, ' $1') .trim() + .split(' ') + return words.map((w) => ACRONYMS[w] ?? w).join(' ') } -/** - * Simplify complex TypeScript type strings for display in docs tables. - */ function simplifyType(rawType) { if (!rawType) return 'unknown' const t = rawType.replace(/\s+/g, ' ').trim() - if (t.startsWith('{') || t.includes('LanguageMessages')) return 'object' + if (t.startsWith('{')) return 'object' + if (t.includes('LanguageMessages')) return 'object' if (t.includes('ListTypeContent') || t.includes('ListTypeOption')) return 'string' - if (t.endsWith('[]')) return simplifyType(t.slice(0, -2)) + '[]' - return t + const withoutUndefined = t.replace(/\s*\|\s*undefined/g, '').trim() + if (withoutUndefined.endsWith('[]')) { + return simplifyType(withoutUndefined.slice(0, -2)) + '[]' + } + return withoutUndefined } -/** - * Extract properties from a TypeLiteralNode. - */ function extractTypeLiteralProps(typeNode, sourceFile) { const props = [] if (!ts.isTypeLiteralNode(typeNode)) return props @@ -73,25 +85,72 @@ function extractTypeLiteralProps(typeNode, sourceFile) { } /** - * Extract component-specific options properties. - * options type is: BaseOptions & { specific?: type } — we want the & { } part. + * Traverse a type node recursively, resolving IndexedAccessTypes by looking up + * the referenced interface in allInterfaces. Collects all TypeLiteralNode properties. + * + * This handles patterns like: + * DateFieldBase['options'] & { condition?: string } + * by resolving DateFieldBase.options → FormFieldBase['options'] & { maxDaysInPast?, maxDaysInFuture? } + * and continuing to collect all literal members. */ -function extractOptionsProps(typeNode, sourceFile) { +function collectProps( + typeNode, + sourceFile, + allInterfaces, + accessKey, + depth = 0 +) { + if (depth > 6) return [] + const props = [] + if (ts.isIntersectionTypeNode(typeNode)) { - const lastType = typeNode.types[typeNode.types.length - 1] - if (ts.isTypeLiteralNode(lastType)) { - return extractTypeLiteralProps(lastType, sourceFile) + for (const member of typeNode.types) { + props.push( + ...collectProps(member, sourceFile, allInterfaces, accessKey, depth) + ) + } + } else if (ts.isTypeLiteralNode(typeNode)) { + props.push(...extractTypeLiteralProps(typeNode, sourceFile)) + } else if (ts.isIndexedAccessTypeNode(typeNode)) { + // Resolve SomeInterface['accessKey'] by looking up the interface + const { objectType, indexType } = typeNode + if (ts.isTypeReferenceNode(objectType) && ts.isLiteralTypeNode(indexType)) { + const ifaceName = objectType.typeName.getText(sourceFile) + const key = indexType.literal.getText(sourceFile).replace(/['"]/g, '') + const iface = allInterfaces[ifaceName] + if (iface && key === accessKey) { + for (const member of iface.members) { + if ( + ts.isPropertySignature(member) && + member.name.getText(sourceFile) === accessKey && + member.type + ) { + props.push( + ...collectProps( + member.type, + sourceFile, + allInterfaces, + accessKey, + depth + 1 + ) + ) + } + } + } } } - if (ts.isTypeLiteralNode(typeNode)) { - return extractTypeLiteralProps(typeNode, sourceFile) - } - return [] + + return props } /** - * Parse all exported interfaces from a .d.ts file. - * Returns a map of interface name -> { options: [], schema: [] }. + * Parse all exported component interfaces from types.d.ts. + * Returns a map: interfaceName -> { options, schema, hasContent, hasList } + * + * - options: component-specific and group-specific options (base props filtered out) + * - schema: schema constraint properties + * - hasContent: whether the component has a 'content' property + * - hasList: whether the component has a 'list' property */ function parseComponentInterfaces(dtsPath) { const content = fs.readFileSync(dtsPath, 'utf-8') @@ -102,60 +161,159 @@ function parseComponentInterfaces(dtsPath) { true ) + // Collect all interfaces for cross-reference resolution + const allInterfaces = {} + ts.forEachChild(sourceFile, (node) => { + if (ts.isInterfaceDeclaration(node)) { + allInterfaces[node.name.text] = node + } + }) + const result = {} - function visit(node) { + ts.forEachChild(sourceFile, (node) => { if ( - ts.isInterfaceDeclaration(node) && - node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) - ) { - const name = node.name.text - const options = [] - const schema = [] + !ts.isInterfaceDeclaration(node) || + !node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + ) + return + + const name = node.name.text + let rawOptions = [] + let schema = [] + let hasContent = false + let hasList = false + + for (const member of node.members) { + if (!ts.isPropertySignature(member)) continue + const propName = member.name.getText(sourceFile) + + if (propName === 'options' && member.type) { + rawOptions = collectProps( + member.type, + sourceFile, + allInterfaces, + 'options' + ) + } + if (propName === 'schema' && member.type) { + schema = collectProps(member.type, sourceFile, allInterfaces, 'schema') + } + if (propName === 'content') hasContent = true + if (propName === 'list') hasList = true + } - for (const member of node.members) { - if (!ts.isPropertySignature(member)) continue - const propName = member.name.getText(sourceFile) + // Remove props that exist on every form component (from FormFieldBase['options']) + const options = rawOptions.filter((p) => !BASE_OPTION_PROPS.has(p.name)) - if (propName === 'options' && member.type) { - options.push(...extractOptionsProps(member.type, sourceFile)) - } - if (propName === 'schema' && member.type) { - schema.push(...extractTypeLiteralProps(member.type, sourceFile)) - } - } + result[name] = { options, schema, hasContent, hasList } + }) - if (options.length > 0 || schema.length > 0) { - result[name] = { options, schema } + return result +} + +/** + * Parse the ComponentType enum to get an ordered list of component names. + */ +function parseComponentOrder(enumsDtsPath) { + const content = fs.readFileSync(enumsDtsPath, 'utf-8') + const sourceFile = ts.createSourceFile( + enumsDtsPath, + content, + ts.ScriptTarget.Latest, + true + ) + const order = [] + + ts.forEachChild(sourceFile, (node) => { + if (!ts.isEnumDeclaration(node) || node.name.text !== 'ComponentType') + return + for (const member of node.members) { + if (ts.isEnumMember(member) && member.initializer) { + order.push(member.initializer.getText(sourceFile).replace(/['"]/g, '')) } } - ts.forEachChild(node, visit) + }) + + return order +} + +/** + * Parse ContentComponentsDef and SelectionComponentsDef type aliases to derive categories. + * Returns a map: componentName -> 'content' | 'selection' + */ +function parseCategories(typesDtsPath) { + const content = fs.readFileSync(typesDtsPath, 'utf-8') + const sourceFile = ts.createSourceFile( + typesDtsPath, + content, + ts.ScriptTarget.Latest, + true + ) + + const categories = {} + + function namesFromUnion(typeNode) { + if (ts.isUnionTypeNode(typeNode)) { + return typeNode.types.flatMap(namesFromUnion) + } + if (ts.isTypeReferenceNode(typeNode)) { + return [typeNode.typeName.getText(sourceFile)] + } + return [] } - visit(sourceFile) - return result + ts.forEachChild(sourceFile, (node) => { + if (!ts.isTypeAliasDeclaration(node)) return + const aliasName = node.name.text + + if (aliasName === 'ContentComponentsDef') { + for (const name of namesFromUnion(node.type)) { + categories[name.replace(/Component$/, '')] = 'content' + } + } else if (aliasName === 'SelectionComponentsDef') { + for (const name of namesFromUnion(node.type)) { + categories[name.replace(/Component$/, '')] = 'selection' + } + } + }) + + return categories +} + +function deriveCategory(name, parsedCategories) { + if (parsedCategories[name]) return parsedCategories[name] + if (GEOSPATIAL_NAMES.some((p) => name.includes(p))) return 'geospatial' + if (name.includes('Payment')) return 'payment' + return 'input' } /** - * Generate markdown content for a single component page. + * Generate a minimal example JSON for a component based on its structure. */ -function generateComponentMd(componentName, interfaceData) { - const meta = metadata.components[componentName] || {} - const description = meta.description || '' - const example = meta.example || { +function generateExample(componentName, interfaceData) { + const example = { type: componentName, name: 'fieldName', - title: 'Field title', - options: {} + title: 'Question title' + } + if (interfaceData.hasContent) example.content = '' + if (interfaceData.hasList) example.list = 'listName' + if (componentName === 'PaymentField') { + example.options = { amount: 2000, description: 'Application fee' } } - const label = meta.label || toLabel(componentName) - const { options: parsedOptions = [], schema = [] } = interfaceData || {} - const options = [...parsedOptions, ...(meta.extraOptions ?? [])] + return example +} + +function generateComponentMd(componentName, interfaceData, sidebarPosition) { + const description = metadata.components[componentName] ?? '' + const label = toLabel(componentName) + const { options = [], schema = [] } = interfaceData const lines = [ `---`, `sidebar_label: "${label}"`, - `sidebar_position: ${meta.sidebarPosition ?? 99}`, + `sidebar_position: ${sidebarPosition}`, `---`, ``, `# ${label}`, @@ -165,7 +323,7 @@ function generateComponentMd(componentName, interfaceData) { `## JSON definition`, ``, '```json', - JSON.stringify(example, null, 2), + JSON.stringify(generateExample(componentName, interfaceData), null, 2), '```', `` ] @@ -176,9 +334,8 @@ function generateComponentMd(componentName, interfaceData) { lines.push(`|----------|------|----------|-------------|`) for (const prop of options) { const desc = metadata.properties[prop.name] ?? '' - const required = prop.optional ? 'No' : 'Yes' lines.push( - `| \`${prop.name}\` | \`${prop.type}\` | ${required} | ${desc} |` + `| \`${prop.name}\` | \`${prop.type}\` | ${prop.optional ? 'No' : 'Yes'} | ${desc} |` ) } lines.push(``) @@ -198,9 +355,6 @@ function generateComponentMd(componentName, interfaceData) { return lines.join('\n') } -/** - * Generate markdown content for a single page controller page. - */ function generatePageMd(controllerKey) { const meta = metadata.pages[controllerKey] if (!meta) return null @@ -238,7 +392,7 @@ function generatePageMd(controllerKey) { `` ) - if (uniqueProperties && uniqueProperties.length > 0) { + if (uniqueProperties?.length > 0) { lines.push(`## Configuration`, ``) lines.push(`| Property | Type | Required | Description |`) lines.push(`|----------|------|----------|-------------|`) @@ -253,31 +407,21 @@ function generatePageMd(controllerKey) { return lines.join('\n') } -/** - * Generate the components index page listing all components by category. - */ -function generateComponentsIndex(componentNames) { - const categories = { +function generateComponentsIndex(componentNames, categories) { + const groups = { input: { label: 'Input fields', items: [] }, selection: { label: 'Selection fields', items: [] }, content: { label: 'Content components', items: [] }, - payment: { label: 'Payment', items: [], subheading: true }, - geospatial: { label: 'Geospatial fields', items: [], subheading: true } + payment: { label: 'Payment', items: [] }, + geospatial: { label: 'Geospatial fields', items: [] } } for (const name of componentNames) { - const meta = metadata.components[name] - if (!meta) continue - const category = meta.category || 'input' - const label = meta.label || toLabel(name) + const category = categories[name] ?? 'input' + const label = toLabel(name) const slug = toKebabCase(name) - if (categories[category]) { - categories[category].items.push({ - label, - slug, - description: meta.description - }) - } + const description = metadata.components[name] ?? '' + groups[category]?.items.push({ label, slug, description }) } const lines = [ @@ -291,20 +435,17 @@ function generateComponentsIndex(componentNames) { `` ] - // Render input fields first, then payment and geospatial as subheadings within it - const subheadings = ['payment', 'geospatial'] - - lines.push(`## ${categories.input.label}`, ``) - for (const item of categories.input.items) { + lines.push(`## ${groups.input.label}`, ``) + for (const item of groups.input.items) { lines.push(`- [**${item.label}**](./${item.slug}.md) — ${item.description}`) } lines.push(``) - for (const key of subheadings) { - const cat = categories[key] - if (cat.items.length === 0) continue - lines.push(`### ${cat.label}`, ``) - for (const item of cat.items) { + for (const key of ['payment', 'geospatial']) { + const group = groups[key] + if (group.items.length === 0) continue + lines.push(`### ${group.label}`, ``) + for (const item of group.items) { lines.push( `- [**${item.label}**](./${item.slug}.md) — ${item.description}` ) @@ -313,10 +454,10 @@ function generateComponentsIndex(componentNames) { } for (const key of ['selection', 'content']) { - const cat = categories[key] - if (cat.items.length === 0) continue - lines.push(`## ${cat.label}`, ``) - for (const item of cat.items) { + const group = groups[key] + if (group.items.length === 0) continue + lines.push(`## ${group.label}`, ``) + for (const item of group.items) { lines.push( `- [**${item.label}**](./${item.slug}.md) — ${item.description}` ) @@ -327,9 +468,6 @@ function generateComponentsIndex(componentNames) { return lines.join('\n') } -/** - * Generate the pages index page listing all page types. - */ function generatePagesIndex() { const lines = [ `---`, @@ -352,6 +490,19 @@ function generatePagesIndex() { } function main() { + const componentsDtsPath = path.join( + formsModelTypesDir, + 'components/types.d.ts' + ) + const enumsDtsPath = path.join(formsModelTypesDir, 'components/enums.d.ts') + + if (!fs.existsSync(componentsDtsPath)) { + console.error( + `Error: cannot find @defra/forms-model types at:\n ${componentsDtsPath}\nIs the package installed?` + ) + process.exit(1) + } + // Set up output directories if (fs.existsSync(componentsOutputDir)) { fs.rmSync(componentsOutputDir, { recursive: true, force: true }) @@ -363,39 +514,44 @@ function main() { } fs.mkdirSync(pagesOutputDir, { recursive: true }) - // Parse component interfaces - const componentDtsPath = path.join( - formsModelTypesDir, - 'components/types.d.ts' - ) - if (!fs.existsSync(componentDtsPath)) { - console.error( - `Error: cannot find @defra/forms-model types at:\n ${componentDtsPath}\nIs the package installed?` - ) - process.exit(1) + // Parse sources + const interfaces = parseComponentInterfaces(componentsDtsPath) + const componentOrder = parseComponentOrder(enumsDtsPath) + const parsedCategories = parseCategories(componentsDtsPath) + + // Build full category map + const categories = {} + for (const name of componentOrder) { + categories[name] = deriveCategory(name, parsedCategories) } - const interfaces = parseComponentInterfaces(componentDtsPath) - // Generate component pages - const componentNames = Object.keys(metadata.components) - for (const name of componentNames) { - const slug = toKebabCase(name) - // Interface names in types.d.ts use a "Component" suffix (e.g. TextFieldComponent) - const interfaceData = interfaces[`${name}Component`] ?? interfaces[name] - const meta = metadata.components[name] - if (!interfaceData && meta?.category !== 'content') { - console.warn( - `Warning: no interface data found for ${name} (tried ${name}Component and ${name})` - ) + // Generate component pages in enum order + for (const [i, name] of componentOrder.entries()) { + const interfaceData = interfaces[`${name}Component`] ?? + interfaces[name] ?? { + options: [], + schema: [], + hasContent: false, + hasList: false + } + + if ( + !interfaces[`${name}Component`] && + !interfaces[name] && + categories[name] !== 'content' + ) { + console.warn(`Warning: no interface data found for ${name}`) } - const content = generateComponentMd(name, interfaceData) + + const slug = toKebabCase(name) + const content = generateComponentMd(name, interfaceData, i + 1) fs.writeFileSync(path.join(componentsOutputDir, `${slug}.md`), content) } // Generate components index fs.writeFileSync( path.join(componentsOutputDir, 'index.md'), - generateComponentsIndex(componentNames) + generateComponentsIndex(componentOrder, categories) ) // Generate page type pages @@ -408,13 +564,10 @@ function main() { } } - // Generate pages index fs.writeFileSync(path.join(pagesOutputDir, 'index.md'), generatePagesIndex()) - const componentCount = componentNames.length - const pageCount = Object.keys(metadata.pages).length console.log( - `Generated ${componentCount} component pages and ${pageCount} page type pages.` + `Generated ${componentOrder.length} component pages and ${Object.keys(metadata.pages).length} page type pages.` ) } From 520dbd53454faa7ea2c10bfad94b00df7796739e Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 17:29:45 +0100 Subject: [PATCH 17/41] fix(docs): correct relative links in features index --- docs/features/index.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/features/index.md b/docs/features/index.md index 7c9964d41..d4df22e1e 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -2,20 +2,20 @@ forms-engine-plugin provides built-in components and page types you can use immediately in your form definitions, as well as advanced features for driving dynamic behaviour or writing custom code. -## [Components](./features/components) +## [Components](./components) A library of built-in form components — text fields, date inputs, radio buttons, file upload, payment, geospatial fields, and more. Add them to your form definition by name. -## [Page Types](./features/pages) +## [Page Types](./pages) Built-in page controllers that define how a page behaves — question pages, repeating groups, file upload pages, summary and confirmation pages. ## Advanced -### [Configuration-based Features](./features/configuration-based) +### [Configuration-based Features](./configuration-based) Drive advanced functionality — such as calling APIs and rendering dynamic content — entirely through form definitions, with no custom code required. -### [Code-based Features](./features/code-based) +### [Code-based Features](./code-based) Implement highly tailored behaviour by writing custom TypeScript/JavaScript that integrates with forms-engine-plugin's extension points. From d1c8c43625ee9fcb2dff20bb096e0c39a1f2b780 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 17:34:00 +0100 Subject: [PATCH 18/41] fix(docs): use absolute URL paths in features index to avoid Docusaurus link validation errors --- docs/features/index.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/features/index.md b/docs/features/index.md index d4df22e1e..2cb10d8fd 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -2,20 +2,20 @@ forms-engine-plugin provides built-in components and page types you can use immediately in your form definitions, as well as advanced features for driving dynamic behaviour or writing custom code. -## [Components](./components) +## [Components](/features/components) A library of built-in form components — text fields, date inputs, radio buttons, file upload, payment, geospatial fields, and more. Add them to your form definition by name. -## [Page Types](./pages) +## [Page Types](/features/pages) Built-in page controllers that define how a page behaves — question pages, repeating groups, file upload pages, summary and confirmation pages. ## Advanced -### [Configuration-based Features](./configuration-based) +### [Configuration-based Features](/features/configuration-based) Drive advanced functionality — such as calling APIs and rendering dynamic content — entirely through form definitions, with no custom code required. -### [Code-based Features](./code-based) +### [Code-based Features](/features/code-based) Implement highly tailored behaviour by writing custom TypeScript/JavaScript that integrates with forms-engine-plugin's extension points. From 478227f5177dec6e61cb984ce36b09f5c7777cae Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 17:39:02 +0100 Subject: [PATCH 19/41] fix(docs): add href to Advanced sidebar group to prevent Layout crash --- docusaurus.config.cjs | 1 + 1 file changed, 1 insertion(+) diff --git a/docusaurus.config.cjs b/docusaurus.config.cjs index d4020c551..7de764166 100644 --- a/docusaurus.config.cjs +++ b/docusaurus.config.cjs @@ -80,6 +80,7 @@ const config = { { text: 'Page Types', href: '/features/pages' }, { text: 'Advanced', + href: '/features', items: [ { text: 'Configuration-based', From 968e93dfd3f61d0cd7942c5b6638344649dd59c2 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 17:46:51 +0100 Subject: [PATCH 20/41] feat(docs): include options/schema structure in generated examples, fill required fields --- scripts/generate-component-docs.js | 43 ++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index bf432025b..962e1586d 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -289,19 +289,52 @@ function deriveCategory(name, parsedCategories) { } /** - * Generate a minimal example JSON for a component based on its structure. + * Return a placeholder value for a given type string. + * Used to populate required fields in generated examples. + */ +function placeholderForType(type) { + if (type === 'number') return 0 + if (type === 'boolean') return true + if (type === 'string') return '' + if (type.endsWith('[]')) return [] + return {} +} + +/** + * Generate an example JSON for a component based on its structure. + * Required options/schema fields are shown with placeholder values. + * Optional fields are omitted — the tables below the example document them. */ function generateExample(componentName, interfaceData) { + const { options = [], schema = [], hasContent, hasList } = interfaceData + const example = { type: componentName, name: 'fieldName', title: 'Question title' } - if (interfaceData.hasContent) example.content = '' - if (interfaceData.hasList) example.list = 'listName' - if (componentName === 'PaymentField') { - example.options = { amount: 2000, description: 'Application fee' } + + if (hasContent) example.content = '' + if (hasList) example.list = 'listName' + + const requiredOptions = options.filter((p) => !p.optional) + if (requiredOptions.length > 0) { + example.options = Object.fromEntries( + requiredOptions.map((p) => [p.name, placeholderForType(p.type)]) + ) + } else if (options.length > 0) { + example.options = {} + } + + const requiredSchema = schema.filter((p) => !p.optional) + if (requiredSchema.length > 0) { + example.schema = Object.fromEntries( + requiredSchema.map((p) => [p.name, placeholderForType(p.type)]) + ) + } else if (schema.length > 0) { + example.schema = {} } + return example } From 6fd6cd0463b8f65764683467d4e4eba820154213 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 18:20:51 +0100 Subject: [PATCH 21/41] refactor(docs): derive page controller structure from forms-model types Page metadata is now `controllerKey: description` strings only, matching the shape of component metadata. Labels, controller values, sidebar positions, unique properties, and JSON examples are all derived from the TypeScript interfaces in @defra/forms-model at generation time. --- scripts/component-metadata.json | 129 ++------------ scripts/generate-component-docs.js | 267 ++++++++++++++++++++++++++--- 2 files changed, 259 insertions(+), 137 deletions(-) diff --git a/scripts/component-metadata.json b/scripts/component-metadata.json index 8e6aa9fed..f1f1714c7 100644 --- a/scripts/component-metadata.json +++ b/scripts/component-metadata.json @@ -29,123 +29,12 @@ "GeospatialField": "Composite location picker supporting multiple geospatial coordinate formats." }, "pages": { - "PageController": { - "label": "Question Page", - "description": "The default page type. Displays a set of components and advances to the next page on submission. Omit the `controller` property to use this page type.", - "controllerValue": null, - "sidebarPosition": 1, - "uniqueProperties": [], - "example": { - "path": "/your-name", - "title": "Your name", - "next": [{ "path": "/next-page" }], - "components": [] - } - }, - "StartPageController": { - "label": "Start Page", - "description": "The entry page of the form. Initialises the session and redirects to the first question. Must use the path `/start`.", - "controllerValue": "StartPageController", - "sidebarPosition": 2, - "uniqueProperties": [], - "example": { - "path": "/start", - "controller": "StartPageController", - "title": "Apply for something", - "next": [{ "path": "/first-question" }], - "components": [] - } - }, - "TerminalPageController": { - "label": "Terminal Page", - "description": "A dead-end page that does not route the user to another page. Use this for outcomes where the journey ends without proceeding to the summary — for example, an ineligibility screen.", - "controllerValue": "TerminalPageController", - "sidebarPosition": 3, - "uniqueProperties": [], - "example": { - "path": "/ineligible", - "controller": "TerminalPageController", - "title": "You are not eligible", - "components": [] - } - }, - "RepeatPageController": { - "label": "Repeat Page", - "description": "Allows the user to add multiple sets of answers to the same group of questions. Answers are stored as an array under the key defined by `repeat.options.name`.", - "controllerValue": "RepeatPageController", - "sidebarPosition": 4, - "uniqueProperties": [ - { - "name": "repeat.options.name", - "type": "string", - "required": true, - "description": "Identifier for the repeatable section, used as the key in form state." - }, - { - "name": "repeat.options.title", - "type": "string", - "required": true, - "description": "Label displayed per repeated item in the list summary." - }, - { - "name": "repeat.schema.min", - "type": "number", - "required": true, - "description": "Minimum number of items the user must add." - }, - { - "name": "repeat.schema.max", - "type": "number", - "required": true, - "description": "Maximum number of items the user can add. Cannot exceed 200." - } - ], - "example": { - "path": "/people", - "controller": "RepeatPageController", - "title": "Add a person", - "repeat": { - "options": { "name": "person", "title": "Person" }, - "schema": { "min": 1, "max": 25 } - }, - "next": [{ "path": "/summary" }], - "components": [] - } - }, - "FileUploadPageController": { - "label": "File Upload Page", - "description": "A question page that handles file upload via the CDP file upload service. Must contain a `FileUploadField` component.", - "controllerValue": "FileUploadPageController", - "sidebarPosition": 5, - "uniqueProperties": [], - "example": { - "path": "/upload-evidence", - "controller": "FileUploadPageController", - "title": "Upload your evidence", - "next": [{ "path": "/next-page" }], - "components": [ - { - "type": "FileUploadField", - "name": "evidence", - "title": "Upload a file", - "options": {}, - "schema": {} - } - ] - } - }, - "SummaryPageController": { - "label": "Summary Page", - "description": "Displays a check-your-answers summary of all form responses before submission. Must use the path `/summary`.", - "controllerValue": "SummaryPageController", - "sidebarPosition": 6, - "uniqueProperties": [], - "example": { - "path": "/summary", - "controller": "SummaryPageController", - "title": "Check your answers" - } - } + "PageController": "The default page type. Displays a set of components and advances to the next page on submission. Omit the `controller` property to use this page type.", + "StartPageController": "The entry page of the form. Initialises the session and redirects to the first question. Must use the path `/start`.", + "TerminalPageController": "A dead-end page that does not route the user to another page. Use this for outcomes where the journey ends without proceeding to the summary — for example, an ineligibility screen.", + "RepeatPageController": "Allows the user to add multiple sets of answers to the same group of questions. Answers are stored as an array under the key defined by `repeat.options.name`.", + "FileUploadPageController": "A question page that handles file upload via the CDP file upload service. Must contain a `FileUploadField` component.", + "SummaryPageController": "Displays a check-your-answers summary of all form responses before submission. Must use the path `/summary`." }, "properties": { "required": "Whether the field must be filled in. Defaults to `true`.", @@ -185,5 +74,11 @@ "latitude": "Latitude constraints (`min`, `max`).", "longitude": "Longitude constraints (`min`, `max`).", "content": "HTML or Markdown content to display." + }, + "pageProperties": { + "repeat.options.name": "Identifier for the repeatable section, used as the key in form state.", + "repeat.options.title": "Label displayed per repeated item in the list summary.", + "repeat.schema.min": "Minimum number of items the user must add.", + "repeat.schema.max": "Maximum number of items the user can add. Cannot exceed 200." } } diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 962e1586d..3d239c676 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -30,6 +30,41 @@ const BASE_OPTION_PROPS = new Set([ 'instructionText' ]) +// Properties shared by all page types — excluded from unique prop derivation +const BASE_PAGE_PROPS = new Set([ + // PageBase + 'id', + 'title', + 'path', + 'condition', + 'events', + 'view', + // Universal across concrete page types + 'controller', + 'section', + 'next', + 'components' +]) + +// Maps each controllerKey (from metadata.pages) to its TypeScript interface name +const CONTROLLER_INTERFACE_MAP = { + PageController: 'PageQuestion', + StartPageController: 'PageStart', + TerminalPageController: 'PageTerminal', + RepeatPageController: 'PageRepeat', + FileUploadPageController: 'PageFileUpload', + SummaryPageController: 'PageSummary' +} + +// Fixed paths required by certain controller types +const CONTROLLER_PATH_HINTS = { + StartPageController: '/start', + SummaryPageController: '/summary' +} + +// Page types that omit next/components from their JSON structure +const PAGES_WITHOUT_NEXT = new Set(['SummaryPageController']) + // Known acronyms for label generation const ACRONYMS = { Uk: 'UK', Os: 'OS', Html: 'HTML' } @@ -212,6 +247,121 @@ function parseComponentInterfaces(dtsPath) { return result } +/** + * Recursively flatten an interface into dotted-path prop descriptors. + * Resolves type references to other interfaces rather than leaving them as opaque types. + * @param {import('typescript').InterfaceDeclaration} iface + * @param {Record} allInterfaces + * @param {import('typescript').SourceFile} sourceFile + * @param {string} prefix - Dotted prefix accumulated so far + * @param {number} [depth] + * @returns {Array<{name: string, type: string, optional: boolean}>} + */ +function flattenInterface(iface, allInterfaces, sourceFile, prefix, depth = 0) { + if (depth > 4) return [] + const props = [] + + for (const member of iface.members) { + if (!ts.isPropertySignature(member)) continue + const propName = member.name.getText(sourceFile) + const optional = !!member.questionToken + const fullName = `${prefix}.${propName}` + + if (member.type && ts.isTypeReferenceNode(member.type)) { + const refName = member.type.typeName.getText(sourceFile) + if (allInterfaces[refName]) { + props.push( + ...flattenInterface( + allInterfaces[refName], + allInterfaces, + sourceFile, + fullName, + depth + 1 + ) + ) + continue + } + } + + const rawType = member.type ? member.type.getText(sourceFile) : 'unknown' + props.push({ name: fullName, type: simplifyType(rawType), optional }) + } + + return props +} + +/** + * Parse page interfaces from form-definition types.d.ts. + * Returns a map: controllerKey -> array of unique props (those not shared by all page types). + * @param {string} dtsPath + * @returns {Record>} + */ +function parsePageInterfaces(dtsPath) { + const content = fs.readFileSync(dtsPath, 'utf-8') + const sourceFile = ts.createSourceFile( + dtsPath, + content, + ts.ScriptTarget.Latest, + true + ) + + const allInterfaces = {} + ts.forEachChild(sourceFile, (node) => { + if (ts.isInterfaceDeclaration(node)) { + allInterfaces[node.name.text] = node + } + }) + + const result = {} + + for (const [controllerKey, interfaceName] of Object.entries( + CONTROLLER_INTERFACE_MAP + )) { + const iface = allInterfaces[interfaceName] + if (!iface) { + result[controllerKey] = [] + continue + } + + const uniqueProps = [] + + for (const member of iface.members) { + if (!ts.isPropertySignature(member)) continue + const propName = member.name.getText(sourceFile) + if (BASE_PAGE_PROPS.has(propName)) continue + + const optional = !!member.questionToken + + if (member.type && ts.isTypeReferenceNode(member.type)) { + const refName = member.type.typeName.getText(sourceFile) + if (allInterfaces[refName]) { + uniqueProps.push( + ...flattenInterface( + allInterfaces[refName], + allInterfaces, + sourceFile, + propName, + 0 + ) + ) + continue + } + } + + const rawType = member.type ? member.type.getText(sourceFile) : 'unknown' + uniqueProps.push({ + name: propName, + type: simplifyType(rawType), + optional + }) + } + + result[controllerKey] = uniqueProps + } + + return result +} + /** * Parse the ComponentType enum to get an ordered list of component names. */ @@ -388,17 +538,87 @@ function generateComponentMd(componentName, interfaceData, sidebarPosition) { return lines.join('\n') } -function generatePageMd(controllerKey) { - const meta = metadata.pages[controllerKey] - if (!meta) return null +/** + * Set a value at a dotted path within an object, creating nested objects as needed. + * @param {Record} obj + * @param {string} dotPath + * @param {unknown} value + */ +function setNestedValue(obj, dotPath, value) { + const parts = dotPath.split('.') + let current = obj + for (let i = 0; i < parts.length - 1; i++) { + const key = parts[i] + if (!current[key] || typeof current[key] !== 'object') current[key] = {} + current = /** @type {Record} */ (current[key]) + } + current[parts[parts.length - 1]] = value +} - const { label, description, controllerValue, uniqueProperties, example } = - meta +/** + * Derive the human-readable label for a controller key by stripping the + * "Controller" suffix and formatting the remaining words. + * e.g. "RepeatPageController" -> "Repeat Page" + * @param {string} controllerKey + * @returns {string} + */ +function controllerLabel(controllerKey) { + return toLabel(controllerKey.replace(/Controller$/, '')) +} + +/** + * Derive the kebab-case slug for a controller key. + * e.g. "RepeatPageController" -> "repeat-page" + * @param {string} controllerKey + * @returns {string} + */ +function controllerSlug(controllerKey) { + return toKebabCase(controllerKey.replace(/Controller$/, '')) +} + +/** + * Generate a JSON example for a page type from its parsed unique properties. + * @param {string} controllerKey + * @param {Array<{name: string, type: string, optional: boolean}>} uniqueProps + * @returns {Record} + */ +function generatePageExample(controllerKey, uniqueProps) { + const controllerValue = + controllerKey === 'PageController' ? null : controllerKey + const path = CONTROLLER_PATH_HINTS[controllerKey] ?? '/page-path' + + const example = /** @type {Record} */ ({ path }) + if (controllerValue) example.controller = controllerValue + example.title = 'Page title' + + for (const prop of uniqueProps.filter((p) => !p.optional)) { + setNestedValue(example, prop.name, placeholderForType(prop.type)) + } + + if (!PAGES_WITHOUT_NEXT.has(controllerKey)) { + example.next = [{ path: '/next-page' }] + example.components = [] + } + + return example +} + +/** + * @param {string} controllerKey + * @param {Array<{name: string, type: string, optional: boolean}>} uniqueProps + * @param {number} sidebarPosition + */ +function generatePageMd(controllerKey, uniqueProps, sidebarPosition) { + const description = metadata.pages[controllerKey] + if (!description) return null + + const label = controllerLabel(controllerKey) + const isDefault = controllerKey === 'PageController' const lines = [ `---`, `sidebar_label: "${label}"`, - `sidebar_position: ${meta.sidebarPosition ?? 99}`, + `sidebar_position: ${sidebarPosition}`, `---`, ``, `# ${label}`, @@ -407,31 +627,32 @@ function generatePageMd(controllerKey) { `` ] - if (controllerValue) { - lines.push(`**Controller value:** \`"${controllerValue}"\``, ``) - } else { + if (isDefault) { lines.push( `**Controller value:** omit the \`controller\` property, or use \`"PageController"\``, `` ) + } else { + lines.push(`**Controller value:** \`"${controllerKey}"\``, ``) } lines.push( `## JSON definition`, ``, '```json', - JSON.stringify(example, null, 2), + JSON.stringify(generatePageExample(controllerKey, uniqueProps), null, 2), '```', `` ) - if (uniqueProperties?.length > 0) { + if (uniqueProps.length > 0) { lines.push(`## Configuration`, ``) lines.push(`| Property | Type | Required | Description |`) lines.push(`|----------|------|----------|-------------|`) - for (const prop of uniqueProperties) { + for (const prop of uniqueProps) { + const desc = metadata.pageProperties?.[prop.name] ?? '' lines.push( - `| \`${prop.name}\` | \`${prop.type}\` | ${prop.required ? 'Yes' : 'No'} | ${prop.description} |` + `| \`${prop.name}\` | \`${prop.type}\` | ${prop.optional ? 'No' : 'Yes'} | ${desc} |` ) } lines.push(``) @@ -513,9 +734,10 @@ function generatePagesIndex() { `` ] - for (const [, meta] of Object.entries(metadata.pages)) { - const slug = meta.label.toLowerCase().replace(/\s+/g, '-') - lines.push(`- [**${meta.label}**](./${slug}.md) — ${meta.description}`) + 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(``) @@ -528,6 +750,10 @@ function main() { 'components/types.d.ts' ) const enumsDtsPath = path.join(formsModelTypesDir, 'components/enums.d.ts') + const formDefinitionDtsPath = path.join( + formsModelTypesDir, + 'form/form-definition/types.d.ts' + ) if (!fs.existsSync(componentsDtsPath)) { console.error( @@ -551,6 +777,7 @@ function main() { const interfaces = parseComponentInterfaces(componentsDtsPath) const componentOrder = parseComponentOrder(enumsDtsPath) const parsedCategories = parseCategories(componentsDtsPath) + const pageInterfaces = parsePageInterfaces(formDefinitionDtsPath) // Build full category map const categories = {} @@ -588,10 +815,10 @@ function main() { ) // Generate page type pages - for (const key of Object.keys(metadata.pages)) { - const meta = metadata.pages[key] - const slug = meta.label.toLowerCase().replace(/\s+/g, '-') - const content = generatePageMd(key) + for (const [i, key] of Object.keys(metadata.pages).entries()) { + const slug = controllerSlug(key) + const uniqueProps = pageInterfaces[key] ?? [] + const content = generatePageMd(key, uniqueProps, i + 1) if (content) { fs.writeFileSync(path.join(pagesOutputDir, `${slug}.md`), content) } From a1519505f3d7b493bc40fd5bd1e16aa42b1c6dff Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 19:45:16 +0100 Subject: [PATCH 22/41] fix(docs): harden component doc generator and add unit tests - Add fs.existsSync guards for enumsDtsPath and formDefinitionDtsPath - Warn when a page key in metadata.pages has no matching interface data - Export pure functions and guard main() for testability - Add 45 unit tests covering all exported pure functions - Fix predocs:dev to run both generators (schema + component) --- package.json | 2 +- scripts/generate-component-docs.js | 44 ++- scripts/generate-component-docs.test.js | 350 ++++++++++++++++++++++++ 3 files changed, 384 insertions(+), 12 deletions(-) create mode 100644 scripts/generate-component-docs.test.js diff --git a/package.json b/package.json index 24c7f73ec..7e698b586 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "dev:debug": "concurrently \"npm run client:watch\" \"npm run server:watch:debug\" --kill-others --names \"client,server\" --prefix-colors \"red.dim,blue.dim\"", "format": "npm run format:check -- --write", "format:check": "prettier --cache --cache-location .cache/prettier --cache-strategy content --check \"**/*.{cjs,js,json,md,mjs,scss,ts}\"", - "predocs:dev": "node scripts/generate-component-docs.js", + "predocs:dev": "node scripts/generate-schema-docs.js && node scripts/generate-component-docs.js", "docs:dev": "BROWSERSLIST_ENV=javascripts docusaurus start --host 0.0.0.0", "docs:build": "BROWSERSLIST_ENV=javascripts docusaurus build", "docs:build:all": "node scripts/generate-schema-docs.js && node scripts/generate-component-docs.js && npm run docs:build", diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 3d239c676..78eec0f1f 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -77,14 +77,14 @@ const GEOSPATIAL_NAMES = [ 'Geospatial' ] -function toKebabCase(str) { +export function toKebabCase(str) { return str.replace( /([A-Z])/g, (match, letter, offset) => (offset > 0 ? '-' : '') + letter.toLowerCase() ) } -function toLabel(name) { +export function toLabel(name) { const words = name .replace(/([A-Z])/g, ' $1') .trim() @@ -92,7 +92,7 @@ function toLabel(name) { return words.map((w) => ACRONYMS[w] ?? w).join(' ') } -function simplifyType(rawType) { +export function simplifyType(rawType) { if (!rawType) return 'unknown' const t = rawType.replace(/\s+/g, ' ').trim() if (t.startsWith('{')) return 'object' @@ -431,7 +431,7 @@ function parseCategories(typesDtsPath) { return categories } -function deriveCategory(name, parsedCategories) { +export function deriveCategory(name, parsedCategories) { if (parsedCategories[name]) return parsedCategories[name] if (GEOSPATIAL_NAMES.some((p) => name.includes(p))) return 'geospatial' if (name.includes('Payment')) return 'payment' @@ -442,7 +442,7 @@ function deriveCategory(name, parsedCategories) { * Return a placeholder value for a given type string. * Used to populate required fields in generated examples. */ -function placeholderForType(type) { +export function placeholderForType(type) { if (type === 'number') return 0 if (type === 'boolean') return true if (type === 'string') return '' @@ -455,7 +455,7 @@ function placeholderForType(type) { * Required options/schema fields are shown with placeholder values. * Optional fields are omitted — the tables below the example document them. */ -function generateExample(componentName, interfaceData) { +export function generateExample(componentName, interfaceData) { const { options = [], schema = [], hasContent, hasList } = interfaceData const example = { @@ -544,7 +544,7 @@ function generateComponentMd(componentName, interfaceData, sidebarPosition) { * @param {string} dotPath * @param {unknown} value */ -function setNestedValue(obj, dotPath, value) { +export function setNestedValue(obj, dotPath, value) { const parts = dotPath.split('.') let current = obj for (let i = 0; i < parts.length - 1; i++) { @@ -562,7 +562,7 @@ function setNestedValue(obj, dotPath, value) { * @param {string} controllerKey * @returns {string} */ -function controllerLabel(controllerKey) { +export function controllerLabel(controllerKey) { return toLabel(controllerKey.replace(/Controller$/, '')) } @@ -572,7 +572,7 @@ function controllerLabel(controllerKey) { * @param {string} controllerKey * @returns {string} */ -function controllerSlug(controllerKey) { +export function controllerSlug(controllerKey) { return toKebabCase(controllerKey.replace(/Controller$/, '')) } @@ -582,7 +582,7 @@ function controllerSlug(controllerKey) { * @param {Array<{name: string, type: string, optional: boolean}>} uniqueProps * @returns {Record} */ -function generatePageExample(controllerKey, uniqueProps) { +export function generatePageExample(controllerKey, uniqueProps) { const controllerValue = controllerKey === 'PageController' ? null : controllerKey const path = CONTROLLER_PATH_HINTS[controllerKey] ?? '/page-path' @@ -762,6 +762,20 @@ function main() { process.exit(1) } + if (!fs.existsSync(enumsDtsPath)) { + console.error( + `Error: cannot find @defra/forms-model enums at:\n ${enumsDtsPath}\nIs the package installed?` + ) + process.exit(1) + } + + if (!fs.existsSync(formDefinitionDtsPath)) { + console.error( + `Error: cannot find @defra/forms-model form-definition types at:\n ${formDefinitionDtsPath}\nIs the package installed?` + ) + process.exit(1) + } + // Set up output directories if (fs.existsSync(componentsOutputDir)) { fs.rmSync(componentsOutputDir, { recursive: true, force: true }) @@ -817,6 +831,11 @@ function main() { // Generate page type pages for (const [i, key] of Object.keys(metadata.pages).entries()) { const slug = controllerSlug(key) + if (pageInterfaces[key] === undefined) { + console.warn( + `Warning: no interface data found for page type ${key} — check CONTROLLER_INTERFACE_MAP` + ) + } const uniqueProps = pageInterfaces[key] ?? [] const content = generatePageMd(key, uniqueProps, i + 1) if (content) { @@ -831,4 +850,7 @@ function main() { ) } -main() +// Only run when executed directly, not when imported as a module +if (import.meta.url === `file://${process.argv[1]}`) { + main() +} diff --git a/scripts/generate-component-docs.test.js b/scripts/generate-component-docs.test.js new file mode 100644 index 000000000..c46613748 --- /dev/null +++ b/scripts/generate-component-docs.test.js @@ -0,0 +1,350 @@ +// @ts-nocheck + +import { jest } from '@jest/globals' + +// Prevent TypeScript from initialising its node system adapter (which uses +// the real fs) when we load the module under test in this environment. +jest.mock('typescript', () => ({ + SyntaxKind: { ExportKeyword: 93 }, + ScriptTarget: { Latest: 99 }, + createSourceFile: jest.fn(), + forEachChild: jest.fn(), + isInterfaceDeclaration: jest.fn(() => false), + isEnumDeclaration: jest.fn(() => false), + isTypeAliasDeclaration: jest.fn(() => false), + isPropertySignature: jest.fn(() => false), + isIntersectionTypeNode: jest.fn(() => false), + isTypeLiteralNode: jest.fn(() => false), + isIndexedAccessTypeNode: jest.fn(() => false), + isTypeReferenceNode: jest.fn(() => false), + isLiteralTypeNode: jest.fn(() => false), + isEnumMember: jest.fn(() => false), + isUnionTypeNode: jest.fn(() => false) +})) + +// jest.mock factories are hoisted before variable declarations, so the +// component-metadata.json payload must be inlined rather than referenced. +jest.mock('fs', () => ({ + existsSync: jest.fn(), + mkdirSync: jest.fn(), + rmSync: jest.fn(), + 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 '' + }), + writeFileSync: jest.fn(), + unlinkSync: jest.fn() +})) + +import { + controllerLabel, + controllerSlug, + deriveCategory, + generateExample, + generatePageExample, + placeholderForType, + setNestedValue, + simplifyType, + toKebabCase, + toLabel +} from './generate-component-docs.js' + +describe('Component Documentation Generator', () => { + describe('toKebabCase', () => { + it('converts PascalCase to kebab-case', () => { + expect(toKebabCase('TextField')).toBe('text-field') + }) + + it('handles multi-word PascalCase', () => { + expect(toKebabCase('RepeatPageController')).toBe('repeat-page-controller') + }) + + it('leaves already-lowercase strings unchanged', () => { + expect(toKebabCase('textfield')).toBe('textfield') + }) + + it('does not add leading hyphen for first character', () => { + expect(toKebabCase('A')).toBe('a') + }) + }) + + describe('toLabel', () => { + it('converts PascalCase to space-separated words', () => { + expect(toLabel('TextField')).toBe('Text Field') + }) + + it('applies ACRONYMS substitutions', () => { + expect(toLabel('UkAddressField')).toBe('UK Address Field') + expect(toLabel('OsGridRefField')).toBe('OS Grid Ref Field') + expect(toLabel('Html')).toBe('HTML') + }) + + it('handles controller keys with trailing word', () => { + expect(toLabel('RepeatPage')).toBe('Repeat Page') + }) + }) + + describe('simplifyType', () => { + it('returns "unknown" for falsy input', () => { + expect(simplifyType('')).toBe('unknown') + expect(simplifyType(null)).toBe('unknown') + expect(simplifyType(undefined)).toBe('unknown') + }) + + it('returns "object" for object type literals', () => { + expect(simplifyType('{ foo: string }')).toBe('object') + }) + + it('returns "object" for LanguageMessages references', () => { + expect(simplifyType('LanguageMessages')).toBe('object') + }) + + it('returns "string" for ListTypeContent and ListTypeOption', () => { + expect(simplifyType('ListTypeContent')).toBe('string') + expect(simplifyType('ListTypeOption')).toBe('string') + }) + + it('strips | undefined from union types', () => { + expect(simplifyType('string | undefined')).toBe('string') + expect(simplifyType('number | undefined')).toBe('number') + }) + + it('handles array types recursively', () => { + expect(simplifyType('string[]')).toBe('string[]') + expect(simplifyType('string | undefined[]')).toBe('string[]') + }) + + it('normalizes extra whitespace', () => { + expect(simplifyType(' string ')).toBe('string') + }) + }) + + describe('placeholderForType', () => { + it('returns 0 for number', () => { + expect(placeholderForType('number')).toBe(0) + }) + + it('returns true for boolean', () => { + expect(placeholderForType('boolean')).toBe(true) + }) + + it('returns empty string for string', () => { + expect(placeholderForType('string')).toBe('') + }) + + it('returns empty array for array types', () => { + expect(placeholderForType('string[]')).toEqual([]) + expect(placeholderForType('number[]')).toEqual([]) + }) + + it('returns empty object for object or unknown types', () => { + expect(placeholderForType('object')).toEqual({}) + expect(placeholderForType('SomeType')).toEqual({}) + }) + }) + + describe('setNestedValue', () => { + it('sets a top-level key', () => { + const obj = {} + setNestedValue(obj, 'foo', 42) + expect(obj).toEqual({ foo: 42 }) + }) + + it('sets a deeply nested key, creating intermediate objects', () => { + const obj = {} + setNestedValue(obj, 'a.b.c', 'value') + expect(obj).toEqual({ a: { b: { c: 'value' } } }) + }) + + it('overwrites an existing value at a path', () => { + const obj = { a: { b: 'old' } } + setNestedValue(obj, 'a.b', 'new') + expect(obj.a.b).toBe('new') + }) + + it('replaces a non-object intermediate value with an object', () => { + const obj = { a: 'string' } + setNestedValue(obj, 'a.b', 1) + expect(obj).toEqual({ a: { b: 1 } }) + }) + }) + + describe('generateExample', () => { + it('includes type, name, and title for a basic component', () => { + const result = generateExample('TextField', { + options: [], + schema: [], + hasContent: false, + hasList: false + }) + expect(result).toMatchObject({ + type: 'TextField', + name: 'fieldName', + title: 'Question title' + }) + }) + + it('includes empty options object when optional options exist', () => { + const result = generateExample('TextField', { + options: [{ name: 'rows', optional: true, type: 'number' }], + schema: [], + hasContent: false, + hasList: false + }) + expect(result.options).toEqual({}) + }) + + it('includes required options with placeholder values', () => { + const result = generateExample('TextField', { + options: [ + { name: 'rows', optional: false, type: 'number' }, + { name: 'classes', optional: true, type: 'string' } + ], + schema: [], + hasContent: false, + hasList: false + }) + expect(result.options).toEqual({ rows: 0 }) + }) + + it('includes required schema fields with placeholder values', () => { + const result = generateExample('NumberField', { + options: [], + schema: [ + { name: 'min', optional: false, type: 'number' }, + { name: 'max', optional: true, type: 'number' } + ], + hasContent: false, + hasList: false + }) + expect(result.schema).toEqual({ min: 0 }) + }) + + it('includes content field when hasContent is true', () => { + const result = generateExample('Html', { + options: [], + schema: [], + hasContent: true, + hasList: false + }) + expect(result).toHaveProperty('content', '') + }) + + it('includes list field when hasList is true', () => { + const result = generateExample('RadiosField', { + options: [], + schema: [], + hasContent: false, + hasList: true + }) + expect(result).toHaveProperty('list', 'listName') + }) + + it('omits options and schema keys when both are empty', () => { + const result = generateExample('HiddenField', { + options: [], + schema: [], + hasContent: false, + hasList: false + }) + expect(result).not.toHaveProperty('options') + expect(result).not.toHaveProperty('schema') + }) + }) + + describe('generatePageExample', () => { + it('omits controller for default PageController', () => { + const result = generatePageExample('PageController', []) + expect(result).not.toHaveProperty('controller') + }) + + it('includes controller value for non-default controllers', () => { + const result = generatePageExample('RepeatPageController', []) + expect(result.controller).toBe('RepeatPageController') + }) + + it('uses fixed path hint for StartPageController', () => { + const result = generatePageExample('StartPageController', []) + expect(result.path).toBe('/start') + }) + + it('uses fixed path hint for SummaryPageController', () => { + const result = generatePageExample('SummaryPageController', []) + expect(result.path).toBe('/summary') + }) + + it('uses generic path for controllers without a hint', () => { + const result = generatePageExample('PageController', []) + expect(result.path).toBe('/page-path') + }) + + it('includes next and components for standard page types', () => { + const result = generatePageExample('PageController', []) + expect(result.next).toEqual([{ path: '/next-page' }]) + expect(result.components).toEqual([]) + }) + + it('omits next and components for SummaryPageController', () => { + const result = generatePageExample('SummaryPageController', []) + expect(result).not.toHaveProperty('next') + expect(result).not.toHaveProperty('components') + }) + + it('populates required unique props with placeholders using setNestedValue', () => { + const result = generatePageExample('RepeatPageController', [ + { name: 'repeat.options.name', optional: false, type: 'string' }, + { name: 'repeat.schema.min', optional: true, type: 'number' } + ]) + expect(result.repeat.options.name).toBe('') + expect(result.repeat).not.toHaveProperty('schema') + }) + }) + + describe('controllerLabel', () => { + it('strips Controller suffix and formats words', () => { + expect(controllerLabel('RepeatPageController')).toBe('Repeat Page') + expect(controllerLabel('StartPageController')).toBe('Start Page') + expect(controllerLabel('SummaryPageController')).toBe('Summary Page') + }) + + it('returns empty string for bare "Controller"', () => { + expect(controllerLabel('Controller')).toBe('') + }) + }) + + describe('controllerSlug', () => { + it('strips Controller suffix and converts to kebab-case', () => { + expect(controllerSlug('RepeatPageController')).toBe('repeat-page') + expect(controllerSlug('StartPageController')).toBe('start-page') + expect(controllerSlug('FileUploadPageController')).toBe( + 'file-upload-page' + ) + }) + }) + + describe('deriveCategory', () => { + it('returns category from parsedCategories when present', () => { + expect(deriveCategory('RadiosField', { RadiosField: 'selection' })).toBe( + 'selection' + ) + }) + + it('returns "geospatial" for known geospatial name fragments', () => { + expect(deriveCategory('EastingNorthingField', {})).toBe('geospatial') + expect(deriveCategory('OsGridRefField', {})).toBe('geospatial') + expect(deriveCategory('LatLongField', {})).toBe('geospatial') + }) + + it('returns "payment" for payment component names', () => { + expect(deriveCategory('PaymentField', {})).toBe('payment') + }) + + it('returns "input" as the default category', () => { + expect(deriveCategory('TextField', {})).toBe('input') + expect(deriveCategory('NumberField', {})).toBe('input') + }) + }) +}) From 9ee4f1328689745f6b924efd9abbf91dbe64f140 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 20:48:49 +0100 Subject: [PATCH 23/41] refactor(docs): derive all doc structure from forms-model types, add component notes - Remove BASE_OPTION_PROPS and BASE_PAGE_PROPS filters so all props appear in each component/page table, sorted alphabetically; props typed as undefined (e.g. section on PageSummary) are automatically excluded - Derive CONTROLLER_INTERFACE_MAP from ControllerType enum instead of hardcoding - Derive CONTROLLER_PATH_HINTS from ControllerPath enum instead of hardcoding - Remove PAGES_WITHOUT_NEXT; derive from uniqueProps at example generation time - Remove GEOSPATIAL_NAMES and payment category; geospatial/payment components now appear under Input fields - Add componentLinks metadata for HiddenField (pre-populate state) and all five geospatial components (map/API key requirement) - Document ordnanceSurveyApiKey/Secret in plugin-options.md with a Geospatial map section - Add pageProperties descriptions for all universal page props --- docs/plugin-options.md | 19 ++ scripts/component-metadata.json | 30 +++ scripts/generate-component-docs.js | 284 +++++++++++++++--------- scripts/generate-component-docs.test.js | 42 ++-- 4 files changed, 249 insertions(+), 126 deletions(-) diff --git a/docs/plugin-options.md b/docs/plugin-options.md index cd72deabb..a21e0a71b 100644 --- a/docs/plugin-options.md +++ b/docs/plugin-options.md @@ -25,6 +25,8 @@ The forms plugin is configured with [registration options](https://hapi.dev/api/ - `preparePageEventRequestOptions` (optional) - A function that will be invoked for http-based [page events](./features/configuration-based/page-events). See [here](./features/configuration-based/page-events#authenticating-a-http-page-event-request-from-forms-engine-plugin-in-your-api) for details - `saveAndExit` (optional) - Configuration for custom session management including key generation, session hydration, and persistence. See [save and exit documentation](./features/code-based/save-and-exit) for details - `onRequest` (optional) - A function that will be invoked on each request to any form route e.g `/{slug}/{path}`. See [onRequest](#onrequest) for more details +- `ordnanceSurveyApiKey` (optional) - Ordnance Survey API key. Required to enable the inline map on geospatial components. See [geospatial map](#geospatial-map) for details +- `ordnanceSurveyApiSecret` (optional) - Ordnance Survey API secret. Required alongside `ordnanceSurveyApiKey` to enable the inline map on geospatial components ## Option details @@ -288,3 +290,20 @@ await server.register({ ``` For detailed documentation and examples, see [Save and Exit](./features/code-based/save-and-exit). + +### Geospatial map + +Geospatial components ([Easting Northing](./features/components/easting-northing-field.md), [OS Grid Ref](./features/components/os-grid-ref-field.md), [National Grid Field Number](./features/components/national-grid-field-number-field.md), [Lat Long](./features/components/lat-long-field.md), [Geospatial](./features/components/geospatial-field.md)) render an inline Ordnance Survey map alongside their input fields. The map lets users click a location to auto-populate the coordinate inputs, rather than typing values manually. + +The map is only activated when both `ordnanceSurveyApiKey` and `ordnanceSurveyApiSecret` are provided at plugin registration. Without them the components still function as plain text inputs. + +```js +await server.register({ + plugin, + options: { + ordnanceSurveyApiKey: process.env.ORDNANCE_SURVEY_API_KEY, + ordnanceSurveyApiSecret: process.env.ORDNANCE_SURVEY_API_SECRET, + // ... other options + } +}) +``` diff --git a/scripts/component-metadata.json b/scripts/component-metadata.json index f1f1714c7..731cc567f 100644 --- a/scripts/component-metadata.json +++ b/scripts/component-metadata.json @@ -75,7 +75,37 @@ "longitude": "Longitude constraints (`min`, `max`).", "content": "HTML or Markdown content to display." }, + "componentLinks": { + "HiddenField": [ + "Hidden fields can be pre-populated from query string parameters — see [Pre-populating state](../code-based/pre-populate-state.md) for details." + ], + "EastingNorthingField": [ + "This component renders an inline Ordnance Survey map that lets users click a location to auto-populate the coordinate inputs. The map requires the `ordnanceSurveyApiKey` and `ordnanceSurveyApiSecret` [plugin options](../../plugin-options.md#geospatial-map) to be set — without them the component falls back to plain text inputs." + ], + "OsGridRefField": [ + "This component renders an inline Ordnance Survey map that lets users click a location to auto-populate the grid reference input. The map requires the `ordnanceSurveyApiKey` and `ordnanceSurveyApiSecret` [plugin options](../../plugin-options.md#geospatial-map) to be set — without them the component falls back to a plain text input." + ], + "NationalGridFieldNumberField": [ + "This component renders an inline Ordnance Survey map that lets users click a location to auto-populate the coordinate input. The map requires the `ordnanceSurveyApiKey` and `ordnanceSurveyApiSecret` [plugin options](../../plugin-options.md#geospatial-map) to be set — without them the component falls back to a plain text input." + ], + "LatLongField": [ + "This component renders an inline Ordnance Survey map that lets users click a location to auto-populate the latitude and longitude inputs. The map requires the `ordnanceSurveyApiKey` and `ordnanceSurveyApiSecret` [plugin options](../../plugin-options.md#geospatial-map) to be set — without them the component falls back to plain text inputs." + ], + "GeospatialField": [ + "This component renders an inline Ordnance Survey map that lets users click a location to auto-populate the coordinate inputs across multiple coordinate formats. The map requires the `ordnanceSurveyApiKey` and `ordnanceSurveyApiSecret` [plugin options](../../plugin-options.md#geospatial-map) to be set — without them the component falls back to plain text inputs." + ] + }, "pageProperties": { + "components": "Array of component definitions rendered on the page.", + "condition": "Name of a condition that controls whether this page is shown.", + "controller": "The page controller class name. Omit or use `\"PageController\"` for the default question page.", + "events": "Lifecycle hooks that fire when the page is loaded or saved.", + "id": "Stable identifier for the page, used internally by the form engine.", + "next": "Array of routing links that define where the form goes after this page.", + "path": "URL path segment for the page (e.g. `/details`).", + "section": "Name of a section that groups this page in the form.", + "title": "Heading displayed to the user at the top of the page.", + "view": "Name of an alternative view template to render for this page.", "repeat.options.name": "Identifier for the repeatable section, used as the key in form state.", "repeat.options.title": "Label displayed per repeated item in the list summary.", "repeat.schema.min": "Minimum number of items the user must add.", diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 78eec0f1f..606c71520 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -20,63 +20,9 @@ const metadata = JSON.parse( fs.readFileSync(path.resolve(__dirname, 'component-metadata.json'), 'utf-8') ) -// Properties from FormFieldBase['options'] that apply to every form component. -// These are excluded from per-component option tables since they're universal. -const BASE_OPTION_PROPS = new Set([ - 'required', - 'optionalText', - 'classes', - 'customValidationMessages', - 'instructionText' -]) - -// Properties shared by all page types — excluded from unique prop derivation -const BASE_PAGE_PROPS = new Set([ - // PageBase - 'id', - 'title', - 'path', - 'condition', - 'events', - 'view', - // Universal across concrete page types - 'controller', - 'section', - 'next', - 'components' -]) - -// Maps each controllerKey (from metadata.pages) to its TypeScript interface name -const CONTROLLER_INTERFACE_MAP = { - PageController: 'PageQuestion', - StartPageController: 'PageStart', - TerminalPageController: 'PageTerminal', - RepeatPageController: 'PageRepeat', - FileUploadPageController: 'PageFileUpload', - SummaryPageController: 'PageSummary' -} - -// Fixed paths required by certain controller types -const CONTROLLER_PATH_HINTS = { - StartPageController: '/start', - SummaryPageController: '/summary' -} - -// Page types that omit next/components from their JSON structure -const PAGES_WITHOUT_NEXT = new Set(['SummaryPageController']) - // Known acronyms for label generation const ACRONYMS = { Uk: 'UK', Os: 'OS', Html: 'HTML' } -// Name fragments that identify geospatial components -const GEOSPATIAL_NAMES = [ - 'EastingNorthing', - 'OsGridRef', - 'NationalGrid', - 'LatLong', - 'Geospatial' -] - export function toKebabCase(str) { return str.replace( /([A-Z])/g, @@ -238,8 +184,11 @@ function parseComponentInterfaces(dtsPath) { if (propName === 'list') hasList = true } - // Remove props that exist on every form component (from FormFieldBase['options']) - const options = rawOptions.filter((p) => !BASE_OPTION_PROPS.has(p.name)) + // Props typed as `undefined` are explicitly excluded for this component (e.g. + // `required?: undefined` on ContentFieldBase). Sort alphabetically for stable output. + const options = rawOptions + .filter((p) => p.type !== 'undefined') + .sort((a, b) => a.name.localeCompare(b.name)) result[name] = { options, schema, hasContent, hasList } }) @@ -290,13 +239,89 @@ function flattenInterface(iface, allInterfaces, sourceFile, prefix, depth = 0) { return props } +/** + * Derive the controller → interface map and per-controller example path hints from types. + * Reads ControllerType and ControllerPath enums plus the PageX interfaces in one pass. + * @param {string} formDefinitionDtsPath + * @param {string} pagesEnumsDtsPath + * @returns {{ controllerMap: Record, pathHints: Record }} + */ +function parseControllerMap(formDefinitionDtsPath, pagesEnumsDtsPath) { + // Collect both ControllerType and ControllerPath enum variant → string value maps + const enumContent = fs.readFileSync(pagesEnumsDtsPath, 'utf-8') + const enumSourceFile = ts.createSourceFile( + pagesEnumsDtsPath, + enumContent, + ts.ScriptTarget.Latest, + true + ) + + const controllerTypeValues = {} + const controllerPathValues = {} + ts.forEachChild(enumSourceFile, (node) => { + if (!ts.isEnumDeclaration(node)) return + const isType = node.name.text === 'ControllerType' + const isPath = node.name.text === 'ControllerPath' + if (!isType && !isPath) return + for (const member of node.members) { + if (!ts.isEnumMember(member) || !member.initializer) continue + const variant = member.name.getText(enumSourceFile) + const value = member.initializer + .getText(enumSourceFile) + .replace(/['"]/g, '') + if (isType) controllerTypeValues[variant] = value + else controllerPathValues[variant] = value + } + }) + + // For each PageX interface, derive the controller key (from `controller` type) and + // the canonical example path (from `path` type when it starts with ControllerPath.X) + const defContent = fs.readFileSync(formDefinitionDtsPath, 'utf-8') + const defSourceFile = ts.createSourceFile( + formDefinitionDtsPath, + defContent, + ts.ScriptTarget.Latest, + true + ) + + const controllerMap = {} + const pathHints = {} + ts.forEachChild(defSourceFile, (node) => { + if (!ts.isInterfaceDeclaration(node)) return + const interfaceName = node.name.text + let controllerKey = null + let pathHint = null + for (const member of node.members) { + if (!ts.isPropertySignature(member) || !member.type) continue + const propName = member.name.getText(defSourceFile) + const rawType = member.type.getText(defSourceFile) + if (propName === 'controller') { + const m = rawType.match(/^ControllerType\.(\w+)$/) + if (m) controllerKey = controllerTypeValues[m[1]] ?? null + } + if (propName === 'path') { + const m = rawType.match(/^ControllerPath\.(\w+)/) + if (m) pathHint = controllerPathValues[m[1]] ?? null + } + } + if (controllerKey) { + controllerMap[controllerKey] = interfaceName + if (pathHint) pathHints[controllerKey] = pathHint + } + }) + + return { controllerMap, pathHints } +} + /** * Parse page interfaces from form-definition types.d.ts. - * Returns a map: controllerKey -> array of unique props (those not shared by all page types). + * Returns a map: controllerKey -> { props, examplePath }. * @param {string} dtsPath - * @returns {Record>} + * @param {Record} controllerMap + * @param {Record} pathHints + * @returns {Record, examplePath: string }>} */ -function parsePageInterfaces(dtsPath) { +function parsePageInterfaces(dtsPath, controllerMap, pathHints) { const content = fs.readFileSync(dtsPath, 'utf-8') const sourceFile = ts.createSourceFile( dtsPath, @@ -312,30 +337,46 @@ function parsePageInterfaces(dtsPath) { } }) + // Collect PageBase members once — merged into every page type below + const pageBaseProps = [] + const pageBaseIface = allInterfaces['PageBase'] + if (pageBaseIface) { + for (const member of pageBaseIface.members) { + if (!ts.isPropertySignature(member)) continue + const propName = member.name.getText(sourceFile) + const optional = !!member.questionToken + const rawType = member.type ? member.type.getText(sourceFile) : 'unknown' + pageBaseProps.push({ + name: propName, + type: simplifyType(rawType), + optional + }) + } + } + const result = {} - for (const [controllerKey, interfaceName] of Object.entries( - CONTROLLER_INTERFACE_MAP - )) { + for (const [controllerKey, interfaceName] of Object.entries(controllerMap)) { const iface = allInterfaces[interfaceName] if (!iface) { - result[controllerKey] = [] + result[controllerKey] = { + props: [], + examplePath: pathHints[controllerKey] ?? '/page-path' + } continue } - const uniqueProps = [] + const props = [] for (const member of iface.members) { if (!ts.isPropertySignature(member)) continue const propName = member.name.getText(sourceFile) - if (BASE_PAGE_PROPS.has(propName)) continue - const optional = !!member.questionToken if (member.type && ts.isTypeReferenceNode(member.type)) { const refName = member.type.typeName.getText(sourceFile) if (allInterfaces[refName]) { - uniqueProps.push( + props.push( ...flattenInterface( allInterfaces[refName], allInterfaces, @@ -349,14 +390,27 @@ function parsePageInterfaces(dtsPath) { } const rawType = member.type ? member.type.getText(sourceFile) : 'unknown' - uniqueProps.push({ + props.push({ name: propName, type: simplifyType(rawType), optional }) } - result[controllerKey] = uniqueProps + // Merge PageBase props for any name not already declared on this page type + const seenNames = new Set(props.map((p) => p.name)) + for (const p of pageBaseProps) { + if (!seenNames.has(p.name)) props.push(p) + } + + // Props typed as `undefined` are explicitly excluded for this page type + // (e.g. `section?: undefined` on PageSummary). Sort alphabetically. + result[controllerKey] = { + props: props + .filter((p) => p.type !== 'undefined') + .sort((a, b) => a.name.localeCompare(b.name)), + examplePath: pathHints[controllerKey] ?? '/page-path' + } } return result @@ -432,10 +486,7 @@ function parseCategories(typesDtsPath) { } export function deriveCategory(name, parsedCategories) { - if (parsedCategories[name]) return parsedCategories[name] - if (GEOSPATIAL_NAMES.some((p) => name.includes(p))) return 'geospatial' - if (name.includes('Payment')) return 'payment' - return 'input' + return parsedCategories[name] ?? 'input' } /** @@ -493,6 +544,8 @@ function generateComponentMd(componentName, interfaceData, sidebarPosition) { const label = toLabel(componentName) const { options = [], schema = [] } = interfaceData + const links = metadata.componentLinks?.[componentName] ?? [] + const lines = [ `---`, `sidebar_label: "${label}"`, @@ -502,14 +555,21 @@ function generateComponentMd(componentName, interfaceData, sidebarPosition) { `# ${label}`, ``, description, - ``, + `` + ] + + for (const text of links) { + lines.push(text, ``) + } + + lines.push( `## JSON definition`, ``, '```json', JSON.stringify(generateExample(componentName, interfaceData), null, 2), '```', `` - ] + ) if (options.length > 0) { lines.push(`## Options`, ``) @@ -580,24 +640,33 @@ export function controllerSlug(controllerKey) { * Generate a JSON example for a page type from its parsed unique properties. * @param {string} controllerKey * @param {Array<{name: string, type: string, optional: boolean}>} uniqueProps + * @param {string} [examplePath] * @returns {Record} */ -export function generatePageExample(controllerKey, uniqueProps) { +export function generatePageExample( + controllerKey, + uniqueProps, + examplePath = '/page-path' +) { const controllerValue = controllerKey === 'PageController' ? null : controllerKey - const path = CONTROLLER_PATH_HINTS[controllerKey] ?? '/page-path' + const path = examplePath const example = /** @type {Record} */ ({ path }) if (controllerValue) example.controller = controllerValue example.title = 'Page title' - for (const prop of uniqueProps.filter((p) => !p.optional)) { + // Skip props already set explicitly above so placeholders don't overwrite them + const hardcoded = new Set(['path', 'title', 'controller']) + for (const prop of uniqueProps.filter( + (p) => !p.optional && !hardcoded.has(p.name) + )) { setNestedValue(example, prop.name, placeholderForType(prop.type)) } - if (!PAGES_WITHOUT_NEXT.has(controllerKey)) { + // Give next a meaningful routing example rather than the empty array placeholder + if (uniqueProps.some((p) => p.name === 'next' && !p.optional)) { example.next = [{ path: '/next-page' }] - example.components = [] } return example @@ -606,9 +675,15 @@ export function generatePageExample(controllerKey, uniqueProps) { /** * @param {string} controllerKey * @param {Array<{name: string, type: string, optional: boolean}>} uniqueProps + * @param {string} examplePath * @param {number} sidebarPosition */ -function generatePageMd(controllerKey, uniqueProps, sidebarPosition) { +function generatePageMd( + controllerKey, + uniqueProps, + examplePath, + sidebarPosition +) { const description = metadata.pages[controllerKey] if (!description) return null @@ -640,7 +715,11 @@ function generatePageMd(controllerKey, uniqueProps, sidebarPosition) { `## JSON definition`, ``, '```json', - JSON.stringify(generatePageExample(controllerKey, uniqueProps), null, 2), + JSON.stringify( + generatePageExample(controllerKey, uniqueProps, examplePath), + null, + 2 + ), '```', `` ) @@ -665,9 +744,7 @@ function generateComponentsIndex(componentNames, categories) { const groups = { input: { label: 'Input fields', items: [] }, selection: { label: 'Selection fields', items: [] }, - content: { label: 'Content components', items: [] }, - payment: { label: 'Payment', items: [] }, - geospatial: { label: 'Geospatial fields', items: [] } + content: { label: 'Content components', items: [] } } for (const name of componentNames) { @@ -695,18 +772,6 @@ function generateComponentsIndex(componentNames, categories) { } lines.push(``) - for (const key of ['payment', 'geospatial']) { - const group = groups[key] - if (group.items.length === 0) continue - lines.push(`### ${group.label}`, ``) - for (const item of group.items) { - lines.push( - `- [**${item.label}**](./${item.slug}.md) — ${item.description}` - ) - } - lines.push(``) - } - for (const key of ['selection', 'content']) { const group = groups[key] if (group.items.length === 0) continue @@ -754,6 +819,7 @@ function main() { formsModelTypesDir, 'form/form-definition/types.d.ts' ) + const pagesEnumsDtsPath = path.join(formsModelTypesDir, 'pages/enums.d.ts') if (!fs.existsSync(componentsDtsPath)) { console.error( @@ -776,6 +842,13 @@ function main() { process.exit(1) } + if (!fs.existsSync(pagesEnumsDtsPath)) { + console.error( + `Error: cannot find @defra/forms-model pages enums at:\n ${pagesEnumsDtsPath}\nIs the package installed?` + ) + process.exit(1) + } + // Set up output directories if (fs.existsSync(componentsOutputDir)) { fs.rmSync(componentsOutputDir, { recursive: true, force: true }) @@ -791,7 +864,15 @@ function main() { const interfaces = parseComponentInterfaces(componentsDtsPath) const componentOrder = parseComponentOrder(enumsDtsPath) const parsedCategories = parseCategories(componentsDtsPath) - const pageInterfaces = parsePageInterfaces(formDefinitionDtsPath) + const { controllerMap, pathHints } = parseControllerMap( + formDefinitionDtsPath, + pagesEnumsDtsPath + ) + const pageInterfaces = parsePageInterfaces( + formDefinitionDtsPath, + controllerMap, + pathHints + ) // Build full category map const categories = {} @@ -833,11 +914,12 @@ function main() { const slug = controllerSlug(key) if (pageInterfaces[key] === undefined) { console.warn( - `Warning: no interface data found for page type ${key} — check CONTROLLER_INTERFACE_MAP` + `Warning: no interface data found for page type ${key} — is it in the ControllerType enum?` ) } - const uniqueProps = pageInterfaces[key] ?? [] - const content = generatePageMd(key, uniqueProps, i + 1) + const { props: uniqueProps = [], examplePath = '/page-path' } = + pageInterfaces[key] ?? {} + const content = generatePageMd(key, uniqueProps, examplePath, i + 1) if (content) { fs.writeFileSync(path.join(pagesOutputDir, `${slug}.md`), content) } diff --git a/scripts/generate-component-docs.test.js b/scripts/generate-component-docs.test.js index c46613748..1b9d8aa91 100644 --- a/scripts/generate-component-docs.test.js +++ b/scripts/generate-component-docs.test.js @@ -1,5 +1,5 @@ // @ts-nocheck - + import { jest } from '@jest/globals' // Prevent TypeScript from initialising its node system adapter (which uses @@ -266,31 +266,32 @@ describe('Component Documentation Generator', () => { expect(result.controller).toBe('RepeatPageController') }) - it('uses fixed path hint for StartPageController', () => { - const result = generatePageExample('StartPageController', []) + it('uses the supplied examplePath', () => { + const result = generatePageExample('StartPageController', [], '/start') expect(result.path).toBe('/start') }) - it('uses fixed path hint for SummaryPageController', () => { - const result = generatePageExample('SummaryPageController', []) - expect(result.path).toBe('/summary') - }) - - it('uses generic path for controllers without a hint', () => { + it('defaults to /page-path when examplePath is omitted', () => { const result = generatePageExample('PageController', []) expect(result.path).toBe('/page-path') }) - it('includes next and components for standard page types', () => { - const result = generatePageExample('PageController', []) + it('gives next a meaningful example value when it is a required prop', () => { + const result = generatePageExample('PageController', [ + { name: 'next', optional: false, type: 'Link[]' }, + { name: 'components', optional: false, type: 'ComponentDef[]' } + ]) expect(result.next).toEqual([{ path: '/next-page' }]) expect(result.components).toEqual([]) }) - it('omits next and components for SummaryPageController', () => { - const result = generatePageExample('SummaryPageController', []) + it('omits next when not present in uniqueProps', () => { + const result = generatePageExample( + 'SummaryPageController', + [], + '/summary' + ) expect(result).not.toHaveProperty('next') - expect(result).not.toHaveProperty('components') }) it('populates required unique props with placeholders using setNestedValue', () => { @@ -332,19 +333,10 @@ describe('Component Documentation Generator', () => { ) }) - it('returns "geospatial" for known geospatial name fragments', () => { - expect(deriveCategory('EastingNorthingField', {})).toBe('geospatial') - expect(deriveCategory('OsGridRefField', {})).toBe('geospatial') - expect(deriveCategory('LatLongField', {})).toBe('geospatial') - }) - - it('returns "payment" for payment component names', () => { - expect(deriveCategory('PaymentField', {})).toBe('payment') - }) - it('returns "input" as the default category', () => { expect(deriveCategory('TextField', {})).toBe('input') - expect(deriveCategory('NumberField', {})).toBe('input') + expect(deriveCategory('PaymentField', {})).toBe('input') + expect(deriveCategory('EastingNorthingField', {})).toBe('input') }) }) }) From 65a2a72e384b43b257eeee56dc149846258c1e0a Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 20:51:11 +0100 Subject: [PATCH 24/41] fix(docs): use relative links in features index to fix base URL routing --- docs/features/index.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/features/index.md b/docs/features/index.md index 2cb10d8fd..d4df22e1e 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -2,20 +2,20 @@ forms-engine-plugin provides built-in components and page types you can use immediately in your form definitions, as well as advanced features for driving dynamic behaviour or writing custom code. -## [Components](/features/components) +## [Components](./components) A library of built-in form components — text fields, date inputs, radio buttons, file upload, payment, geospatial fields, and more. Add them to your form definition by name. -## [Page Types](/features/pages) +## [Page Types](./pages) Built-in page controllers that define how a page behaves — question pages, repeating groups, file upload pages, summary and confirmation pages. ## Advanced -### [Configuration-based Features](/features/configuration-based) +### [Configuration-based Features](./configuration-based) Drive advanced functionality — such as calling APIs and rendering dynamic content — entirely through form definitions, with no custom code required. -### [Code-based Features](/features/code-based) +### [Code-based Features](./code-based) Implement highly tailored behaviour by writing custom TypeScript/JavaScript that integrates with forms-engine-plugin's extension points. From 6f9484a25728a44437b7ba03ae14ba0383e6836c Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 21:03:54 +0100 Subject: [PATCH 25/41] fix(docs): fix features index links, add file upload and page cross-references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix features/index.md heading links — relative paths from an index page require the parent directory name to resolve correctly against the base URL - Add componentLinks note on FileUploadField pointing to FileUploadPageController - Add pageLinks support to generatePageMd for page-level supplementary notes - Add pageLinks note on FileUploadPageController pointing to file-upload.md guide --- docs/features/index.md | 8 ++++---- scripts/component-metadata.json | 8 ++++++++ scripts/generate-component-docs.js | 5 +++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/features/index.md b/docs/features/index.md index d4df22e1e..7c9964d41 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -2,20 +2,20 @@ forms-engine-plugin provides built-in components and page types you can use immediately in your form definitions, as well as advanced features for driving dynamic behaviour or writing custom code. -## [Components](./components) +## [Components](./features/components) A library of built-in form components — text fields, date inputs, radio buttons, file upload, payment, geospatial fields, and more. Add them to your form definition by name. -## [Page Types](./pages) +## [Page Types](./features/pages) Built-in page controllers that define how a page behaves — question pages, repeating groups, file upload pages, summary and confirmation pages. ## Advanced -### [Configuration-based Features](./configuration-based) +### [Configuration-based Features](./features/configuration-based) Drive advanced functionality — such as calling APIs and rendering dynamic content — entirely through form definitions, with no custom code required. -### [Code-based Features](./code-based) +### [Code-based Features](./features/code-based) Implement highly tailored behaviour by writing custom TypeScript/JavaScript that integrates with forms-engine-plugin's extension points. diff --git a/scripts/component-metadata.json b/scripts/component-metadata.json index 731cc567f..16ae4d8f4 100644 --- a/scripts/component-metadata.json +++ b/scripts/component-metadata.json @@ -75,7 +75,15 @@ "longitude": "Longitude constraints (`min`, `max`).", "content": "HTML or Markdown content to display." }, + "pageLinks": { + "FileUploadPageController": [ + "This controller works in conjunction with the CDP file upload service. See the [File Upload guide](../code-based/file-upload.md) for setup instructions, including the required `formSubmissionService` interface." + ] + }, "componentLinks": { + "FileUploadField": [ + "This component is specifically designed to work with the CDP file upload service via the [File Upload Page](../pages/file-upload-page.md) controller — it must be used on a page using `FileUploadPageController`." + ], "HiddenField": [ "Hidden fields can be pre-populated from query string parameters — see [Pre-populating state](../code-based/pre-populate-state.md) for details." ], diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 606c71520..5fb608193 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -689,6 +689,7 @@ function generatePageMd( const label = controllerLabel(controllerKey) const isDefault = controllerKey === 'PageController' + const links = metadata.pageLinks?.[controllerKey] ?? [] const lines = [ `---`, @@ -702,6 +703,10 @@ function generatePageMd( `` ] + for (const text of links) { + lines.push(text, ``) + } + if (isDefault) { lines.push( `**Controller value:** omit the \`controller\` property, or use \`"PageController"\``, From 02d0584987ac07b209250c5cb18575e6bbfb645f Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 21:04:48 +0100 Subject: [PATCH 26/41] docs: replace bullet-point option list with table in plugin-options.md --- docs/plugin-options.md | 44 ++++++++++++++++++------------------------ 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/docs/plugin-options.md b/docs/plugin-options.md index a21e0a71b..1cf4a6f0f 100644 --- a/docs/plugin-options.md +++ b/docs/plugin-options.md @@ -2,31 +2,25 @@ The forms plugin is configured with [registration options](https://hapi.dev/api/?v=21.4.0#plugins) -## Required options - -- `nunjucks` (required) - Template engine configuration. See [nunjucks configuration](#nunjucks-configuration) -- `viewContext` (required) - A function that provides global context to all templates. See [viewContext](#viewcontext) -- `baseUrl` (required) - Base URL of the application (protocol and hostname, e.g., `"https://myservice.gov.uk"`). Used for generating absolute URLs in markdown rendering and other contexts - -## Optional options - -- `model` (optional) - Pre-built `FormModel` instance. When provided, the plugin serves a single static form definition. When omitted, forms are loaded dynamically via `formsService`. See [model](#model) -- `services` (optional) - object containing `formsService`, `formSubmissionService` and `outputService` - - `formsService` - used to load `formMetadata` and `formDefinition` - - `formSubmissionService` - used prepare the form during submission (ignore - subject to change) - - `outputService` - used to save the submission -- `controllers` (optional) - Object map of custom page controllers used to override the default. See [custom controllers](#custom-controllers) -- `globals` (optional) - A map of custom template globals to include. See [custom globals](#custom-globals) -- `filters` (optional) - A map of custom template filters to include. See [custom filters](#custom-filters) -- `cache` (optional) - Caching options. Recommended for production. This can be either: - - a string representing the cache name to use (e.g. hapi's default server cache). See [custom cache](#custom-cache) for more details. - - a custom `CacheService` instance implementing your own caching logic -- `pluginPath` (optional) - The location of the plugin (defaults to `node_modules/@defra/forms-engine-plugin`) -- `preparePageEventRequestOptions` (optional) - A function that will be invoked for http-based [page events](./features/configuration-based/page-events). See [here](./features/configuration-based/page-events#authenticating-a-http-page-event-request-from-forms-engine-plugin-in-your-api) for details -- `saveAndExit` (optional) - Configuration for custom session management including key generation, session hydration, and persistence. See [save and exit documentation](./features/code-based/save-and-exit) for details -- `onRequest` (optional) - A function that will be invoked on each request to any form route e.g `/{slug}/{path}`. See [onRequest](#onrequest) for more details -- `ordnanceSurveyApiKey` (optional) - Ordnance Survey API key. Required to enable the inline map on geospatial components. See [geospatial map](#geospatial-map) for details -- `ordnanceSurveyApiSecret` (optional) - Ordnance Survey API secret. Required alongside `ordnanceSurveyApiKey` to enable the inline map on geospatial components +## Options + +| Option | Required | Description | +| -------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `nunjucks` | Yes | Template engine configuration. See [nunjucks configuration](#nunjucks-configuration) | +| `viewContext` | Yes | A function that provides global context to all templates. See [viewContext](#viewcontext) | +| `baseUrl` | Yes | Base URL of the application (protocol and hostname, e.g. `"https://myservice.gov.uk"`). Used for generating absolute URLs in markdown rendering and other contexts | +| `model` | No | Pre-built `FormModel` instance. When provided, the plugin serves a single static form definition. When omitted, forms are loaded dynamically via `formsService`. See [model](#model) | +| `services` | No | Object containing `formsService`, `formSubmissionService`, and `outputService`. See [services](#services) | +| `controllers` | No | Object map of custom page controllers used to override the default. See [custom controllers](#custom-controllers) | +| `globals` | No | A map of custom template globals to include. See [custom globals](#custom-globals) | +| `filters` | No | A map of custom template filters to include. See [custom filters](#custom-filters) | +| `cache` | No | Caching options. Recommended for production — either a cache name string or a custom `CacheService` instance. See [custom cache](#custom-cache) | +| `pluginPath` | No | The location of the plugin. Defaults to `node_modules/@defra/forms-engine-plugin` | +| `preparePageEventRequestOptions` | No | A function invoked for HTTP-based [page events](./features/configuration-based/page-events) to customise outbound request options | +| `saveAndExit` | No | Configuration for custom session management. See [save and exit](./features/code-based/save-and-exit) | +| `onRequest` | No | A function invoked on each request to any form route (e.g. `/{slug}/{path}`). See [onRequest](#onrequest) | +| `ordnanceSurveyApiKey` | No | Ordnance Survey API key. Required to enable the inline map on geospatial components. See [geospatial map](#geospatial-map) | +| `ordnanceSurveyApiSecret` | No | Ordnance Survey API secret. Required alongside `ordnanceSurveyApiKey` to enable the inline map on geospatial components | ## Option details From b9740534c6a81cd16a0d333d30556f7347eb3d6b Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 21:08:11 +0100 Subject: [PATCH 27/41] docs: write custom controllers section in plugin-options.md --- docs/plugin-options.md | 43 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/plugin-options.md b/docs/plugin-options.md index 1cf4a6f0f..c4042d9b5 100644 --- a/docs/plugin-options.md +++ b/docs/plugin-options.md @@ -30,7 +30,48 @@ See [our services documentation](./features/code-based/custom-services). ### Custom controllers -TODO +The `controllers` option lets you register custom page controller classes that extend the built-in `PageController`. A custom controller is tied to a page in your form definition by setting the page's `controller` property to the key you register it under. + +```ts +import { PageController } from '@defra/forms-engine-plugin/controllers/PageController.js' +import { type FormModel } from '@defra/forms-engine-plugin/types' +import { type Page } from '@defra/forms-model' + +class ConfirmationPageController extends PageController { + constructor(model: FormModel, pageDef: Page) { + super(model, pageDef) + } + + makeGetRouteHandler() { + return async (request, h) => { + // custom logic before rendering + return h.view(this.viewName, { ...await this.getViewModel(request) }) + } + } +} + +await server.register({ + plugin, + options: { + controllers: { + ConfirmationPageController + } + } +}) +``` + +In your form definition, set the `controller` property of any page to the same key: + +```json +{ + "path": "/confirmation", + "title": "Confirmation", + "controller": "ConfirmationPageController", + "components": [] +} +``` + +When the engine instantiates pages, it first checks for a matching built-in controller, then falls back to the `controllers` map. If no match is found the default `PageController` is used. ### nunjucks configuration From c4c4bfd9d30a0ed3c7edd9c4bc2f24ad2a70d873 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 21:09:21 +0100 Subject: [PATCH 28/41] docs: add js syntax highlighting to Custom filters and Custom cache code blocks --- docs/plugin-options.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugin-options.md b/docs/plugin-options.md index c4042d9b5..4976d67a4 100644 --- a/docs/plugin-options.md +++ b/docs/plugin-options.md @@ -221,7 +221,7 @@ In your templates: Use the `filter` plugin option to provide custom template filters. Filters are available in both [nunjucks](https://mozilla.github.io/nunjucks/templating.html#filters) and [liquid](https://liquidjs.com/filters/overview.html) templates. -``` +```js const formatter = new Intl.NumberFormat('en-GB') await server.register({ @@ -244,7 +244,7 @@ In production you should create a custom cache one of the available `@hapi/catbo E.g. [Redis](https://github.com/hapijs/catbox-redis) -``` +```js import { Engine as CatboxRedis } from '@hapi/catbox-redis' const server = new Hapi.Server({ From 6bec93069919bbb06e3a6710c1937318202ac094 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 21:12:45 +0100 Subject: [PATCH 29/41] docs: capitalise Nunjucks in section heading and table link --- docs/plugin-options.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugin-options.md b/docs/plugin-options.md index 4976d67a4..1c6c549be 100644 --- a/docs/plugin-options.md +++ b/docs/plugin-options.md @@ -6,7 +6,7 @@ The forms plugin is configured with [registration options](https://hapi.dev/api/ | Option | Required | Description | | -------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `nunjucks` | Yes | Template engine configuration. See [nunjucks configuration](#nunjucks-configuration) | +| `nunjucks` | Yes | Template engine configuration. See [Nunjucks configuration](#nunjucks-configuration) | | `viewContext` | Yes | A function that provides global context to all templates. See [viewContext](#viewcontext) | | `baseUrl` | Yes | Base URL of the application (protocol and hostname, e.g. `"https://myservice.gov.uk"`). Used for generating absolute URLs in markdown rendering and other contexts | | `model` | No | Pre-built `FormModel` instance. When provided, the plugin serves a single static form definition. When omitted, forms are loaded dynamically via `formsService`. See [model](#model) | @@ -73,7 +73,7 @@ In your form definition, set the `controller` property of any page to the same k When the engine instantiates pages, it first checks for a matching built-in controller, then falls back to the `controllers` map. If no match is found the default `PageController` is used. -### nunjucks configuration +### Nunjucks configuration The `nunjucks` option is required and configures the template engine paths and layout. From 8af44147d4bae3ab72403de06bd650ff09e3820a Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 21:17:39 +0100 Subject: [PATCH 30/41] fix(docs): detect list/content props inherited from base interfaces --- scripts/generate-component-docs.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 5fb608193..46161bbd4 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -184,6 +184,24 @@ function parseComponentInterfaces(dtsPath) { if (propName === 'list') hasList = true } + // Also check inherited members — e.g. `list` lives on ListFieldBase, not on the + // concrete exported interface that extends it. + if (!hasContent || !hasList) { + for (const clause of node.heritageClauses ?? []) { + for (const type of clause.types) { + const baseName = type.expression.getText(sourceFile) + const baseIface = allInterfaces[baseName] + if (!baseIface) continue + for (const member of baseIface.members) { + if (!ts.isPropertySignature(member)) continue + const propName = member.name.getText(sourceFile) + if (propName === 'content') hasContent = true + if (propName === 'list') hasList = true + } + } + } + } + // Props typed as `undefined` are explicitly excluded for this component (e.g. // `required?: undefined` on ContentFieldBase). Sort alphabetically for stable output. const options = rawOptions From 112a0969a947e75fb89c36b4f583bb5ced0643c6 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 21:20:34 +0100 Subject: [PATCH 31/41] fix(docs): resolve full interface inheritance chain in component parser --- scripts/generate-component-docs.js | 64 ++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 46161bbd4..57650a0fe 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -124,6 +124,43 @@ function collectProps( return props } +/** + * Collect all property signatures for an interface including inherited ones. + * Returns a Map keyed by property name; derived declarations take priority over base. + * @param {import('typescript').InterfaceDeclaration} iface + * @param {Record} allInterfaces + * @param {import('typescript').SourceFile} sourceFile + * @returns {Map} + */ +function resolveInterfaceMembers(iface, allInterfaces, sourceFile) { + // Base members first so derived declarations overwrite them + const members = new Map() + + for (const clause of iface.heritageClauses ?? []) { + for (const type of clause.types) { + const baseName = type.expression.getText(sourceFile) + const baseIface = allInterfaces[baseName] + if (baseIface) { + for (const [name, member] of resolveInterfaceMembers( + baseIface, + allInterfaces, + sourceFile + )) { + members.set(name, member) + } + } + } + } + + for (const member of iface.members) { + if (ts.isPropertySignature(member)) { + members.set(member.name.getText(sourceFile), member) + } + } + + return members +} + /** * Parse all exported component interfaces from types.d.ts. * Returns a map: interfaceName -> { options, schema, hasContent, hasList } @@ -165,10 +202,11 @@ function parseComponentInterfaces(dtsPath) { let hasContent = false let hasList = false - for (const member of node.members) { - if (!ts.isPropertySignature(member)) continue - const propName = member.name.getText(sourceFile) - + for (const [propName, member] of resolveInterfaceMembers( + node, + allInterfaces, + sourceFile + )) { if (propName === 'options' && member.type) { rawOptions = collectProps( member.type, @@ -184,24 +222,6 @@ function parseComponentInterfaces(dtsPath) { if (propName === 'list') hasList = true } - // Also check inherited members — e.g. `list` lives on ListFieldBase, not on the - // concrete exported interface that extends it. - if (!hasContent || !hasList) { - for (const clause of node.heritageClauses ?? []) { - for (const type of clause.types) { - const baseName = type.expression.getText(sourceFile) - const baseIface = allInterfaces[baseName] - if (!baseIface) continue - for (const member of baseIface.members) { - if (!ts.isPropertySignature(member)) continue - const propName = member.name.getText(sourceFile) - if (propName === 'content') hasContent = true - if (propName === 'list') hasList = true - } - } - } - } - // Props typed as `undefined` are explicitly excluded for this component (e.g. // `required?: undefined` on ContentFieldBase). Sort alphabetically for stable output. const options = rawOptions From ca071cc1a2960d4d5d3b7f4a7994d1df206f1393 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 21:25:38 +0100 Subject: [PATCH 32/41] fix(docs): replace hasList/hasContent flags with generic top-level props collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Collect all non-universal top-level component properties (anything not in type/name/title/id/options/schema) from the full inheritance chain - Render them in a new ## Properties table above ## Options - Remove hasList and hasContent flags — now redundant - Surface hint and shortDescription (inherited from FormFieldBase) with descriptions - Add list and hint descriptions to component-metadata.json --- scripts/component-metadata.json | 3 ++ scripts/generate-component-docs.js | 53 ++++++++++++++++++++----- scripts/generate-component-docs.test.js | 27 +++++-------- 3 files changed, 55 insertions(+), 28 deletions(-) diff --git a/scripts/component-metadata.json b/scripts/component-metadata.json index 16ae4d8f4..890b0ebc1 100644 --- a/scripts/component-metadata.json +++ b/scripts/component-metadata.json @@ -73,6 +73,9 @@ "northing": "Northing coordinate constraints (`min`, `max`).", "latitude": "Latitude constraints (`min`, `max`).", "longitude": "Longitude constraints (`min`, `max`).", + "hint": "Hint text displayed below the field label to help the user answer the question.", + "list": "Name of a list definition that provides the options to display.", + "shortDescription": "A short description of the field used in the check-your-answers summary.", "content": "HTML or Markdown content to display." }, "pageLinks": { diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 57650a0fe..ac212f7d8 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -199,8 +199,18 @@ function parseComponentInterfaces(dtsPath) { const name = node.name.text let rawOptions = [] let schema = [] - let hasContent = false - let hasList = false + const rawProps = [] + + // Universal props present on every component — shown in the JSON example as + // fixed placeholders, so not worth documenting in a per-component table. + const UNIVERSAL = new Set([ + 'type', + 'name', + 'title', + 'id', + 'options', + 'schema' + ]) for (const [propName, member] of resolveInterfaceMembers( node, @@ -214,12 +224,15 @@ function parseComponentInterfaces(dtsPath) { allInterfaces, 'options' ) - } - if (propName === 'schema' && member.type) { + } else if (propName === 'schema' && member.type) { schema = collectProps(member.type, sourceFile, allInterfaces, 'schema') + } else if (!UNIVERSAL.has(propName)) { + const optional = !!member.questionToken + const rawType = member.type + ? member.type.getText(sourceFile) + : 'unknown' + rawProps.push({ name: propName, type: simplifyType(rawType), optional }) } - if (propName === 'content') hasContent = true - if (propName === 'list') hasList = true } // Props typed as `undefined` are explicitly excluded for this component (e.g. @@ -228,7 +241,11 @@ function parseComponentInterfaces(dtsPath) { .filter((p) => p.type !== 'undefined') .sort((a, b) => a.name.localeCompare(b.name)) - result[name] = { options, schema, hasContent, hasList } + const props = rawProps + .filter((p) => p.type !== 'undefined') + .sort((a, b) => a.name.localeCompare(b.name)) + + result[name] = { options, schema, props } }) return result @@ -545,7 +562,7 @@ export function placeholderForType(type) { * Optional fields are omitted — the tables below the example document them. */ export function generateExample(componentName, interfaceData) { - const { options = [], schema = [], hasContent, hasList } = interfaceData + const { options = [], schema = [], props = [] } = interfaceData const example = { type: componentName, @@ -553,8 +570,9 @@ export function generateExample(componentName, interfaceData) { title: 'Question title' } - if (hasContent) example.content = '' - if (hasList) example.list = 'listName' + for (const prop of props) { + example[prop.name] = placeholderForType(prop.type) + } const requiredOptions = options.filter((p) => !p.optional) if (requiredOptions.length > 0) { @@ -580,7 +598,7 @@ export function generateExample(componentName, interfaceData) { function generateComponentMd(componentName, interfaceData, sidebarPosition) { const description = metadata.components[componentName] ?? '' const label = toLabel(componentName) - const { options = [], schema = [] } = interfaceData + const { options = [], schema = [], props = [] } = interfaceData const links = metadata.componentLinks?.[componentName] ?? [] @@ -609,6 +627,19 @@ function generateComponentMd(componentName, interfaceData, sidebarPosition) { `` ) + if (props.length > 0) { + lines.push(`## Properties`, ``) + lines.push(`| Property | Type | Required | Description |`) + lines.push(`|----------|------|----------|-------------|`) + for (const prop of props) { + const desc = metadata.properties[prop.name] ?? '' + lines.push( + `| \`${prop.name}\` | \`${prop.type}\` | ${prop.optional ? 'No' : 'Yes'} | ${desc} |` + ) + } + lines.push(``) + } + if (options.length > 0) { lines.push(`## Options`, ``) lines.push(`| Property | Type | Required | Description |`) diff --git a/scripts/generate-component-docs.test.js b/scripts/generate-component-docs.test.js index 1b9d8aa91..d1d6a08a9 100644 --- a/scripts/generate-component-docs.test.js +++ b/scripts/generate-component-docs.test.js @@ -177,8 +177,7 @@ describe('Component Documentation Generator', () => { const result = generateExample('TextField', { options: [], schema: [], - hasContent: false, - hasList: false + props: [] }) expect(result).toMatchObject({ type: 'TextField', @@ -191,8 +190,7 @@ describe('Component Documentation Generator', () => { const result = generateExample('TextField', { options: [{ name: 'rows', optional: true, type: 'number' }], schema: [], - hasContent: false, - hasList: false + props: [] }) expect(result.options).toEqual({}) }) @@ -204,8 +202,7 @@ describe('Component Documentation Generator', () => { { name: 'classes', optional: true, type: 'string' } ], schema: [], - hasContent: false, - hasList: false + props: [] }) expect(result.options).toEqual({ rows: 0 }) }) @@ -217,38 +214,34 @@ describe('Component Documentation Generator', () => { { name: 'min', optional: false, type: 'number' }, { name: 'max', optional: true, type: 'number' } ], - hasContent: false, - hasList: false + props: [] }) expect(result.schema).toEqual({ min: 0 }) }) - it('includes content field when hasContent is true', () => { + it('includes top-level props in the example using placeholders', () => { const result = generateExample('Html', { options: [], schema: [], - hasContent: true, - hasList: false + props: [{ name: 'content', optional: false, type: 'string' }] }) expect(result).toHaveProperty('content', '') }) - it('includes list field when hasList is true', () => { + it('includes list as a top-level prop', () => { const result = generateExample('RadiosField', { options: [], schema: [], - hasContent: false, - hasList: true + props: [{ name: 'list', optional: false, type: 'string' }] }) - expect(result).toHaveProperty('list', 'listName') + expect(result).toHaveProperty('list', '') }) it('omits options and schema keys when both are empty', () => { const result = generateExample('HiddenField', { options: [], schema: [], - hasContent: false, - hasList: false + props: [] }) expect(result).not.toHaveProperty('options') expect(result).not.toHaveProperty('schema') From c9062b051522c2b71e1254bb3eb819952b0f6276 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 21:30:21 +0100 Subject: [PATCH 33/41] docs: link list property to schema page, add propertyExamples for meaningful placeholders --- scripts/component-metadata.json | 5 ++++- scripts/generate-component-docs.js | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/component-metadata.json b/scripts/component-metadata.json index 890b0ebc1..4e8822309 100644 --- a/scripts/component-metadata.json +++ b/scripts/component-metadata.json @@ -74,10 +74,13 @@ "latitude": "Latitude constraints (`min`, `max`).", "longitude": "Longitude constraints (`min`, `max`).", "hint": "Hint text displayed below the field label to help the user answer the question.", - "list": "Name of a list definition that provides the options to display.", + "list": "Name of a list definition that provides the options to display. See [list schema](../../schemas/index.md#list-schema) for the list definition format.", "shortDescription": "A short description of the field used in the check-your-answers summary.", "content": "HTML or Markdown content to display." }, + "propertyExamples": { + "list": "myListName" + }, "pageLinks": { "FileUploadPageController": [ "This controller works in conjunction with the CDP file upload service. See the [File Upload guide](../code-based/file-upload.md) for setup instructions, including the required `formSubmissionService` interface." diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index ac212f7d8..17063dcba 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -571,7 +571,8 @@ export function generateExample(componentName, interfaceData) { } for (const prop of props) { - example[prop.name] = placeholderForType(prop.type) + example[prop.name] = + metadata.propertyExamples?.[prop.name] ?? placeholderForType(prop.type) } const requiredOptions = options.filter((p) => !p.optional) From 81265cbb5b44f9329766daf4fab3e100255a5718 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 21:32:37 +0100 Subject: [PATCH 34/41] revert: remove propertyExamples, keep simple placeholderForType --- scripts/component-metadata.json | 3 --- scripts/generate-component-docs.js | 3 +-- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/scripts/component-metadata.json b/scripts/component-metadata.json index 4e8822309..b4ae31ec3 100644 --- a/scripts/component-metadata.json +++ b/scripts/component-metadata.json @@ -78,9 +78,6 @@ "shortDescription": "A short description of the field used in the check-your-answers summary.", "content": "HTML or Markdown content to display." }, - "propertyExamples": { - "list": "myListName" - }, "pageLinks": { "FileUploadPageController": [ "This controller works in conjunction with the CDP file upload service. See the [File Upload guide](../code-based/file-upload.md) for setup instructions, including the required `formSubmissionService` interface." diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 17063dcba..ac212f7d8 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -571,8 +571,7 @@ export function generateExample(componentName, interfaceData) { } for (const prop of props) { - example[prop.name] = - metadata.propertyExamples?.[prop.name] ?? placeholderForType(prop.type) + example[prop.name] = placeholderForType(prop.type) } const requiredOptions = options.filter((p) => !p.optional) From f072fe0fe38db8d364fc7acc97ef421602d5a363 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 21:42:24 +0100 Subject: [PATCH 35/41] docs(scripts): rewrite JSDoc on TS parsing functions to explain why they exist --- scripts/generate-component-docs.js | 87 +++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 25 deletions(-) diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index ac212f7d8..d405cac25 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -38,6 +38,15 @@ export function toLabel(name) { return words.map((w) => ACRONYMS[w] ?? w).join(' ') } +/** + * Type strings from `.d.ts` files are verbose and use internal model names + * that mean nothing in user-facing docs. This function cleans them up: + * `string | undefined` becomes `string`, inline `{ ... }` shapes become + * `object`, and internal list types like `ListTypeContent` become `string`. + * Array notation is preserved, e.g. `ComponentDef[] | undefined` → `ComponentDef[]`. + * @param {string} rawType + * @returns {string} + */ export function simplifyType(rawType) { if (!rawType) return 'unknown' const t = rawType.replace(/\s+/g, ' ').trim() @@ -52,6 +61,15 @@ export function simplifyType(rawType) { return withoutUndefined } +/** + * Some component option types are written as inline object shapes, e.g. + * `options: { required?: boolean; classes?: string }`. This reads those + * inline shapes and returns each property as a plain `{ name, type, optional }` + * object that can be rendered as a table row. + * @param {import('typescript').TypeNode} typeNode + * @param {import('typescript').SourceFile} sourceFile + * @returns {{name: string, type: string, optional: boolean}[]} + */ function extractTypeLiteralProps(typeNode, sourceFile) { const props = [] if (!ts.isTypeLiteralNode(typeNode)) return props @@ -66,13 +84,18 @@ function extractTypeLiteralProps(typeNode, sourceFile) { } /** - * Traverse a type node recursively, resolving IndexedAccessTypes by looking up - * the referenced interface in allInterfaces. Collects all TypeLiteralNode properties. - * - * This handles patterns like: - * DateFieldBase['options'] & { condition?: string } - * by resolving DateFieldBase.options → FormFieldBase['options'] & { maxDaysInPast?, maxDaysInFuture? } - * and continuing to collect all literal members. + * Component options in forms-model are built up in layers. A date field's options type + * might be `DateFieldBase['options'] & { condition?: string }`, where `DateFieldBase['options']` + * itself resolves to `FormFieldBase['options'] & { maxDaysInPast?: number; maxDaysInFuture?: number }`. + * Simply reading the type string gives us nothing useful — we need to follow each reference + * and intersection until we reach the actual properties. This function does that recursively, + * returning a flat list of every property the user can set. + * @param {import('typescript').TypeNode} typeNode + * @param {import('typescript').SourceFile} sourceFile + * @param {Record} allInterfaces + * @param {string} accessKey - The property key being resolved, e.g. `'options'` or `'schema'` + * @param {number} [depth] + * @returns {{name: string, type: string, optional: boolean}[]} */ function collectProps( typeNode, @@ -125,8 +148,11 @@ function collectProps( } /** - * Collect all property signatures for an interface including inherited ones. - * Returns a Map keyed by property name; derived declarations take priority over base. + * TypeScript interfaces only list their own directly-declared members, not the ones + * they inherit. `RadiosFieldComponent extends ListFieldBase`, so iterating its members + * directly would miss `list: string` which lives on `ListFieldBase`. This function + * walks the full `extends` chain and merges everything into a single map, with + * the most-derived declaration winning when the same property appears at multiple levels. * @param {import('typescript').InterfaceDeclaration} iface * @param {Record} allInterfaces * @param {import('typescript').SourceFile} sourceFile @@ -162,13 +188,12 @@ function resolveInterfaceMembers(iface, allInterfaces, sourceFile) { } /** - * Parse all exported component interfaces from types.d.ts. - * Returns a map: interfaceName -> { options, schema, hasContent, hasList } - * - * - options: component-specific and group-specific options (base props filtered out) - * - schema: schema constraint properties - * - hasContent: whether the component has a 'content' property - * - hasList: whether the component has a 'list' property + * Reads every exported component interface from the forms-model types file and + * extracts the information needed to generate each component's doc page: the + * configurable options (the `options` sub-object properties), any schema + * constraints (the `schema` sub-object properties), and any other top-level + * properties specific to that component (e.g. `list` for selection components, + * `hint` and `shortDescription` for all field types). */ function parseComponentInterfaces(dtsPath) { const content = fs.readFileSync(dtsPath, 'utf-8') @@ -252,14 +277,17 @@ function parseComponentInterfaces(dtsPath) { } /** - * Recursively flatten an interface into dotted-path prop descriptors. - * Resolves type references to other interfaces rather than leaving them as opaque types. + * Some page-level properties are typed as named interfaces rather than inline shapes. + * For example, `PageRepeat` has `repeat: Repeat`, where `Repeat` is `{ options: RepeatOptions; schema: RepeatSchema }`. + * Rather than showing a single opaque `repeat: Repeat` row, this function expands the + * referenced interface into dotted paths — `repeat.options.name`, `repeat.schema.min` — + * so the docs table shows the actual structure the user needs to write. * @param {import('typescript').InterfaceDeclaration} iface * @param {Record} allInterfaces * @param {import('typescript').SourceFile} sourceFile - * @param {string} prefix - Dotted prefix accumulated so far + * @param {string} prefix - Dotted path accumulated so far, e.g. `'repeat.options'` * @param {number} [depth] - * @returns {Array<{name: string, type: string, optional: boolean}>} + * @returns {{name: string, type: string, optional: boolean}[]} */ function flattenInterface(iface, allInterfaces, sourceFile, prefix, depth = 0) { if (depth > 4) return [] @@ -295,8 +323,13 @@ function flattenInterface(iface, allInterfaces, sourceFile, prefix, depth = 0) { } /** - * Derive the controller → interface map and per-controller example path hints from types. - * Reads ControllerType and ControllerPath enums plus the PageX interfaces in one pass. + * The string used to reference a page controller in a form definition (`StartPageController`) + * has a different name from the TypeScript interface that describes it (`PageStart`). The + * only place that mapping exists is in the `ControllerType` enum. This function reads that + * enum alongside the page interfaces to build the map automatically — so we never have to + * maintain a hardcoded lookup table. It also reads the `ControllerPath` enum to determine + * the canonical example path for page types whose path is constrained to a fixed value + * (e.g. the start page must use `/start`). * @param {string} formDefinitionDtsPath * @param {string} pagesEnumsDtsPath * @returns {{ controllerMap: Record, pathHints: Record }} @@ -369,12 +402,16 @@ function parseControllerMap(formDefinitionDtsPath, pagesEnumsDtsPath) { } /** - * Parse page interfaces from form-definition types.d.ts. - * Returns a map: controllerKey -> { props, examplePath }. + * Extracts the documented properties for each page type. For each controller key + * (e.g. `RepeatPageController`), this finds the corresponding TypeScript interface, + * collects all its properties including ones inherited from `PageBase`, drops any + * explicitly typed as `undefined` (which means "not applicable to this page type"), + * and expands any named sub-interfaces into dotted paths (e.g. `repeat.options.name`). + * The result also carries the example path to use in the JSON snippet for each page type. * @param {string} dtsPath * @param {Record} controllerMap * @param {Record} pathHints - * @returns {Record, examplePath: string }>} + * @returns {Record} */ function parsePageInterfaces(dtsPath, controllerMap, pathHints) { const content = fs.readFileSync(dtsPath, 'utf-8') From 7b2ff464d4d08ff5f4223b80f8aa31d0cd3abe5d Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 21:56:32 +0100 Subject: [PATCH 36/41] refactor(scripts): hoist UNIVERSAL, extract parseSourceFile, drop next prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hoist UNIVERSAL set to module level (was recreated on every interface iteration) - Extract parseSourceFile helper to deduplicate read+parse+allInterfaces setup - Exclude `next` from page props — v2 engine derives routing from pages[] order and condition --- scripts/generate-component-docs.js | 64 ++++++++----------------- scripts/generate-component-docs.test.js | 18 ------- 2 files changed, 19 insertions(+), 63 deletions(-) diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index d405cac25..99e85a320 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -38,6 +38,8 @@ export function toLabel(name) { return words.map((w) => ACRONYMS[w] ?? w).join(' ') } +const UNIVERSAL = new Set(['type', 'name', 'title', 'id', 'options', 'schema']) + /** * Type strings from `.d.ts` files are verbose and use internal model names * that mean nothing in user-facing docs. This function cleans them up: @@ -187,15 +189,7 @@ function resolveInterfaceMembers(iface, allInterfaces, sourceFile) { return members } -/** - * Reads every exported component interface from the forms-model types file and - * extracts the information needed to generate each component's doc page: the - * configurable options (the `options` sub-object properties), any schema - * constraints (the `schema` sub-object properties), and any other top-level - * properties specific to that component (e.g. `list` for selection components, - * `hint` and `shortDescription` for all field types). - */ -function parseComponentInterfaces(dtsPath) { +function parseSourceFile(dtsPath) { const content = fs.readFileSync(dtsPath, 'utf-8') const sourceFile = ts.createSourceFile( dtsPath, @@ -203,14 +197,23 @@ function parseComponentInterfaces(dtsPath) { ts.ScriptTarget.Latest, true ) - - // Collect all interfaces for cross-reference resolution const allInterfaces = {} ts.forEachChild(sourceFile, (node) => { - if (ts.isInterfaceDeclaration(node)) { - allInterfaces[node.name.text] = node - } + if (ts.isInterfaceDeclaration(node)) allInterfaces[node.name.text] = node }) + return { sourceFile, allInterfaces } +} + +/** + * Reads every exported component interface from the forms-model types file and + * extracts the information needed to generate each component's doc page: the + * configurable options (the `options` sub-object properties), any schema + * constraints (the `schema` sub-object properties), and any other top-level + * properties specific to that component (e.g. `list` for selection components, + * `hint` and `shortDescription` for all field types). + */ +function parseComponentInterfaces(dtsPath) { + const { sourceFile, allInterfaces } = parseSourceFile(dtsPath) const result = {} @@ -226,17 +229,6 @@ function parseComponentInterfaces(dtsPath) { let schema = [] const rawProps = [] - // Universal props present on every component — shown in the JSON example as - // fixed placeholders, so not worth documenting in a per-component table. - const UNIVERSAL = new Set([ - 'type', - 'name', - 'title', - 'id', - 'options', - 'schema' - ]) - for (const [propName, member] of resolveInterfaceMembers( node, allInterfaces, @@ -414,20 +406,7 @@ function parseControllerMap(formDefinitionDtsPath, pagesEnumsDtsPath) { * @returns {Record} */ function parsePageInterfaces(dtsPath, controllerMap, pathHints) { - const content = fs.readFileSync(dtsPath, 'utf-8') - const sourceFile = ts.createSourceFile( - dtsPath, - content, - ts.ScriptTarget.Latest, - true - ) - - const allInterfaces = {} - ts.forEachChild(sourceFile, (node) => { - if (ts.isInterfaceDeclaration(node)) { - allInterfaces[node.name.text] = node - } - }) + const { sourceFile, allInterfaces } = parseSourceFile(dtsPath) // Collect PageBase members once — merged into every page type below const pageBaseProps = [] @@ -499,7 +478,7 @@ function parsePageInterfaces(dtsPath, controllerMap, pathHints) { // (e.g. `section?: undefined` on PageSummary). Sort alphabetically. result[controllerKey] = { props: props - .filter((p) => p.type !== 'undefined') + .filter((p) => p.type !== 'undefined' && p.name !== 'next') // v2 engine derives routing from pages[] order and condition .sort((a, b) => a.name.localeCompare(b.name)), examplePath: pathHints[controllerKey] ?? '/page-path' } @@ -770,11 +749,6 @@ export function generatePageExample( setNestedValue(example, prop.name, placeholderForType(prop.type)) } - // Give next a meaningful routing example rather than the empty array placeholder - if (uniqueProps.some((p) => p.name === 'next' && !p.optional)) { - example.next = [{ path: '/next-page' }] - } - return example } diff --git a/scripts/generate-component-docs.test.js b/scripts/generate-component-docs.test.js index d1d6a08a9..228996dbf 100644 --- a/scripts/generate-component-docs.test.js +++ b/scripts/generate-component-docs.test.js @@ -269,24 +269,6 @@ describe('Component Documentation Generator', () => { expect(result.path).toBe('/page-path') }) - it('gives next a meaningful example value when it is a required prop', () => { - const result = generatePageExample('PageController', [ - { name: 'next', optional: false, type: 'Link[]' }, - { name: 'components', optional: false, type: 'ComponentDef[]' } - ]) - expect(result.next).toEqual([{ path: '/next-page' }]) - expect(result.components).toEqual([]) - }) - - it('omits next when not present in uniqueProps', () => { - const result = generatePageExample( - 'SummaryPageController', - [], - '/summary' - ) - expect(result).not.toHaveProperty('next') - }) - it('populates required unique props with placeholders using setNestedValue', () => { const result = generatePageExample('RepeatPageController', [ { name: 'repeat.options.name', optional: false, type: 'string' }, From 19db580debd78482c537c8991c7c87ec6f9f1f6b Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 22:08:29 +0100 Subject: [PATCH 37/41] fix(scripts): reduce complexity of simplifyNestedTitles, fix bare return, use replaceAll --- scripts/generate-schema-docs.js | 52 ++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/scripts/generate-schema-docs.js b/scripts/generate-schema-docs.js index ed1bb3dc9..1efc69a27 100644 --- a/scripts/generate-schema-docs.js +++ b/scripts/generate-schema-docs.js @@ -66,30 +66,11 @@ export function getSchemaFiles() { * @param {JsonSchema} schema - The schema to simplify in place * @param {string} [originalParentTitle] - Original title of the parent schema */ -export function simplifyNestedTitles(schema, originalParentTitle = '') { - if (!schema || typeof schema !== 'object') return - - const originalTitle = schema.title ?? '' - - if (originalTitle && originalParentTitle) { - const normalizedTitle = originalTitle.replace(/\s+/g, ' ').trim() - const normalizedParent = originalParentTitle.replace(/\s+/g, ' ').trim() - - if (normalizedTitle.startsWith(normalizedParent)) { - const stripped = normalizedTitle.slice(normalizedParent.length).trim() - if (stripped) { - schema.title = stripped - } - } - } - - // Pass the ORIGINAL title to children so multi-level stripping works correctly - const nextParent = originalTitle || originalParentTitle - +function recurseSchemaChildren(schema, parentTitle) { for (const keyword of /** @type {const} */ (['anyOf', 'oneOf', 'allOf'])) { if (Array.isArray(schema[keyword])) { for (const sub of schema[keyword]) { - simplifyNestedTitles(/** @type {JsonSchema} */ (sub), nextParent) + simplifyNestedTitles(/** @type {JsonSchema} */ (sub), parentTitle) } } } @@ -97,20 +78,43 @@ export function simplifyNestedTitles(schema, originalParentTitle = '') { if (schema.items) { if (Array.isArray(schema.items)) { for (const item of schema.items) { - simplifyNestedTitles(/** @type {JsonSchema} */ (item), nextParent) + simplifyNestedTitles(/** @type {JsonSchema} */ (item), parentTitle) } } else { - simplifyNestedTitles(schema.items, nextParent) + simplifyNestedTitles(schema.items, parentTitle) } } if (schema.properties) { for (const propSchema of Object.values(schema.properties)) { - simplifyNestedTitles(/** @type {JsonSchema} */ (propSchema), nextParent) + simplifyNestedTitles(/** @type {JsonSchema} */ (propSchema), parentTitle) } } } +export function simplifyNestedTitles(schema, originalParentTitle = '') { + if (!schema || typeof schema !== 'object') { + return + } + + const originalTitle = schema.title ?? '' + + if (originalTitle && originalParentTitle) { + const normalizedTitle = originalTitle.replaceAll(/\s+/g, ' ').trim() + const normalizedParent = originalParentTitle.replaceAll(/\s+/g, ' ').trim() + + if (normalizedTitle.startsWith(normalizedParent)) { + const stripped = normalizedTitle.slice(normalizedParent.length).trim() + if (stripped) { + schema.title = stripped + } + } + } + + // Pass the ORIGINAL title to children so multi-level stripping works correctly + recurseSchemaChildren(schema, originalTitle || originalParentTitle) +} + /** * Process schema content by adding ID if missing and building title map * @param {JsonSchema} schema - Schema content to process From c78f2f4d51048baf5ba4186f3a1aeb0164ae76b2 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 22:17:57 +0100 Subject: [PATCH 38/41] fix(scripts): add JSDoc types to satisfy strict tsc, reduce recurseSchemaChildren complexity --- scripts/generate-component-docs.js | 66 +++++++++++++++++++++++++++++- scripts/generate-schema-docs.js | 36 ++++++++++------ 2 files changed, 89 insertions(+), 13 deletions(-) diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 99e85a320..af5e2acce 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -20,9 +20,18 @@ const metadata = JSON.parse( fs.readFileSync(path.resolve(__dirname, 'component-metadata.json'), 'utf-8') ) -// Known acronyms for label generation +/** + * @typedef {{ name: string, type: string, optional: boolean }} PropEntry + * @typedef {{ options: PropEntry[], schema: PropEntry[], props: PropEntry[] }} ComponentData + */ + +/** @type {Record} */ const ACRONYMS = { Uk: 'UK', Os: 'OS', Html: 'HTML' } +/** + * @param {string} str + * @returns {string} + */ export function toKebabCase(str) { return str.replace( /([A-Z])/g, @@ -30,6 +39,10 @@ export function toKebabCase(str) { ) } +/** + * @param {string} name + * @returns {string} + */ export function toLabel(name) { const words = name .replace(/([A-Z])/g, ' $1') @@ -73,6 +86,7 @@ export function simplifyType(rawType) { * @returns {{name: string, type: string, optional: boolean}[]} */ function extractTypeLiteralProps(typeNode, sourceFile) { + /** @type {PropEntry[]} */ const props = [] if (!ts.isTypeLiteralNode(typeNode)) return props for (const member of typeNode.members) { @@ -189,6 +203,10 @@ function resolveInterfaceMembers(iface, allInterfaces, sourceFile) { return members } +/** + * @param {string} dtsPath + * @returns {{ sourceFile: import('typescript').SourceFile, allInterfaces: Record }} + */ function parseSourceFile(dtsPath) { const content = fs.readFileSync(dtsPath, 'utf-8') const sourceFile = ts.createSourceFile( @@ -197,6 +215,7 @@ function parseSourceFile(dtsPath) { ts.ScriptTarget.Latest, true ) + /** @type {Record} */ const allInterfaces = {} ts.forEachChild(sourceFile, (node) => { if (ts.isInterfaceDeclaration(node)) allInterfaces[node.name.text] = node @@ -211,10 +230,13 @@ function parseSourceFile(dtsPath) { * constraints (the `schema` sub-object properties), and any other top-level * properties specific to that component (e.g. `list` for selection components, * `hint` and `shortDescription` for all field types). + * @param {string} dtsPath + * @returns {Record} */ function parseComponentInterfaces(dtsPath) { const { sourceFile, allInterfaces } = parseSourceFile(dtsPath) + /** @type {Record} */ const result = {} ts.forEachChild(sourceFile, (node) => { @@ -225,8 +247,11 @@ function parseComponentInterfaces(dtsPath) { return const name = node.name.text + /** @type {PropEntry[]} */ let rawOptions = [] + /** @type {PropEntry[]} */ let schema = [] + /** @type {PropEntry[]} */ const rawProps = [] for (const [propName, member] of resolveInterfaceMembers( @@ -336,7 +361,9 @@ function parseControllerMap(formDefinitionDtsPath, pagesEnumsDtsPath) { true ) + /** @type {Record} */ const controllerTypeValues = {} + /** @type {Record} */ const controllerPathValues = {} ts.forEachChild(enumSourceFile, (node) => { if (!ts.isEnumDeclaration(node)) return @@ -364,7 +391,9 @@ function parseControllerMap(formDefinitionDtsPath, pagesEnumsDtsPath) { true ) + /** @type {Record} */ const controllerMap = {} + /** @type {Record} */ const pathHints = {} ts.forEachChild(defSourceFile, (node) => { if (!ts.isInterfaceDeclaration(node)) return @@ -425,6 +454,7 @@ function parsePageInterfaces(dtsPath, controllerMap, pathHints) { } } + /** @type {Record} */ const result = {} for (const [controllerKey, interfaceName] of Object.entries(controllerMap)) { @@ -489,6 +519,8 @@ function parsePageInterfaces(dtsPath, controllerMap, pathHints) { /** * Parse the ComponentType enum to get an ordered list of component names. + * @param {string} enumsDtsPath + * @returns {string[]} */ function parseComponentOrder(enumsDtsPath) { const content = fs.readFileSync(enumsDtsPath, 'utf-8') @@ -498,6 +530,7 @@ function parseComponentOrder(enumsDtsPath) { ts.ScriptTarget.Latest, true ) + /** @type {string[]} */ const order = [] ts.forEachChild(sourceFile, (node) => { @@ -516,6 +549,8 @@ function parseComponentOrder(enumsDtsPath) { /** * Parse ContentComponentsDef and SelectionComponentsDef type aliases to derive categories. * Returns a map: componentName -> 'content' | 'selection' + * @param {string} typesDtsPath + * @returns {Record} */ function parseCategories(typesDtsPath) { const content = fs.readFileSync(typesDtsPath, 'utf-8') @@ -526,8 +561,13 @@ function parseCategories(typesDtsPath) { true ) + /** @type {Record} */ const categories = {} + /** + * @param {import('typescript').TypeNode} typeNode + * @returns {string[]} + */ function namesFromUnion(typeNode) { if (ts.isUnionTypeNode(typeNode)) { return typeNode.types.flatMap(namesFromUnion) @@ -556,6 +596,11 @@ function parseCategories(typesDtsPath) { return categories } +/** + * @param {string} name + * @param {Record} parsedCategories + * @returns {string} + */ export function deriveCategory(name, parsedCategories) { return parsedCategories[name] ?? 'input' } @@ -563,6 +608,8 @@ export function deriveCategory(name, parsedCategories) { /** * Return a placeholder value for a given type string. * Used to populate required fields in generated examples. + * @param {string} type + * @returns {unknown} */ export function placeholderForType(type) { if (type === 'number') return 0 @@ -576,10 +623,14 @@ export function placeholderForType(type) { * Generate an example JSON for a component based on its structure. * Required options/schema fields are shown with placeholder values. * Optional fields are omitted — the tables below the example document them. + * @param {string} componentName + * @param {ComponentData} interfaceData + * @returns {Record} */ export function generateExample(componentName, interfaceData) { const { options = [], schema = [], props = [] } = interfaceData + /** @type {Record} */ const example = { type: componentName, name: 'fieldName', @@ -611,6 +662,12 @@ export function generateExample(componentName, interfaceData) { return example } +/** + * @param {string} componentName + * @param {ComponentData} interfaceData + * @param {number} sidebarPosition + * @returns {string} + */ function generateComponentMd(componentName, interfaceData, sidebarPosition) { const description = metadata.components[componentName] ?? '' const label = toLabel(componentName) @@ -825,7 +882,13 @@ function generatePageMd( return lines.join('\n') } +/** + * @param {string[]} componentNames + * @param {Record} categories + * @returns {string} + */ function generateComponentsIndex(componentNames, categories) { + /** @type {Record} */ const groups = { input: { label: 'Input fields', items: [] }, selection: { label: 'Selection fields', items: [] }, @@ -960,6 +1023,7 @@ function main() { ) // Build full category map + /** @type {Record} */ const categories = {} for (const name of componentOrder) { categories[name] = deriveCategory(name, parsedCategories) diff --git a/scripts/generate-schema-docs.js b/scripts/generate-schema-docs.js index 1efc69a27..4b92b408a 100644 --- a/scripts/generate-schema-docs.js +++ b/scripts/generate-schema-docs.js @@ -54,19 +54,10 @@ export function getSchemaFiles() { } /** - * Recursively simplifies titles in nested schemas by stripping redundant parent - * prefixes. For example, if parent title is "Components (array)" and child title - * is "Components (array) Item", the child title is simplified to "Item". - * - * This prevents jsonschema2md from generating excessively long filenames that - * exceed the OS 255-byte filename limit when Docusaurus renders them as .html. - * - * The original (pre-simplification) title is passed to child schemas for - * matching, so the chain of prefix-stripping works correctly across all levels. - * @param {JsonSchema} schema - The schema to simplify in place - * @param {string} [originalParentTitle] - Original title of the parent schema + * @param {JsonSchema} schema + * @param {string} parentTitle */ -function recurseSchemaChildren(schema, parentTitle) { +function recurseComposedTypes(schema, parentTitle) { for (const keyword of /** @type {const} */ (['anyOf', 'oneOf', 'allOf'])) { if (Array.isArray(schema[keyword])) { for (const sub of schema[keyword]) { @@ -74,6 +65,14 @@ function recurseSchemaChildren(schema, parentTitle) { } } } +} + +/** + * @param {JsonSchema} schema + * @param {string} parentTitle + */ +function recurseSchemaChildren(schema, parentTitle) { + recurseComposedTypes(schema, parentTitle) if (schema.items) { if (Array.isArray(schema.items)) { @@ -92,6 +91,19 @@ function recurseSchemaChildren(schema, parentTitle) { } } +/** + * Recursively simplifies titles in nested schemas by stripping redundant parent + * prefixes. For example, if parent title is "Components (array)" and child title + * is "Components (array) Item", the child title is simplified to "Item". + * + * This prevents jsonschema2md from generating excessively long filenames that + * exceed the OS 255-byte filename limit when Docusaurus renders them as .html. + * + * The original (pre-simplification) title is passed to child schemas for + * matching, so the chain of prefix-stripping works correctly across all levels. + * @param {JsonSchema} schema - The schema to simplify in place + * @param {string} [originalParentTitle] - Original title of the parent schema + */ export function simplifyNestedTitles(schema, originalParentTitle = '') { if (!schema || typeof schema !== 'object') { return From d3a345c760ef2060c1abc3f7141419f2d974dfb3 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 22:39:58 +0100 Subject: [PATCH 39/41] fix(docs): make all getting-started sidebar items consistent links H3 subheadings under Steps 1, 6 and Foundational knowledge caused the theme to render those H2s as non-interactive spans rather than anchor links. Adding excludes them from sidebar generation so every top-level step appears as a link. --- docs/getting-started.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index c535b299b..b19c38567 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -12,17 +12,17 @@ When developing with forms-engine-plugin, you should favour development using th 2. Use configuration-driven advanced functionality to integrate with backends and dynamically change page content (page events, page templates) 3. Use custom views, custom components and page controllers to implement highly tailored and niche logic (custom Nunjucks, custom Javascript) -### Contributing back to forms-engine-plugin +### Contributing back to forms-engine-plugin When you build custom components and page controllers, they might be useful for other teams in Defra to utilise. For example, many teams collect CPH numbers but have no way to validate it's correct. Rather than creating a new CPH number component and letting it sit in your codebase for just your team, see our [contribution guide](./contributing) to learn how to contribute this back to forms-engine-plugin for everyone to benefit from. ## Step 1: Add forms-engine-plugin as a dependency -### Installation +### Installation `npm install @defra/forms-engine-plugin --save` -### Dependencies +### Dependencies The following are [plugin dependencies]() that are required to be registered with hapi: @@ -197,7 +197,7 @@ The configuration defines several top-level elements: To understand the full set of options available to you, consult our [schema documentation](https://defra.github.io/forms-engine-plugin/schemas/). Specifically, the [form definition schema](https://defra.github.io/forms-engine-plugin/schemas/form-definition-v2-payload-schema). -### Config +### Config #### Pages From 0c934e05bf2b1831d1171f8465ebce3e2c692613 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 22:44:41 +0100 Subject: [PATCH 40/41] docs(getting-started): simplify and tighten prose - Rewrite Foundational knowledge section for conciseness - Promote "Optional dependencies" to a proper heading - Align CDP wording in Step 4 (both items now say "If you are on CDP") - Remove redundant "as files" in Step 6 intro - Fix trailing "and" on conditions bullet, expand pages bullet description --- docs/getting-started.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index b19c38567..081628adc 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2,19 +2,19 @@ ## Foundational knowledge -forms-engine-plugin is a hapi plugin for a frontend service, which allows development teams to construct forms using configuration and minimal code. Forms are closely based on the knowledge, components and patterns from the GDS Design System. Forms should remain as lightweight as possible, with business logic being implemented in a backend/BFF API and forms-engine-plugin used as a simple presentation layer. +forms-engine-plugin is a hapi plugin that lets teams build GOV.UK forms using configuration and minimal code, based on GDS Design System patterns. Keep business logic in a backend/BFF API and treat the plugin as a thin presentation layer. -You should aim, wherever possible, to utilise the existing behaviours of forms-engine-plugin. Our team puts a lot of effort into development, user testing and accessibility testing to ensure the forms created with forms-engine-plugin will be of a consistently high quality. Where your team introduces custom behaviour, such as custom components or custom pages, this work will now need to be done by your team. Where possible, favour fixing something upstream in the plugin so many teams can benefit from the work we do. Then, if you still need custom behaviour - go for it! forms-engine-plugin is designed to be extended, just be wise with how you spend your efforts. +Prefer built-in behaviour wherever possible — it's been developed, user-tested, and accessibility-tested to a consistent standard. If you need something the plugin doesn't support, consider fixing it upstream so all teams benefit. When custom code is genuinely the right call, the plugin is designed to be extended. -When developing with forms-engine-plugin, you should favour development using the below priority order. This will ensure your team is writing the minimum amount of code, focusing your efforts on custom code where the requirements are niche and there is value. +Favour this priority order: -1. Use out-of-the box forms-engine-plugin components and page types (components, controllers) -2. Use configuration-driven advanced functionality to integrate with backends and dynamically change page content (page events, page templates) -3. Use custom views, custom components and page controllers to implement highly tailored and niche logic (custom Nunjucks, custom Javascript) +1. Built-in components and page types +2. Configuration-driven features (page events, templates) to integrate with backends +3. Custom views, components, and controllers for niche requirements ### Contributing back to forms-engine-plugin -When you build custom components and page controllers, they might be useful for other teams in Defra to utilise. For example, many teams collect CPH numbers but have no way to validate it's correct. Rather than creating a new CPH number component and letting it sit in your codebase for just your team, see our [contribution guide](./contributing) to learn how to contribute this back to forms-engine-plugin for everyone to benefit from. +Custom components you build may be useful to other Defra teams. See the [contribution guide](./contributing) to share them upstream rather than keeping them in your own codebase. ## Step 1: Add forms-engine-plugin as a dependency @@ -40,7 +40,7 @@ Additional npm dependencies that you will need are: - [nunjucks](https://www.npmjs.com/package/nunjucks) - [templating engine](https://mozilla.github.io/nunjucks/) used by GOV.UK design system - [govuk-frontend](https://www.npmjs.com/package/govuk-frontend) - [code](https://github.com/alphagov/govuk-frontend) you need to build a user interface for government platforms and services -Optional dependencies +### Optional dependencies `npm install @hapi/inert --save` @@ -134,7 +134,7 @@ await server.start() 1. Import forms-engine-plugin's styling -If you are using the CDP templates, you just need to ensure your `src/client/stylesheets/application.scss` file contains: +If you are on CDP, ensure your `src/client/stylesheets/application.scss` file contains: ```sass @use "pkg:@defra/forms-engine-plugin"; @@ -184,16 +184,16 @@ GOOGLE_ANALYTICS_TRACKING_ID='12345' ## Step 6: Creating and loading a form -Forms in forms-engine-plugin are represented by a configuration object called a "form definition". The form definition can be stored in a location and format of your choosing by providing a `formsService` as a registration option. If you are using our 'loader' pattern as recommended in step 2, you will likely be writing YAML or JSON files in your repository as files. +Forms in forms-engine-plugin are represented by a configuration object called a "form definition". The form definition can be stored in a location and format of your choosing by providing a `formsService` as a registration option. If you are using our 'loader' pattern as recommended in step 2, you will likely be writing YAML or JSON files in your repository. Our examples primarily use JSON. If you are using YAML, simply convert the data structure from JSON to YAML and the examples will still work. The configuration defines several top-level elements: -- `pages` - includes a `path`, `title` +- `pages` - the form journey, each representing a single web page with a path, title, and components - `components` - one or more questions on a page -- `conditions` - used to conditionally show and hide pages and -- `lists` - data used to in selection fields like [Select](https://design-system.service.gov.uk/components/select/), [Checkboxes](https://design-system.service.gov.uk/components/checkboxes/) and [Radios](https://design-system.service.gov.uk/components/radios/) +- `conditions` - used to conditionally show and hide pages +- `lists` - data used in selection fields like [Select](https://design-system.service.gov.uk/components/select/), [Checkboxes](https://design-system.service.gov.uk/components/checkboxes/) and [Radios](https://design-system.service.gov.uk/components/radios/) To understand the full set of options available to you, consult our [schema documentation](https://defra.github.io/forms-engine-plugin/schemas/). Specifically, the [form definition schema](https://defra.github.io/forms-engine-plugin/schemas/form-definition-v2-payload-schema). From 7932c8c2a081c2852ef524222184bfdfb34d2fb0 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 1 May 2026 22:51:30 +0100 Subject: [PATCH 41/41] fix(docs): correct code bugs and add missing SESSION_COOKIE_PASSWORD - Add missing node:path, @hapi/vision and nunjucks imports to Step 3 - Add @hapi/vision registration block (was listed as a dependency but never shown being registered in the full example) - Fix viewContext return object: missing comma and string key syntax - Add SESSION_COOKIE_PASSWORD as a required env var in Step 5 --- docs/getting-started.md | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 081628adc..e531b5b7a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -67,11 +67,14 @@ await server.register({ Full example: ```javascript +import { join } from 'node:path' import hapi from '@hapi/hapi' +import vision from '@hapi/vision' import yar from '@hapi/yar' import crumb from '@hapi/crumb' import inert from '@hapi/inert' import pino from 'hapi-pino' +import nunjucks from 'nunjucks' import plugin from '@defra/forms-engine-plugin' const server = hapi.server({ @@ -93,6 +96,23 @@ await server.register({ const paths = [join(config.get('appDir'), 'views')] +await server.register({ + plugin: vision, + options: { + engines: { + html: { + compile(path, { environment }) { + return (context) => nunjucks.compile(path, environment).render(context) + } + } + }, + path: paths, + compileOptions: { + environment: nunjucks.configure(paths) + } + } +}) + // Register the `forms-engine-plugin` await server.register({ plugin, @@ -120,8 +140,8 @@ await server.register({ const user = await userService.getUser(request.auth.credentials) return { - "greeting": "Hello" // available to render on a nunjucks page as {{ greeting }} - "username": user.username // available to render on a nunjucks page as {{ username }} + greeting: 'Hello', // available to render on a nunjucks page as {{ greeting }} + username: user.username // available to render on a nunjucks page as {{ username }} } } } @@ -156,6 +176,12 @@ Example: https://github.com/DEFRA/forms-runner/blob/24c5623946cdfddca593bcba8a68 ## Step 5: Environment variables +The following variable is always required: + +```shell +SESSION_COOKIE_PASSWORD=your-secret-password-at-least-32-chars +``` + Blocks marked with `# FEATURE: ` are optional and can be omitted if the feature is not used. ```shell