diff --git a/docs/contributing/coding-standards/components.md b/docs/contributing/coding-standards/components.md index 4277dfaeb2..ab24e3b7d4 100644 --- a/docs/contributing/coding-standards/components.md +++ b/docs/contributing/coding-standards/components.md @@ -13,7 +13,7 @@ When creating your component, you should create the following files in the compo - `README.md` - Summary documentation with links to the installation instructions and component documentation on - `_[component-name].scss` - An SCSS file to generate the styles for this component only. It delegates the CSS generation to the \_index.scss file. - `_index.scss` - The actual styles for the component that you can import in 2 ways - [on their own using `[component-name].scss`](https://frontend.design-system.service.gov.uk/importing-css-assets-and-javascript/#import-specific-parts-of-the-css) or [alongside other components in `components/_all.scss`](https://frontend.design-system.service.gov.uk/importing-css-assets-and-javascript/#import-specific-parts-of-the-css) -- `[component-name].yaml` - Lists the component's Nunjucks macro options and includes examples using these options. Both the options and examples are used to generate component documentation in the review app. The examples are also used to test component behaviour, and to generate [fixtures for testing alternative implementations of the design system](https://frontend.design-system.service.gov.uk/testing-your-html/). +- `options/data.mjs` - Lists the component's Nunjucks macro options (or params) and includes examples using these options. Both the options and examples are used to generate component documentation in the review app. The examples are also used to test component behaviour, and to generate [fixtures for testing alternative implementations of the design system](https://frontend.design-system.service.gov.uk/testing-your-html/). - `macro.njk` - The main entry point for rendering the component. It provides a `govuk[ComponentName](params)` macro, delegating render to the `template.njk` file - `template.njk` - The template used for rendering the component using any `params` provided to the macro - `template.test.js` - Tests to ensure the component renders as intended with its various options diff --git a/docs/contributing/coding-standards/nunjucks-api.md b/docs/contributing/coding-standards/nunjucks-api.md index 6ba3116d5d..32d736b144 100644 --- a/docs/contributing/coding-standards/nunjucks-api.md +++ b/docs/contributing/coding-standards/nunjucks-api.md @@ -4,9 +4,9 @@ We have chosen as Nunjucks as the templating language for GOV.UK Frontend compon To provide a level of consistency for developers we have standardised option names, their expected input, use and placement. There are exceptions, and if so they are documented accordingly. -The options (arguments) accepted by the component macro are specified in a `[component-name].yaml` file as `params`. Each option should have the following attributes: `name`, `type`, `required`, `description`. +The macro options (or params) accepted by the component macro are specified in an `options/data.mjs` file. Each option should have the following attributes: `name`, `type`, `required`, `description`. -An option can additionally contain `params` that denotes nested items in the option (see [breadcrumbs component](/packages/govuk-frontend/src/govuk/components/breadcrumbs/breadcrumbs.yaml#L6)) and `isComponent: true` where the option is another component (see [checkboxes component](/packages/govuk-frontend/src/govuk/components/checkboxes/checkboxes.yaml#L10)). +An option can additionally contain `params` that denotes nested items in the option (see [breadcrumbs component](/packages/govuk-frontend/src/govuk/components/breadcrumbs/options/params.mjs#L12)) and `isComponent: true` where the option is another component (see [checkboxes component](/packages/govuk-frontend/src/govuk/components/checkboxes/options/params.mjs#L16)). Component macro options are shipped as `macro-options.json` in `packages/govuk-frontend/dist`. diff --git a/docs/contributing/tasks.md b/docs/contributing/tasks.md index 89bc2507bd..bdc7cfd122 100644 --- a/docs/contributing/tasks.md +++ b/docs/contributing/tasks.md @@ -11,7 +11,7 @@ npm scripts are defined in `package.json`. These trigger a number of Gulp tasks. **`npm start` will trigger `npm run dev` that will:** - runs `npm run build` -- starts the review app, restarting when `.mjs`, `.json` or `.yaml` files change +- starts the review app, restarting when `.js`, `.mjs` or `.json` files change - compile again when frontend `.mjs` and `.scss` files change **`npm test` will do the following:** diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index fb8bee9d39..345ff15a3e 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -49,10 +49,10 @@ Check that: You should add an example to the review app if the existing examples do not reflect the changes you've made. -1. Open `packages/govuk-frontend/src/govuk/components//.yaml`, where `` is the component you've changed. -2. Add or update examples in the `examples` list. +1. Open `packages/govuk-frontend/src/govuk/components//options/data.mjs`, where `` is the component you've changed. +2. Add or update examples in the `examples` export. -If you've created a new component, create a new `packages/govuk-frontend/src/govuk//.yaml` file instead, where `` is the name of the component you've created. +If you've created a new component, create a new `packages/govuk-frontend/src/govuk//options/data.mjs` file instead, where `` is the name of the component you've created. ## 4. Test in supported browsers and assistive technology @@ -84,7 +84,7 @@ You should write new tests if you’ve created a new component, or changed the w If you're new to testing, see existing test files for examples of things to do. Do not let the tests keep you from submitting your contribution! If you're not sure which tests are needed or are having trouble updating them, submit your pull request anyway. We will help you create the tests and solve problems during code review. -Some test files use examples from each component’s `.yaml` file, for example `packages/govuk-frontend/src/govuk/components/button/button.yaml`. When you add or update these tests, you can use the existing examples or add new ones. +Some test files use examples from each component’s `options/data.mjs` file, for example `packages/govuk-frontend/src/govuk/components/button/options/data.mjs`. When you add or update these tests, you can use the existing examples or add new ones. Use `hidden: true` in a new example if you do not want to include the example in the review app. The example will still appear in our [test fixtures](http://frontend.design-system.service.gov.uk/testing-your-html/). diff --git a/docs/releasing/testing-and-linting.md b/docs/releasing/testing-and-linting.md index 65f21af103..8f2c1d5b8a 100644 --- a/docs/releasing/testing-and-linting.md +++ b/docs/releasing/testing-and-linting.md @@ -103,7 +103,7 @@ Tests should be written using ES modules (`*.mjs`) by default, but use CommonJS ### Component tests -We write functional tests for every component to check the output of our Nunjucks code. These are found in `template.test.js` files in each component directory. These Nunjucks tests render the component examples defined in the component yaml files, and assert that the HTML tags, attributes and classes are as expected. For example: checking that when you pass in an `id` to the component using the Nunjucks macro, it outputs the component with an `id` attribute equal to that value. +We write functional tests for every component to check the output of our Nunjucks code. These are found in `template.test.js` files in each component directory. These Nunjucks tests render the component examples defined in the component options, and assert that the HTML tags, attributes and classes are as expected. For example: checking that when you pass in an `id` to the component using the Nunjucks macro, it outputs the component with an `id` attribute equal to that value. If a component uses JavaScript, we also write functional tests in a `[component name].test.js` file, for example [checkboxes.test.js](/packages/govuk-frontend/src/govuk/components/checkboxes/checkboxes.test.js). These component tests check that interactions, such as a mouse click, have the expected result. diff --git a/jest.config.mjs b/jest.config.mjs index 443e66ad27..3d00f16b93 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -48,7 +48,7 @@ const config = { // Transform some `*.js` to compatible CommonJS ...Object.fromEntries( - ['slash'].map((packagePath) => [ + ['@govuk-frontend/lib/components', 'slash'].map((packagePath) => [ replacePathSepForRegex(`${packageResolveToPath(packagePath)}$`), [ 'babel-jest', diff --git a/packages/govuk-frontend/package.json b/packages/govuk-frontend/package.json index b86142de34..d68c0cec8e 100644 --- a/packages/govuk-frontend/package.json +++ b/packages/govuk-frontend/package.json @@ -52,6 +52,7 @@ "build": "npm run build:package", "build:package": "gulp build:package --color", "build:release": "gulp build:release --color", + "build:fixtures": "gulp fixtures --color", "build:stats": "npm run stats --workspace @govuk-frontend/stats", "build:types": "tsc --build tsconfig.build.json", "clean": "npm run clean:package", diff --git a/packages/govuk-frontend/tasks/build/package.unit.test.mjs b/packages/govuk-frontend/tasks/build/package.unit.test.mjs index 460b4758bc..cb793752d5 100644 --- a/packages/govuk-frontend/tasks/build/package.unit.test.mjs +++ b/packages/govuk-frontend/tasks/build/package.unit.test.mjs @@ -59,6 +59,9 @@ describe('packages/govuk-frontend/dist/', () => { '!**/*.test.*', '!**/__snapshots__/', '!**/__snapshots__/**', + '!**/govuk-prototype-kit.config.mjs', + '!**/options/examples.mjs', + '!**/options/params.mjs', '!**/tsconfig?(.build).json', '!README.md' ] @@ -67,8 +70,15 @@ describe('packages/govuk-frontend/dist/', () => { const listingExpected = listingSource .filter(filterPath(filterPatterns)) - // Removes GOV.UK Prototype kit config (moved to package top level) - .flatMap(mapPathTo(['**/govuk-prototype-kit.config.mjs'], () => [])) + // Replaces source component '**/options/data.mjs' with: + // - `fixtures.json` fixtures for tests + // - `macro-options.json` component options + .flatMap( + mapPathTo(['**/options/data.mjs'], ({ dir: requirePath }) => [ + join(requirePath, '../fixtures.json'), + join(requirePath, '../macro-options.json') + ]) + ) // All source `**/*.mjs` files compiled to ES modules .flatMap( @@ -125,16 +135,6 @@ describe('packages/govuk-frontend/dist/', () => { join(requirePath, `${name}.scss.map`) // with source map ]) ) - - // Replaces source component '*.yaml' with: - // - `fixtures.json` fixtures for tests - // - `macro-options.json` component options - .flatMap( - mapPathTo(['**/*.yaml'], ({ dir: requirePath }) => [ - join(requirePath, 'fixtures.json'), - join(requirePath, 'macro-options.json') - ]) - ) .sort() // Compare output files with '.npmignore' filter diff --git a/packages/govuk-frontend/tasks/fixtures.mjs b/packages/govuk-frontend/tasks/fixtures.mjs index aa236bb3c7..6f6f56cc93 100644 --- a/packages/govuk-frontend/tasks/fixtures.mjs +++ b/packages/govuk-frontend/tasks/fixtures.mjs @@ -1,3 +1,5 @@ +import { join } from 'path' + import { components, task } from '@govuk-frontend/tasks' import gulp from 'gulp' @@ -9,16 +11,21 @@ import gulp from 'gulp' export const compile = (options) => gulp.series( /** - * Generate GOV.UK Frontend fixtures.json from ${componentName}.yaml + * Generate GOV.UK Frontend fixtures.json from ${componentName}/options/data.mjs */ task.name('compile:fixtures', () => - components.generateFixtures('**/*.yaml', options) + components.generateFixtures({ + srcPath: options.srcPath, + destPath: join(options.destPath, 'govuk/components') + }) ), /** - * Generate GOV.UK Frontend macro-options.json from ${componentName}.yaml + * Generate GOV.UK Frontend macro-options.json from ${componentName}/options/data.mjs */ task.name('compile:macro-options', () => - components.generateMacroOptions('**/*.yaml', options) + components.generateMacroOptions({ + destPath: join(options.destPath, 'govuk/components') + }) ) ) diff --git a/packages/govuk-frontend/tasks/watch.mjs b/packages/govuk-frontend/tasks/watch.mjs index 08f6117a81..407b0b2084 100644 --- a/packages/govuk-frontend/tasks/watch.mjs +++ b/packages/govuk-frontend/tasks/watch.mjs @@ -1,10 +1,11 @@ import { join } from 'path' +import { paths } from '@govuk-frontend/config' import { npm, task } from '@govuk-frontend/tasks' import gulp from 'gulp' import slash from 'slash' -import { assets, fixtures, scripts, styles, templates } from './index.mjs' +import { assets, scripts, styles, templates } from './index.mjs' /** * Watch task @@ -76,7 +77,10 @@ export const watch = (options) => */ task.name('compile:js watch', () => gulp.watch( - '**/*.{cjs,js,mjs}', + [ + '**/*.{cjs,js,mjs}', // Watch all script files + `!**/options/**` // Except component options + ], { cwd: options.srcPath, ignored: ['**/*.test.*'] }, // Run JavaScripts compile @@ -89,11 +93,13 @@ export const watch = (options) => */ task.name('compile:fixtures watch', () => gulp.watch( - 'govuk/components/*/*.yaml', + '**/options/**', { cwd: options.srcPath }, - // Run fixtures compile - fixtures(options) + // Build fixtures and macro options via npm script otherwise + // imported component data is cached by the ES module loader + // https://nodejs.org/api/esm.html#no-requirecache + npm.script('build:fixtures', ['--silent'], { basePath: paths.package }) ) ), diff --git a/shared/lib/components.js b/shared/lib/components.js index 81b4bfbade..442b01e401 100644 --- a/shared/lib/components.js +++ b/shared/lib/components.js @@ -1,11 +1,16 @@ -const { dirname, join } = require('path') +const { dirname, join, relative } = require('path') const { pathToFileURL } = require('url') const nunjucks = require('nunjucks') const { outdent } = require('outdent') +const slash = require('slash') const { getListing, getDirectories } = require('./files') -const { packageTypeToPath, componentNameToMacroName } = require('./names') +const { + packageNameToPath, + packageTypeToPath, + componentNameToMacroName +} = require('./names') // Nunjucks default environment const env = nunjucksEnv() @@ -43,6 +48,28 @@ function nunjucksEnv(searchPaths = [], nunjucksOptions = {}, packageOptions) { }) } +/** + * Load single component data (from source) + * + * @param {string} componentName - Component name + * @returns {Promise} Component data + */ +async function getComponentData(componentName) { + const componentDataPath = join( + packageNameToPath('govuk-frontend'), + `src/govuk/components/${componentName}/options/data.mjs` + ) + + const { default: componentData } = await import( + slash(relative(__dirname, componentDataPath)) + ) + + return { + name: componentName, + ...componentData + } +} + /** * Load single component fixtures * @@ -274,6 +301,7 @@ function renderTemplate(templatePath, options) { } module.exports = { + getComponentData, getComponentFixtures, getComponentsFixtures, getComponentFiles, diff --git a/shared/tasks/components.mjs b/shared/tasks/components.mjs index b8f3cfc291..8945b2555d 100644 --- a/shared/tasks/components.mjs +++ b/shared/tasks/components.mjs @@ -1,8 +1,12 @@ -import { basename, dirname, join } from 'path' +import { join } from 'path' import { paths } from '@govuk-frontend/config' -import { nunjucksEnv, render } from '@govuk-frontend/lib/components' -import { getListing, getYaml } from '@govuk-frontend/lib/files' +import { + nunjucksEnv, + getComponentData, + getComponentNames, + render +} from '@govuk-frontend/lib/components' import slug from 'slug' import { files } from './index.mjs' @@ -10,27 +14,19 @@ import { files } from './index.mjs' /** * Generate fixtures.json from component data * - * @param {AssetEntry[0]} pattern - Path to ${componentName}.yaml * @param {Pick} options - Asset options */ -export async function generateFixtures(pattern, { srcPath, destPath }) { - const componentDataPaths = await getListing(pattern, { - cwd: srcPath - }) +export async function generateFixtures({ srcPath, destPath }) { + const componentNames = await getComponentNames() - // Loop component data paths - const fixtures = componentDataPaths.map(async (componentDataPath) => { - const fixture = await generateFixture(componentDataPath, { srcPath }) + // Loop component names + const fixtures = componentNames.map(async (componentName) => { + const fixture = await generateFixture(componentName, { srcPath }) - // Write to destination - await files.write(componentDataPath, { + // Write fixtures.json to destination + await files.write(join(componentName, 'fixtures.json'), { destPath, - // Rename to fixtures.json - filePath({ dir }) { - return join(dir, 'fixtures.json') - }, - // Add fixtures as JSON (formatted) async fileContents() { return JSON.stringify(fixture, null, 4) @@ -48,32 +44,40 @@ export async function generateFixtures(pattern, { srcPath, destPath }) { /** * Generate macro-options.json from component data * - * @param {AssetEntry[0]} pattern - Path to ${componentName}.yaml - * @param {Pick} options - Asset options + * @param {Pick} options - Asset options */ -export async function generateMacroOptions(pattern, { srcPath, destPath }) { - const componentDataPaths = await getListing(pattern, { - cwd: srcPath - }) +export async function generateMacroOptions({ destPath }) { + const componentNames = await getComponentNames() + + /** + * Convert params object to macro options array + * + * @param {ComponentData["params"]} [params] - Params object with name keys + * @returns {MacroOptionFixture["params"] | undefined} Params array of objects + */ + function paramsToMacroOptions(params) { + if (!params) { + return + } - // Loop component data paths - const macroOptions = componentDataPaths.map(async (componentDataPath) => { - const macroOption = await generateMacroOption(componentDataPath, { - srcPath - }) + return Object.entries(params).map(([name, param]) => ({ + name, + ...param, + params: paramsToMacroOptions(param.params) + })) + } - // Write to destination - await files.write(componentDataPath, { - destPath, + // Loop component names + const macroOptions = componentNames.map(async (componentName) => { + const { params } = await getComponentData(componentName) - // Rename to 'macro-options.json' - filePath({ dir }) { - return join(dir, 'macro-options.json') - }, + // Write macro-options.json to destination + await files.write(join(componentName, 'macro-options.json'), { + destPath, // Add macro options as JSON (formatted) async fileContents() { - return JSON.stringify(macroOption, null, 4) + return JSON.stringify(paramsToMacroOptions(params), null, 4) } }) }) @@ -86,28 +90,20 @@ export async function generateMacroOptions(pattern, { srcPath, destPath }) { } /** - * Component fixtures YAML to JSON + * Component fixtures to JSON * - * @param {string} componentDataPath - Path to ${componentName}.yaml + * @param {string} componentName - Component name * @param {Pick} options - Asset options * @returns {Promise} Component fixtures object */ -async function generateFixture(componentDataPath, options) { - /** @type {ComponentData} */ - const json = await getYaml(join(options.srcPath, componentDataPath)) - - if (!json?.examples) { - throw new Error(`${componentDataPath} is missing "examples"`) - } +async function generateFixture(componentName, options) { + const componentData = await getComponentData(componentName) // Nunjucks environment const env = nunjucksEnv([options.srcPath]) - // Nunjucks template - const componentName = basename(dirname(componentDataPath)) - // Loop examples - const fixtures = json.examples.map( + const fixtures = componentData.examples.map( /** * @param {ComponentExample} example - Component example * @returns {Promise} Component fixture @@ -145,28 +141,10 @@ async function generateFixture(componentDataPath, options) { return { component: componentName, fixtures: await Promise.all(fixtures), - previewLayout: json.previewLayout + previewLayout: componentData.previewLayout } } -/** - * Macro options YAML to JSON - * - * @param {string} componentDataPath - Path to ${componentName}.yaml - * @param {Pick} options - Asset options - * @returns {Promise} Component macro options - */ -async function generateMacroOption(componentDataPath, options) { - /** @type {ComponentData} */ - const json = await getYaml(join(options.srcPath, componentDataPath)) - - if (!json?.params) { - throw new Error(`${componentDataPath} is missing "params"`) - } - - return json.params -} - /** * @typedef {import('./assets.mjs').AssetEntry} AssetEntry * @typedef {import('@govuk-frontend/lib/components').ComponentData} ComponentData @@ -175,3 +153,11 @@ async function generateMacroOption(componentDataPath, options) { * @typedef {import('@govuk-frontend/lib/components').ComponentFixture} ComponentFixture * @typedef {import('@govuk-frontend/lib/components').ComponentFixtures} ComponentFixtures */ + +/** + * Macro options fixture with params as arrays + * (used by the Design System website) + * + * @typedef {Omit & { name: string, params: MacroOptionNestedFixture[] }} MacroOptionFixture + * @typedef {Omit & { name: string, params?: MacroOptionNestedFixture[] }} MacroOptionNestedFixture + */