@madgex/fert 7.2.0 → 7.2.1
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/README.md +50 -0
- package/bin/commands/build.js +4 -1
- package/bin/commands/dev-server.js +2 -2
- package/bin/commands/validate.js +1 -1
- package/bin/utils/validation.js +2 -1
- package/bin/validators/redirects-csv.validator.js +147 -0
- package/constants.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -341,3 +341,53 @@ Here are the default npm scripts in a Fert-scaffolded project.
|
|
|
341
341
|
}
|
|
342
342
|
}
|
|
343
343
|
```
|
|
344
|
+
|
|
345
|
+
## Local Development
|
|
346
|
+
|
|
347
|
+
Use this when you want to test or debug this local FERT codebase against a real branding repo.
|
|
348
|
+
|
|
349
|
+
Link the local package:
|
|
350
|
+
|
|
351
|
+
```bash
|
|
352
|
+
cd ~/Repos/madgex-frontend-rollout-tool
|
|
353
|
+
npm link
|
|
354
|
+
|
|
355
|
+
cd ~/Repos/madgex-ff6102ff-0f4b-43d1-a2c7-83b835b8dee5
|
|
356
|
+
npm link @madgex/fert
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
That gives you both of these:
|
|
360
|
+
|
|
361
|
+
- a global `fert` command pointing at this local repo
|
|
362
|
+
- a linked `@madgex/fert` inside the branding repo's `node_modules`
|
|
363
|
+
|
|
364
|
+
From the branding repo, the closest match to normal usage is to run the repo scripts or `npx fert`:
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
npm run dev
|
|
368
|
+
npx fert validate
|
|
369
|
+
npx fert build --target=production
|
|
370
|
+
npx fert publish --target=dev --dry-run
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
You can also run `fert ...` directly in a shell after the first `npm link`, because that creates the global symlink.
|
|
374
|
+
|
|
375
|
+
To step-debug local FERT code in VS Code, open this repo, set breakpoints, open a JavaScript Debug Terminal, `cd` to the branding repo, then run any `fert` command there. Breakpoints in this repo will be hit automatically.
|
|
376
|
+
|
|
377
|
+
If you do not want to link, run the CLI directly from the branding repo:
|
|
378
|
+
|
|
379
|
+
```bash
|
|
380
|
+
node ~/Repos/madgex-frontend-rollout-tool/bin/cli.js dev
|
|
381
|
+
node ~/Repos/madgex-frontend-rollout-tool/bin/cli.js validate
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
To revert back to the published package:
|
|
385
|
+
|
|
386
|
+
```bash
|
|
387
|
+
cd ~/Repos/madgex-ff6102ff-0f4b-43d1-a2c7-83b835b8dee5
|
|
388
|
+
npm unlink @madgex/fert
|
|
389
|
+
npm install
|
|
390
|
+
|
|
391
|
+
cd ~/Repos/madgex-frontend-rollout-tool
|
|
392
|
+
npm unlink
|
|
393
|
+
```
|
package/bin/commands/build.js
CHANGED
|
@@ -22,7 +22,10 @@ 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'], {
|
|
25
|
+
await validation.runServiceValidators(['brand-json', 'translations', 'redirects-csv'], {
|
|
26
|
+
fertConfig,
|
|
27
|
+
throwable: true,
|
|
28
|
+
});
|
|
26
29
|
|
|
27
30
|
if (!options.only) {
|
|
28
31
|
await rimraf(path.resolve(fertConfig.workingDir, 'dist'));
|
|
@@ -33,10 +33,10 @@ 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'], { fertConfig });
|
|
36
|
+
await validation.runServiceValidators(['brand-json', 'translations', 'redirects-csv'], { fertConfig });
|
|
37
37
|
});
|
|
38
38
|
// initial validation run
|
|
39
|
-
await validation.runServiceValidators(['brand-json', 'translations'], { fertConfig });
|
|
39
|
+
await validation.runServiceValidators(['brand-json', 'translations', 'redirects-csv'], { fertConfig });
|
|
40
40
|
|
|
41
41
|
// building tokens
|
|
42
42
|
await buildTokens(fertConfig);
|
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(['brand-json', 'translations'], {
|
|
18
|
+
await validation.runServiceValidators(['brand-json', 'translations', 'redirects-csv'], {
|
|
19
19
|
fertConfig,
|
|
20
20
|
throwable: true,
|
|
21
21
|
});
|
package/bin/utils/validation.js
CHANGED
|
@@ -2,9 +2,10 @@ import chalk from 'chalk';
|
|
|
2
2
|
import { log } from './logging.js';
|
|
3
3
|
import * as validatorBrandJson from '../validators/brand-json.validator.js';
|
|
4
4
|
import * as validatorTranslations from '../validators/translations.validator.js';
|
|
5
|
+
import * as validatorRedirectsCsv from '../validators/redirects-csv.validator.js';
|
|
5
6
|
|
|
6
7
|
/** @type {Array<Validator>} */
|
|
7
|
-
const validators = [validatorBrandJson, validatorTranslations];
|
|
8
|
+
const validators = [validatorBrandJson, validatorTranslations, validatorRedirectsCsv];
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
*
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { REDIRECTS_CSV_FILENAME } from '../../constants.js';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import * as validation from '../utils/validation.js';
|
|
5
|
+
|
|
6
|
+
export const name = 'redirects-csv';
|
|
7
|
+
export const description = 'Validates redirects.csv for correct formatting and valid status codes';
|
|
8
|
+
|
|
9
|
+
const VALID_STATUS_CODES = ['301', '302', '307', '308'];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extract `{name}` tokens from a Hapi Call path or string-template string.
|
|
13
|
+
* Hapi wildcards like `{name*}` or `{name*2}` are normalised to `name`.
|
|
14
|
+
*/
|
|
15
|
+
function extractParamNames(str) {
|
|
16
|
+
const names = new Set();
|
|
17
|
+
const re = /\{([^}]+)\}/g;
|
|
18
|
+
let m;
|
|
19
|
+
while ((m = re.exec(str)) !== null) {
|
|
20
|
+
const raw = m[1];
|
|
21
|
+
// strip Hapi wildcard suffix e.g. `name*`, `name*2`
|
|
22
|
+
const name = raw.replace(/\*\d*$/, '');
|
|
23
|
+
names.add(name);
|
|
24
|
+
}
|
|
25
|
+
return names;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isAbsoluteUrl(str) {
|
|
29
|
+
return /^https?:\/\//i.test(str);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Strip trailing slash, matching the redirect consumer's behaviour. */
|
|
33
|
+
function stripTrailingSlash(str) {
|
|
34
|
+
return str.length > 1 && str.endsWith('/') ? str.slice(0, -1) : str;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function validate({ fertConfig }) {
|
|
38
|
+
const resultsCollection = validation.createResultCollection();
|
|
39
|
+
|
|
40
|
+
const redirectsCsvPath = path.join(fertConfig.workingDir, 'public', REDIRECTS_CSV_FILENAME);
|
|
41
|
+
if (!fs.existsSync(redirectsCsvPath)) {
|
|
42
|
+
return resultsCollection.results;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let csvData;
|
|
46
|
+
try {
|
|
47
|
+
csvData = fs.readFileSync(redirectsCsvPath, 'utf-8');
|
|
48
|
+
} catch (error) {
|
|
49
|
+
resultsCollection
|
|
50
|
+
.addResult(`Error reading ${REDIRECTS_CSV_FILENAME}`)
|
|
51
|
+
.filePath(redirectsCsvPath)
|
|
52
|
+
.err()
|
|
53
|
+
.detail(error.message);
|
|
54
|
+
return resultsCollection.results;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const lines = csvData.split('\n');
|
|
58
|
+
const seenSources = new Map(); // source -> first line number where seen
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < lines.length; i++) {
|
|
61
|
+
const line = lines[i];
|
|
62
|
+
if (line.trim() === '') continue;
|
|
63
|
+
|
|
64
|
+
const parts = line.split(',').map((part) => part.trim());
|
|
65
|
+
const source = parts[0];
|
|
66
|
+
const destination = parts[1];
|
|
67
|
+
const status = parts[2];
|
|
68
|
+
const lineRef = `Line ${i + 1}: "${line.trim()}"`;
|
|
69
|
+
|
|
70
|
+
const addError = (message, detail) =>
|
|
71
|
+
resultsCollection.addResult(`${message}: ${lineRef}`).filePath(redirectsCsvPath).err().detail(detail);
|
|
72
|
+
|
|
73
|
+
// Column count — need at least source + destination
|
|
74
|
+
if (parts.length < 2) {
|
|
75
|
+
addError('Missing destination', 'Each row must have at least "source,destination"');
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Source
|
|
80
|
+
if (!source) {
|
|
81
|
+
addError('Missing source', 'Source (column 1) is required');
|
|
82
|
+
} else {
|
|
83
|
+
if (!source.startsWith('/')) {
|
|
84
|
+
addError('Source must start with /', `Source "${source}" must start with a /`);
|
|
85
|
+
}
|
|
86
|
+
if (source.includes('?') || source.includes('#')) {
|
|
87
|
+
addError(
|
|
88
|
+
'Source must not contain ? or #',
|
|
89
|
+
`Source "${source}" must not include query strings or fragments — the redirect handler preserves the request query string automatically`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Destination
|
|
95
|
+
if (!destination) {
|
|
96
|
+
addError('Missing destination', 'Destination (column 2) is required');
|
|
97
|
+
} else if (!destination.startsWith('/') && !isAbsoluteUrl(destination)) {
|
|
98
|
+
addError(
|
|
99
|
+
'Destination must be a relative path or absolute URL',
|
|
100
|
+
`Destination "${destination}" must start with / or be an absolute http(s):// URL`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Self-redirect + path-parameter consistency
|
|
105
|
+
if (source && destination) {
|
|
106
|
+
if (stripTrailingSlash(source) === stripTrailingSlash(destination)) {
|
|
107
|
+
addError(
|
|
108
|
+
'Self-redirect',
|
|
109
|
+
`Source and destination are the same ("${source}") — this would create an infinite redirect loop`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const sourceParams = extractParamNames(source);
|
|
114
|
+
const destParams = extractParamNames(destination);
|
|
115
|
+
for (const p of destParams) {
|
|
116
|
+
if (!sourceParams.has(p)) {
|
|
117
|
+
addError(
|
|
118
|
+
'Destination references unknown path parameter',
|
|
119
|
+
`Destination "${destination}" references {${p}} which is not captured in source "${source}"`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Status code
|
|
126
|
+
if (status && !VALID_STATUS_CODES.includes(status)) {
|
|
127
|
+
addError(
|
|
128
|
+
'Invalid status code',
|
|
129
|
+
`Status "${status}" is not supported. Use 301 (permanent) or 302 (temporary); 307/308 preserve the HTTP method`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Duplicate source
|
|
134
|
+
if (source) {
|
|
135
|
+
if (seenSources.has(source)) {
|
|
136
|
+
addError(
|
|
137
|
+
'Duplicate source',
|
|
138
|
+
`Source "${source}" is already defined on line ${seenSources.get(source)} — only the first occurrence is applied`,
|
|
139
|
+
);
|
|
140
|
+
} else {
|
|
141
|
+
seenSources.set(source, i + 1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return resultsCollection.results;
|
|
147
|
+
}
|
package/constants.js
CHANGED
|
@@ -19,6 +19,7 @@ export const AWS_REGION = 'eu-west-1';
|
|
|
19
19
|
export const PROPERTY_ID_API = 'https://property-identification-api.cs.madgexhosting.net/properties/';
|
|
20
20
|
export const ASSETS_API_URL = 'https://asset-store.job.madgexhosting.net';
|
|
21
21
|
export const BRAND_JSON_FILENAME = 'brand.json';
|
|
22
|
+
export const REDIRECTS_CSV_FILENAME = 'redirects.csv';
|
|
22
23
|
export const FERT_CONFIG_FILENAME = 'fert.config.js';
|
|
23
24
|
export const FERT_SERVICE_CONFIG_FILENAME = 'fert.service.config.js';
|
|
24
25
|
export const ASSET_STORE_API = 'https://asset-store-api.job.madgexhosting.net/';
|