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/docs/features/index.md b/docs/features/index.md index e976a9492..7c9964d41 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -1,11 +1,21 @@ # 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. -## [Configuration-based Features](./features/configuration-based) +## [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. + +## 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/docs/getting-started.md b/docs/getting-started.md index c535b299b..e531b5b7a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2,27 +2,27 @@ ## 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 +### 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 -### Installation +### Installation `npm install @defra/forms-engine-plugin --save` -### Dependencies +### Dependencies The following are [plugin dependencies]() that are required to be registered with hapi: @@ -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` @@ -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 }} } } } @@ -134,7 +154,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"; @@ -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 @@ -184,20 +210,20 @@ 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). -### Config +### Config #### Pages diff --git a/docs/plugin-options.md b/docs/plugin-options.md index cd72deabb..1c6c549be 100644 --- a/docs/plugin-options.md +++ b/docs/plugin-options.md @@ -2,29 +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 +## 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 @@ -34,9 +30,50 @@ 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. -### nunjucks configuration +```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 The `nunjucks` option is required and configures the template engine paths and layout. @@ -184,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({ @@ -207,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({ @@ -288,3 +325,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/docusaurus.config.cjs b/docusaurus.config.cjs index 2d5fa4f8c..7de764166 100644 --- a/docusaurus.config.cjs +++ b/docusaurus.config.cjs @@ -76,41 +76,19 @@ const config = { href: '/features', sidebar: [ { text: 'Overview', href: '/features' }, + { text: 'Components', href: '/features/components' }, + { text: 'Page Types', href: '/features/pages' }, { - text: 'Configuration-based', - href: '/features/configuration-based', + text: 'Advanced', + href: '/features', 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', - 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' } ] } diff --git a/package.json b/package.json index f013196c0..7e698b586 100644 --- a/package.json +++ b/package.json @@ -44,9 +44,10 @@ "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-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 && 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", diff --git a/scripts/component-metadata.json b/scripts/component-metadata.json new file mode 100644 index 000000000..b4ae31ec3 --- /dev/null +++ b/scripts/component-metadata.json @@ -0,0 +1,125 @@ +{ + "components": { + "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": "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`.", + "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`).", + "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. 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." + }, + "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." + ], + "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.", + "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 new file mode 100644 index 000000000..af5e2acce --- /dev/null +++ b/scripts/generate-component-docs.js @@ -0,0 +1,1087 @@ +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') +) + +/** + * @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, + (match, letter, offset) => (offset > 0 ? '-' : '') + letter.toLowerCase() + ) +} + +/** + * @param {string} name + * @returns {string} + */ +export function toLabel(name) { + const words = name + .replace(/([A-Z])/g, ' $1') + .trim() + .split(' ') + 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: + * `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() + if (t.startsWith('{')) return 'object' + if (t.includes('LanguageMessages')) return 'object' + if (t.includes('ListTypeContent') || t.includes('ListTypeOption')) + return 'string' + const withoutUndefined = t.replace(/\s*\|\s*undefined/g, '').trim() + if (withoutUndefined.endsWith('[]')) { + return simplifyType(withoutUndefined.slice(0, -2)) + '[]' + } + 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) { + /** @type {PropEntry[]} */ + 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 +} + +/** + * 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, + sourceFile, + allInterfaces, + accessKey, + depth = 0 +) { + if (depth > 6) return [] + const props = [] + + if (ts.isIntersectionTypeNode(typeNode)) { + 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 + ) + ) + } + } + } + } + } + + return props +} + +/** + * 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 + * @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 +} + +/** + * @param {string} dtsPath + * @returns {{ sourceFile: import('typescript').SourceFile, allInterfaces: Record }} + */ +function parseSourceFile(dtsPath) { + const content = fs.readFileSync(dtsPath, 'utf-8') + const sourceFile = ts.createSourceFile( + dtsPath, + content, + ts.ScriptTarget.Latest, + true + ) + /** @type {Record} */ + const allInterfaces = {} + ts.forEachChild(sourceFile, (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). + * @param {string} dtsPath + * @returns {Record} + */ +function parseComponentInterfaces(dtsPath) { + const { sourceFile, allInterfaces } = parseSourceFile(dtsPath) + + /** @type {Record} */ + const result = {} + + ts.forEachChild(sourceFile, (node) => { + if ( + !ts.isInterfaceDeclaration(node) || + !node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + ) + return + + const name = node.name.text + /** @type {PropEntry[]} */ + let rawOptions = [] + /** @type {PropEntry[]} */ + let schema = [] + /** @type {PropEntry[]} */ + const rawProps = [] + + for (const [propName, member] of resolveInterfaceMembers( + node, + allInterfaces, + sourceFile + )) { + if (propName === 'options' && member.type) { + rawOptions = collectProps( + member.type, + sourceFile, + allInterfaces, + 'options' + ) + } 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 }) + } + } + + // 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)) + + const props = rawProps + .filter((p) => p.type !== 'undefined') + .sort((a, b) => a.name.localeCompare(b.name)) + + result[name] = { options, schema, props } + }) + + return result +} + +/** + * 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 path accumulated so far, e.g. `'repeat.options'` + * @param {number} [depth] + * @returns {{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 +} + +/** + * 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 }} + */ +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 + ) + + /** @type {Record} */ + const controllerTypeValues = {} + /** @type {Record} */ + 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 + ) + + /** @type {Record} */ + const controllerMap = {} + /** @type {Record} */ + 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 } +} + +/** + * 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} + */ +function parsePageInterfaces(dtsPath, controllerMap, pathHints) { + const { sourceFile, allInterfaces } = parseSourceFile(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 + }) + } + } + + /** @type {Record} */ + const result = {} + + for (const [controllerKey, interfaceName] of Object.entries(controllerMap)) { + const iface = allInterfaces[interfaceName] + if (!iface) { + result[controllerKey] = { + props: [], + examplePath: pathHints[controllerKey] ?? '/page-path' + } + continue + } + + const props = [] + + for (const member of iface.members) { + if (!ts.isPropertySignature(member)) continue + const propName = member.name.getText(sourceFile) + const optional = !!member.questionToken + + if (member.type && ts.isTypeReferenceNode(member.type)) { + const refName = member.type.typeName.getText(sourceFile) + if (allInterfaces[refName]) { + props.push( + ...flattenInterface( + allInterfaces[refName], + allInterfaces, + sourceFile, + propName, + 0 + ) + ) + continue + } + } + + const rawType = member.type ? member.type.getText(sourceFile) : 'unknown' + props.push({ + name: propName, + type: simplifyType(rawType), + optional + }) + } + + // 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' && 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' + } + } + + return result +} + +/** + * 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') + const sourceFile = ts.createSourceFile( + enumsDtsPath, + content, + ts.ScriptTarget.Latest, + true + ) + /** @type {string[]} */ + 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, '')) + } + } + }) + + return order +} + +/** + * 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') + const sourceFile = ts.createSourceFile( + typesDtsPath, + content, + ts.ScriptTarget.Latest, + true + ) + + /** @type {Record} */ + const categories = {} + + /** + * @param {import('typescript').TypeNode} typeNode + * @returns {string[]} + */ + function namesFromUnion(typeNode) { + if (ts.isUnionTypeNode(typeNode)) { + return typeNode.types.flatMap(namesFromUnion) + } + if (ts.isTypeReferenceNode(typeNode)) { + return [typeNode.typeName.getText(sourceFile)] + } + return [] + } + + 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 +} + +/** + * @param {string} name + * @param {Record} parsedCategories + * @returns {string} + */ +export function deriveCategory(name, parsedCategories) { + return parsedCategories[name] ?? 'input' +} + +/** + * 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 + 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. + * @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', + title: 'Question title' + } + + for (const prop of props) { + example[prop.name] = placeholderForType(prop.type) + } + + 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 +} + +/** + * @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) + const { options = [], schema = [], props = [] } = interfaceData + + const links = metadata.componentLinks?.[componentName] ?? [] + + const lines = [ + `---`, + `sidebar_label: "${label}"`, + `sidebar_position: ${sidebarPosition}`, + `---`, + ``, + `# ${label}`, + ``, + description, + `` + ] + + for (const text of links) { + lines.push(text, ``) + } + + lines.push( + `## JSON definition`, + ``, + '```json', + JSON.stringify(generateExample(componentName, interfaceData), null, 2), + '```', + `` + ) + + 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 |`) + lines.push(`|----------|------|----------|-------------|`) + for (const prop of options) { + const desc = metadata.properties[prop.name] ?? '' + lines.push( + `| \`${prop.name}\` | \`${prop.type}\` | ${prop.optional ? 'No' : 'Yes'} | ${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') +} + +/** + * Set a value at a dotted path within an object, creating nested objects as needed. + * @param {Record} obj + * @param {string} dotPath + * @param {unknown} value + */ +export 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 +} + +/** + * 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} + */ +export 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} + */ +export 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 + * @param {string} [examplePath] + * @returns {Record} + */ +export function generatePageExample( + controllerKey, + uniqueProps, + examplePath = '/page-path' +) { + const controllerValue = + controllerKey === 'PageController' ? null : controllerKey + const path = examplePath + + const example = /** @type {Record} */ ({ path }) + if (controllerValue) example.controller = controllerValue + example.title = 'Page title' + + // 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)) + } + + return example +} + +/** + * @param {string} controllerKey + * @param {Array<{name: string, type: string, optional: boolean}>} uniqueProps + * @param {string} examplePath + * @param {number} sidebarPosition + */ +function generatePageMd( + controllerKey, + uniqueProps, + examplePath, + sidebarPosition +) { + const description = metadata.pages[controllerKey] + if (!description) return null + + const label = controllerLabel(controllerKey) + const isDefault = controllerKey === 'PageController' + const links = metadata.pageLinks?.[controllerKey] ?? [] + + const lines = [ + `---`, + `sidebar_label: "${label}"`, + `sidebar_position: ${sidebarPosition}`, + `---`, + ``, + `# ${label}`, + ``, + description, + `` + ] + + for (const text of links) { + lines.push(text, ``) + } + + 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( + generatePageExample(controllerKey, uniqueProps, examplePath), + null, + 2 + ), + '```', + `` + ) + + if (uniqueProps.length > 0) { + lines.push(`## Configuration`, ``) + lines.push(`| Property | Type | Required | Description |`) + lines.push(`|----------|------|----------|-------------|`) + for (const prop of uniqueProps) { + const desc = metadata.pageProperties?.[prop.name] ?? '' + lines.push( + `| \`${prop.name}\` | \`${prop.type}\` | ${prop.optional ? 'No' : 'Yes'} | ${desc} |` + ) + } + lines.push(``) + } + + 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: [] }, + content: { label: 'Content components', items: [] } + } + + for (const name of componentNames) { + const category = categories[name] ?? 'input' + const label = toLabel(name) + const slug = toKebabCase(name) + const description = metadata.components[name] ?? '' + groups[category]?.items.push({ label, slug, 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.`, + `` + ] + + 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 ['selection', 'content']) { + 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(``) + } + + return lines.join('\n') +} + +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 [key, description] of Object.entries(metadata.pages)) { + const label = controllerLabel(key) + const slug = controllerSlug(key) + lines.push(`- [**${label}**](./${slug}.md) — ${description}`) + } + + lines.push(``) + return lines.join('\n') +} + +function main() { + const componentsDtsPath = path.join( + formsModelTypesDir, + 'components/types.d.ts' + ) + const enumsDtsPath = path.join(formsModelTypesDir, 'components/enums.d.ts') + const formDefinitionDtsPath = path.join( + formsModelTypesDir, + 'form/form-definition/types.d.ts' + ) + const pagesEnumsDtsPath = path.join(formsModelTypesDir, 'pages/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) + } + + 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) + } + + 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 }) + } + fs.mkdirSync(componentsOutputDir, { recursive: true }) + + if (fs.existsSync(pagesOutputDir)) { + fs.rmSync(pagesOutputDir, { recursive: true, force: true }) + } + fs.mkdirSync(pagesOutputDir, { recursive: true }) + + // Parse sources + const interfaces = parseComponentInterfaces(componentsDtsPath) + const componentOrder = parseComponentOrder(enumsDtsPath) + const parsedCategories = parseCategories(componentsDtsPath) + const { controllerMap, pathHints } = parseControllerMap( + formDefinitionDtsPath, + pagesEnumsDtsPath + ) + const pageInterfaces = parsePageInterfaces( + formDefinitionDtsPath, + controllerMap, + pathHints + ) + + // Build full category map + /** @type {Record} */ + const categories = {} + for (const name of componentOrder) { + categories[name] = deriveCategory(name, parsedCategories) + } + + // 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 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(componentOrder, categories) + ) + + // 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} — is it in the ControllerType enum?` + ) + } + 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) + } + } + + fs.writeFileSync(path.join(pagesOutputDir, 'index.md'), generatePagesIndex()) + + console.log( + `Generated ${componentOrder.length} component pages and ${Object.keys(metadata.pages).length} page type pages.` + ) +} + +// 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..228996dbf --- /dev/null +++ b/scripts/generate-component-docs.test.js @@ -0,0 +1,317 @@ +// @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: [], + props: [] + }) + 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: [], + props: [] + }) + 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: [], + props: [] + }) + 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' } + ], + props: [] + }) + expect(result.schema).toEqual({ min: 0 }) + }) + + it('includes top-level props in the example using placeholders', () => { + const result = generateExample('Html', { + options: [], + schema: [], + props: [{ name: 'content', optional: false, type: 'string' }] + }) + expect(result).toHaveProperty('content', '') + }) + + it('includes list as a top-level prop', () => { + const result = generateExample('RadiosField', { + options: [], + schema: [], + props: [{ name: 'list', optional: false, type: 'string' }] + }) + expect(result).toHaveProperty('list', '') + }) + + it('omits options and schema keys when both are empty', () => { + const result = generateExample('HiddenField', { + options: [], + schema: [], + props: [] + }) + 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 the supplied examplePath', () => { + const result = generatePageExample('StartPageController', [], '/start') + expect(result.path).toBe('/start') + }) + + it('defaults to /page-path when examplePath is omitted', () => { + const result = generatePageExample('PageController', []) + expect(result.path).toBe('/page-path') + }) + + 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 "input" as the default category', () => { + expect(deriveCategory('TextField', {})).toBe('input') + expect(deriveCategory('PaymentField', {})).toBe('input') + expect(deriveCategory('EastingNorthingField', {})).toBe('input') + }) + }) +}) diff --git a/scripts/generate-schema-docs.js b/scripts/generate-schema-docs.js index 15ecb50f5..4b92b408a 100644 --- a/scripts/generate-schema-docs.js +++ b/scripts/generate-schema-docs.js @@ -53,6 +53,80 @@ export function getSchemaFiles() { .sort((a, b) => a.localeCompare(b)) } +/** + * @param {JsonSchema} schema + * @param {string} 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]) { + simplifyNestedTitles(/** @type {JsonSchema} */ (sub), parentTitle) + } + } + } +} + +/** + * @param {JsonSchema} schema + * @param {string} parentTitle + */ +function recurseSchemaChildren(schema, parentTitle) { + recurseComposedTypes(schema, parentTitle) + + if (schema.items) { + if (Array.isArray(schema.items)) { + for (const item of schema.items) { + simplifyNestedTitles(/** @type {JsonSchema} */ (item), parentTitle) + } + } else { + simplifyNestedTitles(schema.items, parentTitle) + } + } + + if (schema.properties) { + for (const propSchema of Object.values(schema.properties)) { + simplifyNestedTitles(/** @type {JsonSchema} */ (propSchema), 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 + } + + 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 @@ -65,6 +139,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 }