diff --git a/packages/govuk-frontend-review/browsersync.config.js b/packages/govuk-frontend-review/browsersync.config.js index aa1a6b7f32..5a8e85a685 100644 --- a/packages/govuk-frontend-review/browsersync.config.js +++ b/packages/govuk-frontend-review/browsersync.config.js @@ -1,7 +1,7 @@ const { join } = require('path') const { paths, ports } = require('govuk-frontend-config') -const { packageNameToPath } = require('govuk-frontend-lib/names') +const { packageTypeToPath } = require('govuk-frontend-lib/names') /** * Browsersync config @@ -25,7 +25,10 @@ module.exports = { join(paths.app, 'dist/javascripts/**/*.js'), join(paths.app, 'dist/stylesheets/**/*.css'), join(paths.app, 'src/views/**/*.njk'), - packageNameToPath('govuk-frontend', 'dist/govuk/**/*.njk') + packageTypeToPath('govuk-frontend', { + modulePath: '**/*.njk', + moduleRoot: paths.app + }) ], ignore: ['**/*.test.*'], diff --git a/packages/govuk-frontend-review/nodemon.json b/packages/govuk-frontend-review/nodemon.json index c23bcf23ce..31d12be622 100644 --- a/packages/govuk-frontend-review/nodemon.json +++ b/packages/govuk-frontend-review/nodemon.json @@ -10,7 +10,7 @@ "events": { "restart": "browser-sync reload --config browsersync.config.js" }, - "ext": "mjs,json,yaml", + "ext": "js,mjs,json,yaml", "quiet": true, "signal": "SIGINT" } diff --git a/packages/govuk-frontend-review/rollup.config.mjs b/packages/govuk-frontend-review/rollup.config.mjs index 342bc1ad3f..5f11ad618b 100644 --- a/packages/govuk-frontend-review/rollup.config.mjs +++ b/packages/govuk-frontend-review/rollup.config.mjs @@ -1,5 +1,6 @@ import resolve from '@rollup/plugin-node-resolve' import terser from '@rollup/plugin-terser' +import { paths } from 'govuk-frontend-config' import { defineConfig } from 'rollup' /** @@ -38,6 +39,6 @@ export default defineConfig(({ i: input }) => ({ * Input plugins */ plugins: [ - resolve() + resolve({ rootDir: paths.app }) ] })) diff --git a/packages/govuk-frontend-review/src/common/middleware/assets.mjs b/packages/govuk-frontend-review/src/common/middleware/assets.mjs index 3feb2e63c6..4df75ce9bb 100644 --- a/packages/govuk-frontend-review/src/common/middleware/assets.mjs +++ b/packages/govuk-frontend-review/src/common/middleware/assets.mjs @@ -2,7 +2,7 @@ import { join } from 'path' import express from 'express' import { paths } from 'govuk-frontend-config' -import { packageNameToPath } from 'govuk-frontend-lib/names' +import { packageTypeToPath } from 'govuk-frontend-lib/names' const router = express.Router() @@ -10,7 +10,7 @@ const router = express.Router() * Add middleware to serve static assets */ -router.use('/assets', express.static(packageNameToPath('govuk-frontend', 'dist/govuk/assets'))) +router.use('/assets', express.static(packageTypeToPath('govuk-frontend', { modulePath: 'assets', moduleRoot: paths.app }))) router.use('/javascripts', express.static(join(paths.app, 'dist/javascripts'))) router.use('/stylesheets', express.static(join(paths.app, 'dist/stylesheets'))) diff --git a/packages/govuk-frontend-review/src/common/middleware/vendor.mjs b/packages/govuk-frontend-review/src/common/middleware/vendor.mjs index 8c38b834aa..c1856c9059 100644 --- a/packages/govuk-frontend-review/src/common/middleware/vendor.mjs +++ b/packages/govuk-frontend-review/src/common/middleware/vendor.mjs @@ -1,3 +1,5 @@ +import { join } from 'path' + import express from 'express' import { packageNameToPath } from 'govuk-frontend-lib/names' @@ -7,6 +9,6 @@ const router = express.Router() * Add middleware to serve dependencies * from node_modules */ -router.use('/iframe-resizer/', express.static(packageNameToPath('iframe-resizer', 'js'))) +router.use('/iframe-resizer/', express.static(join(packageNameToPath('iframe-resizer'), 'js'))) export default router diff --git a/packages/govuk-frontend-review/src/common/nunjucks/globals/get-html-code.mjs b/packages/govuk-frontend-review/src/common/nunjucks/globals/get-html-code.mjs index f650260063..43f4f8df11 100644 --- a/packages/govuk-frontend-review/src/common/nunjucks/globals/get-html-code.mjs +++ b/packages/govuk-frontend-review/src/common/nunjucks/globals/get-html-code.mjs @@ -1,4 +1,5 @@ -import { packageNameToPath } from 'govuk-frontend-lib/names' +import { paths } from 'govuk-frontend-config' +import { packageTypeToPath } from 'govuk-frontend-lib/names' import beautify from 'js-beautify' /** @@ -9,7 +10,10 @@ import beautify from 'js-beautify' * @returns {string} Nunjucks code */ export function getHTMLCode (componentName, params) { - const templatePath = packageNameToPath('govuk-frontend', `dist/govuk/components/${componentName}/template.njk`) + const templatePath = packageTypeToPath('govuk-frontend', { + modulePath: `components/${componentName}/template.njk`, + moduleRoot: paths.app + }) // Render to HTML const html = this.env.render(templatePath, { params }).trim() diff --git a/packages/govuk-frontend-review/src/common/nunjucks/index.mjs b/packages/govuk-frontend-review/src/common/nunjucks/index.mjs index ec5d7eabcc..9184574c6e 100644 --- a/packages/govuk-frontend-review/src/common/nunjucks/index.mjs +++ b/packages/govuk-frontend-review/src/common/nunjucks/index.mjs @@ -1,20 +1,28 @@ import { join } from 'path' import { paths } from 'govuk-frontend-config' -import { packageNameToPath } from 'govuk-frontend-lib/names' +import { packageResolveToPath } from 'govuk-frontend-lib/names' import nunjucks from 'nunjucks' import * as filters from './filters/index.mjs' import * as globals from './globals/index.mjs' +/** + * Initialise renderer with Nunjucks environment + * + * @param {import('express').Application} app - Express.js review app + * @returns {import('nunjucks').Environment} Nunjucks Environment + */ export function renderer (app) { - const appViews = [ + const env = nunjucks.configure([ join(paths.app, 'src/views'), - packageNameToPath('govuk-frontend', 'dist') - ] - // Initialise nunjucks environment - const env = nunjucks.configure(appViews, { + // Remove `govuk/` suffix using `modulePath` + packageResolveToPath('govuk-frontend', { + modulePath: '../', + moduleRoot: paths.app + }) + ], { autoescape: true, // output with dangerous characters are escaped automatically express: app, // the Express.js review app that nunjucks should install to noCache: true, // never use a cache and recompile templates each time diff --git a/packages/govuk-frontend-review/typedoc.config.js b/packages/govuk-frontend-review/typedoc.config.js index 26854f6dd2..8209e8a3c4 100644 --- a/packages/govuk-frontend-review/typedoc.config.js +++ b/packages/govuk-frontend-review/typedoc.config.js @@ -1,4 +1,6 @@ -const { packageNameToPath } = require('govuk-frontend-lib/names') +const { join } = require('path') + +const { packageResolveToPath, packageNameToPath } = require('govuk-frontend-lib/names') /** * @type {import('typedoc').TypeDocOptions} @@ -9,9 +11,9 @@ module.exports = { sourceLinkTemplate: 'https://github.com/alphagov/govuk-frontend/blob/{gitRevision}/{path}#L{line}', // Configure paths - basePath: packageNameToPath('govuk-frontend', 'src'), - entryPoints: [packageNameToPath('govuk-frontend', 'src/govuk/all.mjs')], - tsconfig: packageNameToPath('govuk-frontend', 'tsconfig.build.json'), + basePath: join(packageNameToPath('govuk-frontend'), 'src'), + entryPoints: [packageResolveToPath('govuk-frontend/src/govuk/all.mjs')], + tsconfig: packageResolveToPath('govuk-frontend/tsconfig.build.json'), out: './dist/docs/jsdoc', // Ignore warnings about CharacterCountTranslations using I18n (@private) diff --git a/packages/govuk-frontend/src/govuk-prototype-kit/govuk-prototype-kit.config.mjs b/packages/govuk-frontend/src/govuk-prototype-kit/govuk-prototype-kit.config.mjs index e47c1d25c0..73f30625cf 100644 --- a/packages/govuk-frontend/src/govuk-prototype-kit/govuk-prototype-kit.config.mjs +++ b/packages/govuk-frontend/src/govuk-prototype-kit/govuk-prototype-kit.config.mjs @@ -1,3 +1,5 @@ +import { join } from 'path' + import { filterPath, getComponentNames, getListing } from 'govuk-frontend-lib/files' import { componentNameToMacroName, packageNameToPath } from 'govuk-frontend-lib/names' import slash from 'slash' @@ -8,7 +10,7 @@ import slash from 'slash' * @returns {Promise} GOV.UK Prototype Kit config */ export default async () => { - const srcPath = packageNameToPath('govuk-frontend', 'src') + const srcPath = join(packageNameToPath('govuk-frontend'), 'src') // Locate component macros const componentMacros = await getListing('**/components/**/macro.njk', { cwd: srcPath }) diff --git a/shared/helpers/nunjucks.js b/shared/helpers/nunjucks.js index 4529f85a0a..a0f22eb453 100644 --- a/shared/helpers/nunjucks.js +++ b/shared/helpers/nunjucks.js @@ -1,10 +1,12 @@ +const { join } = require('path') + const cheerio = require('cheerio') const { componentNameToMacroName, packageNameToPath } = require('govuk-frontend-lib/names') const nunjucks = require('nunjucks') const { outdent } = require('outdent') const nunjucksPaths = [ - packageNameToPath('govuk-frontend', 'src') + join(packageNameToPath('govuk-frontend'), 'src') ] const nunjucksEnv = nunjucks.configure(nunjucksPaths, { diff --git a/shared/lib/files.js b/shared/lib/files.js index 2f278f908e..25f9cada7f 100644 --- a/shared/lib/files.js +++ b/shared/lib/files.js @@ -91,7 +91,7 @@ const getYaml = async (configPath) => { * @returns {Promise} Component data */ const getComponentData = async (componentName) => { - const yamlPath = packageNameToPath('govuk-frontend', `src/govuk/components/${componentName}/${componentName}.yaml`) + const yamlPath = join(packageNameToPath('govuk-frontend'), `src/govuk/components/${componentName}/${componentName}.yaml`) /** @type {ComponentData} */ const yamlData = await getYaml(yamlPath) @@ -119,7 +119,7 @@ const getComponentsData = async () => { * @returns {Promise} Component files */ const getComponentFiles = (componentName = '') => - getListing(packageNameToPath('govuk-frontend', join('src/govuk/components', componentName, '**/*'))) + getListing(join(packageNameToPath('govuk-frontend'), `src/govuk/components/${componentName}/**/*`)) /** * Get component names (with optional filter) @@ -128,7 +128,7 @@ const getComponentFiles = (componentName = '') => * @returns {Promise} Component names */ const getComponentNames = async (filter) => { - const componentNames = await getDirectories(packageNameToPath('govuk-frontend', '**/src/govuk/components/')) + const componentNames = await getDirectories(join(packageNameToPath('govuk-frontend'), '**/src/govuk/components/')) if (filter) { const componentFiles = await getComponentFiles() diff --git a/shared/lib/names.js b/shared/lib/names.js index d47e681904..9258da2c82 100644 --- a/shared/lib/names.js +++ b/shared/lib/names.js @@ -1,5 +1,6 @@ const { dirname, join, parse } = require('path') +const { paths } = require('govuk-frontend-config') const { minimatch } = require('minimatch') /** @@ -61,23 +62,92 @@ function componentPathToModuleName (componentPath) { : 'GOVUKFrontend' } +/** + * Resolve path to package entry from any npm workspace + * + * Once the package entry is resolved, the option `modulePath` can be used to + * append a new path relative to the package entry, for example `i18n.mjs` + * + * @param {string} packageEntry - Installed npm package entry, for example `govuk-frontend/src/govuk/all.mjs` + * @param {Pick} [options] - Package resolution options + * @returns {string} Path to installed npm package entry + */ +function packageResolveToPath (packageEntry, { modulePath, moduleRoot } = {}) { + const packagePath = require.resolve(packageEntry, { + paths: [moduleRoot ?? paths.root] + }) + + // Append optional module path + return modulePath !== undefined ? join(dirname(packagePath), modulePath) : packagePath +} + +/** + * Return path to package entry from any npm workspace, by type + * + * Wraps {@link packageResolveToPath} to allow the appended `modulePath` to + * include unresolvable paths, globs or files that are not yet built + * + * {@link https://github.com/alphagov/govuk-frontend/issues/3755} + * + * @example + * Resolving components relative to a default package entry + * + * - GOV.UK Frontend v4 './govuk/components/accordion/accordion.mjs' + * - GOV.UK Frontend v5 './dist/govuk/components/accordion/accordion.mjs' + * + * ```mjs + * const templatePath = packageResolveToPath('govuk-frontend', { + * modulePath: `components/accordion/accordion.mjs` + * }) + * ``` + * @param {string} packageName - Installed npm package name + * @param {PackageOptions} [options] - Package resolution options + * @returns {string} Path to installed npm package field + */ +function packageTypeToPath (packageName, { modulePath, moduleRoot, type = 'commonjs' } = {}) { + const packageEntry = `${packageName}/package.json` + const packageField = type === 'module' ? 'module' : 'main' + + // Package field as child path + const entryPath = require(packageResolveToPath(packageEntry, { moduleRoot }))[packageField] + const childPath = modulePath !== undefined ? join(dirname(entryPath), modulePath) : entryPath + + // Append optional module path + return packageResolveToPath(packageEntry, { + modulePath: childPath, + moduleRoot + }) +} + /** * Resolve path to package from any npm workspace * - * Used by npm workspaces to find packages that might be hoisted to + * Used to find npm workspace packages that might be hoisted to * the project root node_modules * * @param {string} packageName - Installed npm package name - * @param {string} [childPath] - Optional child directory path + * @param {Pick} [options] - Package resolution options * @returns {string} Path to installed npm package */ -function packageNameToPath (packageName, childPath = '') { - return join(dirname(require.resolve(`${packageName}/package.json`)), childPath) +function packageNameToPath (packageName, options) { + return packageResolveToPath(`${packageName}/package.json`, { + modulePath: '', + ...options + }) } module.exports = { componentNameToClassName, componentNameToMacroName, componentPathToModuleName, + packageResolveToPath, + packageTypeToPath, packageNameToPath } + +/** + * @typedef {object} PackageOptions + * @property {string} [type=commonjs] - Package type from package.json, for example `module` + * @property {string} [modulePath] - Module path (optional, relative to package entry), for example `i18n.mjs` + * @property {string} [moduleRoot] - Module root (optional, absolute directory path to resolve `node_modules` from) + */ diff --git a/shared/lib/names.unit.test.mjs b/shared/lib/names.unit.test.mjs index 4edc3a62cb..c46e8c0aea 100644 --- a/shared/lib/names.unit.test.mjs +++ b/shared/lib/names.unit.test.mjs @@ -6,6 +6,8 @@ import { componentNameToClassName, componentNameToMacroName, componentPathToModuleName, + packageResolveToPath, + packageTypeToPath, packageNameToPath } from './names.js' @@ -125,16 +127,107 @@ describe('componentPathToModuleName', () => { }) }) +describe('packageResolveToPath', () => { + const packages = [ + { + packageEntry: 'govuk-frontend/package.json', + resolvedPath: join(paths.package, 'package.json') + }, + { + packageEntry: 'govuk-frontend/src/govuk/all.mjs', + resolvedPath: join(paths.package, 'src/govuk/all.mjs') + }, + { + packageEntry: 'govuk-frontend/src/govuk/all.mjs', + options: { modulePath: 'i18n.mjs' }, + resolvedPath: join(paths.package, 'src/govuk/i18n.mjs') + }, + { + packageEntry: 'govuk-frontend/src/govuk/all.mjs', + options: { modulePath: 'components/accordion/accordion.mjs' }, + resolvedPath: join(paths.package, 'src/govuk/components/accordion/accordion.mjs') + } + ] + + it.each(packages)("locates path for npm package entry '$packageEntry'", ({ packageEntry, options, resolvedPath }) => { + expect(packageResolveToPath(packageEntry, options)) + .toBe(resolvedPath) + }) +}) + +describe('packageTypeToPath', () => { + const packages = [ + { + packageName: 'govuk-frontend', + resolvedPath: join(paths.package, 'dist/govuk/all.js') + }, + { + packageName: 'govuk-frontend', + options: { modulePath: 'i18n.js' }, + resolvedPath: join(paths.package, 'dist/govuk/i18n.js') + }, + { + packageName: 'govuk-frontend', + options: { type: 'module' }, + resolvedPath: join(paths.package, 'dist/govuk-esm/all.mjs') + }, + { + packageName: 'govuk-frontend', + options: { modulePath: 'i18n.mjs', type: 'module' }, + resolvedPath: join(paths.package, 'dist/govuk-esm/i18n.mjs') + } + ] + + it.each(packages)("locates path for npm package '$packageName' field '$packageField'", ({ packageName, options, resolvedPath }) => { + expect(packageTypeToPath(packageName, options)) + .toBe(resolvedPath) + }) +}) + describe('packageNameToPath', () => { const packages = [ { - name: 'govuk-frontend', - path: paths.package + packageName: 'govuk-frontend', + resolvedPath: paths.package + }, + { + packageName: 'govuk-frontend-review', + resolvedPath: paths.app + } + ] + + it.each(packages)("locates path for npm package '$packageName'", ({ packageName, resolvedPath }) => { + expect(packageNameToPath(packageName)) + .toBe(resolvedPath) + }) +}) + +describe("packageNameToPath (with custom 'node_module' paths)", () => { + const packages = [ + { + packageName: 'govuk-frontend', + options: { moduleRoot: paths.root }, + resolvedPath: paths.package + }, + { + packageName: 'govuk-frontend-review', + options: { moduleRoot: paths.root }, + resolvedPath: paths.app + }, + { + packageName: 'autoprefixer', + options: { moduleRoot: paths.package }, + resolvedPath: join(paths.root, 'node_modules/autoprefixer') + }, + { + packageName: 'postcss', + options: { moduleRoot: paths.app }, + resolvedPath: join(paths.root, 'node_modules/postcss') } ] - it.each(packages)("locates path for npm package '$name'", ({ name, path }) => { - expect(packageNameToPath(name)) - .toBe(path) + it.each(packages)("locates path for npm package '$packageName'", ({ packageName, options = {}, resolvedPath }) => { + expect(packageNameToPath(packageName, options)) + .toBe(resolvedPath) }) }) diff --git a/shared/stats/rollup.config.mjs b/shared/stats/rollup.config.mjs index bd95478035..ab347be4b4 100644 --- a/shared/stats/rollup.config.mjs +++ b/shared/stats/rollup.config.mjs @@ -1,18 +1,22 @@ -import { dirname, join, parse } from 'path' +import { dirname, join, parse, relative } from 'path' import resolve from '@rollup/plugin-node-resolve' -import { packageNameToPath } from 'govuk-frontend-lib/names' +import { paths } from 'govuk-frontend-config' +import { packageTypeToPath } from 'govuk-frontend-lib/names' import { defineConfig } from 'rollup' import { visualizer } from 'rollup-plugin-visualizer' -import { modulePaths } from './src/index.mjs' +import { modulePaths, packageOptions } from './src/index.mjs' + +// Locate GOV.UK Frontend +const packagePath = packageTypeToPath('govuk-frontend', packageOptions) /** * Rollup config with stats output */ export default defineConfig(modulePaths .map((modulePath) => /** @satisfies {import('rollup').RollupOptions} */({ - input: join('govuk-frontend/dist/govuk-esm', modulePath), + input: relative(paths.stats, packageTypeToPath('govuk-frontend', { ...packageOptions, modulePath })), /** * Output options @@ -26,19 +30,19 @@ export default defineConfig(modulePaths * Input plugins */ plugins: [ - resolve(), + resolve({ rootDir: paths.stats }), // Stats: File size visualizer({ filename: join('dist', dirname(modulePath), `${parse(modulePath).name}.yaml`), - projectRoot: packageNameToPath('govuk-frontend', 'dist/govuk-esm/'), + projectRoot: dirname(packagePath), template: 'list' }), // Stats: Module tree map visualizer({ filename: join('dist', dirname(modulePath), `${parse(modulePath).name}.html`), - projectRoot: packageNameToPath('govuk-frontend', 'dist/govuk-esm/'), + projectRoot: dirname(packagePath), template: 'treemap' }) ] diff --git a/shared/stats/src/index.mjs b/shared/stats/src/index.mjs index 2ff1259a4e..0a635dd74e 100644 --- a/shared/stats/src/index.mjs +++ b/shared/stats/src/index.mjs @@ -9,10 +9,21 @@ import { getComponentNames, filterPath, getYaml } from 'govuk-frontend-lib/files const componentNamesWithJavaScript = await getComponentNames((componentName, componentFiles) => componentFiles.some(filterPath([`**/${componentName}.mjs`]))) +/** + * Package options + * + * @type {import('govuk-frontend-lib/names').PackageOptions} + */ +export const packageOptions = { + type: 'module', + modulePath: 'all.mjs', + moduleRoot: paths.stats +} + /** * Rollup input paths */ -export const modulePaths = ['all.mjs'] +export const modulePaths = [packageOptions.modulePath] .concat(componentNamesWithJavaScript.map((componentName) => `components/${componentName}/${componentName}.mjs`)) diff --git a/shared/tasks/styles.mjs b/shared/tasks/styles.mjs index 86f8d77d9c..f4a1f63833 100644 --- a/shared/tasks/styles.mjs +++ b/shared/tasks/styles.mjs @@ -4,7 +4,7 @@ import { join, parse } from 'path' import chalk from 'chalk' import { paths } from 'govuk-frontend-config' import { getListing } from 'govuk-frontend-lib/files' -import { packageNameToPath } from 'govuk-frontend-lib/names' +import { packageResolveToPath } from 'govuk-frontend-lib/names' import PluginError from 'plugin-error' import postcss from 'postcss' // eslint-disable-next-line import/default @@ -39,7 +39,7 @@ export async function compile (pattern, options) { * * @param {AssetEntry} assetEntry - Asset entry */ -export async function compileStylesheet ([modulePath, { configPath, srcPath, destPath, filePath }]) { +export async function compileStylesheet ([modulePath, { basePath, configPath, srcPath, destPath, filePath }]) { const moduleSrcPath = join(srcPath, modulePath) const moduleDestPath = join(destPath, filePath ? filePath(parse(modulePath)) : modulePath) @@ -81,7 +81,14 @@ export async function compileStylesheet ([modulePath, { configPath, srcPath, des // Resolve @imports via loadPaths: [ - packageNameToPath('govuk-frontend', 'dist'), + // Remove `govuk/` suffix using `modulePath` + packageResolveToPath('govuk-frontend', { + modulePath: '../', + moduleRoot: basePath + }), + + // Resolve local packages first + join(basePath, 'node_modules'), join(paths.root, 'node_modules') ],