diff --git a/lib/bundle-validator.js b/lib/bundle-validator.js index 92327741..9f6d800a 100644 --- a/lib/bundle-validator.js +++ b/lib/bundle-validator.js @@ -15,6 +15,7 @@ const privateThemeConfigValidationSchema = require('./schemas/privateThemeConfig const themeConfigValidationSchema = require('./schemas/themeConfig.json'); const themeValidationSchema = require('./schemas/themeSchema.json'); const ScssValidator = require('./ScssValidator'); +const LangHelpersValidator = require('./lang/validator'); const VALID_IMAGE_TYPES = ['.jpg', '.jpeg', '.png', '.gif']; const WIDTH_COMPOSED = 600; @@ -42,6 +43,7 @@ class BundleValidator { this.objectsToValidate = ['head.scripts', 'footer.scripts']; this.jsonSchemaValidatorOptions = { schemaId: 'auto', allErrors: true }; this.scssValidator = new ScssValidator(themePath, themeConfig); + this.langHelpersValidator = new LangHelpersValidator(themePath, themeConfig); // Array of tasks used in async.series this.validationTasks = [ @@ -50,6 +52,7 @@ class BundleValidator { this._validateSchemaTranslations.bind(this), this._validateTemplatesFrontmatter.bind(this), this._validateCssFiles.bind(this), + this._validateLangFiles.bind(this), ]; if (!this.isPrivate) { @@ -405,6 +408,10 @@ class BundleValidator { return true; } + async _validateLangFiles() { + await this.langHelpersValidator.run(); + } + async _validateCssFiles() { await this.scssValidator.run(); } diff --git a/lib/bundle-validator.spec.js b/lib/bundle-validator.spec.js index 11ec6725..9d6eb114 100644 --- a/lib/bundle-validator.spec.js +++ b/lib/bundle-validator.spec.js @@ -101,7 +101,7 @@ describe('BundleValidator', () => { const res = await promisify(validator.validateTheme.bind(validator))(); - expect(res).toHaveLength(6); // 6 validation tasks + expect(res).toHaveLength(7); // 7 validation tasks expect(res).not.toContain(false); }); diff --git a/lib/lang/validator.js b/lib/lang/validator.js new file mode 100644 index 00000000..5601905a --- /dev/null +++ b/lib/lang/validator.js @@ -0,0 +1,113 @@ +require('colors'); +const fs = require('fs'); +const path = require('path'); +const { recursiveReadDir } = require('../utils/fsUtils'); + +const LANG_HELPER_REGEXP = /{{\s*lang\s*(?:'|")((?:\w*(?:-\w*)*(\.\w*(?:-\w*)*)*)+)/gim; + +class LangpathsValidator { + /** + * + * @param {String} themePath + */ + constructor(themePath) { + this.themePath = themePath; + } + + async run(defaultLang = null) { + const templatesPath = path.join(this.themePath, 'templates'); + const paths = await this.getLangHelpersPaths(templatesPath); + const dedupePaths = [...new Set(paths)]; + const langFiles = await this.getLangFilesContent(defaultLang); + const errors = this.validate(dedupePaths, langFiles); + this.printErrors(errors); + return errors; + } + + printErrors(errors) { + if (errors.length > 0) { + console.log( + 'Warning: Your theme has some missing translations used in the theme:'.yellow, + ); + console.log(errors.join('\n').yellow); + } + } + + searchLangPaths(fileContent, langPath) { + const keys = langPath.split('.'); + let value = fileContent; + + for (const key of keys) { + // eslint-disable-next-line no-prototype-builtins + if (value && value.hasOwnProperty(key)) { + value = value[key]; + } else { + return false; + } + } + + return value; + } + + validate(paths, langFiles) { + const errors = [ + ...this.checkLangFiles(langFiles), + ...this.checkForMissingTranslations(paths, langFiles), + ]; + return errors; + } + + checkForMissingTranslations(paths, langFiles) { + const errors = []; + for (const langPath of paths) { + // eslint-disable-next-line no-restricted-syntax,guard-for-in + for (const langFile in langFiles) { + const translation = this.searchLangPaths(langFiles[langFile], langPath); + if (!translation) { + errors.push(`Missing translation for ${langPath} in ${langFile}`); + } + } + } + return errors; + } + + checkLangFiles(files) { + if (files.length === 0) { + return ['No lang files found in your theme']; + } + return []; + } + + async getLangHelpersPaths(templatesPath) { + const files = await recursiveReadDir(templatesPath); + const paths = []; + for await (const file of files) { + const content = await fs.promises.readFile(file, { encoding: 'utf-8' }); + const result = content.matchAll(LANG_HELPER_REGEXP); + const arr = [...result]; + if (arr.length > 0) { + const langPath = arr[0][1]; + paths.push(langPath); + } + } + return paths; + } + + async getLangFilesContent(defaultLang = null) { + const filesContent = {}; + const langPath = path.join(this.themePath, 'lang'); + let files = await recursiveReadDir(langPath); + + if (defaultLang) { + files = files.filter((file) => file.includes(defaultLang)); + } + + for await (const file of files) { + const content = await fs.promises.readFile(file, { encoding: 'utf-8' }); + filesContent[file] = JSON.parse(content); + } + return filesContent; + } +} + +module.exports = LangpathsValidator; diff --git a/lib/lang/validator.spec.js b/lib/lang/validator.spec.js new file mode 100644 index 00000000..efc511eb --- /dev/null +++ b/lib/lang/validator.spec.js @@ -0,0 +1,37 @@ +const path = require('path'); + +const LangFilesValidator = require('./validator'); + +describe('lang/validator.js tests', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('valid', () => { + it('run with no errors', async () => { + const themePath = path.join(process.cwd(), 'test/_mocks/themes/valid'); + const validator = new LangFilesValidator(themePath); + const errors = await validator.run(); + + expect(errors).toHaveLength(0); + }); + + it('run with no errors providing default lang', async () => { + const themePath = path.join(process.cwd(), 'test/_mocks/themes/valid'); + const validator = new LangFilesValidator(themePath); + const errors = await validator.run('en'); + + expect(errors).toHaveLength(0); + }); + }); + + describe('not valid', () => { + it('run with lang helper that is not presented in lang file', async () => { + const themePath = path.join(process.cwd(), 'test/_mocks/themes/invalid-translations'); + const validator = new LangFilesValidator(themePath); + const errors = await validator.run(); + + expect(errors).toHaveLength(1); + }); + }); +}); diff --git a/lib/stencil-start.js b/lib/stencil-start.js index 040665cb..cd796d61 100755 --- a/lib/stencil-start.js +++ b/lib/stencil-start.js @@ -16,6 +16,7 @@ const cliCommonModule = require('./cliCommon'); const themeApiClientModule = require('./theme-api-client'); const storeSettingsApiClientModule = require('./store-settings-api-client'); const LangHelper = require('./lang-helper'); +const LangValidator = require('./lang/validator'); class StencilStart { constructor({ @@ -32,6 +33,7 @@ class StencilStart { CyclesDetector = Cycles, stencilPushUtils = stencilPushUtilsModule, logger = console, + langValidator = new LangValidator(THEME_PATH), } = {}) { this._browserSync = browserSync; this._themeApiClient = themeApiClient; @@ -46,6 +48,7 @@ class StencilStart { this._CyclesDetector = CyclesDetector; this._stencilPushUtils = stencilPushUtils; this._logger = logger; + this._langValidator = langValidator; } async run(cliOptions) { @@ -289,6 +292,7 @@ class StencilStart { } else { try { await this._langHelper.checkLangKeysPresence(filesPaths, defaultShopperLanguage); + await this._langValidator.run(defaultShopperLanguage); } catch (e) { this._logger.error(e); } diff --git a/test/_mocks/themes/invalid-translations/lang/en.json b/test/_mocks/themes/invalid-translations/lang/en.json new file mode 100644 index 00000000..4fabf988 --- /dev/null +++ b/test/_mocks/themes/invalid-translations/lang/en.json @@ -0,0 +1,20 @@ +{ + "header": { + "welcome_back": "Welcome back, {name}" + }, + "footer": { + "brands": "Popular Brands", + "navigate": "Navigate", + "info": "Info", + "categories": "Categories", + "call_us": "Call us at {phone_number}" + }, + "home": { + "heading": "Home" + }, + "blog": { + "recent_posts": "Recent Posts", + "label": "Blog", + "posted_by": "Posted by {name}" + } +} diff --git a/test/_mocks/themes/invalid-translations/templates/components/a.html b/test/_mocks/themes/invalid-translations/templates/components/a.html new file mode 100644 index 00000000..78981922 --- /dev/null +++ b/test/_mocks/themes/invalid-translations/templates/components/a.html @@ -0,0 +1 @@ +a diff --git a/test/_mocks/themes/invalid-translations/templates/components/b.html b/test/_mocks/themes/invalid-translations/templates/components/b.html new file mode 100644 index 00000000..37208fae --- /dev/null +++ b/test/_mocks/themes/invalid-translations/templates/components/b.html @@ -0,0 +1,7 @@ +b + + + + +more text here \ No newline at end of file diff --git a/test/_mocks/themes/invalid-translations/templates/pages/page.html b/test/_mocks/themes/invalid-translations/templates/pages/page.html new file mode 100644 index 00000000..687945d8 --- /dev/null +++ b/test/_mocks/themes/invalid-translations/templates/pages/page.html @@ -0,0 +1,9 @@ + + +
+