@madgex/fert 7.1.2 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -11,6 +11,7 @@ import { serviceCommandBootstrap } from './commands/_service-command-bootstrap.j
11
11
  import { configsCommand } from './commands/configs.js';
12
12
  import { initCommand } from './commands/init.js';
13
13
  import { validateCommand } from './commands/validate.js';
14
+ import { rootTasksBootstrap } from './commands/_root-tasks-bootstrap.js';
14
15
 
15
16
  const cli = cac('fert');
16
17
 
@@ -24,7 +25,10 @@ const run = () => {
24
25
  .option('--host [host]', '[string] specify hostname')
25
26
  .option('--port <port>', '[number] specify port')
26
27
  .option('--service-name <serviceName>', '[string] run a single service')
27
- .action((...args) => serviceCommandBootstrap('dev', ...args));
28
+ .action(async (...args) => {
29
+ await rootTasksBootstrap('dev', ...args);
30
+ await serviceCommandBootstrap('dev', ...args);
31
+ });
28
32
 
29
33
  cli
30
34
  .command('build', 'Build project. Can supply a branding directory if running FERT standalone')
@@ -32,14 +36,20 @@ const run = () => {
32
36
  .option('--config [dir]', 'Use custom rollup config file')
33
37
  .option('--target <env>', 'Environment to build for, "dev" or "production"')
34
38
  .option('--service-name <serviceName>', '[string] run a single service')
35
- .action((...args) => serviceCommandBootstrap('build', ...args));
39
+ .action(async (...args) => {
40
+ await rootTasksBootstrap('build', ...args);
41
+ await serviceCommandBootstrap('build', ...args);
42
+ });
36
43
 
37
44
  cli
38
45
  .command('publish', 'Publish the project')
39
46
  .option('--target <env>', 'Environment to publish to, "dev" or "production"')
40
47
  .option('--service-name <serviceName>', '[string] run a single service')
41
48
  .option('--dry-run', 'Dry run, dont actually upload anything')
42
- .action((...args) => serviceCommandBootstrap('publish', ...args));
49
+ .action(async (...args) => {
50
+ await rootTasksBootstrap('publish', ...args);
51
+ await serviceCommandBootstrap('publish', ...args);
52
+ });
43
53
 
44
54
  cli
45
55
  .command('configs', 'Query/Publish project configs')
@@ -0,0 +1,45 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import chokidar from 'chokidar5';
4
+ import { validateLocalConfigs } from '../utils/configs.js';
5
+ import { findFertConfigDir, loadConfigFromFile } from '../utils/index.js';
6
+
7
+ /**
8
+ * Tasks to run against the root directory of a Branding repo (not a service folder).
9
+ *
10
+ * These are run once on a cli command
11
+ *
12
+ * @param {"dev"|"build"|"publish"} command
13
+ * @param {*} options
14
+ * @returns
15
+ */
16
+ export async function rootTasksBootstrap(command /* , options */) {
17
+ const rootDir = await findFertConfigDir();
18
+ const configFromFile = await loadConfigFromFile({ dir: rootDir });
19
+
20
+ if (command === 'dev') {
21
+ await validateLocalConfigs({
22
+ workingDir: rootDir,
23
+ clientPropertyId: configFromFile.clientPropertyId,
24
+ throwable: false,
25
+ });
26
+ // watch local configs - revalidate on change
27
+
28
+ const configPaths = await Array.fromAsync(fs.promises.glob(path.resolve(rootDir, './config/*.json')));
29
+ chokidar.watch(configPaths, { ignoreInitial: true }).on('all', async () => {
30
+ await validateLocalConfigs({
31
+ workingDir: rootDir,
32
+ clientPropertyId: configFromFile.clientPropertyId,
33
+ throwable: false,
34
+ });
35
+ });
36
+ return;
37
+ }
38
+
39
+ if (command === 'build') {
40
+ await validateLocalConfigs({
41
+ workingDir: rootDir,
42
+ clientPropertyId: configFromFile.clientPropertyId,
43
+ });
44
+ }
45
+ }
@@ -15,6 +15,10 @@ const commandMap = {
15
15
  /**
16
16
  * determine if we are running a single service, otherwise search for all services
17
17
  * Then run command
18
+ *
19
+ * @param {"dev"|"build"|"publish"} command
20
+ * @param {*} options
21
+ * @returns
18
22
  */
19
23
  export async function serviceCommandBootstrap(command, options) {
20
24
  // explicitly run a single service - via CLI option
@@ -64,9 +68,12 @@ export async function serviceCommandBootstrap(command, options) {
64
68
  serviceName: serviceConfig.serviceName,
65
69
  });
66
70
  } catch (err) {
67
- throw new Error(`Failed to run command ${command} on service ${serviceConfig.serviceName}: ${err.message}`, {
68
- cause: err,
69
- });
71
+ throw new Error(
72
+ `Failed to run command ${chalk.green(command)} on service ${chalk.green(serviceConfig.serviceName)}: ${chalk.red(err.message)}`,
73
+ {
74
+ cause: err,
75
+ },
76
+ );
70
77
  }
71
78
  }
72
79
  }
@@ -5,14 +5,15 @@ import { log } from '../utils/logging.js';
5
5
  import { bundleEntry } from './build-tasks/bundle-entry.js';
6
6
  import { buildCssFromTokens } from './build-tasks/build-tokens.js';
7
7
  import { buildExternalAssets } from './build-tasks/build-external-assets.js';
8
- import { validateLocalConfigs } from '../utils/configs.js';
9
- import { runValidators, reportResults } from '../utils/validation-helpers.js';
10
- import * as brandJsonValidator from '../validators/brand-json-validator.js';
11
-
12
- const validators = [brandJsonValidator];
8
+ import * as validation from '../utils/validation.js';
13
9
 
14
10
  export { bundleEntry, buildExternalAssets };
15
11
 
12
+ /**
13
+ * Service-level task
14
+ * @param {*} options
15
+ * @returns
16
+ */
16
17
  export async function build(options = {}) {
17
18
  const fertConfig = await resolveConfig(options);
18
19
  const validTargets = ['dev', 'production'];
@@ -21,19 +22,7 @@ export async function build(options = {}) {
21
22
  throw Error(`Missing or invalid --target option. Choose from [${validTargets}]`);
22
23
  }
23
24
 
24
- await validateLocalConfigs({
25
- workingDir: fertConfig.rootDir,
26
- clientPropertyId: fertConfig.clientPropertyId,
27
- });
28
-
29
- const validationResults = await runValidators(validators, {
30
- rootDir: fertConfig.rootDir,
31
- options,
32
- });
33
- const validationExitCode = reportResults(validationResults, fertConfig.rootDir);
34
- if (validationExitCode !== 0) {
35
- throw new Error('Build aborted due to validation errors');
36
- }
25
+ await validation.runServiceValidators(['brand-json', 'translations'], { fertConfig, throwable: true });
37
26
 
38
27
  if (!options.only) {
39
28
  await rimraf(path.resolve(fertConfig.workingDir, 'dist'));
@@ -1,17 +1,24 @@
1
1
  import path from 'node:path';
2
- // eslint-disable-next-line n/no-unsupported-features/node-builtins
3
- import { glob } from 'node:fs/promises';
2
+
4
3
  import chalk from 'chalk';
5
4
  import open from 'open';
6
5
  import chokidar from 'chokidar5';
7
- import { resolveConfig, findFertConfigDir } from '../utils/index.js';
8
- import { validateLocalConfigs } from '../utils/configs.js';
9
- import { runValidators, reportResults } from '../utils/validation-helpers.js';
6
+ import { resolveConfig } from '../utils/index.js';
7
+ import * as validation from '../utils/validation.js';
10
8
  import { devServer } from '../../server/server.js';
11
9
  import { buildTokens, buildExternalAssets } from './build.js';
12
- import { BRAND_JSON_FILENAME, FERT_CONFIG_FILENAME, FERT_SERVICE_CONFIG_FILENAME } from '../../constants.js';
13
- import * as brandJsonValidator from '../validators/brand-json-validator.js';
10
+ import {
11
+ BRAND_JSON_FILENAME,
12
+ FERT_CONFIG_FILENAME,
13
+ FERT_SERVICE_CONFIG_FILENAME,
14
+ SERVICE_TRANSLATIONS_FILEPATH,
15
+ } from '../../constants.js';
14
16
 
17
+ /**
18
+ * Service-level task
19
+ * @param {*} options
20
+ * @returns
21
+ */
15
22
  export async function createDevServer(options = {}) {
16
23
  let fertConfig = await resolveConfig(options);
17
24
 
@@ -21,36 +28,24 @@ export async function createDevServer(options = {}) {
21
28
  )}\n`,
22
29
  );
23
30
 
24
- await validateLocalConfigs({
25
- workingDir: fertConfig.rootDir,
26
- clientPropertyId: fertConfig.clientPropertyId,
27
- throwable: false,
28
- });
29
- await buildTokens(fertConfig);
30
-
31
- const fertConfigDir = await findFertConfigDir();
32
-
33
- // watch local configs - revalidate on change
34
- const configPaths = await Array.fromAsync(glob(path.resolve(fertConfigDir, './config/*.json')));
35
- chokidar.watch(configPaths, { ignoreInitial: true }).on('all', async () => {
36
- await validateLocalConfigs({
37
- workingDir: fertConfig.rootDir,
38
- clientPropertyId: fertConfig.clientPropertyId,
39
- throwable: false,
40
- });
41
- });
42
-
43
- // watch brand.json & fert.config.js - refresh on change
31
+ // validation
44
32
  const brandPath = path.resolve(fertConfig.workingDir, BRAND_JSON_FILENAME);
33
+ const translationPath = path.resolve(fertConfig.workingDir, SERVICE_TRANSLATIONS_FILEPATH);
34
+ // watch brand.json & translation.json - re-validate
35
+ chokidar.watch([brandPath, translationPath], { ignoreInitial: true }).on('all', async () => {
36
+ await validation.runServiceValidators(['brand-json', 'translations'], { fertConfig });
37
+ });
38
+ // initial validation run
39
+ await validation.runServiceValidators(['brand-json', 'translations'], { fertConfig });
45
40
 
46
- chokidar.watch(brandPath, { ignoreInitial: true }).on('all', async () => {
41
+ // building tokens
42
+ await buildTokens(fertConfig);
43
+ // rebuild tokens for brand json update
44
+ chokidar.watch([brandPath]).on('all', async () => {
47
45
  await buildTokens(fertConfig);
48
-
49
- const results = await runValidators([brandJsonValidator], { rootDir: fertConfig.rootDir, options });
50
- reportResults(results, fertConfig.rootDir);
51
46
  });
52
47
 
53
- const configPath = path.resolve(fertConfigDir, FERT_CONFIG_FILENAME);
48
+ const configPath = path.resolve(fertConfig.rootDir, FERT_CONFIG_FILENAME);
54
49
  const serviceConfigPath = path.resolve(fertConfig.workingDir, FERT_SERVICE_CONFIG_FILENAME);
55
50
  chokidar.watch([configPath, serviceConfigPath]).on('change', async () => {
56
51
  console.log('Config changed, reloading…');
@@ -68,7 +63,8 @@ export async function createDevServer(options = {}) {
68
63
  // start server
69
64
 
70
65
  /**
71
- * having a port number to start with helps keep the ports changing too much in development
66
+ * having a port number to start with helps keep the ports changing too much in development.
67
+ * This is the exception to the rule, do not create map-like objects for hardcoded service names.
72
68
  */
73
69
  const STARTING_PORT_FOR_SERVICE_FOR_CONVENIENCE = {
74
70
  'jobseekers-frontend': 4001,
@@ -17,6 +17,11 @@ import {
17
17
  ASSET_STORE_INVALIDATION_PATH,
18
18
  } from '../../constants.js';
19
19
 
20
+ /**
21
+ * Service-level task
22
+ * @param {*} options
23
+ * @returns
24
+ */
20
25
  export async function publish(options) {
21
26
  const fertConfig = await resolveConfig(options);
22
27
  const validTargets = ['dev', 'prod', 'production'];
@@ -1,27 +1,23 @@
1
- import { findFertConfigDir } from '../utils/index.js';
2
- import { runValidators, reportResults } from '../utils/validation-helpers.js';
1
+ import { loadServiceConfigFiles, resolveConfig } from '../utils/index.js';
2
+ import * as validation from '../utils/validation.js';
3
3
  import { log } from '../utils/logging.js';
4
4
 
5
- // Register all validators here
6
- import * as brandJsonValidator from '../validators/brand-json-validator.js';
7
-
8
- const validators = [brandJsonValidator];
9
-
10
- export async function validateCommand(options) {
5
+ /**
6
+ * root-level task
7
+ * @param {*} options
8
+ * @returns
9
+ */
10
+ export async function validateCommand(/* options */) {
11
11
  log.info('Running validations…\n');
12
12
 
13
- const rootDir = await findFertConfigDir();
14
-
15
- const context = {
16
- rootDir,
17
- options,
18
- };
19
-
20
- const results = await runValidators(validators, context);
21
- const exitCode = reportResults(results, rootDir);
13
+ const serviceConfigs = await loadServiceConfigFiles();
14
+ // run validators for each service
15
+ for (const { serviceConfig } of serviceConfigs) {
16
+ const fertConfig = await resolveConfig({ serviceName: serviceConfig.serviceName });
22
17
 
23
- if (exitCode !== 0) {
24
- // eslint-disable-next-line n/no-process-exit
25
- process.exit(exitCode);
18
+ await validation.runServiceValidators(['brand-json', 'translations'], {
19
+ fertConfig,
20
+ throwable: true,
21
+ });
26
22
  }
27
23
  }
@@ -6,7 +6,7 @@ import { PersistentCacheWithTtl } from './persistent-cache-with-ttl.js';
6
6
  const cache = new PersistentCacheWithTtl('cpid-cache', {
7
7
  ttl: ONE_WEEK,
8
8
  });
9
-
9
+ /** @return {Promise<Client>} */
10
10
  export async function doCpidLookup(clientPropertyId) {
11
11
  const API_URL = new URL(clientPropertyId, PROPERTY_ID_API).toString();
12
12
  try {
@@ -33,7 +33,7 @@ export async function doCpidLookup(clientPropertyId) {
33
33
  throw error;
34
34
  }
35
35
  }
36
-
36
+ /** @return {Promise<Client>} */
37
37
  export async function cpidLookup(clientPropertyId, options = {}) {
38
38
  if (options.purgeCache) {
39
39
  cache.purgeCacheFile();
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs';
3
3
  import { pathToFileURL } from 'node:url';
4
- // eslint-disable-next-line n/no-unsupported-features/node-builtins
4
+
5
5
  import { glob } from 'node:fs/promises';
6
6
  import { findUp } from 'find-up-simple';
7
7
  import chalk from 'chalk';
@@ -18,13 +18,20 @@ export function printBanner() {
18
18
  console.log(`\n${chalk.green.bold('Fert')} v${VERSION}`);
19
19
  }
20
20
 
21
- export async function getConfig(cpid, configNames = []) {
21
+ /**
22
+ * get a config values from `clientconfig` from config-api
23
+ *
24
+ * @param {string} cpid
25
+ * @param {Array<string>} configNames array of config keys to fetch from clientconfig
26
+ * @returns
27
+ */
28
+ export async function getClientConfig(cpid, configNames = []) {
22
29
  Hoek.assert(Array.isArray(configNames), 'configNames must be an array');
23
30
 
24
31
  const result = {};
25
32
 
26
33
  for (let config of configNames) {
27
- const url = CONFIG_API.replace('{cpid}', cpid).replace('{config}', config);
34
+ const url = CONFIG_API.replace('{configGroup}', 'clientconfig').replace('{cpid}', cpid).replace('{config}', config);
28
35
  const { data } = await fetch(url).then((response) => response.json());
29
36
  const { value } = data?.[0] || {};
30
37
 
@@ -89,7 +96,7 @@ export async function resolveConfig(options = {}) {
89
96
 
90
97
  fertConfig.externalAssets = resolveExternalAssets(fertConfig.externalAssets);
91
98
 
92
- fertConfig.config = await getConfig(fertConfig.clientPropertyId, ['JobseekerSiteWebSitePath', 'SiteName']);
99
+ fertConfig.config = await getClientConfig(fertConfig.clientPropertyId, ['JobseekerSiteWebSitePath', 'SiteName']);
93
100
 
94
101
  return {
95
102
  ...fertConfig,
@@ -117,7 +124,7 @@ export async function findFertConfigDir() {
117
124
  /**
118
125
  * find and load all service config files, return config and location
119
126
  * @param {string} root directory path
120
- * @returns
127
+ * @returns {Array<{dir:string, serviceConfig:FertServiceConfigFile}>}
121
128
  */
122
129
  export async function loadServiceConfigFiles() {
123
130
  const rootDir = await findFertConfigDir();
@@ -141,6 +148,14 @@ export async function loadServiceConfigFiles() {
141
148
  return serviceConfigs;
142
149
  }
143
150
 
151
+ /**
152
+ * load FERT JSON file from a directory, optionally specifying the config files name.
153
+ * @param {object} options
154
+ * @param {string} [options.dir] defaults to process.cwd()
155
+ * @param {string} options.filename default FERT_CONFIG_FILENAME, e.g. use `FERT_SERVICE_CONFIG_FILENAME` to load service-level config file
156
+ * @param {boolean} options.silent set to `true` to not throw if file cant be loaded
157
+ * @returns {FertConfigFile | FertServiceConfigFile} depending on filename provided
158
+ */
144
159
  export async function loadConfigFromFile({ dir = process.cwd(), filename = FERT_CONFIG_FILENAME, silent = false }) {
145
160
  const configPath = path.resolve(dir, filename);
146
161
  try {
@@ -166,7 +181,7 @@ export function ensureTrailingSlash(str) {
166
181
 
167
182
  export async function exists(_path) {
168
183
  try {
169
- fs.access(_path);
184
+ await fs.promises.access(_path);
170
185
  return true;
171
186
  } catch {
172
187
  return false;
@@ -0,0 +1,145 @@
1
+ import chalk from 'chalk';
2
+ import { log } from './logging.js';
3
+ import * as validatorBrandJson from '../validators/brand-json.validator.js';
4
+ import * as validatorTranslations from '../validators/translations.validator.js';
5
+
6
+ /** @type {Array<Validator>} */
7
+ const validators = [validatorBrandJson, validatorTranslations];
8
+
9
+ /**
10
+ *
11
+ * Runs an array of validator functions and collects all results. This is targeting a service, and requires a fertConfig.
12
+ * Each validator receives `{fertConfig}` and must return an array of result objects.
13
+ *
14
+ * @param {Array<string>} validatorNames, array of strings matching name from validator js file. e.g. ['brand-json', 'translations']
15
+ * @param {object} options
16
+ * @param {FertConfig} options.fertConfig required
17
+ * @param {object} options.throwable to throw if validation has errors
18
+ * @returns {Array<ValidationResult>}
19
+ */
20
+ export async function runServiceValidators(validatorNames = [], { fertConfig, throwable = false } = {}) {
21
+ const results = [];
22
+
23
+ for (const validator of validators) {
24
+ if (!validatorNames.includes(validator.name)) continue;
25
+ const validatorResults = await validator.validate({ fertConfig });
26
+ results.push(...validatorResults);
27
+ }
28
+ const validationExitCode = reportResults(results, fertConfig);
29
+ if (throwable && validationExitCode !== 0) {
30
+ throw new Error('Validation has errors');
31
+ }
32
+
33
+ return results;
34
+ }
35
+ /** @type {ValidationSeverity} */
36
+ const Severity = Object.freeze({
37
+ ERROR: 'ERROR',
38
+ WARNING: 'WARNING',
39
+ });
40
+
41
+ /**
42
+ * @returns {ValidationResultCollection}
43
+ */
44
+ export function createResultCollection() {
45
+ /** @type {Array<ValidationResult>} */
46
+ const results = [];
47
+ /** @type {ValidationResultCollection} */
48
+ return {
49
+ results,
50
+ /** add a result to results collection, and returns a builder to modify the results severity, filePath and detail */
51
+ addResult(message) {
52
+ /** @type {ValidationResult} */
53
+ const result = { severity: Severity.WARNING, message, filePath: undefined, detail: undefined };
54
+ results.push(result);
55
+ /** @type {ValidationResultBuilder} */
56
+ return {
57
+ /** set ValidationResult.severity to ERROR */
58
+ err() {
59
+ result.severity = Severity.ERROR;
60
+ return this;
61
+ },
62
+ /** set ValidationResult.filePath */
63
+ filePath(_filePath) {
64
+ result.filePath = _filePath;
65
+ return this;
66
+ },
67
+ /** set ValidationResult.filePath */
68
+ detail(_detail) {
69
+ result.detail = _detail;
70
+ return this;
71
+ },
72
+ };
73
+ },
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Prints validation results — errors first, then warnings grouped by file.
79
+ * Full paths are shown.
80
+ * Returns 1 if any ERROR results exist, 0 otherwise.
81
+ *
82
+ * @param {Array<ValidationResult>} results
83
+ * @param {FertConfig} fertConfig
84
+ * @returns {0|1}
85
+ */
86
+ export function reportResults(results, fertConfig) {
87
+ const errors = results.filter((r) => r.severity === Severity.ERROR);
88
+ if (errors.length) {
89
+ console.log(chalk.red.bold(`\n✖ ${errors.length} error${errors.length === 1 ? '' : 's'}\n`));
90
+ logResults(errors);
91
+ }
92
+
93
+ const warnings = results.filter((r) => r.severity === Severity.WARNING);
94
+ if (warnings.length) {
95
+ console.log(chalk.yellow.bold(`\n⚠ ${warnings.length} warning${warnings.length === 1 ? '' : 's'}\n`));
96
+ logResults(warnings);
97
+ }
98
+
99
+ if (!errors.length && !warnings.length) {
100
+ log.success(`All validations passed for ${chalk.green(fertConfig.workingDir)}`);
101
+ } else {
102
+ log.warn(`all validations did not passed for ${chalk.yellow(fertConfig.workingDir)}`);
103
+ }
104
+
105
+ return errors.length > 0 ? 1 : 0;
106
+ }
107
+
108
+ /**
109
+ * Group errors by filePath
110
+ * @param {Array<ValidationResult>} results
111
+ * @returns {Map<string, Array<ValidationResult>>}
112
+ */
113
+ function groupByFilePath(results) {
114
+ const groups = new Map();
115
+ for (const result of results) {
116
+ const key = result.filePath ?? 'no-file-path';
117
+ if (!groups.has(key)) groups.set(key, []);
118
+ groups.get(key).push(result);
119
+ }
120
+ return groups;
121
+ }
122
+
123
+ /**
124
+ * Log each ValidationResult in the array
125
+ * @param {Array<ValidationResult>} results
126
+ */
127
+ function logResults(results) {
128
+ // order results, and only print filePath once per file
129
+ const groups = groupByFilePath(results);
130
+ for (const [filePath, items] of groups) {
131
+ if (filePath) {
132
+ console.log(chalk.dim(`${filePath}`));
133
+ }
134
+ for (const item of items) {
135
+ const color = item.severity === Severity.ERROR ? chalk.red : chalk.yellow;
136
+ const symbol = item.severity === Severity.ERROR ? '✖' : '⚠';
137
+ const indent = filePath ? ' ' : ' ';
138
+ console.log(color.bold(symbol), `${indent}${item.message}`);
139
+ if (item.detail) {
140
+ console.log(chalk.dim(` ${indent}${item.detail}`));
141
+ }
142
+ }
143
+ console.log('\n');
144
+ }
145
+ }
@@ -3,9 +3,9 @@ import fs from 'node:fs';
3
3
  import { createStyleDictionary } from '@madgex/design-system/style-dictionary';
4
4
  // eslint-disable-next-line n/no-extraneous-import
5
5
  import { resolveReferences } from 'style-dictionary/utils';
6
- import { loadServiceConfigFiles } from '../utils/index.js';
7
- import { Severity, createResult } from '../utils/validation-helpers.js';
6
+ import * as validation from '../utils/validation.js';
8
7
  import { BRAND_JSON_FILENAME } from '../../constants.js';
8
+ import { exists } from '../utils/index.js';
9
9
 
10
10
  export const name = 'brand-json';
11
11
  export const description = 'Validates brand.json overrides against the MDS base token schema';
@@ -83,78 +83,64 @@ async function loadBaseSchema() {
83
83
  await cleanTempFiles();
84
84
  return { basePaths, baseResolvedValues, baseTokens };
85
85
  }
86
-
87
- export async function validate() {
88
- const results = [];
86
+ /**
87
+ * Main validation function.
88
+ * Validates brand json override file for specified service (fertConfig)
89
+ * @property {object} options
90
+ * @property {FertConfig} options.fertConfig
91
+ * @returns {Promise<Array<ValidationResult>>} Array of validation results
92
+ */
93
+ export async function validate({ fertConfig }) {
94
+ const resultsCollection = validation.createResultCollection();
89
95
  const { basePaths, baseResolvedValues, baseTokens } = await loadBaseSchema();
90
96
 
91
- let serviceConfigs;
97
+ const brandJsonPath = path.join(fertConfig.workingDir, BRAND_JSON_FILENAME);
98
+
99
+ if (!(await exists(brandJsonPath))) {
100
+ resultsCollection
101
+ .addResult(`Missing ${BRAND_JSON_FILENAME}`)
102
+ .filePath(brandJsonPath)
103
+ .detail('Each service should have a brand.json for token overrides');
104
+ return resultsCollection.results; // abort early
105
+ }
106
+
107
+ let brandData;
92
108
  try {
93
- serviceConfigs = await loadServiceConfigFiles();
94
- } catch {
95
- results.push(
96
- createResult(Severity.WARNING, 'Could not discover services — skipping brand.json validation', {
97
- detail: 'Ensure you are running from a branding repo with a fert.config.js',
98
- }),
99
- );
100
- return results;
109
+ brandData = JSON.parse(fs.readFileSync(brandJsonPath, 'utf-8'));
110
+ } catch (err) {
111
+ resultsCollection
112
+ .addResult(`Invalid JSON in ${BRAND_JSON_FILENAME}`)
113
+ .err()
114
+ .filePath(brandJsonPath)
115
+ .detail(err.message);
116
+ return resultsCollection.results; // abort early
101
117
  }
102
118
 
103
- for (const { dir } of serviceConfigs) {
104
- const brandJsonPath = path.join(dir, BRAND_JSON_FILENAME);
105
-
106
- if (!fs.existsSync(brandJsonPath)) {
107
- results.push(
108
- createResult(Severity.WARNING, `Missing ${BRAND_JSON_FILENAME}`, {
109
- filePath: brandJsonPath,
110
- detail: 'Each service should have a brand.json for token overrides',
111
- }),
112
- );
113
- continue;
114
- }
119
+ const overridePaths = extractTokenPaths(brandData);
115
120
 
116
- let brandData;
117
- try {
118
- brandData = JSON.parse(fs.readFileSync(brandJsonPath, 'utf-8'));
119
- } catch (err) {
120
- results.push(
121
- createResult(Severity.ERROR, `Invalid JSON in ${BRAND_JSON_FILENAME}`, {
122
- filePath: brandJsonPath,
123
- detail: err.message,
124
- }),
125
- );
126
- continue;
121
+ for (const overridePath of overridePaths) {
122
+ if (!basePaths.has(overridePath)) {
123
+ resultsCollection
124
+ .addResult(`Unknown token path: "${overridePath}"`)
125
+ .filePath(brandJsonPath)
126
+ .detail('This path does not exist in the MDS base schema and will have no effect');
127
127
  }
128
128
 
129
- const overridePaths = extractTokenPaths(brandData);
130
-
131
- for (const overridePath of overridePaths) {
132
- if (!basePaths.has(overridePath)) {
133
- results.push(
134
- createResult(Severity.WARNING, `Unknown token path: "${overridePath}"`, {
135
- filePath: brandJsonPath,
136
- detail: 'This path does not exist in the MDS base schema and will have no effect',
137
- }),
138
- );
139
- continue;
140
- }
141
-
142
- const rawOverrideValue = getValueAtPath(brandData, overridePath);
143
- if (rawOverrideValue === undefined) continue;
144
-
129
+ const rawOverrideValue = getValueAtPath(brandData, overridePath);
130
+ if (rawOverrideValue !== undefined) {
145
131
  const resolvedOverride = resolveToFinalValue(rawOverrideValue, baseTokens);
146
132
  const resolvedBase = baseResolvedValues.get(overridePath);
147
133
 
148
134
  if (resolvedOverride === resolvedBase) {
149
- results.push(
150
- createResult(Severity.WARNING, `Redundant override: "${overridePath}"`, {
151
- filePath: brandJsonPath,
152
- detail: `Value "${String(rawOverrideValue)}" resolves to the same value as the MDS base ("${String(resolvedBase)}")`,
153
- }),
154
- );
135
+ resultsCollection
136
+ .addResult(`Redundant override: "${overridePath}"`)
137
+ .filePath(brandJsonPath)
138
+ .detail(
139
+ `Value "${String(rawOverrideValue)}" resolves to the same value as the MDS base ("${String(resolvedBase)}")`,
140
+ );
155
141
  }
156
142
  }
157
143
  }
158
144
 
159
- return results;
145
+ return resultsCollection.results;
160
146
  }
@@ -0,0 +1,137 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import * as validation from '../utils/validation.js';
4
+ import { PersistentCacheWithTtl } from '../utils/persistent-cache-with-ttl.js';
5
+ import { ONE_WEEK, SERVICE_API_TRANSLATIONS, SERVICE_TRANSLATIONS_FILEPATH } from '../../constants.js';
6
+ import { exists } from '../utils/index.js';
7
+ import { log } from '../utils/logging.js';
8
+
9
+ export const name = 'translations';
10
+ export const description = 'Validates translation override files against base translations from upstream repositories';
11
+
12
+ const cache = new PersistentCacheWithTtl('base-translations', { ttl: ONE_WEEK });
13
+
14
+ /**
15
+ * Fetches base translations from remote version of this service.
16
+ * Uses caching with 1-week TTL to minimize API calls while dev server is running.
17
+ *
18
+ * @property {FertConfig} fertConfig
19
+ * @returns {Promise<Object|null>} Base translations object, or null if fetch fails
20
+ */
21
+ async function fetchBaseTranslations(fertConfig) {
22
+ const cacheKey = `translations-${fertConfig.serviceName}-${fertConfig.clientPropertyId}`;
23
+ // Check cache first
24
+ const cached = cache.get(cacheKey);
25
+ if (cached) return cached;
26
+ const url = new URL(SERVICE_API_TRANSLATIONS.replace('{serviceName}', fertConfig.serviceName));
27
+ // let service endpoint know we dont want to include client translations overrides, that's what we're looking at in our branding repo!
28
+ url.searchParams.set('excludeClientTranslationOverrides', true);
29
+
30
+ const res = await fetch(url.href, {
31
+ headers: { 'x-client-property-id': fertConfig.clientPropertyId },
32
+ signal: AbortSignal.timeout(10000),
33
+ });
34
+
35
+ if (!res.ok) {
36
+ const text = await res.text();
37
+ throw new Error(`failed to fetch base translations - ${text}`);
38
+ }
39
+
40
+ const translations = await res.json();
41
+ cache.set(cacheKey, translations);
42
+ return translations;
43
+ }
44
+
45
+ /**
46
+ * Recursively flattens a nested translation object into a Map of key paths to values.
47
+ * Example: { header: { "sign-in": "Sign in" } } → Map { "header.sign-in" => "Sign in" }
48
+ *
49
+ * @param {Object} obj - Translation object to flatten
50
+ * @param {string} prefix - Current path prefix
51
+ * @returns {Map<string, any>} Map of flattened key paths to their values
52
+ */
53
+ export function flattenKeysWithValues(obj, prefix = '') {
54
+ const entries = new Map();
55
+
56
+ if (!obj || typeof obj !== 'object') {
57
+ return entries;
58
+ }
59
+
60
+ for (const [key, value] of Object.entries(obj)) {
61
+ const currentPath = prefix ? `${prefix}.${key}` : key;
62
+
63
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
64
+ // Recurse into nested objects
65
+ const childEntries = flattenKeysWithValues(value, currentPath);
66
+ for (const [childKey, childValue] of childEntries) {
67
+ entries.set(childKey, childValue);
68
+ }
69
+ } else {
70
+ // Leaf node - add the path with its value
71
+ entries.set(currentPath, value);
72
+ }
73
+ }
74
+
75
+ return entries;
76
+ }
77
+ /**
78
+ * Main validation function.
79
+ * Validates translation override file for specified service (fertConfig)
80
+ * @property {object} options
81
+ * @property {FertConfig} options.fertConfig
82
+ * @returns {Promise<Array<ValidationResult>>} Array of validation results
83
+ */
84
+ export async function validate({ fertConfig }) {
85
+ const resultsCollection = validation.createResultCollection();
86
+
87
+ const overridePath = path.resolve(fertConfig.workingDir, SERVICE_TRANSLATIONS_FILEPATH);
88
+ // cant read override file - nothing to validate
89
+ if (!(await exists(overridePath))) return resultsCollection.results; // abort early
90
+
91
+ // Parse override file (not using `import` to not cache json file as a module)
92
+ let overrideTranslations;
93
+ try {
94
+ const overrideContent = await fs.promises.readFile(overridePath, 'utf-8');
95
+ overrideTranslations = JSON.parse(overrideContent);
96
+ } catch (err) {
97
+ resultsCollection.addResult(`Failed to parse translation override: ${err.message}`).err().filePath(overridePath);
98
+ return resultsCollection.results; // abort early
99
+ }
100
+
101
+ // Fetch remote base translations
102
+ let baseTranslations;
103
+ try {
104
+ baseTranslations = await fetchBaseTranslations(fertConfig);
105
+ } catch (err) {
106
+ log.warn(`Could not fetch base translations for ${fertConfig.serviceName}. Skipping validation. ${err.message}`);
107
+
108
+ return resultsCollection.results; // abort early
109
+ }
110
+
111
+ // Flatten both translation objects with values
112
+ const baseEntries = flattenKeysWithValues(baseTranslations);
113
+ const overrideEntries = flattenKeysWithValues(overrideTranslations);
114
+
115
+ // Check each override key
116
+ for (const [overrideKey, overrideValue] of overrideEntries) {
117
+ if (!baseEntries.has(overrideKey)) {
118
+ // Extra key that doesn't exist in base
119
+ resultsCollection
120
+ .addResult(
121
+ `Unknown key: Translation key "${overrideKey}" is defined in override but does not exist in base translations`,
122
+ )
123
+ .filePath(overridePath)
124
+ .detail(
125
+ `This key may be unused or invalid. Base translations for ${fertConfig.serviceName} do not include this key.`,
126
+ );
127
+ } else if (baseEntries.get(overrideKey) === overrideValue) {
128
+ // Redundant override with same value as base
129
+ resultsCollection
130
+ .addResult(`Redundant override: "${overrideKey}"`)
131
+ .filePath(overridePath)
132
+ .detail(`Value "${String(overrideValue)}" is the same as the base translation and can be removed`);
133
+ }
134
+ }
135
+
136
+ return resultsCollection.results;
137
+ }
package/constants.js CHANGED
@@ -1,13 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import os from 'node:os';
4
- import { createRequire } from 'node:module';
5
+ import pkg from './package.json' with { type: 'json' };
5
6
 
6
- const require = createRequire(import.meta.url);
7
-
8
- export const { version: VERSION } = JSON.parse(
9
- fs.readFileSync(path.resolve(import.meta.dirname, './package.json')).toString(),
10
- );
7
+ export const VERSION = pkg.version;
11
8
 
12
9
  export const ONE_MINUTE = 60000;
13
10
  export const ONE_HOUR = ONE_MINUTE * 60;
@@ -17,7 +14,7 @@ export const ONE_WEEK = ONE_DAY * 7;
17
14
  export const TMP_DIR = fs.realpathSync(os.tmpdir());
18
15
  export const FERT_PACKAGE_DIR = path.resolve('./');
19
16
  export const CUSTOM_TEMPLATES_DIR = './pages';
20
- export const MDS_PATH = path.dirname(require.resolve('@madgex/design-system/package.json'));
17
+ export const MDS_PATH = path.dirname(fileURLToPath(import.meta.resolve('@madgex/design-system/package.json')));
21
18
  export const AWS_REGION = 'eu-west-1';
22
19
  export const PROPERTY_ID_API = 'https://property-identification-api.cs.madgexhosting.net/properties/';
23
20
  export const ASSETS_API_URL = 'https://asset-store.job.madgexhosting.net';
@@ -50,9 +47,14 @@ export const ASSET_STORE_INVALIDATION_PATH = `/{fertConfig.client.rootClientProp
50
47
  export const ASSET_STORE_USER_GUID = 'a386d4b6-f2df-4b80-ad1f-0349e23f530b';
51
48
 
52
49
  export const CONFIG_API =
53
- 'https://configuration-api.job.madgexhosting.net/configs/override/{cpid}/production/clientconfig/{config}';
50
+ 'https://configuration-api.job.madgexhosting.net/configs/override/{cpid}/production/{configGroup}/{config}';
54
51
  export const CONFIG_DIR = 'config';
55
52
 
53
+ /** we assume the remove version of a service hosts an api translations endpoint */
54
+ export const SERVICE_API_TRANSLATIONS = 'https://{serviceName}.job.madgexhosting.net/api/translations';
55
+
56
+ export const SERVICE_TRANSLATIONS_FILEPATH = 'public/translation.json';
57
+
56
58
  export const MOCK_AUTH_OBJECT = {
57
59
  'recruiterservices-frontend': {
58
60
  credentials: {
package/jsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "ESNext",
4
+ "moduleResolution": "bundler",
5
+ "target": "ESNext",
6
+ "checkJs": false
7
+ },
8
+ "include": ["**/*.js", "types.d.ts"],
9
+ "exclude": ["node_modules"]
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@madgex/fert",
3
- "version": "7.1.2",
3
+ "version": "7.2.0",
4
4
  "description": "Tool to help build the V6 branding",
5
5
  "bin": {
6
6
  "fert": "./bin/cli.js"
@@ -19,7 +19,7 @@
19
19
  "url": "https://github.com/wiley/madgex-frontend-rollout-tool.git"
20
20
  },
21
21
  "engines": {
22
- "node": ">=22.16"
22
+ "node": ">=22.17"
23
23
  },
24
24
  "author": "Madgex",
25
25
  "license": "UNLICENSED",
@@ -51,7 +51,7 @@
51
51
  "simple-git": "3.33.0",
52
52
  "simple-update-notifier": "2.0.0",
53
53
  "uuid-validate": "0.0.3",
54
- "vite": "7.3.1",
54
+ "vite": "7.3.2",
55
55
  "vite-plugin-static-copy": "3.4.0"
56
56
  },
57
57
  "devDependencies": {
@@ -62,7 +62,7 @@
62
62
  "eslint": "10.1.0",
63
63
  "husky": "9.1.7",
64
64
  "lint-staged": "16.4.0",
65
- "msw": "^2.12.14",
65
+ "msw": "2.13.0",
66
66
  "prettier": "3.8.1",
67
67
  "semantic-release": "25.0.3"
68
68
  },
package/types.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ type ValidationSeverity = {
2
+ ERROR: 'ERROR';
3
+ WARNING: 'WARNING';
4
+ };
5
+
6
+ type Validator = {
7
+ name: string;
8
+ description?: string;
9
+ validate: function;
10
+ };
11
+
12
+ type ValidationResult = {
13
+ severity: ValidationSeverity;
14
+ message: string;
15
+ filePath: string;
16
+ detail: string;
17
+ };
18
+
19
+ type ValidationResultCollection = {
20
+ results: ValidationResult[];
21
+ addResult(message: string): ValidationResultBuilder;
22
+ };
23
+ type ValidationResultBuilder = {
24
+ err(): ValidationResultBuilder;
25
+ filePath(filePath: string): ValidationResultBuilder;
26
+ detail(detail: string): ValidationResultBuilder;
27
+ };
28
+
29
+ interface FertConfigFile {
30
+ clientPropertyId: string;
31
+ }
32
+ interface FertServiceConfigFile {
33
+ serviceName: string;
34
+ entry?: string;
35
+ externalAssets?: {
36
+ links?: string[] | string;
37
+ scripts?: string[] | string;
38
+ };
39
+ }
40
+ type Client = {
41
+ brandName: string;
42
+ parentCpid: string;
43
+ rootClientPropertyId: string;
44
+ isAffiliate: boolean;
45
+ };
46
+ type FertConfig = FertConfigFile &
47
+ FertServiceConfigFile & {
48
+ /** always the root of the branding repo where fert.config.js file is */
49
+ rootDir: string;
50
+ /** always the service directory where fert.service.config.js is */
51
+ workingDir: any;
52
+ cli: object;
53
+ client: Client;
54
+ };
@@ -1,87 +0,0 @@
1
- import path from 'node:path';
2
- import chalk from 'chalk';
3
- import { log } from './logging.js';
4
-
5
- export const Severity = Object.freeze({
6
- ERROR: 'ERROR',
7
- WARNING: 'WARNING',
8
- });
9
-
10
- /**
11
- * Creates a validation result entry.
12
- */
13
- export function createResult(severity, message, { filePath, detail } = {}) {
14
- return { severity, message, filePath, detail };
15
- }
16
-
17
- /**
18
- * Runs an array of validator functions and collects all results.
19
- * Each validator receives `context` and must return an array of result objects.
20
- */
21
- export async function runValidators(validators, context) {
22
- const results = [];
23
-
24
- for (const validator of validators) {
25
- const validatorResults = await validator.validate(context);
26
- results.push(...validatorResults);
27
- }
28
-
29
- return results;
30
- }
31
-
32
- /**
33
- * Prints validation results — errors first, then warnings grouped by file.
34
- * Paths are shown relative to rootDir.
35
- * Returns 1 if any ERROR results exist, 0 otherwise.
36
- */
37
- export function reportResults(results, rootDir = process.cwd()) {
38
- const errors = results.filter((r) => r.severity === Severity.ERROR);
39
- const warnings = results.filter((r) => r.severity === Severity.WARNING);
40
-
41
- if (errors.length) {
42
- console.log(chalk.red.bold(`\n✖ ${errors.length} error${errors.length === 1 ? '' : 's'}\n`));
43
- printGrouped(groupByFile(errors), rootDir, Severity.ERROR);
44
- }
45
-
46
- if (warnings.length) {
47
- console.log(chalk.yellow.bold(`\n⚠ ${warnings.length} warning${warnings.length === 1 ? '' : 's'}\n`));
48
- printGrouped(groupByFile(warnings), rootDir, Severity.WARNING);
49
- }
50
-
51
- if (!errors.length && !warnings.length) {
52
- log.success('All validations passed');
53
- }
54
-
55
- console.log('');
56
-
57
- return errors.length > 0 ? 1 : 0;
58
- }
59
-
60
- function groupByFile(results) {
61
- const groups = new Map();
62
- for (const result of results) {
63
- const key = result.filePath ?? null;
64
- if (!groups.has(key)) groups.set(key, []);
65
- groups.get(key).push(result);
66
- }
67
- return groups;
68
- }
69
-
70
- function printGrouped(groups, rootDir, severity) {
71
- const color = severity === Severity.ERROR ? chalk.red : chalk.yellow;
72
- const symbol = severity === Severity.ERROR ? '✖' : '⚠';
73
-
74
- for (const [filePath, items] of groups) {
75
- if (filePath) {
76
- console.log(chalk.dim(` ${path.relative(rootDir, filePath)}`));
77
- }
78
- for (const item of items) {
79
- const indent = filePath ? ' ' : ' ';
80
- console.log(color.bold(symbol), `${indent}${item.message}`);
81
- if (item.detail) {
82
- console.log(chalk.dim(` ${indent}${item.detail}`));
83
- }
84
- }
85
- console.log('');
86
- }
87
- }