@madgex/fert 7.2.0 → 7.3.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/README.md +50 -0
- package/bin/cli.js +4 -0
- package/bin/commands/build.js +4 -1
- package/bin/commands/dev-server.js +2 -2
- package/bin/commands/init-template-tasks/copy-theme-files.js +61 -0
- package/bin/commands/init-template-tasks/generate-entry-nunjucks-file.js +64 -0
- package/bin/commands/init-template-tasks/get-macro-name-from-nunjucks.js +13 -0
- package/bin/commands/init-template-tasks/get-nunjucks-dependencies.js +58 -0
- package/bin/commands/init-template-tasks/get-out-dir.js +6 -0
- package/bin/commands/init-template.js +94 -0
- package/bin/commands/validate.js +1 -1
- package/bin/utils/cpid-lookup.js +2 -1
- package/bin/utils/get-assets-path.js +4 -4
- package/bin/utils/index.js +1 -26
- package/bin/utils/validation.js +2 -1
- package/bin/validators/redirects-csv.validator.js +147 -0
- package/constants.js +1 -2
- package/docs/FAQ.md +1 -0
- package/docs/README.md +30 -11
- package/package.json +2 -2
- package/repo-template/services/jobseekers-frontend/templates/footer.njk +18 -19
- package/repo-template/services/jobseekers-frontend/templates/header.njk +19 -74
- package/repo-template/services/recruiterservices-frontend/templates/footer.njk +19 -18
- package/repo-template/services/recruiterservices-frontend/templates/header.njk +19 -82
- package/types.d.ts +1 -0
- package/repo-template/services/jobseekers-frontend/templates/context/footer-nav.njk +0 -27
- package/repo-template/services/jobseekers-frontend/templates/context/main-nav.njk +0 -41
- package/repo-template/services/jobseekers-frontend/templates/context/user-nav.njk +0 -17
- package/repo-template/services/jobseekers-frontend/templates/includes/footer-nav.njk +0 -15
- package/repo-template/services/jobseekers-frontend/templates/includes/main-nav.njk +0 -19
- package/repo-template/services/jobseekers-frontend/templates/includes/user-nav/authenticated.njk +0 -34
- package/repo-template/services/jobseekers-frontend/templates/includes/user-nav/unauthenticated.njk +0 -9
- package/repo-template/services/jobseekers-frontend/templates/translations/da.njk +0 -26
- package/repo-template/services/jobseekers-frontend/templates/translations/de.njk +0 -26
- package/repo-template/services/jobseekers-frontend/templates/translations/en.njk +0 -27
- package/repo-template/services/jobseekers-frontend/templates/translations/es.njk +0 -26
- package/repo-template/services/jobseekers-frontend/templates/translations/fr.njk +0 -26
- package/repo-template/services/jobseekers-frontend/templates/translations/nb.njk +0 -26
- package/repo-template/services/jobseekers-frontend/templates/translations/nl.njk +0 -26
- package/repo-template/services/jobseekers-frontend/templates/translations/sv.njk +0 -26
- package/repo-template/services/jobseekers-frontend/templates/translations/zh-cn.njk +0 -26
- package/repo-template/services/recruiterservices-frontend/templates/context/links.njk +0 -136
- package/repo-template/services/recruiterservices-frontend/templates/includes/basket-nav.njk +0 -25
- package/repo-template/services/recruiterservices-frontend/templates/includes/footer/footer-nav.njk +0 -35
- package/repo-template/services/recruiterservices-frontend/templates/includes/footer/social-links.njk +0 -14
- package/repo-template/services/recruiterservices-frontend/templates/includes/primary-nav.njk +0 -18
- package/repo-template/services/recruiterservices-frontend/templates/includes/user-nav/authenticated.njk +0 -36
- package/repo-template/services/recruiterservices-frontend/templates/includes/user-nav/switch-recruiters.njk +0 -13
- package/repo-template/services/recruiterservices-frontend/templates/includes/user-nav/unauthenticated.njk +0 -11
- package/repo-template/services/recruiterservices-frontend/templates/includes/user-nav/user-nav.njk +0 -9
- package/repo-template/services/recruiterservices-frontend/templates/translations/en.njk +0 -29
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/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
|
|
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);
|
|
@@ -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,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
|
+
}
|
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/cpid-lookup.js
CHANGED
|
@@ -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 {
|
|
6
|
+
* @param {FertConfig} fertConfig
|
|
7
7
|
* @param {object} environment
|
|
8
|
-
* @returns {URL}
|
|
8
|
+
* @returns {URL} Absolute Public URL for assets
|
|
9
9
|
*/
|
|
10
10
|
export async function getAssetsPath(fertConfig, environment) {
|
|
11
|
-
const { clientPropertyId, serviceName, client
|
|
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:
|
|
29
|
+
siteName: client.name || client.brandName,
|
|
30
30
|
}),
|
|
31
31
|
});
|
|
32
32
|
|
package/bin/utils/index.js
CHANGED
|
@@ -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 {
|
|
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,
|
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/';
|
|
@@ -46,8 +47,6 @@ export const ASSET_STORE_INVALIDATION_PATH = `/{fertConfig.client.rootClientProp
|
|
|
46
47
|
|
|
47
48
|
export const ASSET_STORE_USER_GUID = 'a386d4b6-f2df-4b80-ad1f-0349e23f530b';
|
|
48
49
|
|
|
49
|
-
export const CONFIG_API =
|
|
50
|
-
'https://configuration-api.job.madgexhosting.net/configs/override/{cpid}/production/{configGroup}/{config}';
|
|
51
50
|
export const CONFIG_DIR = 'config';
|
|
52
51
|
|
|
53
52
|
/** we assume the remove version of a service hosts an api translations endpoint */
|
package/docs/FAQ.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
-
|