@researchdatabox/redbox-dev-tools 0.0.1-beta.9950 → 5.0.0-RC1

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.
Files changed (37) hide show
  1. package/README.md +25 -3
  2. package/bin/wrapper-webpack.js +31 -12
  3. package/dist/cli.js +6 -2
  4. package/dist/commands/form-config.js +8 -11
  5. package/dist/commands/sync-translation.d.ts +23 -0
  6. package/dist/commands/sync-translation.js +348 -0
  7. package/dist/templates/hook-archetype.js +4 -3
  8. package/package.json +4 -4
  9. package/templates/hook-archetype/standard/.gitignore.hbs +1 -0
  10. package/templates/hook-archetype/standard/.npmignore.hbs +2 -1
  11. package/templates/hook-archetype/standard/AGENTS.md.hbs +18 -1
  12. package/templates/hook-archetype/standard/Dockerfile.hbs +12 -11
  13. package/templates/hook-archetype/standard/README.md.hbs +28 -10
  14. package/templates/hook-archetype/standard/assets/default/.gitkeep +1 -0
  15. package/templates/hook-archetype/standard/assets/images/.gitkeep +1 -0
  16. package/templates/hook-archetype/standard/assets/styles/.gitkeep +1 -0
  17. package/templates/hook-archetype/standard/language-defaults/README.md.hbs +10 -0
  18. package/templates/hook-archetype/standard/language-defaults/en/translation.json.hbs +3 -0
  19. package/templates/hook-archetype/standard/language-defaults/meta.json.hbs +6 -0
  20. package/templates/hook-archetype/standard/package.json.hbs +10 -6
  21. package/templates/hook-archetype/standard/src/index.ts.hbs +11 -11
  22. package/templates/hook-archetype/standard/src/migrations/index.ts.hbs +19 -0
  23. package/templates/hook-archetype/standard/support/bootstrap-data/namedqueries/.gitkeep +1 -0
  24. package/templates/hook-archetype/standard/support/bootstrap-data/records/.gitkeep +1 -0
  25. package/templates/hook-archetype/standard/support/bootstrap-data/reports/.gitkeep +1 -0
  26. package/templates/hook-archetype/standard/support/bootstrap-data/vocabularies/.gitkeep +1 -0
  27. package/templates/hook-archetype/standard/support/development/core-mount.env.hbs +3 -0
  28. package/templates/hook-archetype/standard/support/development/docker-compose.core-mount.yml.hbs +37 -0
  29. package/templates/hook-archetype/standard/support/development/docker-compose.yml.hbs +53 -20
  30. package/templates/hook-archetype/standard/support/development/prepare-core-mount.sh.hbs +61 -0
  31. package/templates/hook-archetype/standard/support/integration-testing/docker-compose.mocha.yml.hbs +45 -11
  32. package/templates/hook-archetype/standard/support/integration-testing/run-mocha-hook.sh.hbs +5 -0
  33. package/templates/hook-archetype/standard/support/integration-testing/run-mocha-redbox.sh.hbs +100 -0
  34. package/templates/hook-archetype/standard/support/prepare-minio.sh.hbs +74 -0
  35. package/templates/hook-archetype/standard/test/integration/hook-registration.test.ts.hbs +3 -1
  36. package/templates/hook-archetype/standard/test/unit/index.test.ts.hbs +11 -5
  37. package/templates/hook-archetype/standard/views/default/.gitkeep +1 -0
package/README.md CHANGED
@@ -31,11 +31,11 @@ npx @researchdatabox/redbox-dev-tools init redbox-hook-my-client
31
31
  This will:
32
32
 
33
33
  - Generate the standard hook archetype in the current directory
34
- - Create `src/`, `test/`, `support/`, `assets/`, `views/`, and `language-defaults/`
34
+ - Create `src/`, `test/`, `support/`, `assets/`, `views/`, `language-defaults/`, and `support/bootstrap-data/`
35
35
  - Create a `package.json` with the shared Redbox dependency contract
36
- - Generate starter controller, service, form-config, config, Docker, and test files
36
+ - Generate starter controller, service, form-config, config, migration registry, Docker, development, and test files
37
37
 
38
- The standard archetype is based on the current hook structure used for `redbox-hook-jcu`, but stripped back to generic placeholders so a new client hook can start from the same shape without inheriting client-specific logic.
38
+ The standard archetype is based on the current hook structure used for migrated client hooks such as `redbox-hook-jcu` and `redbox-hook-cqu`, but stripped back to generic placeholders so a new client hook can start from the same shape without inheriting client-specific logic.
39
39
 
40
40
  The archetype is now backed by file-based Handlebars templates under [templates/hook-archetype](templates/hook-archetype), which gives us a clean path for future variants such as branding-heavy hooks, lighter hooks, or alternate TypeScript setups.
41
41
 
@@ -102,6 +102,7 @@ module.exports = hook;
102
102
  Hooks should declare only:
103
103
 
104
104
  - `peerDependencies.@researchdatabox/redbox-core`
105
+ - `devDependencies.@researchdatabox/redbox-core`
105
106
  - `devDependencies.@researchdatabox/redbox-dev-tools`
106
107
  - direct `dependencies` for hook-owned runtime libraries only
107
108
 
@@ -142,6 +143,19 @@ Template variants are selected with `--template`:
142
143
  npx @researchdatabox/redbox-dev-tools init redbox-hook-my-client --template standard
143
144
  ```
144
145
 
146
+ The generated project includes a typed migration registry under `src/migrations`, client customisation slots under `support/bootstrap-data`, `assets`, `views`, and `language-defaults`, and Docker-backed local development scripts:
147
+
148
+ ```bash
149
+ npm run dev:run:build
150
+ npm run dev:run
151
+ ```
152
+
153
+ To mount a local `redbox-portal` checkout while migrating a client hook, set `REDBOX_PORTAL_PATH` in `support/development/core-mount.env` and run:
154
+
155
+ ```bash
156
+ npm run dev:run:core
157
+ ```
158
+
145
159
  ### `check`
146
160
 
147
161
  Validate that the current hook package does not directly pin shared Redbox runtime or toolchain dependencies.
@@ -209,6 +223,14 @@ Optional Angular service scaffold:
209
223
  npx @researchdatabox/redbox-dev-tools generate form-component my-widget --app form --with-service
210
224
  ```
211
225
 
226
+ ### `sync-translation`
227
+
228
+ Read translations from API and write to language defaults files.
229
+
230
+ ```bash
231
+ npx @researchdatabox/redbox-dev-tools sync-translation -a "https://example.com" -l ~/redbox-portal/language-defaults
232
+ ```
233
+
212
234
  ### `help`
213
235
 
214
236
  Show CLI help and usage information.
@@ -1,9 +1,23 @@
1
1
  // This file is a helper for dev only. It makes it easier to run webpack when parts of the mono repo are in different stages of being built. Refer to the redbox-core webpack.ts for how the hook runs webpack
2
2
  const path = require('path');
3
3
  const { createRequire } = require('module');
4
- const redboxCore = require('@researchdatabox/redbox-core');
5
4
 
6
5
  const requireFromHere = createRequire(__filename);
6
+ let redboxCore;
7
+
8
+ const getRedboxCore = () => {
9
+ if (redboxCore !== undefined) {
10
+ return redboxCore;
11
+ }
12
+
13
+ try {
14
+ redboxCore = require('@researchdatabox/redbox-core');
15
+ } catch (e) {
16
+ redboxCore = null;
17
+ }
18
+
19
+ return redboxCore;
20
+ };
7
21
 
8
22
  const ensureTsNode = () => {
9
23
  try {
@@ -14,7 +28,7 @@ const ensureTsNode = () => {
14
28
  }
15
29
  };
16
30
 
17
- let defineWebpackHook = redboxCore.defineWebpackHook || (redboxCore.default && redboxCore.default.defineWebpackHook);
31
+ let defineWebpackHook;
18
32
  if (!defineWebpackHook) {
19
33
  try {
20
34
  defineWebpackHook = require('@researchdatabox/redbox-core/dist/hooks/webpack').defineWebpackHook;
@@ -25,6 +39,10 @@ if (!defineWebpackHook && ensureTsNode()) {
25
39
  defineWebpackHook = requireFromHere('@researchdatabox/redbox-core/src/hooks/webpack.ts').defineWebpackHook;
26
40
  } catch (e) {}
27
41
  }
42
+ if (!defineWebpackHook) {
43
+ const core = getRedboxCore();
44
+ defineWebpackHook = core && (core.defineWebpackHook || (core.default && core.default.defineWebpackHook));
45
+ }
28
46
 
29
47
  // Resolve webpack config: prefer exported `Config.webpack.config`, fall back to repo `config/webpack.js`.
30
48
  let webpackConfig = [];
@@ -39,16 +57,6 @@ try {
39
57
  } catch (e) {
40
58
  // ignore
41
59
  }
42
- if (!webpackConfig || (Array.isArray(webpackConfig) && webpackConfig.length === 0)) {
43
- try {
44
- const Config = redboxCore.Config || (redboxCore.default && redboxCore.default.Config);
45
- if (Config && Config.webpack && Config.webpack.config) {
46
- webpackConfig = Config.webpack.config;
47
- }
48
- } catch (e) {
49
- // ignore
50
- }
51
- }
52
60
  if (!webpackConfig || (Array.isArray(webpackConfig) && webpackConfig.length === 0)) {
53
61
  try {
54
62
  const distWebpack = require('@researchdatabox/redbox-core/dist/config/webpack.config');
@@ -63,6 +71,17 @@ if (!webpackConfig || (Array.isArray(webpackConfig) && webpackConfig.length ===
63
71
  } catch (e) {}
64
72
  }
65
73
  }
74
+ if (!webpackConfig || (Array.isArray(webpackConfig) && webpackConfig.length === 0)) {
75
+ try {
76
+ const core = getRedboxCore();
77
+ const Config = core && (core.Config || (core.default && core.default.Config));
78
+ if (Config && Config.webpack && Config.webpack.config) {
79
+ webpackConfig = Config.webpack.config;
80
+ }
81
+ } catch (e) {
82
+ // ignore
83
+ }
84
+ }
66
85
 
67
86
  sails = {
68
87
  config: {
package/dist/cli.js CHANGED
@@ -42,12 +42,14 @@ const hook_archetype_1 = require("./templates/hook-archetype");
42
42
  const form_config_1 = require("./commands/form-config");
43
43
  const completion_1 = require("./completion");
44
44
  const cli_parsers_1 = require("./cli-parsers");
45
+ const sync_translation_1 = require("./commands/sync-translation");
45
46
  const program = new commander_1.Command();
47
+ const packageJson = readPackageJson(path.resolve(__dirname, '..', 'package.json'));
46
48
  // List of dependencies that are common in our legacy hooks but should not be in modern hook packages as they are supplied by redbox-core
47
49
  const forbiddenHookDeps = ['axios', 'rxjs', 'lodash', 'mocha', 'chai', 'ts-node', 'typescript'];
48
50
  const allowedSharedHookDeps = {
49
51
  dependencies: new Set(),
50
- devDependencies: new Set(['@researchdatabox/redbox-dev-tools']),
52
+ devDependencies: new Set(['@researchdatabox/redbox-core', '@researchdatabox/redbox-dev-tools']),
51
53
  peerDependencies: new Set(['@researchdatabox/redbox-core']),
52
54
  };
53
55
  function readPackageJson(pkgPath) {
@@ -80,6 +82,7 @@ function migrateHookDependencyContract(pkg) {
80
82
  delete nextPkg.peerDependencies[depName];
81
83
  }
82
84
  nextPkg.peerDependencies['@researchdatabox/redbox-core'] = nextPkg.peerDependencies['@researchdatabox/redbox-core'] ?? '*';
85
+ nextPkg.devDependencies['@researchdatabox/redbox-core'] = nextPkg.devDependencies['@researchdatabox/redbox-core'] ?? nextPkg.peerDependencies['@researchdatabox/redbox-core'];
83
86
  nextPkg.devDependencies['@researchdatabox/redbox-dev-tools'] = nextPkg.devDependencies['@researchdatabox/redbox-dev-tools'] ?? '*';
84
87
  return nextPkg;
85
88
  }
@@ -104,7 +107,7 @@ function findForbiddenHookDeps(pkg) {
104
107
  program
105
108
  .name('redbox-dev-tools')
106
109
  .description('CLI for ReDBox hook development and code generation')
107
- .version('1.0.0')
110
+ .version(packageJson.version ?? '0.0.0')
108
111
  .option('--root <path>', 'ReDBox repo root directory (required for generators)')
109
112
  .option('--core-types-root <path>', 'Override core-types package root')
110
113
  .option('--angular-root <path>', 'Override angular projects root')
@@ -131,6 +134,7 @@ program
131
134
  (0, form_config_1.registerMigrateFigshareConfigCommand)(program);
132
135
  (0, form_config_1.registerClientFormConfigCommand)(program);
133
136
  (0, form_config_1.registerQuestionTreeDiagramCommand)(program);
137
+ (0, sync_translation_1.registerSyncTranslationCommand)(program);
134
138
  program
135
139
  .command('init [name]')
136
140
  .description('Initialize a new ReDBox hook project from the standard archetype')
@@ -267,7 +267,6 @@ function createMigratedFigsharePublishingConfig(brand, figshareApi, figshareApiE
267
267
  },
268
268
  record: {
269
269
  articleIdPath: mapping.recordFigArticleId ?? 'metadata.figshare_article_id',
270
- articleUrlPaths: Array.isArray(mapping.recordFigArticleURL) ? mapping.recordFigArticleURL : [mapping.recordFigArticleURL ?? 'metadata.figshare_article_location'],
271
270
  dataLocationsPath: mapping.recordDataLocations ?? 'metadata.dataLocations',
272
271
  statusPath: 'metadata.figshareStatus',
273
272
  errorPath: 'metadata.figshareError',
@@ -280,7 +279,6 @@ function createMigratedFigsharePublishingConfig(brand, figshareApi, figshareApiE
280
279
  selectedFlagPath: 'selected'
281
280
  },
282
281
  authors: {
283
- source: 'defaultRedboxContributors',
284
282
  uniqueBy: mapping.recordAuthorUniqueBy ?? 'email',
285
283
  externalNameField: mapping.recordAuthorExternalName ?? 'text_full_name',
286
284
  maxInlineAuthors: 50,
@@ -293,11 +291,10 @@ function createMigratedFigsharePublishingConfig(brand, figshareApi, figshareApiE
293
291
  keywords: { kind: 'path', path: 'metadata.finalKeywords', defaultValue: [] },
294
292
  funding: { kind: 'path', path: 'metadata.funder' },
295
293
  license: { source: { kind: 'path', path: mapping.recordLicensePath ?? 'metadata.license' }, matchBy: 'urlContains', required: true },
296
- categories: { source: { kind: 'path', path: mapping.recordCategoryPath ?? 'metadata.forCodes', defaultValue: [] }, mappingStrategy: 'for2020Mapping' },
294
+ categories: { source: { kind: 'path', path: mapping.recordCategoryPath ?? 'metadata.forCodes', defaultValue: [] } },
297
295
  customFields: []
298
296
  },
299
297
  categories: {
300
- strategy: 'for2020Mapping',
301
298
  mappingTable: ((figshareForMapping.FORMapping ?? []).map((entry) => ({
302
299
  sourceCode: String(entry.redboxCode ?? entry.sourceCode ?? ''),
303
300
  figshareCategoryId: Number(entry.figshareCategoryId ?? entry.figCode ?? 0)
@@ -307,11 +304,8 @@ function createMigratedFigsharePublishingConfig(brand, figshareApi, figshareApiE
307
304
  assets: {
308
305
  enableHostedFiles: true,
309
306
  enableLinkFiles: true,
310
- dedupeStrategy: 'sourceId',
311
307
  staging: {
312
- tempDir: String(figshareApi.attachmentsFigshareTempDir ?? ''),
313
- cleanupPolicy: 'deleteAfterSuccess',
314
- diskSpaceThresholdBytes: 1073741824
308
+ cleanupPolicy: 'deleteAfterSuccess'
315
309
  }
316
310
  },
317
311
  embargo: {
@@ -320,7 +314,6 @@ function createMigratedFigsharePublishingConfig(brand, figshareApi, figshareApiE
320
314
  accessRights: {
321
315
  accessRights: { kind: 'path', path: 'metadata.accessRights' },
322
316
  fullEmbargoUntil: { kind: 'path', path: 'metadata.embargoUntil' },
323
- fileEmbargoUntil: { kind: 'path', path: 'metadata.embargoUntil' },
324
317
  reason: { kind: 'path', path: 'metadata.embargoReason' }
325
318
  }
326
319
  },
@@ -329,7 +322,6 @@ function createMigratedFigsharePublishingConfig(brand, figshareApi, figshareApiE
329
322
  uploadedFilesCleanupDelay: typeof mapping.scheduleUploadedFilesCleanupJob === 'string' ? mapping.scheduleUploadedFilesCleanupJob : 'in 5 minutes'
330
323
  },
331
324
  workflow: {
332
- transitionRules: [],
333
325
  transitionJob: {
334
326
  enabled: String(mapping.figshareScheduledTransitionRecordWorkflowFromArticlePropertiesJob?.enabled ?? 'false') === 'true',
335
327
  namedQuery: String(mapping.figshareScheduledTransitionRecordWorkflowFromArticlePropertiesJob?.namedQuery ?? ''),
@@ -346,7 +338,12 @@ function createMigratedFigsharePublishingConfig(brand, figshareApi, figshareApiE
346
338
  },
347
339
  writeBack: {
348
340
  articleId: 'metadata.figshare_article_id',
349
- articleUrls: ['metadata.figshare_article_location'],
341
+ // The legacy article URL paths are write targets, so they belong to writeBack.
342
+ articleUrls: Array.isArray(mapping.recordFigArticleURL)
343
+ ? mapping.recordFigArticleURL
344
+ : [typeof mapping.recordFigArticleURL === 'string' && mapping.recordFigArticleURL.trim() !== ''
345
+ ? mapping.recordFigArticleURL
346
+ : 'metadata.figshare_article_location'],
350
347
  extraFields: []
351
348
  },
352
349
  migrationReport: {
@@ -0,0 +1,23 @@
1
+ import { Command } from "commander";
2
+ declare const contentFormats: readonly ["plain", "html"];
3
+ type ContentFormats = typeof contentFormats[number];
4
+ type MetaEntryKey = string;
5
+ type MetaEntryValue = {
6
+ category?: string;
7
+ description?: string;
8
+ contentFormat?: ContentFormats;
9
+ };
10
+ export type MetaEntries = Record<MetaEntryKey, MetaEntryValue>;
11
+ export type MetaData = {
12
+ source: string;
13
+ data: MetaEntries;
14
+ };
15
+ export type TranslationLocaleRaw = {
16
+ locale: string;
17
+ source: string;
18
+ data: Record<string, unknown>;
19
+ };
20
+ export type TranslationEntries = Record<string, string>;
21
+ export type TranslationLocaleEntries = Record<string, TranslationEntries>;
22
+ export declare function registerSyncTranslationCommand(program: Command): void;
23
+ export {};
@@ -0,0 +1,348 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.registerSyncTranslationCommand = registerSyncTranslationCommand;
7
+ const commander_1 = require("commander");
8
+ const promises_1 = __importDefault(require("node:fs/promises"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const contentFormats = ["plain", "html"];
11
+ /**
12
+ * Use a type predicate to narrow a string to a content format.
13
+ * @param value The value to check.
14
+ */
15
+ function isContentFormat(value) {
16
+ return contentFormats.some(contentFormat => contentFormat === value);
17
+ }
18
+ /**
19
+ * Build a path from a base path and additional parts.
20
+ * @param basePath The base path.
21
+ * @param parts The additional path parts.
22
+ */
23
+ function buildPath(basePath, ...parts) {
24
+ if (!basePath.trim()) {
25
+ throw new Error(`Invalid base path ${basePath}`);
26
+ }
27
+ const basePathResolved = node_path_1.default.resolve(basePath);
28
+ const resolved = node_path_1.default.resolve(...[basePathResolved, ...parts]);
29
+ // Ensure resolved path is inside basePath
30
+ if (!resolved.startsWith(basePathResolved + node_path_1.default.sep) && resolved !== basePathResolved) {
31
+ throw new Error(`Invalid path ${resolved}`);
32
+ }
33
+ return resolved;
34
+ }
35
+ /**
36
+ * Get the last non-empty string.
37
+ * @param value The strings.
38
+ */
39
+ function lastNonEmptyString(...value) {
40
+ for (let index = (value.length - 1); index >= 0; index--) {
41
+ const item = value[index];
42
+ if (typeof item === 'string' && item.length > 0) {
43
+ return item;
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+ /**
49
+ * Read the translation json files.
50
+ * @param filesPath The path to the top directory containing the meta.json file.
51
+ */
52
+ async function readTranslationFiles(filesPath) {
53
+ // Read files
54
+ const metaPath = buildPath(filesPath, 'meta.json');
55
+ console.log(`Load translation meta file ${metaPath}`);
56
+ const metaData = JSON.parse(await promises_1.default.readFile(metaPath, { encoding: 'utf8' }));
57
+ const localeNames = (await promises_1.default.readdir(filesPath, { withFileTypes: true }))
58
+ .filter(dirent => dirent.isDirectory())
59
+ .map(dirent => dirent.name);
60
+ const localeTranslationData = [];
61
+ for (const locale of localeNames) {
62
+ let p;
63
+ try {
64
+ p = buildPath(filesPath, locale, 'translation.json');
65
+ const translationStat = await promises_1.default.stat(p);
66
+ if (!translationStat.isFile()) {
67
+ continue;
68
+ }
69
+ }
70
+ catch (err) {
71
+ if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
72
+ // Nested folders such as language-defaults/demo are documentation
73
+ // or sample data, not locale roots.
74
+ continue;
75
+ }
76
+ throw err;
77
+ }
78
+ if (!p) {
79
+ continue;
80
+ }
81
+ const data = JSON.parse(await promises_1.default.readFile(p, { encoding: 'utf8' }));
82
+ console.log(`Load translation locale file ${p}`);
83
+ localeTranslationData.push({ locale, source: p, data });
84
+ }
85
+ return {
86
+ meta: {
87
+ source: metaPath,
88
+ data: metaData,
89
+ },
90
+ locales: localeTranslationData,
91
+ };
92
+ }
93
+ /**
94
+ * Write updated translation json files.
95
+ * @param opts The options.
96
+ * @param opts.meta The meta details.
97
+ * @param opts.locales The translation locale data.
98
+ * @param opts.dryRun Whether this is a dry run.
99
+ * @param opts.writePath The top-level path to write the output.
100
+ */
101
+ async function writeTranslationFiles(opts) {
102
+ const metaPath = buildPath(opts?.writePath, 'meta.json');
103
+ if (opts.dryRun) {
104
+ console.log(`[dry-run] Not writing updated meta to ${metaPath}`);
105
+ }
106
+ else {
107
+ // Write merged meta back to file
108
+ console.log(`Writing updated meta to ${metaPath}`);
109
+ await promises_1.default.mkdir(node_path_1.default.dirname(metaPath), { recursive: true });
110
+ await promises_1.default.writeFile(metaPath, JSON.stringify(opts.meta, null, 2));
111
+ }
112
+ // Write translation files
113
+ for (const [locale, data] of Object.entries(opts.locales)) {
114
+ const translationPath = buildPath(opts?.writePath, locale, 'translation.json');
115
+ if (opts.dryRun) {
116
+ console.log(`[dry-run] Not writing updated translation data to ${translationPath}`);
117
+ }
118
+ else {
119
+ console.log(`Writing updated translation data to ${translationPath}`);
120
+ await promises_1.default.mkdir(node_path_1.default.dirname(translationPath), { recursive: true });
121
+ await promises_1.default.writeFile(translationPath, JSON.stringify(data, null, 2));
122
+ }
123
+ }
124
+ }
125
+ /**
126
+ * Read translation data from ReDBox API.
127
+ * @param apiBaseUrl The base url for the API calls.
128
+ */
129
+ async function readTranslationApi(apiBaseUrl) {
130
+ if (!apiBaseUrl.includes('://')) {
131
+ throw new Error(`Api base url is not a valid url '${apiBaseUrl}'`);
132
+ }
133
+ if (!apiBaseUrl.endsWith('/')) {
134
+ apiBaseUrl += '/';
135
+ }
136
+ const translationUrl = new URL('default/rdmp/locales/__locale__/translation.json', apiBaseUrl);
137
+ const entriesUrl = new URL('default/rdmp/app/i18n/entries', apiBaseUrl);
138
+ console.log(`Load translation entries url ${entriesUrl}`);
139
+ const entriesResponse = await fetch(entriesUrl, { signal: AbortSignal.timeout(5000) });
140
+ if (!entriesResponse.ok) {
141
+ throw new Error(`Could not fetch translation entries: status ${entriesResponse.status} body ${await entriesResponse.text()}`);
142
+ }
143
+ const entriesData = await entriesResponse.json();
144
+ const metaData = Object.create(null);
145
+ const localeTranslationData = [];
146
+ const localeNames = Array.from(new Set(entriesData.map(e => e.locale).filter(l => typeof l === 'string'))).sort();
147
+ for (const localeName of localeNames) {
148
+ if (!localeName || !localeName.trim() || localeName.includes('.') || localeName.includes('/')) {
149
+ console.warn(`Ignoring invalid locale name '${localeName}'.`);
150
+ continue;
151
+ }
152
+ const translationLocaleUrl = new URL(translationUrl.toString().replace('/__locale__/', `/${encodeURIComponent(localeName)}/`));
153
+ console.log(`Load translation locale url ${translationLocaleUrl}`);
154
+ const translationResponse = await fetch(translationLocaleUrl, { signal: AbortSignal.timeout(5000) });
155
+ if (!translationResponse.ok) {
156
+ throw new Error(`Could not fetch translation locale: status ${translationResponse.status} body ${await translationResponse.text()}`);
157
+ }
158
+ localeTranslationData.push({
159
+ locale: localeName,
160
+ source: translationLocaleUrl.toString(),
161
+ data: await translationResponse.json()
162
+ });
163
+ }
164
+ for (const entryData of entriesData) {
165
+ if (!entryData.key) {
166
+ continue;
167
+ }
168
+ if (!(entryData.key in metaData)) {
169
+ metaData[entryData.key] = Object.create(null);
170
+ }
171
+ if (entryData.category) {
172
+ metaData[entryData.key].category = entryData.category;
173
+ }
174
+ if (entryData.contentFormat) {
175
+ metaData[entryData.key].contentFormat = entryData.contentFormat;
176
+ }
177
+ if (entryData.description) {
178
+ metaData[entryData.key].description = entryData.description;
179
+ }
180
+ }
181
+ return {
182
+ meta: {
183
+ source: entriesUrl.toString(),
184
+ data: metaData,
185
+ },
186
+ locales: localeTranslationData,
187
+ };
188
+ }
189
+ /**
190
+ * Merge two or more meta entries.
191
+ * @param opts The merge options.
192
+ * @param opts.key The translation key.
193
+ * @param opts.value The translation value.
194
+ * @param opts.items The meta entries.
195
+ * @param opts.guessContentFormat Whether to guess the content format from the translation value.
196
+ */
197
+ function mergeMetaItems(opts) {
198
+ // content format
199
+ const contentFormatRaw = lastNonEmptyString(...opts.items?.map(i => i?.contentFormat));
200
+ let contentFormat = isContentFormat(contentFormatRaw) ? contentFormatRaw : undefined;
201
+ const isHtml = opts.guessContentFormat === true &&
202
+ opts.value !== undefined &&
203
+ contentFormat === undefined &&
204
+ (typeof opts.value === 'string' && (opts.value?.includes('<') &&
205
+ opts.value?.includes('</') &&
206
+ opts.value?.includes('>')));
207
+ if (isHtml) {
208
+ contentFormat = "html";
209
+ }
210
+ // description
211
+ const description = lastNonEmptyString(...[
212
+ opts?.key === undefined ? "" : `Translation for ${opts.key}`,
213
+ ...opts.items?.map(i => i?.description),
214
+ ]);
215
+ // category
216
+ const keySplit = typeof opts.key === 'string' ? opts.key.split(/[_\-:.@]+/).filter(i => !!i) : [];
217
+ const category = lastNonEmptyString(...[
218
+ keySplit.length > 0 ? keySplit[0] : undefined,
219
+ ...opts.items?.map(i => i?.category),
220
+ ]);
221
+ const result = Object.create(null);
222
+ result.category = category;
223
+ result.description = description;
224
+ result.contentFormat = contentFormat;
225
+ return result;
226
+ }
227
+ function registerSyncTranslationCommand(program) {
228
+ const sourcesCollected = [];
229
+ program
230
+ .command('sync-translation')
231
+ .description('Read translations from API and/or json files, and write to language translation files.')
232
+ .option('-l, --language-defaults <path>', 'Path to read the language-defaults directory containing locales and meta json files. Can be provided multiple times.', (value, previous) => {
233
+ previous.push({ type: 'language-defaults', value });
234
+ return previous;
235
+ }, sourcesCollected)
236
+ .option('-a, --api-base <url>', 'Base url for the API to read translation data. Can be provided multiple times.', (value, previous) => {
237
+ previous.push({ type: 'api-base', value });
238
+ return previous;
239
+ }, sourcesCollected)
240
+ .requiredOption('-o, --output <path>', 'Path to the output directory or file.')
241
+ .addOption(new commander_1.Option('-f, --format <format>', 'The output format.')
242
+ .choices(['language-defaults'])
243
+ .default('language-defaults'))
244
+ .action(async (options) => {
245
+ try {
246
+ const globalOptions = program.opts();
247
+ const outputPath = options.output;
248
+ const outputFormat = options.format;
249
+ const sources = sourcesCollected.splice(0);
250
+ if (sources.length === 0) {
251
+ throw new Error(`Must provide at least one of language-defaults path or api-base url.`);
252
+ }
253
+ // Get existing translation data
254
+ const sourceMeta = [];
255
+ const sourceTranslations = [];
256
+ // Precedence order: later items overwrite earlier items.
257
+ // Populate source meta and translations in order provided in command line.
258
+ for (const { type, value } of sources) {
259
+ if (type === 'language-defaults') {
260
+ const { meta, locales } = await readTranslationFiles(value);
261
+ sourceMeta.push(meta);
262
+ sourceTranslations.push(...locales);
263
+ }
264
+ else if (type === 'api-base') {
265
+ const { meta, locales } = await readTranslationApi(value);
266
+ sourceMeta.push(meta);
267
+ sourceTranslations.push(...locales);
268
+ }
269
+ else {
270
+ throw new Error(`Unknown source type '${type}'.`);
271
+ }
272
+ }
273
+ // The keys for locales are the source of truth,
274
+ // so any keys in meta that are not in any locale data will be dropped.
275
+ const keys = new Set(sourceTranslations.flatMap(i => Object.keys(i.data)));
276
+ // Cache translations by locale so each source is grouped only once.
277
+ const sourceTranslationsByLocale = new Map();
278
+ for (const sourceTranslation of sourceTranslations) {
279
+ const localeTranslations = sourceTranslationsByLocale.get(sourceTranslation.locale) ?? [];
280
+ localeTranslations.push(sourceTranslation);
281
+ sourceTranslationsByLocale.set(sourceTranslation.locale, localeTranslations);
282
+ }
283
+ // The unique locale names.
284
+ const localeNames = Array.from(sourceTranslationsByLocale.keys()).sort();
285
+ // Gather translation and meta entries.
286
+ const meta = Object.create(null);
287
+ const locales = Object.create(null);
288
+ for (const key of keys) {
289
+ if (!key) {
290
+ continue;
291
+ }
292
+ for (const localeName of localeNames) {
293
+ if (!localeName) {
294
+ continue;
295
+ }
296
+ if (!(localeName in locales)) {
297
+ locales[localeName] = Object.create(null);
298
+ }
299
+ const localeTranslationData = sourceTranslationsByLocale.get(localeName) ?? [];
300
+ // Translation value for a locale key.
301
+ const values = localeTranslationData
302
+ .map(i => i.data?.[key])
303
+ .filter(i => typeof i === 'string')
304
+ .filter(i => i?.trim().length > 0);
305
+ if (new Set(values).size > 1) {
306
+ console.warn(`Translation '${localeName}' key '${key}' has more than one value: ${JSON.stringify(values)}`);
307
+ }
308
+ const valuesNonString = localeTranslationData
309
+ .map(i => i.data?.[key])
310
+ .filter(i => typeof i !== 'string' && i !== undefined);
311
+ if (valuesNonString.length > 0) {
312
+ console.warn(`Translation '${localeName}' key '${key}' has non-string values that were ignored:`, valuesNonString);
313
+ }
314
+ const value = lastNonEmptyString(...values);
315
+ locales[localeName][key] = value ?? "";
316
+ }
317
+ // Use the first populated locale in sorted order when inferring meta.
318
+ const valueForMeta = localeNames
319
+ .map(localeName => locales[localeName]?.[key])
320
+ .find(value => typeof value === 'string' && value.trim().length > 0);
321
+ meta[key] = mergeMetaItems({
322
+ key,
323
+ value: valueForMeta,
324
+ items: sourceMeta.map(i => i.data?.[key]),
325
+ guessContentFormat: true,
326
+ }) ?? Object.create(null);
327
+ }
328
+ // Write output
329
+ if (outputFormat === "language-defaults") {
330
+ await writeTranslationFiles({
331
+ meta,
332
+ locales,
333
+ writePath: outputPath,
334
+ dryRun: globalOptions.dryRun,
335
+ });
336
+ }
337
+ else {
338
+ throw new Error(`Unknown output format '${outputFormat}'`);
339
+ }
340
+ console.log(`\nšŸ› ļø Ran sync-translation`);
341
+ console.log('\nāœ… Done!\n');
342
+ }
343
+ catch (error) {
344
+ console.error(`\nāŒ Error: `, error);
345
+ process.exit(1);
346
+ }
347
+ });
348
+ }
@@ -40,7 +40,8 @@ exports.generateHookArchetype = generateHookArchetype;
40
40
  const fs = __importStar(require("fs"));
41
41
  const path = __importStar(require("path"));
42
42
  const handlebars_1 = __importDefault(require("handlebars"));
43
- handlebars_1.default.registerHelper('json', (value) => JSON.stringify(value));
43
+ const handlebars = handlebars_1.default.create();
44
+ handlebars.registerHelper('json', (value) => JSON.stringify(value));
44
45
  function toPascalCase(value) {
45
46
  return value
46
47
  .split(/[^A-Za-z0-9]+/)
@@ -74,7 +75,7 @@ function walkTemplateFiles(dir) {
74
75
  }
75
76
  function renderTemplate(sourcePath, context) {
76
77
  const templateSource = fs.readFileSync(sourcePath, 'utf8');
77
- const template = handlebars_1.default.compile(templateSource, { noEscape: true });
78
+ const template = handlebars.compile(templateSource, { noEscape: true });
78
79
  return template(context);
79
80
  }
80
81
  function toOutputRelativePath(templateRoot, filePath) {
@@ -146,7 +147,7 @@ function buildTemplateContext(options) {
146
147
  hookShortName,
147
148
  hookPascalName,
148
149
  description: options.description ?? `ReDBox hook for ${hookPascalName}`,
149
- integrationDirName: hookShortName,
150
+ integrationDirName: `hook-${hookShortName}`,
150
151
  redboxCoreVersion: readDependencyPackageVersion(packageRoot, 'redbox-core'),
151
152
  redboxDevToolsVersion: readOwnPackageVersion(packageRoot),
152
153
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@researchdatabox/redbox-dev-tools",
3
- "version": "0.0.1-beta.9950",
3
+ "version": "5.0.0-RC1",
4
4
  "description": "ReDBox customisation dev tools - shared configuration, generators, and migration tooling",
5
5
  "type": "commonjs",
6
6
  "main": "index.js",
@@ -44,14 +44,14 @@
44
44
  },
45
45
  "homepage": "https://github.com/redbox-mint/redbox-portal/tree/v4.5.1#readme",
46
46
  "dependencies": {
47
- "@researchdatabox/redbox-core": "0.0.1-beta.9950",
48
- "@researchdatabox/sails-ng-common": "0.0.1-beta.9950",
47
+ "@researchdatabox/redbox-core": "5.0.0-RC1",
48
+ "@researchdatabox/sails-ng-common": "5.0.0-RC1",
49
49
  "@tsconfig/node24": "24.0.4",
50
50
  "@types/chai": "5.2.3",
51
51
  "@types/lodash": "4.17.24",
52
52
  "@types/mocha": "10.0.10",
53
53
  "@types/node": "24.12.4",
54
- "axios": "1.16.1",
54
+ "axios": "1.18.0",
55
55
  "chai": "6.2.2",
56
56
  "commander": "15.0.0",
57
57
  "handlebars": "4.7.9",