@madgex/fert 7.2.1 → 7.4.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.
Files changed (54) hide show
  1. package/bin/cli.js +4 -0
  2. package/bin/commands/build.js +1 -1
  3. package/bin/commands/dev-server.js +10 -3
  4. package/bin/commands/init-template-tasks/copy-theme-files.js +61 -0
  5. package/bin/commands/init-template-tasks/generate-entry-nunjucks-file.js +64 -0
  6. package/bin/commands/init-template-tasks/get-macro-name-from-nunjucks.js +13 -0
  7. package/bin/commands/init-template-tasks/get-nunjucks-dependencies.js +58 -0
  8. package/bin/commands/init-template-tasks/get-out-dir.js +6 -0
  9. package/bin/commands/init-template.js +94 -0
  10. package/bin/commands/publish.js +6 -0
  11. package/bin/commands/validate.js +1 -1
  12. package/bin/utils/cpid-lookup.js +2 -1
  13. package/bin/utils/get-assets-path.js +4 -4
  14. package/bin/utils/index.js +1 -26
  15. package/bin/utils/validation.js +22 -1
  16. package/bin/validators/css.validator.js +73 -0
  17. package/bin/validators/nav-links.validator.js +74 -0
  18. package/bin/validators/required-assets.validator.js +34 -0
  19. package/bin/validators/service-config.validator.js +121 -0
  20. package/constants.js +0 -2
  21. package/docs/FAQ.md +1 -0
  22. package/docs/README.md +30 -11
  23. package/package.json +2 -2
  24. package/repo-template/services/jobseekers-frontend/templates/footer.njk +18 -19
  25. package/repo-template/services/jobseekers-frontend/templates/header.njk +19 -74
  26. package/repo-template/services/recruiterservices-frontend/templates/footer.njk +19 -18
  27. package/repo-template/services/recruiterservices-frontend/templates/header.njk +19 -82
  28. package/types.d.ts +1 -0
  29. package/repo-template/services/jobseekers-frontend/templates/context/footer-nav.njk +0 -27
  30. package/repo-template/services/jobseekers-frontend/templates/context/main-nav.njk +0 -41
  31. package/repo-template/services/jobseekers-frontend/templates/context/user-nav.njk +0 -17
  32. package/repo-template/services/jobseekers-frontend/templates/includes/footer-nav.njk +0 -15
  33. package/repo-template/services/jobseekers-frontend/templates/includes/main-nav.njk +0 -19
  34. package/repo-template/services/jobseekers-frontend/templates/includes/user-nav/authenticated.njk +0 -34
  35. package/repo-template/services/jobseekers-frontend/templates/includes/user-nav/unauthenticated.njk +0 -9
  36. package/repo-template/services/jobseekers-frontend/templates/translations/da.njk +0 -26
  37. package/repo-template/services/jobseekers-frontend/templates/translations/de.njk +0 -26
  38. package/repo-template/services/jobseekers-frontend/templates/translations/en.njk +0 -27
  39. package/repo-template/services/jobseekers-frontend/templates/translations/es.njk +0 -26
  40. package/repo-template/services/jobseekers-frontend/templates/translations/fr.njk +0 -26
  41. package/repo-template/services/jobseekers-frontend/templates/translations/nb.njk +0 -26
  42. package/repo-template/services/jobseekers-frontend/templates/translations/nl.njk +0 -26
  43. package/repo-template/services/jobseekers-frontend/templates/translations/sv.njk +0 -26
  44. package/repo-template/services/jobseekers-frontend/templates/translations/zh-cn.njk +0 -26
  45. package/repo-template/services/recruiterservices-frontend/templates/context/links.njk +0 -136
  46. package/repo-template/services/recruiterservices-frontend/templates/includes/basket-nav.njk +0 -25
  47. package/repo-template/services/recruiterservices-frontend/templates/includes/footer/footer-nav.njk +0 -35
  48. package/repo-template/services/recruiterservices-frontend/templates/includes/footer/social-links.njk +0 -14
  49. package/repo-template/services/recruiterservices-frontend/templates/includes/primary-nav.njk +0 -18
  50. package/repo-template/services/recruiterservices-frontend/templates/includes/user-nav/authenticated.njk +0 -36
  51. package/repo-template/services/recruiterservices-frontend/templates/includes/user-nav/switch-recruiters.njk +0 -13
  52. package/repo-template/services/recruiterservices-frontend/templates/includes/user-nav/unauthenticated.njk +0 -11
  53. package/repo-template/services/recruiterservices-frontend/templates/includes/user-nav/user-nav.njk +0 -9
  54. package/repo-template/services/recruiterservices-frontend/templates/translations/en.njk +0 -29
package/bin/cli.js CHANGED
@@ -12,6 +12,7 @@ import { configsCommand } from './commands/configs.js';
12
12
  import { initCommand } from './commands/init.js';
13
13
  import { validateCommand } from './commands/validate.js';
14
14
  import { rootTasksBootstrap } from './commands/_root-tasks-bootstrap.js';
15
+ import { initTemplateCommand } from './commands/init-template.js';
15
16
 
16
17
  const cli = cac('fert');
17
18
 
@@ -66,6 +67,9 @@ const run = () => {
66
67
  .command('init [root]', 'Create a new branding project')
67
68
  .option('--cpid <cpid>', 'Specify the clientPropertyId to use in the new project')
68
69
  .action((...args) => initCommand(...args));
70
+ cli
71
+ .command('init-template', 'Create a custom template for a specific service & header or footer')
72
+ .action((...args) => initTemplateCommand(...args));
69
73
 
70
74
  cli.command('validate', 'Validate branding project for common issues').action((...args) => validateCommand(...args));
71
75
 
@@ -22,7 +22,7 @@ export async function build(options = {}) {
22
22
  throw Error(`Missing or invalid --target option. Choose from [${validTargets}]`);
23
23
  }
24
24
 
25
- await validation.runServiceValidators(['brand-json', 'translations', 'redirects-csv'], {
25
+ await validation.runServiceValidators(validation.getAllValidators(), {
26
26
  fertConfig,
27
27
  throwable: true,
28
28
  });
@@ -33,10 +33,14 @@ export async function createDevServer(options = {}) {
33
33
  const translationPath = path.resolve(fertConfig.workingDir, SERVICE_TRANSLATIONS_FILEPATH);
34
34
  // watch brand.json & translation.json - re-validate
35
35
  chokidar.watch([brandPath, translationPath], { ignoreInitial: true }).on('all', async () => {
36
- await validation.runServiceValidators(['brand-json', 'translations', 'redirects-csv'], { fertConfig });
36
+ await validation.runServiceValidators(['brand-json', 'translations'], {
37
+ fertConfig,
38
+ });
39
+ });
40
+ // initial validation run (all validators on startup)
41
+ await validation.runServiceValidators(validation.getAllValidators(), {
42
+ fertConfig,
37
43
  });
38
- // initial validation run
39
- await validation.runServiceValidators(['brand-json', 'translations', 'redirects-csv'], { fertConfig });
40
44
 
41
45
  // building tokens
42
46
  await buildTokens(fertConfig);
@@ -50,6 +54,9 @@ export async function createDevServer(options = {}) {
50
54
  chokidar.watch([configPath, serviceConfigPath]).on('change', async () => {
51
55
  console.log('Config changed, reloading…');
52
56
  fertConfig = await resolveConfig(options);
57
+ await validation.runServiceValidators(['service-config'], {
58
+ fertConfig,
59
+ });
53
60
  await buildExternalAssets(fertConfig);
54
61
 
55
62
  // update server's config & tell Vite to refresh the page
@@ -0,0 +1,61 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { log } from '../../utils/logging.js';
4
+ import { getOutDir } from './get-out-dir.js';
5
+ import { getNunjucksDependencies } from './get-nunjucks-dependencies.js';
6
+
7
+ /**
8
+ * Collect all dependency include/import files (and all icon .svg files) for a given theme entry file.
9
+ * Copy all these files to new output directory (e.g. service's templates folder)
10
+ * Main macro inside entryFilePath is renamed to `CUSTOM`
11
+ * @param {import('../init-template.js').Result} result
12
+ * @param {string} themeDir absolute dir path of `theme` folder in header footer podlet package
13
+ */
14
+ export async function copyThemeFiles(result, themeDir) {
15
+ const outDir = getOutDir(result);
16
+ const { entryFilePath } = result.theme;
17
+ // icons are dynamic includes, so we grab all `.svg` icon paths to copy along with everything else
18
+ const iconFiles = await Array.fromAsync(fs.promises.glob(path.resolve(themeDir, '_common/icons/*.svg')));
19
+
20
+ // Discover all dependencies starting from the input file
21
+ const collectedFiles = new Set([...getNunjucksDependencies(entryFilePath), ...iconFiles]);
22
+
23
+ // Create output directory and copy all files
24
+ fs.mkdirSync(outDir, { recursive: true });
25
+
26
+ for (const absPath of collectedFiles) {
27
+ const relPath = path.relative(themeDir, absPath);
28
+ const destPath = path.join(outDir, relPath);
29
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
30
+
31
+ const source = fs.readFileSync(absPath, 'utf8');
32
+ let rewritten = prependTemplatePaths(source);
33
+ // this is the entry file
34
+ if (absPath === entryFilePath) {
35
+ rewritten = rewritten.replaceAll(result.theme.themeName, 'CUSTOM');
36
+ }
37
+ fs.writeFileSync(destPath, rewritten, 'utf8');
38
+ }
39
+
40
+ log.success(`\nStandalone editable theme initialised at: ${outDir}`);
41
+ }
42
+
43
+ /**
44
+ * Rewrite import/include paths in a file to prepend `templatePath` variable.
45
+ * `templatePath` required to make header-footer-podlet imports work with its HTTP nunjucks loader
46
+ * @param {string} source - The file contents
47
+ * @returns {string} The rewritten source
48
+ */
49
+ export function prependTemplatePaths(source) {
50
+ return source
51
+ .replace(
52
+ // Rewrite from-imports: {% from 'path' import ... %} -> {% from templatePath + 'path' import ... %}
53
+ /(\{%-?\s*from\s+)/g,
54
+ (match, prefix) => `${prefix}templatePath + `,
55
+ )
56
+ .replace(
57
+ // Rewrite includes: {% include 'path' %} -> {% include templatePath + 'path' %}
58
+ /(\{%-?\s*include\s+)/g,
59
+ (match, prefix) => `${prefix}templatePath + `,
60
+ );
61
+ }
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import * as vite from 'vite';
4
+
5
+ /**
6
+ * Create new entry file (e.g. `header.njk` or `footer.njk`), pointing at the new copied theme macro called `CUSTOM`.
7
+ * Also generate the correct arguments to pass to the macro using the Storybook story for that theme.
8
+ *
9
+ * @param {import('../init-template.js').Result} result
10
+ * @param {string} hfpDir absolute dir path for header footer podlet package
11
+ * @param {string} themeDir absolute dir path of `theme` folder in header footer podlet package
12
+ * */
13
+ export async function generateEntryNunjucksFile(result, hfpDir, themeDir) {
14
+ const { entryFilePath, type } = result.theme;
15
+
16
+ // the story file for the theme, we are trusting the filename contains the theme name
17
+ const storyFile = path.resolve(hfpDir, `.storybook/stories/components/${result.theme.themeName}.stories.jsx`);
18
+
19
+ /** Bundle the story file as CJS, stubbing out ServerRender to avoid React requirement */
20
+ const response = await vite.build({
21
+ root: path.dirname(storyFile),
22
+ plugins: [
23
+ {
24
+ name: 'stub-server-render',
25
+ enforce: 'pre',
26
+ resolveId(source) {
27
+ // replace import with replaceable string
28
+ if (source.endsWith('ServerRender.jsx')) return '\0server-render-stub';
29
+ return undefined;
30
+ },
31
+ load(id) {
32
+ // replace string with empty const, we don't need/want `ServerRender`, we just want `default.args` object
33
+ if (id === '\0server-render-stub') return 'export const ServerRender = null;';
34
+ return undefined;
35
+ },
36
+ },
37
+ ],
38
+ build: {
39
+ lib: { entry: storyFile, formats: ['cjs'] },
40
+ write: false,
41
+ rollupOptions: { output: { sourcemap: false, manualChunks: undefined } }, // all in 1 "file" output
42
+ },
43
+ logLevel: 'silent',
44
+ });
45
+
46
+ /** Evaluate the CJS bundle to extract the default export's args */
47
+ const code = response[0].output[0].code;
48
+ const moduleExports = {};
49
+ const moduleObj = { exports: moduleExports };
50
+ new Function('exports', 'module', code)(moduleExports, moduleObj);
51
+
52
+ const args = moduleObj.exports.default.args;
53
+
54
+ args.serviceName = result.service.serviceConfig.serviceName;
55
+
56
+ const rootDir = path.resolve(result.service.dir, 'templates');
57
+ const relEntryFilePath = path.relative(themeDir, entryFilePath);
58
+ const argsJson = JSON.stringify(args, null, 4);
59
+ fs.writeFileSync(
60
+ path.join(rootDir, `${type}.njk`),
61
+ `{% from templatePath + './custom-${type}/${relEntryFilePath}' import CUSTOM with context %}\n{{ CUSTOM(${argsJson}) }}\n`,
62
+ { encoding: 'utf-8' },
63
+ );
64
+ }
@@ -0,0 +1,13 @@
1
+ import * as parser from 'nunjucks/src/parser.js';
2
+ import * as nodes from 'nunjucks/src/nodes.js';
3
+
4
+ /**
5
+ * Extract the first macro name from a template source.
6
+ * @param {string} source - The file contents
7
+ */
8
+ export function getMacroNameFromNunjucks(source) {
9
+ const ast = parser.parse(source);
10
+ const macros = ast.findAll(nodes.Macro);
11
+ if (macros.length === 0) return undefined;
12
+ return macros[0].name.value;
13
+ }
@@ -0,0 +1,58 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import * as parser from 'nunjucks/src/parser.js';
4
+ import * as nodes from 'nunjucks/src/nodes.js';
5
+
6
+ /**
7
+ * Recursively discover all included/imported files using the nunjucks AST, given an entry nunjucks file
8
+ * @param {string} filePath - Absolute path to the entry file
9
+ * @param {Set<string>} _discovered - Set of all discovered absolute file paths
10
+ * @returns {Set<string>} Set of all discovered absolute file paths (including the entry)
11
+ */
12
+ export function getNunjucksDependencies(filePath, _discovered = new Set()) {
13
+ let discovered = new Set(_discovered);
14
+ if (discovered.has(filePath)) return discovered;
15
+ discovered.add(filePath);
16
+
17
+ if (!fs.existsSync(filePath)) {
18
+ console.error(`WARNING: File not found: ${filePath}`);
19
+ return discovered;
20
+ }
21
+
22
+ const source = fs.readFileSync(filePath, 'utf8');
23
+ const dir = path.dirname(filePath);
24
+
25
+ for (const relDepPath of getDependencyPaths(source)) {
26
+ const absDepPath = path.resolve(dir, relDepPath);
27
+ discovered = getNunjucksDependencies(absDepPath, discovered);
28
+ }
29
+ return discovered;
30
+ }
31
+
32
+ /**
33
+ * Extract all template paths (from imports and includes) from a source string using the AST.
34
+ * Dynamic imports and includes are not collected.
35
+ *
36
+ * @param {string} source - Nunjucks template source
37
+ * @returns {string[]} Array of *relative* paths referenced in the template
38
+ */
39
+ export function getDependencyPaths(source) {
40
+ const ast = parser.parse(source);
41
+ const paths = [];
42
+
43
+ for (const node of ast.findAll(nodes.FromImport)) {
44
+ // `node.template.value` will not exist for dynamic import
45
+ if (node.template && node.template.value) {
46
+ paths.push(node.template.value);
47
+ }
48
+ }
49
+
50
+ for (const node of ast.findAll(nodes.Include)) {
51
+ // `node.template.value` will not exist for dynamic include
52
+ if (node.template && node.template.value) {
53
+ paths.push(node.template.value);
54
+ }
55
+ }
56
+
57
+ return paths;
58
+ }
@@ -0,0 +1,6 @@
1
+ import path from 'node:path';
2
+
3
+ /** @param {import("../init-template.js").Result} result */
4
+ export function getOutDir(result) {
5
+ return path.resolve(result.service.dir, 'templates', `custom-${result.theme.type}`);
6
+ }
@@ -0,0 +1,94 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import chalk from 'chalk';
5
+ import prompts from 'prompts';
6
+ import { findFertConfigDir, loadServiceConfigFiles } from '../utils/index.js';
7
+ import { log } from '../utils/logging.js';
8
+ import { generateEntryNunjucksFile } from './init-template-tasks/generate-entry-nunjucks-file.js';
9
+ import { copyThemeFiles } from './init-template-tasks/copy-theme-files.js';
10
+ import { getMacroNameFromNunjucks } from './init-template-tasks/get-macro-name-from-nunjucks.js';
11
+
12
+ /** header-footer-podlet-server package root dir */
13
+ const hfpDir = path.dirname(fileURLToPath(import.meta.resolve('@private/header-footer-podlet-server/package.json')));
14
+ /** main themes dir inside header-footer-podlet-server */
15
+ const themeDir = path.resolve(hfpDir, 'lib/templates/themes');
16
+
17
+ /**
18
+ * Object constructed based on user selection - has all info required to copy a theme's files to the correct service template dir
19
+ * @typedef Result
20
+ * @type {object}
21
+ * @property {{dir:string, serviceConfig: FertServiceConfigFile}} service
22
+ * @property {{type:"header"|"footer", themeName:string, entryFilePath:string}} theme
23
+ * */
24
+
25
+ /**
26
+ * This command copies theme files from header-footer-podlet, into a service's template folder, to be used standalone for creating custom headers and footers.
27
+ *
28
+ * 1. collect user prompt choices
29
+ * 2. copy theme files based on choices - modified files to use `templatePath` and rename the theme macro name to `CUSTOM`
30
+ * 3. create new entry file (header.njk or footer.njk) pointing to the copied theme files
31
+ */
32
+ export async function initTemplateCommand(/* options = {} */) {
33
+ /** @type {Result} */
34
+ let result;
35
+ const rootDir = await findFertConfigDir();
36
+
37
+ const serviceConfigs = await loadServiceConfigFiles();
38
+ if (!serviceConfigs.length) {
39
+ log.warn('No services found to populate with template');
40
+ return;
41
+ }
42
+
43
+ /** entry file paths for each theme, all entry files are named `header.njk` or `footer.njk` */
44
+ let themes = await Array.fromAsync(fs.promises.glob(path.resolve(themeDir, '**/{header,footer}.njk')));
45
+ // cheeky hardcode exclude `simplified` themes, as they are not really to be used again
46
+ themes = themes.filter((theme) => !theme.includes('simplified'));
47
+
48
+ /** @type {prompts.PromptObject[]} Ask user which service to copy files to, and which theme to copy */
49
+ const promptArr = [
50
+ {
51
+ type: 'select',
52
+ name: 'service',
53
+ message: 'Which Service?',
54
+ choices: serviceConfigs.map((item) => ({
55
+ title: item.serviceConfig.serviceName,
56
+ description: `Found at ${path.relative(rootDir, item?.dir)}`,
57
+ value: item,
58
+ })),
59
+ initial: 0,
60
+ },
61
+ {
62
+ type: 'select',
63
+ name: 'theme',
64
+ message: 'Which Theme?',
65
+ choices: themes.map((themeEntryFilePath) => {
66
+ // cheekily get `header` or `footer` "type" from the filename, we only collected `header.njk` and `footer.njk` in `themes` glob above.
67
+ const type = path.basename(themeEntryFilePath, '.njk');
68
+ const entryFileNjkSrc = fs.readFileSync(themeEntryFilePath, 'utf-8');
69
+ const themeName = getMacroNameFromNunjucks(entryFileNjkSrc);
70
+ return {
71
+ title: themeName,
72
+ description: `Type: ${type}`,
73
+ value: { type, themeName, entryFilePath: themeEntryFilePath },
74
+ };
75
+ }),
76
+ initial: 0,
77
+ },
78
+ ];
79
+
80
+ try {
81
+ result = await prompts(promptArr, {
82
+ onCancel: () => {
83
+ throw new Error(chalk.red('✖') + ' Operation cancelled');
84
+ },
85
+ });
86
+ } catch (cancelled) {
87
+ console.log(cancelled.message);
88
+ return;
89
+ }
90
+
91
+ await copyThemeFiles(result, themeDir);
92
+
93
+ await generateEntryNunjucksFile(result, hfpDir, themeDir);
94
+ }
@@ -3,6 +3,7 @@ import * as Hoek from '@hapi/hoek';
3
3
  import chalk from 'chalk';
4
4
  import { resolveConfig } from '../utils/index.js';
5
5
  import { log } from '../utils/logging.js';
6
+ import * as validation from '../utils/validation.js';
6
7
  import { getAwsParam } from './publish-tasks/get-aws-parameter.js';
7
8
  import { AssetStoreUploader } from './publish-tasks/asset-store-uploader.js';
8
9
  import { getCloudFrontDistributionsForDomain } from '../utils/lookup-cf-distribution-ids.js';
@@ -36,6 +37,11 @@ export async function publish(options) {
36
37
  throw Error(`Missing or invalid --target option. Choose from [${validTargets}]`);
37
38
  }
38
39
 
40
+ await validation.runServiceValidators(validation.getAllValidators(), {
41
+ fertConfig,
42
+ throwable: true,
43
+ });
44
+
39
45
  if (options.dryRun) {
40
46
  console.log(chalk.green('Running Publish in Dry Mode'));
41
47
  }
@@ -15,7 +15,7 @@ export async function validateCommand(/* options */) {
15
15
  for (const { serviceConfig } of serviceConfigs) {
16
16
  const fertConfig = await resolveConfig({ serviceName: serviceConfig.serviceName });
17
17
 
18
- await validation.runServiceValidators(['brand-json', 'translations', 'redirects-csv'], {
18
+ await validation.runServiceValidators(validation.getAllValidators(), {
19
19
  fertConfig,
20
20
  throwable: true,
21
21
  });
@@ -16,8 +16,9 @@ export async function doCpidLookup(clientPropertyId) {
16
16
  throw new Error(`${results.status} - ${errorMessage}`);
17
17
  }
18
18
  const { data } = await results.json();
19
- const { brandName, parentId: parentCpid } = data;
19
+ const { name, brandName, parentId: parentCpid } = data;
20
20
  return {
21
+ name,
21
22
  brandName,
22
23
  parentCpid: parentCpid ?? false,
23
24
  rootClientPropertyId: parentCpid ?? clientPropertyId,
@@ -3,12 +3,12 @@ import { ASSET_RELAY_API } from '../../constants.js';
3
3
  /**
4
4
  * fetch the Absolute URL `publicUrl` from asset-relay API, for current client & service
5
5
  *
6
- * @param {object} fertConfig
6
+ * @param {FertConfig} fertConfig
7
7
  * @param {object} environment
8
- * @returns {URL} Absoloute Public URL for assets
8
+ * @returns {URL} Absolute Public URL for assets
9
9
  */
10
10
  export async function getAssetsPath(fertConfig, environment) {
11
- const { clientPropertyId, serviceName, client, config } = fertConfig;
11
+ const { clientPropertyId, serviceName, client } = fertConfig;
12
12
 
13
13
  // Try to get existing mapping
14
14
  const getUrl = new URL(ASSET_RELAY_API);
@@ -26,7 +26,7 @@ export async function getAssetsPath(fertConfig, environment) {
26
26
  clientPropertyId,
27
27
  rootClientPropertyId: client.rootClientPropertyId,
28
28
  serviceName,
29
- siteName: config.SiteName,
29
+ siteName: client.name || client.brandName,
30
30
  }),
31
31
  });
32
32
 
@@ -12,35 +12,12 @@ import { cpidLookup } from './cpid-lookup.js';
12
12
  import { cpIdMatchesGitRemote } from './cpid-matches-git-remote.js';
13
13
  import { resolveExternalAssets } from './resolve-external-assets.js';
14
14
  import { log } from './logging.js';
15
- import { CONFIG_API, VERSION, FERT_CONFIG_FILENAME, FERT_SERVICE_CONFIG_FILENAME } from '../../constants.js';
15
+ import { VERSION, FERT_CONFIG_FILENAME, FERT_SERVICE_CONFIG_FILENAME } from '../../constants.js';
16
16
 
17
17
  export function printBanner() {
18
18
  console.log(`\n${chalk.green.bold('Fert')} v${VERSION}`);
19
19
  }
20
20
 
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 = []) {
29
- Hoek.assert(Array.isArray(configNames), 'configNames must be an array');
30
-
31
- const result = {};
32
-
33
- for (let config of configNames) {
34
- const url = CONFIG_API.replace('{configGroup}', 'clientconfig').replace('{cpid}', cpid).replace('{config}', config);
35
- const { data } = await fetch(url).then((response) => response.json());
36
- const { value } = data?.[0] || {};
37
-
38
- result[config] = value;
39
- }
40
-
41
- return result;
42
- }
43
-
44
21
  export async function resolveConfig(options = {}) {
45
22
  Hoek.assert(options.serviceName, 'serviceName is required');
46
23
 
@@ -96,8 +73,6 @@ export async function resolveConfig(options = {}) {
96
73
 
97
74
  fertConfig.externalAssets = resolveExternalAssets(fertConfig.externalAssets);
98
75
 
99
- fertConfig.config = await getClientConfig(fertConfig.clientPropertyId, ['JobseekerSiteWebSitePath', 'SiteName']);
100
-
101
76
  return {
102
77
  ...fertConfig,
103
78
  rootDir,
@@ -1,11 +1,32 @@
1
1
  import chalk from 'chalk';
2
2
  import { log } from './logging.js';
3
+ import * as validatorServiceConfig from '../validators/service-config.validator.js';
3
4
  import * as validatorBrandJson from '../validators/brand-json.validator.js';
4
5
  import * as validatorTranslations from '../validators/translations.validator.js';
5
6
  import * as validatorRedirectsCsv from '../validators/redirects-csv.validator.js';
7
+ import * as validatorRequiredAssets from '../validators/required-assets.validator.js';
8
+ import * as validatorCss from '../validators/css.validator.js';
9
+ import * as validatorNavLinks from '../validators/nav-links.validator.js';
6
10
 
7
11
  /** @type {Array<Validator>} */
8
- const validators = [validatorBrandJson, validatorTranslations, validatorRedirectsCsv];
12
+ const validators = [
13
+ validatorServiceConfig,
14
+ validatorBrandJson,
15
+ validatorTranslations,
16
+ validatorRedirectsCsv,
17
+ validatorRequiredAssets,
18
+ validatorCss,
19
+ validatorNavLinks,
20
+ ];
21
+
22
+ /**
23
+ * Returns array of all validator names (derived from the validators registry).
24
+ * This is the canonical list - use this instead of maintaining a separate list.
25
+ * @returns {Array<string>}
26
+ */
27
+ export function getAllValidators() {
28
+ return validators.map((v) => v.name);
29
+ }
9
30
 
10
31
  /**
11
32
  *
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import * as validation from '../utils/validation.js';
4
+
5
+ export const name = 'css';
6
+ export const description = 'Validates src CSS for forbidden class prefixes';
7
+
8
+ const FORBIDDEN_CLASS_PREFIX = /\.(?<prefix>mds)-[a-z0-9-]+/gi;
9
+
10
+ /**
11
+ * Recursively collect all CSS files under a directory.
12
+ *
13
+ * @param {string} dirPath
14
+ * @returns {Array<string>}
15
+ */
16
+ function getCssFilesRecursive(dirPath) {
17
+ const cssFiles = [];
18
+
19
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
20
+ for (const entry of entries) {
21
+ const entryPath = path.join(dirPath, entry.name);
22
+
23
+ if (entry.isDirectory()) {
24
+ cssFiles.push(...getCssFilesRecursive(entryPath));
25
+ continue;
26
+ }
27
+
28
+ if (entry.isFile() && ['.css', '.scss'].includes(path.extname(entry.name).toLowerCase())) {
29
+ cssFiles.push(entryPath);
30
+ }
31
+ }
32
+
33
+ return cssFiles;
34
+ }
35
+
36
+ /**
37
+ * @property {object} options
38
+ * @property {FertConfig} options.fertConfig
39
+ * @returns {Promise<Array<ValidationResult>>}
40
+ */
41
+ export async function validate({ fertConfig }) {
42
+ const resultsCollection = validation.createResultCollection();
43
+ const srcPath = path.resolve(fertConfig.workingDir, 'src');
44
+
45
+ if (!fs.existsSync(srcPath)) {
46
+ return resultsCollection.results;
47
+ }
48
+
49
+ const cssFiles = getCssFilesRecursive(srcPath);
50
+
51
+ for (const cssFilePath of cssFiles) {
52
+ let cssContent;
53
+ try {
54
+ cssContent = fs.readFileSync(cssFilePath, 'utf-8');
55
+ } catch (error) {
56
+ resultsCollection.addResult('Error reading CSS file').err().filePath(cssFilePath).detail(error.message);
57
+ continue;
58
+ }
59
+
60
+ const matches = [...cssContent.matchAll(FORBIDDEN_CLASS_PREFIX)];
61
+ if (!matches.length) continue;
62
+
63
+ const offendingPrefixes = [...new Set(matches.map((match) => `${match.groups?.prefix}-`))];
64
+ for (const prefix of offendingPrefixes) {
65
+ resultsCollection
66
+ .addResult(`Found forbidden class prefix ${prefix} in ${cssFilePath}`)
67
+ .filePath(cssFilePath)
68
+ .detail('Class prefixes starting with mds- should not be used in dist CSS files.');
69
+ }
70
+ }
71
+
72
+ return resultsCollection.results;
73
+ }
@@ -0,0 +1,74 @@
1
+ import { glob } from 'node:fs/promises';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import * as validation from '../utils/validation.js';
5
+
6
+ export const name = 'nav-links';
7
+ export const description = 'Validates navigation link href conventions per service type';
8
+
9
+ /** Expected href conventions per service */
10
+ const SERVICE_CONVENTIONS = {
11
+ 'jobseekers-frontend': { pattern: /^\/staticpages(\/|$)/, expected: '/staticpages' },
12
+ 'recruiterservices-frontend': { pattern: /^\/static-page(\/|$)/, expected: '/static-page' },
13
+ };
14
+
15
+ /**
16
+ * Extract all static-page href values from template content using regex.
17
+ * Matches hrefs starting with /static-page/ or /staticpages/.
18
+ * Supports both JavaScript object literal syntax (href: "...") and HTML attribute syntax (href="...").
19
+ *
20
+ * @param {string} content
21
+ * @returns {Array<string>}
22
+ */
23
+ export function extractStaticPageHrefs(content) {
24
+ const hrefRegex = /['"]?href['"]?\s*[:=]\s*['"](\/(static-page|staticpages)\/[^'"]*)['"]/gi;
25
+ const hrefs = [];
26
+ let match;
27
+ while ((match = hrefRegex.exec(content)) !== null) {
28
+ hrefs.push(match[1]);
29
+ }
30
+ return hrefs;
31
+ }
32
+
33
+ /**
34
+ * @param {object} options
35
+ * @param {FertConfig} options.fertConfig
36
+ * @returns {Promise<Array<ValidationResult>>}
37
+ */
38
+ export async function validate({ fertConfig }) {
39
+ const resultsCollection = validation.createResultCollection();
40
+ const templatesDir = path.resolve(fertConfig.workingDir, 'templates');
41
+ const convention = SERVICE_CONVENTIONS[fertConfig.serviceName];
42
+
43
+ const templateFiles = await Array.fromAsync(glob('**/*.njk', { cwd: templatesDir }));
44
+
45
+ if (templateFiles.length === 0) {
46
+ resultsCollection
47
+ .addResult('No templates found, skipping nav link validation')
48
+ .detail(`Expected .njk templates in ${templatesDir}`);
49
+ return resultsCollection.results;
50
+ }
51
+
52
+ for (const file of templateFiles) {
53
+ const templatePath = path.join(templatesDir, file);
54
+ const content = fs.readFileSync(templatePath, 'utf-8');
55
+ const staticPageHrefs = extractStaticPageHrefs(content);
56
+
57
+ if (staticPageHrefs.length === 0 || !convention) continue;
58
+
59
+ const failing = staticPageHrefs.filter((href) => !convention.pattern.test(href));
60
+
61
+ if (failing.length === 0) continue;
62
+
63
+ resultsCollection
64
+ .addResult(
65
+ `${failing.length} of ${staticPageHrefs.length} static page link(s) do not follow the convention for ${fertConfig.serviceName}`,
66
+ )
67
+ .filePath(templatePath)
68
+ .detail(
69
+ `${fertConfig.serviceName} should use '${convention.expected}' prefix. Failing links:\n${failing.map((h) => ` - ${h}`).join('\n')}`,
70
+ );
71
+ }
72
+
73
+ return resultsCollection.results;
74
+ }