@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.
- package/README.md +25 -3
- package/bin/wrapper-webpack.js +31 -12
- package/dist/cli.js +6 -2
- package/dist/commands/form-config.js +8 -11
- package/dist/commands/sync-translation.d.ts +23 -0
- package/dist/commands/sync-translation.js +348 -0
- package/dist/templates/hook-archetype.js +4 -3
- package/package.json +4 -4
- package/templates/hook-archetype/standard/.gitignore.hbs +1 -0
- package/templates/hook-archetype/standard/.npmignore.hbs +2 -1
- package/templates/hook-archetype/standard/AGENTS.md.hbs +18 -1
- package/templates/hook-archetype/standard/Dockerfile.hbs +12 -11
- package/templates/hook-archetype/standard/README.md.hbs +28 -10
- package/templates/hook-archetype/standard/assets/default/.gitkeep +1 -0
- package/templates/hook-archetype/standard/assets/images/.gitkeep +1 -0
- package/templates/hook-archetype/standard/assets/styles/.gitkeep +1 -0
- package/templates/hook-archetype/standard/language-defaults/README.md.hbs +10 -0
- package/templates/hook-archetype/standard/language-defaults/en/translation.json.hbs +3 -0
- package/templates/hook-archetype/standard/language-defaults/meta.json.hbs +6 -0
- package/templates/hook-archetype/standard/package.json.hbs +10 -6
- package/templates/hook-archetype/standard/src/index.ts.hbs +11 -11
- package/templates/hook-archetype/standard/src/migrations/index.ts.hbs +19 -0
- package/templates/hook-archetype/standard/support/bootstrap-data/namedqueries/.gitkeep +1 -0
- package/templates/hook-archetype/standard/support/bootstrap-data/records/.gitkeep +1 -0
- package/templates/hook-archetype/standard/support/bootstrap-data/reports/.gitkeep +1 -0
- package/templates/hook-archetype/standard/support/bootstrap-data/vocabularies/.gitkeep +1 -0
- package/templates/hook-archetype/standard/support/development/core-mount.env.hbs +3 -0
- package/templates/hook-archetype/standard/support/development/docker-compose.core-mount.yml.hbs +37 -0
- package/templates/hook-archetype/standard/support/development/docker-compose.yml.hbs +53 -20
- package/templates/hook-archetype/standard/support/development/prepare-core-mount.sh.hbs +61 -0
- package/templates/hook-archetype/standard/support/integration-testing/docker-compose.mocha.yml.hbs +45 -11
- package/templates/hook-archetype/standard/support/integration-testing/run-mocha-hook.sh.hbs +5 -0
- package/templates/hook-archetype/standard/support/integration-testing/run-mocha-redbox.sh.hbs +100 -0
- package/templates/hook-archetype/standard/support/prepare-minio.sh.hbs +74 -0
- package/templates/hook-archetype/standard/test/integration/hook-registration.test.ts.hbs +3 -1
- package/templates/hook-archetype/standard/test/unit/index.test.ts.hbs +11 -5
- 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 `
|
|
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.
|
package/bin/wrapper-webpack.js
CHANGED
|
@@ -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
|
|
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('
|
|
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: [] }
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
|
48
|
-
"@researchdatabox/sails-ng-common": "0.0
|
|
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.
|
|
54
|
+
"axios": "1.18.0",
|
|
55
55
|
"chai": "6.2.2",
|
|
56
56
|
"commander": "15.0.0",
|
|
57
57
|
"handlebars": "4.7.9",
|