@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 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
+ ```
@@ -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'], { fertConfig, throwable: true });
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);
@@ -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
  });
@@ -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/';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@madgex/fert",
3
- "version": "7.2.0",
3
+ "version": "7.2.1",
4
4
  "description": "Tool to help build the V6 branding",
5
5
  "bin": {
6
6
  "fert": "./bin/cli.js"