@madgex/fert 7.3.0 → 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.
- package/bin/commands/build.js +1 -1
- package/bin/commands/dev-server.js +10 -3
- package/bin/commands/publish.js +6 -0
- package/bin/commands/validate.js +1 -1
- package/bin/utils/validation.js +22 -1
- package/bin/validators/css.validator.js +73 -0
- package/bin/validators/nav-links.validator.js +74 -0
- package/bin/validators/required-assets.validator.js +34 -0
- package/bin/validators/service-config.validator.js +121 -0
- package/package.json +1 -1
package/bin/commands/build.js
CHANGED
|
@@ -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(
|
|
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'
|
|
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
|
package/bin/commands/publish.js
CHANGED
|
@@ -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
|
}
|
package/bin/commands/validate.js
CHANGED
|
@@ -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(
|
|
18
|
+
await validation.runServiceValidators(validation.getAllValidators(), {
|
|
19
19
|
fertConfig,
|
|
20
20
|
throwable: true,
|
|
21
21
|
});
|
package/bin/utils/validation.js
CHANGED
|
@@ -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 = [
|
|
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
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import * as validation from '../utils/validation.js';
|
|
3
|
+
import { exists } from '../utils/index.js';
|
|
4
|
+
|
|
5
|
+
export const name = 'required-assets';
|
|
6
|
+
export const description = 'Validates that required asset files exist in the service public directory';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Main validation function.
|
|
10
|
+
* Checks for required assets like favicon.ico
|
|
11
|
+
* @property {object} options
|
|
12
|
+
* @property {FertConfig} options.fertConfig - Resolved config
|
|
13
|
+
* @returns {Promise<Array<ValidationResult>>} Array of validation results
|
|
14
|
+
*/
|
|
15
|
+
export async function validate({ fertConfig }) {
|
|
16
|
+
const resultsCollection = validation.createResultCollection();
|
|
17
|
+
|
|
18
|
+
const publicDir = path.resolve(fertConfig.workingDir, 'public');
|
|
19
|
+
|
|
20
|
+
// Check for favicon.ico (ERROR if missing)
|
|
21
|
+
const faviconPath = path.join(publicDir, 'images', 'favicon.ico');
|
|
22
|
+
if (!(await exists(faviconPath))) {
|
|
23
|
+
resultsCollection
|
|
24
|
+
.addResult('Missing required favicon.ico')
|
|
25
|
+
.err()
|
|
26
|
+
.filePath(faviconPath)
|
|
27
|
+
.detail('Every service must include a favicon.ico file at public/images/favicon.ico');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// TODO: Add font file validation (WARNING) if referenced in brand.json or CSS
|
|
31
|
+
// TODO: Add icon file validation (WARNING) if referenced in templates or CSS
|
|
32
|
+
|
|
33
|
+
return resultsCollection.results;
|
|
34
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import * as validation from '../utils/validation.js';
|
|
3
|
+
import { FERT_SERVICE_CONFIG_FILENAME } from '../../constants.js';
|
|
4
|
+
import { exists, loadConfigFromFile } from '../utils/index.js';
|
|
5
|
+
|
|
6
|
+
export const name = 'service-config';
|
|
7
|
+
export const description = 'Validates fert.service.config.js structure and required fields';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Main validation function.
|
|
11
|
+
* Validates the raw service config file (not the merged/resolved fertConfig).
|
|
12
|
+
* @property {object} options
|
|
13
|
+
* @property {FertConfig} options.fertConfig - Resolved config (used to locate the raw config file)
|
|
14
|
+
* @returns {Promise<Array<ValidationResult>>} Array of validation results
|
|
15
|
+
*/
|
|
16
|
+
export async function validate({ fertConfig }) {
|
|
17
|
+
const resultsCollection = validation.createResultCollection();
|
|
18
|
+
|
|
19
|
+
const configPath = path.resolve(fertConfig.workingDir, FERT_SERVICE_CONFIG_FILENAME);
|
|
20
|
+
|
|
21
|
+
// Load raw service config from file
|
|
22
|
+
if (!(await exists(configPath))) {
|
|
23
|
+
resultsCollection
|
|
24
|
+
.addResult(`Service config file not found: ${FERT_SERVICE_CONFIG_FILENAME}`)
|
|
25
|
+
.err()
|
|
26
|
+
.filePath(configPath)
|
|
27
|
+
.detail('Each service must have a fert.service.config.js file');
|
|
28
|
+
return resultsCollection.results;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let rawConfig;
|
|
32
|
+
try {
|
|
33
|
+
rawConfig = await loadConfigFromFile({
|
|
34
|
+
dir: fertConfig.workingDir,
|
|
35
|
+
filename: FERT_SERVICE_CONFIG_FILENAME,
|
|
36
|
+
silent: true,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (!rawConfig) {
|
|
40
|
+
resultsCollection
|
|
41
|
+
.addResult(`Failed to load service config file`)
|
|
42
|
+
.err()
|
|
43
|
+
.filePath(configPath)
|
|
44
|
+
.detail('The config file could not be imported. Check for syntax errors.');
|
|
45
|
+
return resultsCollection.results;
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
resultsCollection
|
|
49
|
+
.addResult(`Failed to parse service config: ${err.message}`)
|
|
50
|
+
.err()
|
|
51
|
+
.filePath(configPath)
|
|
52
|
+
.detail('Ensure the file exports a valid configuration object');
|
|
53
|
+
return resultsCollection.results;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Validate serviceName (required, ERROR)
|
|
57
|
+
if (!rawConfig.serviceName) {
|
|
58
|
+
resultsCollection
|
|
59
|
+
.addResult('Missing required field: serviceName')
|
|
60
|
+
.err()
|
|
61
|
+
.filePath(configPath)
|
|
62
|
+
.detail('serviceName must be specified as a string (e.g., "jobseekers-frontend")');
|
|
63
|
+
} else if (typeof rawConfig.serviceName !== 'string') {
|
|
64
|
+
resultsCollection
|
|
65
|
+
.addResult('Invalid serviceName type')
|
|
66
|
+
.err()
|
|
67
|
+
.filePath(configPath)
|
|
68
|
+
.detail(`serviceName must be a string, got ${typeof rawConfig.serviceName}`);
|
|
69
|
+
} else if (rawConfig.serviceName.trim() === '') {
|
|
70
|
+
resultsCollection
|
|
71
|
+
.addResult('serviceName cannot be empty')
|
|
72
|
+
.err()
|
|
73
|
+
.filePath(configPath)
|
|
74
|
+
.detail('serviceName must be a non-empty string (e.g., "jobseekers-frontend")');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Validate entry (required, ERROR)
|
|
78
|
+
if (!rawConfig.entry) {
|
|
79
|
+
resultsCollection
|
|
80
|
+
.addResult('Missing required field: entry')
|
|
81
|
+
.err()
|
|
82
|
+
.filePath(configPath)
|
|
83
|
+
.detail('entry must be specified as a string path (e.g., "./src/index.js")');
|
|
84
|
+
} else if (typeof rawConfig.entry !== 'string') {
|
|
85
|
+
resultsCollection
|
|
86
|
+
.addResult('Invalid entry type')
|
|
87
|
+
.err()
|
|
88
|
+
.filePath(configPath)
|
|
89
|
+
.detail(`entry must be a string, got ${typeof rawConfig.entry}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Validate externalAssets.links (optional, WARNING)
|
|
93
|
+
if (rawConfig.externalAssets?.links !== undefined) {
|
|
94
|
+
const { links } = rawConfig.externalAssets;
|
|
95
|
+
const isValidType =
|
|
96
|
+
typeof links === 'string' || (Array.isArray(links) && links.every((link) => typeof link === 'string'));
|
|
97
|
+
|
|
98
|
+
if (!isValidType) {
|
|
99
|
+
resultsCollection
|
|
100
|
+
.addResult('Invalid externalAssets.links type')
|
|
101
|
+
.filePath(configPath)
|
|
102
|
+
.detail('externalAssets.links must be a string or an array of strings');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Validate externalAssets.scripts (optional, WARNING)
|
|
107
|
+
if (rawConfig.externalAssets?.scripts !== undefined) {
|
|
108
|
+
const { scripts } = rawConfig.externalAssets;
|
|
109
|
+
const isValidType =
|
|
110
|
+
typeof scripts === 'string' || (Array.isArray(scripts) && scripts.every((script) => typeof script === 'string'));
|
|
111
|
+
|
|
112
|
+
if (!isValidType) {
|
|
113
|
+
resultsCollection
|
|
114
|
+
.addResult('Invalid externalAssets.scripts type')
|
|
115
|
+
.filePath(configPath)
|
|
116
|
+
.detail('externalAssets.scripts must be a string or an array of strings');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return resultsCollection.results;
|
|
121
|
+
}
|