Skip to content

Commit cf1e2eb

Browse files
Merge pull request #287 from ibi-group/check-i18n-ymls
Check i18n ymls
2 parents dee04f4 + c301fed commit cf1e2eb

File tree

8 files changed

+841
-14
lines changed

8 files changed

+841
-14
lines changed

Diff for: package.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"@babel/preset-env": "^7.10",
1818
"@babel/preset-react": "^7.10",
1919
"@babel/preset-typescript": "^7.13.0",
20+
"@formatjs/cli": "^4.2.32",
2021
"@semantic-release/git": "^9.0.0",
2122
"@storybook/addon-a11y": "^6.4.19",
2223
"@storybook/addon-actions": "^6.4.19",
@@ -58,6 +59,7 @@
5859
"jest-resolve": "^24.8.0",
5960
"jest-styled-components": "^7.0.5",
6061
"jest-yaml-transform": "^0.2.0",
62+
"js-yaml": "^4.1.0",
6163
"leaflet": "^1.6.0",
6264
"lerna": "^3.18.4",
6365
"lint-staged": "^8.2.0",
@@ -90,6 +92,7 @@
9092
"bootstrap": "lerna bootstrap --use-workspaces",
9193
"build:cjs": "lerna exec --parallel -- babel --extensions '.js,.ts,.tsx' --ignore **/*.story.js,**/*.story.ts,**/*.story.d.ts,**/*.story.tsx,**/*.spec.js,**/*.spec.ts,**/*.test.js,**/*.test.ts,**/__tests__/**,**/__unpublished__/** --root-mode upward --source-maps true src -d lib",
9294
"build:esm": "lerna exec --parallel -- cross-env BABEL_ENV=esm babel --extensions '.js,.ts,.tsx' --ignore **/*.story.js,**/*.story.ts,**/*.story.d.ts,**/*.story.tsx,**/*.spec.js,**/*.spec.ts,**/*.test.js,**/*.test.ts,**/__tests__/**,**/__unpublished__/** --root-mode upward --source-maps true src -d esm",
95+
"check:i18n": "node packages/scripts/lib/validate-i18n.js packages/**/src packages/**/i18n",
9396
"prepublish": "yarn typescript && yarn build:cjs && yarn build:esm",
9497
"check-eslint-config": "eslint --print-config jestconfig.js | eslint-config-prettier-check",
9598
"coverage": "jest --coverage",
@@ -102,7 +105,7 @@
102105
"lint": "yarn lint:js && yarn lint:styles",
103106
"prettier": "prettier --write \"**/*.{json,md,yml}\"",
104107
"semantic-release": "lerna exec --concurrency 1 -- semantic-release -e semantic-release-monorepo",
105-
"test": "yarn lint:js && yarn lint:styles && yarn typescript && yarn unit && yarn a11y-test",
108+
"test": "yarn lint:js && yarn lint:styles && yarn check:i18n && yarn typescript && yarn unit && yarn a11y-test",
106109
"typescript": "lerna run tsc",
107110
"unit": "jest --testPathIgnorePatterns a11y",
108111
"update-internal-dependencies": "node scripts/update-internal-dependencies.js"

Diff for: packages/location-field/src/index.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,7 @@ const LocationField = ({
593593
isActive={itemIndex === activeIndex}
594594
key={optionKey++}
595595
onClick={locationSelected}
596+
// @ts-ignore Fixed in another PR
596597
title={coreUtils.map.formatStoredPlaceName(userLocation)}
597598
/>
598599
);

Diff for: packages/scripts/package.json

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "@opentripplanner/scripts",
3+
"version": "1.0.0",
4+
"description": "Utility scripts for the OTP-UI library",
5+
"main": "lib/index.js",
6+
"module": "esm/index.js",
7+
"types": "lib/index.d.js",
8+
"repository": "https://github.com/opentripplanner/otp-ui.git",
9+
"homepage": "https://github.com/opentripplanner/otp-ui#readme",
10+
"author": "Binh Dam",
11+
"license": "MIT",
12+
"private": false,
13+
"dependencies": {
14+
"@formatjs/cli": "^4.2.33",
15+
"flat": "^5.0.2",
16+
"glob": "^8.0.3",
17+
"glob-promise": "^4.2.2",
18+
"js-yaml": "^4.1.0"
19+
},
20+
"scripts": {
21+
"collect-i18n": "node lib/collect-i18n-messages.js ../**/src ../**/i18n/en-US.yml > i18n.csv",
22+
"tsc": "tsc",
23+
"validate-i18n": "node lib/validate-i18n.js ../**/src ../**/i18n"
24+
},
25+
"bugs": {
26+
"url": "https://github.com/opentripplanner/otp-ui/issues"
27+
},
28+
"gitHead": "0af1b7cda60bd4252b219dcf893e01c2acb2ed5d"
29+
}

Diff for: packages/scripts/src/collect-i18n-messages.ts

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/* eslint-disable no-console */
2+
/**
3+
* This script collects message ids gathered by the formatjs extract command in the specified files and folder(s)
4+
* and creates a CSV file with the id, description, and messages in the selected language(s).
5+
* This script is shipped as part of a package so it can be used in other code bases as needed.
6+
*/
7+
// Example usage for all packages and all languages in this repo:
8+
// node path-to/lib/collect-i18n-messages.js ../**/src ../**/i18n
9+
// Example usage for all packages and one language in this repo:
10+
// node path-to-lib/collect-i18n-messages.js ../**/src ../**/i18n/en-US.yml
11+
12+
import { extract } from "@formatjs/cli";
13+
import flatten from "flat";
14+
15+
import { isNotSpecialId, loadYamlFile, sortSourceAndYmlFiles } from "./util";
16+
17+
// The data that corresponds to rows in the CSV output.
18+
type MessageData = Record<
19+
string,
20+
Record<string, string> & {
21+
description: string;
22+
}
23+
>;
24+
25+
/**
26+
* Collect all messages and create a formatted output.
27+
*/
28+
async function collectAndPrintOutMessages({ sourceFiles, ymlFilesByLocale }) {
29+
// Gather message ids from code.
30+
const messagesFromCode = JSON.parse(await extract(sourceFiles, {}));
31+
const messageIdsFromCode = Object.keys(messagesFromCode);
32+
const allLocales = Object.keys(ymlFilesByLocale);
33+
34+
// CSV heading
35+
console.log(`ID,Description,${allLocales.join(",")}`);
36+
37+
// Will contain id, description, and a column for each language.
38+
const messageData: MessageData = {};
39+
40+
// For each locale, check that all ids in messages are in the yml files.
41+
// Accessorily, log message ids from yml files that are not used in the code.
42+
await Promise.all(
43+
allLocales.map(async locale => {
44+
const allI18nPromises = ymlFilesByLocale[locale].map(loadYamlFile);
45+
const allI18nMessages = await Promise.all(allI18nPromises);
46+
let allI18nMessagesFlattened = {};
47+
48+
allI18nMessages.forEach(i18nMessages => {
49+
const flattenedMessages: Record<string, string> = flatten(i18nMessages);
50+
allI18nMessagesFlattened = {
51+
...allI18nMessagesFlattened,
52+
...flattenedMessages
53+
};
54+
});
55+
56+
messageIdsFromCode.filter(isNotSpecialId).forEach(id => {
57+
const { description } = messagesFromCode[id];
58+
const message = allI18nMessagesFlattened[id]?.trim() || undefined;
59+
60+
if (!messageData[id]) {
61+
messageData[id] = {
62+
description
63+
};
64+
}
65+
messageData[id][locale] = message;
66+
});
67+
})
68+
);
69+
70+
Object.keys(messageData).forEach(id => {
71+
const row = messageData[id];
72+
const messages = allLocales.map(locale => row[locale]);
73+
console.log(`${id},"${row.description}","${messages}"`);
74+
});
75+
}
76+
77+
sortSourceAndYmlFiles(process.argv).then(collectAndPrintOutMessages);

Diff for: packages/scripts/src/util.ts

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { promises as fs } from "fs";
2+
import { load } from "js-yaml";
3+
import glob from "glob-promise";
4+
import path from "path";
5+
6+
export interface SourceFilesAndYmlFilesByLocale {
7+
sourceFiles: string[];
8+
ymlFilesByLocale: Record<string, string>;
9+
}
10+
11+
function shouldProcessFile(fileName: string): boolean {
12+
return (
13+
!fileName.includes("/__") &&
14+
!fileName.includes("node_modules") &&
15+
!fileName.endsWith(".d.ts")
16+
);
17+
}
18+
19+
/**
20+
* @returns true if the id is not special or reserved (i.e. doesn't start with "_").
21+
*/
22+
export function isNotSpecialId(id: string): boolean {
23+
return !id.startsWith("_");
24+
}
25+
26+
/**
27+
* Helper function that sorts yml and source files into two buckets.
28+
* @param argv The value from process.argv.
29+
* @returns A composite object with a list for yml files by locale, and a list for source files.
30+
*/
31+
export async function sortSourceAndYmlFiles(argv: string[]) {
32+
const sourceFiles = [];
33+
const ymlFilesByLocale = {};
34+
35+
// Places the give file into the source or yml file bucket above.
36+
function sortFile(fileName: string): void {
37+
const parsedArg = path.parse(fileName);
38+
if (parsedArg.ext === ".yml") {
39+
const locale = parsedArg.name;
40+
if (!ymlFilesByLocale[locale]) {
41+
ymlFilesByLocale[locale] = [];
42+
}
43+
const ymlFilesForLocale = ymlFilesByLocale[locale];
44+
if (!ymlFilesForLocale.includes(fileName)) {
45+
ymlFilesForLocale.push(fileName);
46+
}
47+
ymlFilesByLocale[locale].push(fileName);
48+
} else if (!sourceFiles.includes(fileName)) {
49+
sourceFiles.push(fileName);
50+
}
51+
}
52+
53+
// Note: reminder that node.js provides the first two argv values:
54+
// - argv[0] is the name of the executable file.
55+
// - argv[1] is the path to the script file.
56+
// - argv[2] and beyond are the folders passed to the script.
57+
const allGlobPromises = [];
58+
for (let i = 2; i < argv.length; i++) {
59+
// List the files recursively (glob) for this folder.
60+
const arg = argv[i];
61+
62+
// If argument ends with .yml, treat as a file.
63+
if (arg.endsWith(".yml")) {
64+
sortFile(arg);
65+
} else {
66+
// Otherwise, it is a folder, and use glob to get files recursively.
67+
// For glob argument info, see their docs at https://github.com/ahmadnassri/node-glob-promise#api.
68+
allGlobPromises.push(glob(`${arg}/**/*.{{j,t}s{,x},yml}`));
69+
}
70+
}
71+
72+
const allFileLists = await Promise.all(allGlobPromises);
73+
allFileLists.forEach(files =>
74+
files.filter(shouldProcessFile).forEach(sortFile)
75+
);
76+
77+
return {
78+
sourceFiles,
79+
ymlFilesByLocale
80+
};
81+
}
82+
83+
/**
84+
* Load yaml from a file into a js object
85+
*/
86+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
87+
export async function loadYamlFile(filename: string): Promise<any> {
88+
return load(await fs.readFile(filename));
89+
}

Diff for: packages/scripts/src/validate-i18n.ts

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/* eslint-disable no-console */
2+
/**
3+
* This script checks that message ids gathered by the formatjs extract command
4+
* are present in the specified folder(s).
5+
* It will produce an error code if message ids are present in a language but not another,
6+
* or if message ids are in a i18n yml files but not in the code or vice-versa.
7+
* This script is shipped as part of a package so it can be used in other code bases as needed.
8+
*/
9+
// Example usage for one package in this repo:
10+
// node path-to/lib/validate-i18n.js ../trip-details/src ../trip-details/i18n
11+
// Example usage for all packages in this repo:
12+
// node path-to/lib/validate-i18n.js ../**/src ../**/i18n
13+
14+
const { extract } = require("@formatjs/cli");
15+
const flatten = require("flat");
16+
17+
const {
18+
isNotSpecialId,
19+
loadYamlFile,
20+
sortSourceAndYmlFiles
21+
} = require("./util");
22+
23+
/**
24+
* Checks message ids completeness between code and yml files for all locales in repo.
25+
*/
26+
async function checkI18n({ sourceFiles, ymlFilesByLocale }) {
27+
// Gather message ids from code.
28+
const messagesFromCode = JSON.parse(await extract(sourceFiles, {}));
29+
const messageIdsFromCode = Object.keys(messagesFromCode);
30+
console.log(
31+
`Checking ${messageIdsFromCode.length} strings from ${
32+
Object.keys(ymlFilesByLocale["en-US"]).length
33+
} message files against ${sourceFiles.length} source files.`
34+
);
35+
let errorCount = 0;
36+
37+
// For each locale, check that all ids in messages are in the yml files.
38+
// Accessorily, log message ids from yml files that are not used in the code.
39+
await Promise.all(
40+
Object.keys(ymlFilesByLocale).map(async locale => {
41+
const idsChecked = [];
42+
const idsNotInCode = [];
43+
44+
const allI18nPromises = ymlFilesByLocale[locale].map(loadYamlFile);
45+
const allI18nMessages = await Promise.all(allI18nPromises);
46+
47+
allI18nMessages.forEach(i18nMessages => {
48+
const flattenedMessages = flatten(i18nMessages);
49+
50+
// Message ids from code must be present in yml.
51+
messageIdsFromCode
52+
.filter(id => flattenedMessages[id])
53+
.forEach(id => idsChecked.push(id));
54+
55+
// Message ids from yml (except those starting with "_") must be present in code.
56+
Object.keys(flattenedMessages)
57+
.filter(isNotSpecialId)
58+
.filter(id => !messageIdsFromCode.includes(id))
59+
.forEach(id => idsNotInCode.push(id));
60+
});
61+
62+
// Collect ids in code not found in yml.
63+
const missingIdsForLocale = messageIdsFromCode.filter(
64+
id => !idsChecked.includes(id)
65+
);
66+
67+
// Print errors.
68+
missingIdsForLocale.forEach(id => {
69+
console.error(`Message '${id}' is missing from locale ${locale}.`);
70+
});
71+
idsNotInCode.forEach(id => {
72+
console.error(
73+
`Message '${id}' from locale ${locale} is not used in code.`
74+
);
75+
});
76+
errorCount += missingIdsForLocale.length + idsNotInCode.length;
77+
})
78+
);
79+
80+
console.log(`There were ${errorCount} error(s).`);
81+
if (errorCount > 0) {
82+
process.exit(1);
83+
}
84+
}
85+
86+
sortSourceAndYmlFiles(process.argv).then(checkI18n);

Diff for: packages/scripts/tsconfig.json

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": "./lib"
5+
},
6+
"include": ["src/**/*"]
7+
}

0 commit comments

Comments
 (0)