@madgex/fert 7.0.14 → 7.1.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
|
@@ -10,6 +10,7 @@ import { printBanner } from './utils/index.js';
|
|
|
10
10
|
import { serviceCommandBootstrap } from './commands/_service-command-bootstrap.js';
|
|
11
11
|
import { configsCommand } from './commands/configs.js';
|
|
12
12
|
import { initCommand } from './commands/init.js';
|
|
13
|
+
import { validateCommand } from './commands/validate.js';
|
|
13
14
|
|
|
14
15
|
const cli = cac('fert');
|
|
15
16
|
|
|
@@ -56,6 +57,8 @@ const run = () => {
|
|
|
56
57
|
.option('--cpid <cpid>', 'Specify the clientPropertyId to use in the new project')
|
|
57
58
|
.action((...args) => initCommand(...args));
|
|
58
59
|
|
|
60
|
+
cli.command('validate', 'Validate branding project for common issues').action((...args) => validateCommand(...args));
|
|
61
|
+
|
|
59
62
|
cli.option('--no-cache', 'Do not use cache');
|
|
60
63
|
cli.option('--purge-cache', 'Purge all caches');
|
|
61
64
|
cli.help();
|
package/bin/commands/build.js
CHANGED
|
@@ -6,6 +6,10 @@ 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
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];
|
|
9
13
|
|
|
10
14
|
export { bundleEntry, buildExternalAssets };
|
|
11
15
|
|
|
@@ -22,6 +26,15 @@ export async function build(options = {}) {
|
|
|
22
26
|
clientPropertyId: fertConfig.clientPropertyId,
|
|
23
27
|
});
|
|
24
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
|
+
}
|
|
37
|
+
|
|
25
38
|
if (!options.only) {
|
|
26
39
|
await rimraf(path.resolve(fertConfig.workingDir, 'dist'));
|
|
27
40
|
await buildTokens(fertConfig, options);
|
|
@@ -6,9 +6,11 @@ import open from 'open';
|
|
|
6
6
|
import chokidar from 'chokidar5';
|
|
7
7
|
import { resolveConfig, findFertConfigDir } from '../utils/index.js';
|
|
8
8
|
import { validateLocalConfigs } from '../utils/configs.js';
|
|
9
|
+
import { runValidators, reportResults } from '../utils/validation-helpers.js';
|
|
9
10
|
import { devServer } from '../../server/server.js';
|
|
10
11
|
import { buildTokens, buildExternalAssets } from './build.js';
|
|
11
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';
|
|
12
14
|
|
|
13
15
|
export async function createDevServer(options = {}) {
|
|
14
16
|
let fertConfig = await resolveConfig(options);
|
|
@@ -43,6 +45,9 @@ export async function createDevServer(options = {}) {
|
|
|
43
45
|
|
|
44
46
|
chokidar.watch(brandPath, { ignoreInitial: true }).on('all', async () => {
|
|
45
47
|
await buildTokens(fertConfig);
|
|
48
|
+
|
|
49
|
+
const results = await runValidators([brandJsonValidator], { rootDir: fertConfig.rootDir, options });
|
|
50
|
+
reportResults(results, rootDir);
|
|
46
51
|
});
|
|
47
52
|
|
|
48
53
|
const configPath = path.resolve(fertConfigDir, FERT_CONFIG_FILENAME);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { findFertConfigDir } from '../utils/index.js';
|
|
2
|
+
import { runValidators, reportResults } from '../utils/validation-helpers.js';
|
|
3
|
+
import { log } from '../utils/logging.js';
|
|
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) {
|
|
11
|
+
log.info('Running validations…\n');
|
|
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);
|
|
22
|
+
|
|
23
|
+
if (exitCode !== 0) {
|
|
24
|
+
// eslint-disable-next-line n/no-process-exit
|
|
25
|
+
process.exit(exitCode);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { createStyleDictionary } from '@madgex/design-system/style-dictionary';
|
|
4
|
+
// eslint-disable-next-line n/no-extraneous-import
|
|
5
|
+
import { resolveReferences } from 'style-dictionary/utils';
|
|
6
|
+
import { loadServiceConfigFiles } from '../utils/index.js';
|
|
7
|
+
import { Severity, createResult } from '../utils/validation-helpers.js';
|
|
8
|
+
import { BRAND_JSON_FILENAME } from '../../constants.js';
|
|
9
|
+
|
|
10
|
+
export const name = 'brand-json';
|
|
11
|
+
export const description = 'Validates brand.json overrides against the MDS base token schema';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Recursively extracts all dot-separated token paths from a token object.
|
|
15
|
+
* Stops at `$value` leaves and skips `$`-prefixed metadata keys.
|
|
16
|
+
*/
|
|
17
|
+
export function extractTokenPaths(obj, prefix = '') {
|
|
18
|
+
const paths = new Set();
|
|
19
|
+
|
|
20
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
21
|
+
if (key.startsWith('$')) continue;
|
|
22
|
+
|
|
23
|
+
const currentPath = prefix ? `${prefix}.${key}` : key;
|
|
24
|
+
|
|
25
|
+
if (value && typeof value === 'object' && !('$value' in value)) {
|
|
26
|
+
const childPaths = extractTokenPaths(value, currentPath);
|
|
27
|
+
if (childPaths.size > 0) {
|
|
28
|
+
for (const childPath of childPaths) {
|
|
29
|
+
paths.add(childPath);
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
// Empty group — emit the path itself so it can be validated against the schema
|
|
33
|
+
paths.add(currentPath);
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
paths.add(currentPath);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return paths;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Recursively resolves a token `$value` through any `{references}` to a final scalar.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveToFinalValue(rawValue, tokens) {
|
|
47
|
+
if (typeof rawValue !== 'string' || !rawValue.includes('{')) return rawValue;
|
|
48
|
+
const resolved = resolveReferences(rawValue, tokens, { usesDtcg: true});
|
|
49
|
+
if (resolved) {
|
|
50
|
+
return resolveToFinalValue(resolved, tokens);
|
|
51
|
+
}
|
|
52
|
+
return resolved;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Walks a nested object by dot-separated path and returns the `$value` at that leaf.
|
|
57
|
+
*/
|
|
58
|
+
export function getValueAtPath(obj, dotPath) {
|
|
59
|
+
let current = obj;
|
|
60
|
+
for (const part of dotPath.split('.')) {
|
|
61
|
+
if (!current || typeof current !== 'object') return undefined;
|
|
62
|
+
current = current[part];
|
|
63
|
+
}
|
|
64
|
+
return current?.$value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Loads all MDS base tokens via StyleDictionary and returns the set of valid
|
|
69
|
+
* token paths, their resolved values, and the tokens tree for reference resolution.
|
|
70
|
+
*/
|
|
71
|
+
async function loadBaseSchema() {
|
|
72
|
+
const { styleDictionary, cleanTempFiles } = await createStyleDictionary();
|
|
73
|
+
const basePaths = new Set();
|
|
74
|
+
const baseResolvedValues = new Map();
|
|
75
|
+
|
|
76
|
+
for (const token of styleDictionary.allTokens) {
|
|
77
|
+
const tokenPath = token.key.slice(1, -1);
|
|
78
|
+
basePaths.add(tokenPath);
|
|
79
|
+
baseResolvedValues.set(tokenPath, resolveToFinalValue(token.$value, styleDictionary.tokens));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const baseTokens = styleDictionary.tokens;
|
|
83
|
+
await cleanTempFiles();
|
|
84
|
+
return { basePaths, baseResolvedValues, baseTokens };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function validate() {
|
|
88
|
+
const results = [];
|
|
89
|
+
const { basePaths, baseResolvedValues, baseTokens } = await loadBaseSchema();
|
|
90
|
+
|
|
91
|
+
let serviceConfigs;
|
|
92
|
+
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;
|
|
101
|
+
}
|
|
102
|
+
|
|
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
|
+
}
|
|
115
|
+
|
|
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;
|
|
127
|
+
}
|
|
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
|
+
|
|
145
|
+
const resolvedOverride = resolveToFinalValue(rawOverrideValue, baseTokens);
|
|
146
|
+
const resolvedBase = baseResolvedValues.get(overridePath);
|
|
147
|
+
|
|
148
|
+
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
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return results;
|
|
160
|
+
}
|