diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9b4332b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +# Leave PO/MO/POT files alone +[**.{po,mo,pot}] +insert_final_newline = false + +# Matches multiple files with brace expansion notation +# Set default charset +[**.js] +charset = utf-8 +trim_trailing_whitespace = true +indent_style = tab +indent_size = 2 + +# Matches the exact files either package.json or .travis.yml +[*.json] +indent_style = space +indent_size = 2 diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..6d77097 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,38 @@ +module.exports = { + 'env': { + 'commonjs': true, + 'es2021': true, + 'node': true + }, + 'extends': 'eslint:recommended', + 'overrides': [ + { + 'env': { 'jest': true }, + 'files': ['test/**'], + 'plugins': ['jest'], + 'extends': ['plugin:jest/recommended'], + 'rules': {} + } + ], + 'parserOptions': { + 'ecmaVersion': 'latest' + }, + 'rules': { + 'indent': [ + 'error', + 'tab' + ], + 'linebreak-style': [ + 'error', + 'unix' + ], + 'quotes': [ + 'error', + 'single' + ], + 'semi': [ + 'error', + 'always' + ], + } +}; diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..09bc62c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Set the default behavior, in case people don't have core.autocrlf set. +* text=auto \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59b3f8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/node_modules +/other +*.bak +# jest.config.js diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..fc2dc85 --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +/test +/other +.editorconfig +.gitattributes +.gitignore +.eslintrc.js +jest.config.js +*.bak diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..21e54fe --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Philip van Heemstra + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..c37719b --- /dev/null +++ b/jest.config.js @@ -0,0 +1,195 @@ +/* + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/configuration + */ + +module.exports = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "C:\\Users\\phili\\AppData\\Local\\Temp\\jest", + + // Automatically clear mock calls, instances, contexts and results before every test + // clearMocks: false, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: false, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + // coverageDirectory: "coverage", + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "\\\\node_modules\\\\" + // ], + + // Indicates which provider should be used to instrument code for coverage + // coverageProvider: "v8", + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // The default configuration for fake timers + // fakeTimers: { + // "enableGlobally": false + // }, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "mjs", + // "cjs", + // "jsx", + // "ts", + // "tsx", + // "json", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + // moduleNameMapper: {}, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: undefined, + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state before every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state and implementation before every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + rootDir: './test/', + + // A list of paths to directories that Jest should use to search for files in + roots: [ + "" + ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + // testEnvironment: "jest-environment-node", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + // ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "\\\\node_modules\\\\" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jest-circus/runner", + + // A map from regular expressions to paths to transformers + // transform: undefined, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "\\\\node_modules\\\\", + // "\\.pnp\\.[^\\\\]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + verbose: false, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..501d341 --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "fill-pot-po", + "version": "0.3.0", + "description": "Create pre-filled PO files from POT file, using previous PO files.", + "main": "src/index.js", + "scripts": { + "lint": "eslint src/*.js test/*.js", + "lint:fix": "eslint --fix src/*.js test/*.js", + "test": "jest", + "preversion": "npm run lint && npm test", + "postversion": "git push && git push --tags" + }, + "keywords": [ + "pot", + "po", + "fill", + "merge", + "auto-fill", + "prefill", + "pre-fill", + "generate", + "create", + "i18n", + "l10n", + "gettext", + "translation" + ], + "repository": "https://github.com/vheemstra/fill-pot-po.git", + "homepage": "https://github.com/vheemstra/fill-pot-po", + "bugs": "https://github.com/vheemstra/fill-pot-po/issues", + "author": { + "name": "Philip van Heemstra", + "url": "https://github.com/vheemstra" + }, + "engines": { + "node": ">=12" + }, + "files": [ + "src" + ], + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.3", + "color-support": "^1.1.3", + "gettext-parser": "^5.1.2", + "matched": "^5.0.1", + "vinyl": "^2.2.1" + }, + "devDependencies": { + "eslint": "^8.16.0", + "eslint-plugin-jest": "^26.4.5", + "gulp": "^4.0.2", + "gulp-wp-pot": "^2.5.0", + "jest": "^28.1.0" + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..97bc525 --- /dev/null +++ b/readme.md @@ -0,0 +1,39 @@ +# fill-pot-po + +## Information + +| Package | wp-pot | +| ----------- | ---------------------------------------------------- | +| Description | Generate pre-filled PO files from POT file, using source PO files. | + +## Install + +```sh +npm install --save-dev fill-pot-po +``` + +## Example usage + +### Basic + +```js +const fillPotPo = require('fill-pot-po'); + +fillPotPo({ + ... +}); +``` + +## Options + +... + +## Related + +- [gulp-fill-pot-po](https://github.com/vheemstra/gulp-fill-pot-po) - Run fill-pot-po in gulp +- [gettext-parser](https://github.com/smhg/gettext-parser) - Parse and compile gettext PO and MO files with NodeJS +- [gulp-wp-pot](https://github.com/wp-pot/gulp-wp-pot) - Generate POT files in WordPress project in gulp + +## License + +MIT © [Philip van Heemstra](https://github.com/vheemstra) diff --git a/src/async.js b/src/async.js new file mode 100644 index 0000000..5e3355e --- /dev/null +++ b/src/async.js @@ -0,0 +1,147 @@ +'use strict'; + +const PluginError = require('./plugin-error'); +const c = require('ansi-colors'); +c.enabled = require('color-support').hasBasic; + +const Vinyl = require('vinyl'); +const { readFile } = require('fs'); +const gettextParser = require('gettext-parser'); + +const prepareOptions = require('./options'); +const { resolvePOTFilepaths, getPOFilepaths, generatePO, logResults } = require('./shared'); + +/** + * Reads and parses PO file. + * + * @param {string} po_filepath + * @param {function} resolve Resolve callback + * @param {function} reject Reject callback + * + * @resolve {object} PO content + * @reject {string} File reading error + * + * @return {void} + */ +function parsePO(po_filepath, resolve, reject) { + // Async - Read, parse and process PO file + readFile(po_filepath, (err, file_content) => { + if (err) reject(err); + const po_object = gettextParser.po.parse(file_content); + resolve(po_object); + }); +} + +/** + * Process POT file. + * + * - Finds the PO filepaths + * - Reads and parses the POT file + * + * @param {string} pot_filepath + * @param {object} options + * + * @resolve {array} [ + * {object} POT content + * {array} PO filepaths + * ] + * @reject {string} File reading error + * + * @return {void} + */ +function processPOT(pot_file, options, resolve, reject) { + const isVinyl = Vinyl.isVinyl(pot_file); + const pot_filepath = isVinyl ? pot_file.path : pot_file; + + // Get filepaths of POs + const po_filepaths = getPOFilepaths(pot_filepath, options); + + if (po_filepaths.length) { + if (isVinyl) { + const pot_object = gettextParser.po.parse(pot_file.contents); + resolve([pot_object, po_filepaths]); + } else { + // Async - Read and parse POT file + readFile(pot_filepath, (err, file_content) => { + if (err) reject(err); + const pot_object = gettextParser.po.parse(file_content); + resolve([pot_object, po_filepaths]); + }); + } + } else { + resolve(null); + } +} + +function fillPotPo(cb, options) { + if (typeof cb !== 'function') { + throw new PluginError('fillPotPo() requires a callback function as first parameter'); + } + + // Set options + try { + options = prepareOptions(options); + options = resolvePOTFilepaths(options); + } catch (error) { + cb([false, error.toString()]); + return; + } + + const po_input_files = []; + + // Process all POT files + Promise.all( options.potSources.map(pot_file => { + const pot_filepath = Vinyl.isVinyl(pot_file) ? pot_file.relative : pot_file; + + return new Promise((resolve, reject) => { + + processPOT(pot_file, options, resolve, reject); + + }).then(async (value) => { + + if (!value) { + po_input_files.push([]); + return []; + } + + // Process all PO files + let pot_object = value[0]; + let po_files = value[1]; + po_input_files.push(po_files); + const po_results = await Promise.all( po_files.map(po_file => { + return new Promise((resolve, reject) => { + + parsePO(po_file, resolve, reject); + + }).then(po_object => { + + // Generate PO and add to collection + return generatePO(pot_object, po_object, po_file, options); + + }).catch(error => { + throw new PluginError(`${c.bold(error)} ${c.gray(`(PO ${c.white(po_file)})`)}`); + }); + + }) ); + return po_results; + + }).catch(error => { + throw new PluginError(`${error.message} ${c.gray(`(POT ${c.white(pot_filepath)})`)}`); + }); + + }) ).then(pot_results => { + if (options.logResults) { + logResults(options._potFiles, po_input_files, pot_results, options.destDir); + } + + // Flatten into array with all PO files + pot_results = [].concat(...pot_results); + cb([true, pot_results]); + }).catch(error => { + cb([false, error.toString()]); + }); + + return; +} + +module.exports = fillPotPo; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..08505fb --- /dev/null +++ b/src/index.js @@ -0,0 +1,8 @@ +'use strict'; + +const fillPotPo = require('./async'); +const fillPotPoSync = require('./sync'); + +// See: https://stackoverflow.com/a/54047219/2142071 +const the_module = module.exports = fillPotPo; +the_module.sync = fillPotPoSync; diff --git a/src/options.js b/src/options.js new file mode 100644 index 0000000..638fc37 --- /dev/null +++ b/src/options.js @@ -0,0 +1,192 @@ +'use strict'; + +const PluginError = require('./plugin-error'); +const { isArray, isObject, isString, isBool, isArrayOfStrings } = require('./utils'); + +// const { sync: matchedSync } = require('matched'); +// const { pathLineSort } = require('./utils'); +const { resolve, relative } = require('path'); + +/** + * Wrapper for PluginError for all errors with options. + */ +class OptionsError extends PluginError { + constructor( message ) { + super( message, 'options' ); + } +} + +let cwd = './'; + +/** + * Validate user supplied options. + * + * @param {mixed} options + * + * @throws OptionError on invalid or missing options. + * + * @return {object} + */ +function validateOptionsInput(options) { + if (isObject(options)) { + if (typeof options.potSources !== 'undefined' && !isArray(options.potSources)) { + throw new OptionsError('Option potSources should be an array.'); + } + + if ( + typeof options.poSources !== 'undefined' + && options.poSources + && ! (isString(options.poSources) || isArrayOfStrings(options.poSources)) + ) { + throw new OptionsError('Option poSources should be a glob string or glob array.'); + } + + if (typeof options.wrapLength !== 'undefined' + && (typeof options.wrapLength !== 'number' || 0 >= options.wrapLength) + ) { + throw new OptionsError('If set, option wrapLength should be a number higher than 0.'); + } + + const if_set_string = [ 'srcDir', 'destDir', 'domain', + ]; + if_set_string.forEach(k => { + if (typeof options[k] !== 'undefined' && ! isString(options[k])) { + throw new OptionsError(`Option ${k} should be a string.`); + } + }); + + const no_newlines = [ 'srcDir', 'destDir', 'domain' ]; + no_newlines.forEach(k => { + if (typeof options[k] !== 'undefined' && options[k].match(/\n/)) { + throw new OptionsError(`Option ${k} can't contain newline characters.`); + } + }); + + const if_set_bool = [ + 'writeFiles', + 'domainFromPOTPath', + 'domainInPOPath', + 'defaultContextAsFallback', + 'appendNonIncludedFromPO', + 'includePORevisionDate', + 'logResults', + ]; + if_set_bool.forEach(k => { + if (typeof options[k] !== 'undefined' && ! isBool(options[k])) { + throw new OptionsError(`Option ${k} should be a boolean.`); + } + }); + } else if (isString(options) || isArrayOfStrings(options)) { + options = { + poSources: options + }; + } else if (typeof options !== 'undefined') { + throw new OptionsError('Options should be an object of options, glob string or glob array.'); + } else { + options = {}; + } + + return options; +} + +/** + * Clean and standardize user supplied options. + * + * @param {object} options + * + * @return {object} + */ +function sanitizeAndStandardizeOptionsInput(options) { + if (typeof options.poSources !== 'undefined') { + // Make array with one or more non-empty strings + if (!isArray(options.poSources)) { + options.poSources = [options.poSources]; + } + options.poSources = options.poSources + .map(v => (typeof v === 'string' ? v.trim() : v)) + .filter(v => (typeof v === 'string' && v.length > 0)) + ; + if (0 >= options.poSources.length) { + delete options.poSources; + } + } + if (options.srcDir) { + // NOTE: all paths starting with a slash are considered absolute paths + options.srcDir = resolve(options.srcDir.trim()); + options.srcDir = `${relative(cwd, options.srcDir)}/` // add trailing slash + .replaceAll(/\\/g, '/') // only forward slashes + .replaceAll(/\/+/g, '/') // remove duplicate slashes + .replaceAll(/^\//g, '') // remove leading slash + ; + } + if (options.destDir) { + // NOTE: all paths starting with a slash are considered absolute paths + options.destDir = resolve(options.destDir.trim()); + options.destDir = `${relative(cwd, options.destDir)}/` // add trailing slash + .replaceAll(/\\/g, '/') // only forward slashes + .replaceAll(/\/+/g, '/') // remove duplicate slashes + .replaceAll(/^\//g, '') // remove leading slash + ; + } + if (options.wrapLength) { + // Make integer + options.wrapLength = Math.ceil(options.wrapLength); + } + + return options; +} + +/** + * Process user supplied options and merge with default options. + * + * @param {mixed} options + * + * @throws OptionError on invalid, missing or incompatible options. + * + * @return {object} + */ +function prepareOptions(options, writeFiles) { + cwd = resolve(); + + // Validate/check options + options = validateOptionsInput(options); + + // Sanitize/clean and standardize options + options = sanitizeAndStandardizeOptionsInput(options); + + const defaultOptions = { + potSources: ['**/*.pot', '!node_modules/**'], + poSources: null, + srcDir: '', + srcGlobOptions: {}, + writeFiles: (typeof writeFiles !== 'undefined') ? writeFiles : true, + destDir: '', + domainFromPOTPath: true, + domain: '', + domainInPOPath: true, + wrapLength: 77, + defaultContextAsFallback: false, + appendNonIncludedFromPO: false, + includePORevisionDate: false, + logResults: false, + }; + + // Merge with defaults + options = Object.assign({}, defaultOptions, options); + + /** + * Check for logical errors + */ + + if ( + options.domainFromPOTPath === false + && options.domainInPOPath === true + && 0 >= options.domain.length + ) { + throw new OptionsError('Option domain should be a non-empty string when domainFromPOTPath is false and domainInPOPath is true.'); + } + + return options; +} + +module.exports = prepareOptions; diff --git a/src/plugin-error.js b/src/plugin-error.js new file mode 100644 index 0000000..6af1ec5 --- /dev/null +++ b/src/plugin-error.js @@ -0,0 +1,41 @@ +'use strict'; + +const util = require('util'); +const c = require('ansi-colors'); +c.enabled = require('color-support').hasBasic; + +const pluginname = require('../package.json').name; + + +// class PluginError extends Error { +class PluginError { + constructor( message, category = '' ) { + // super( message ); + // this.name = 'PluginError'; + this.message = message; + this.category = category.slice(0, 1).toUpperCase() + category.slice(1).toLowerCase(); + } + + toString() { + return `${c.cyan(pluginname)} ${c.bold.red(`${this.category}Error`)} ${this.message}`; + } + + // See: https://nodejs.org/api/util.html#custom-inspection-functions-on-objects + [util.inspect.custom]() { // (depth, options, inspect) { + return this.toString(); + } +} + +module.exports = PluginError; + +/* + * TODO? ideas for new way of error writing + compile/display + */ +// PluginError('mergePotPo() requires a callback function as first argument.').during('processing').ofType('PO').of('file.po') +// PluginError('Missing file.').during('processing', 'PO', 'file.po') +// PluginError('should be integer').during('processing').ofType('option').of('lineWrap') + +// ${item_type}${item_name}${belonging_to}${error} + +// // (Option )(lineWrap) () ( should be an integer). +// // () (First argument)( of mergePotPO())( should be a callback function). \ No newline at end of file diff --git a/src/shared.js b/src/shared.js new file mode 100644 index 0000000..a1c0f21 --- /dev/null +++ b/src/shared.js @@ -0,0 +1,347 @@ +'use strict'; + +const packageJSON = require('../package.json'); +const { basename, dirname } = require('path'); +const { sync: matchedSync } = require('matched'); +const { existsSync, mkdirSync, writeFileSync } = require('fs'); +const gettextParser = require('gettext-parser'); +const { isString, pathLineSort } = require('./utils'); +const PluginError = require('./plugin-error'); +const c = require('ansi-colors'); +c.enabled = require('color-support').hasBasic; + + +/** + * Resolve POT sources globs to filepaths. + * If options.potSources is array of Vinyl objects, leave them as-is. + * + * @param {object} options + * @return {object} options + */ +function resolvePOTFilepaths(options) { + // Resolve POT filepaths to process, if array of strings + if (options.potSources.length && isString(options.potSources[0])) { + options.potSources = matchedSync(options.potSources); + } + + // Store POT filepaths for logging + options._potFiles = options.potSources.map(f => (isString(f) ? f : f.path)); + + if (0 >= options.potSources.length) { + throw new PluginError('No POT files found to process.'); + } + + if (1 < options.potSources.length && options.poSources) { + throw new PluginError('When processing multiple POT files, leave option poSources empty.\nElse, the same generated PO files will be overwritten for each POT file.', 'options'); + } + + return options; +} + +/** + * Get filepaths for all PO files to process. + * + * @param {string} pot_filepath + * @param {object} options + * + * @return {array} PO filepaths + */ +function getPOFilepaths(pot_filepath, options) { + const pot_name = basename(pot_filepath, '.pot'); + const domain = options.domainFromPOTPath ? pot_name: options.domain; + const po_dir = options.srcDir ? options.srcDir: `${dirname(pot_filepath)}/`; + + // TODO? also search subdirectories? + // preserving glob base for re-use at write and/or return paths + // use: https://github.com/gulpjs/glob-parent + // options.srcSearchSubdirectories = true/false + + // TODO? + // const srcDirs = matchedSync(`${po_dir}`); // Always has trailing separator + + // Auto-compile PO files glob + const po_files_glob = []; + if (options.poSources) { + // TODO? prefix all with po_dir ? + po_files_glob.push(...options.poSources); + } else { + const locale_glob = '[a-z][a-z]?([a-z])?(_[A-Z][A-Z]?([A-Z]))?(_formal)'; + const domain_glob = options.domainInPOPath ? `${domain}-`: ''; + po_files_glob.push(`${po_dir}${domain_glob}${locale_glob}.po`); + // TODO? + // const sub_dirs = options.srcSearchSubdirectories ? '**/': ''; + // po_files_glob.push(`${po_dir}${sub_dirs}${domain_glob}${locale_glob}.po`); + } + + // Find and sort file paths + const po_filepaths = pathLineSort(matchedSync(po_files_glob, options.srcGlobOptions)); + // TODO? + // store or return srcDirs (for subtracting from PO paths later on) + return po_filepaths; +} + +/** + * Create the new PO file. + * + * - Clones the POT object + * - Fills the object with available translations from PO file + * - Compiles new PO content + * - Optionally writes content to file + * - Returns filepath and content of new PO file + * + * @param {object} pot_object + * @param {object} po_object + * @param {string} po_filepath + * @param {object} options + * + * @return {array} [ + * New PO filepath + * New PO compiled content + * ] + */ +function generatePO(pot_object, po_object, po_filepath, options) { + // Deep clone POT as base for the new PO + let new_po_object = JSON.parse(JSON.stringify(pot_object)); + + // Pre-fill template with PO strings + new_po_object = fillPO(new_po_object, po_object, options); + + // Compile object to PO + const new_po_output = compilePO(new_po_object, options); + + // Optionally, write to file + // TODO? preserve subdirectories from search glob? + const new_po_filepath = basename(po_filepath); + if (options.writeFiles) { + writePO(`${options.destDir}${new_po_filepath}`, new_po_output); + } + + // Add Buffer to collection + return [new_po_filepath, new_po_output]; +} + +/** + * Fill new PO object with translations from PO file. + * + * - Finds and uses translations from PO file + * When fallback translations from default context are used, + * flags these as fuzzy. + * - Optionally, append all non-included translation strings from PO file + * and flags them as "DEPRECATED". + * If used as fallback translation, flags with a note about that as well. + * + * @param {object} new_po_object + * @param {object} po_object + * @param {object} options + * + * @return {object} Prefilled PO object + */ +function fillPO(new_po_object, po_object, options) { + // Traverse template contexts + for (const [ctxt, entries] of Object.entries(new_po_object.translations)) { + // Traverse template entries + for (const [msgid, entry] of Object.entries(entries)) { + // If the PO has a translation for this + // with equal number of strings, use to pre-fill it. + if ( + po_object.translations[ ctxt ] && + po_object.translations[ ctxt ][ msgid ] && + po_object.translations[ ctxt ][ msgid ]['msgstr'].length === entry['msgstr'].length + ) { + new_po_object.translations[ ctxt ][ msgid ]['msgstr'] = [ ...po_object.translations[ ctxt ][ msgid ]['msgstr'] ]; + } else if ( + options.defaultContextAsFallback && + po_object.translations[''] && + po_object.translations[''][ msgid ] && + po_object.translations[''][ msgid ]['msgstr'].length === entry['msgstr'].length + ) { + // Optionally, fallback to default context + new_po_object.translations[ ctxt ][ msgid ]['msgstr'] = [ ...po_object.translations[''][ msgid ]['msgstr'] ]; + + // Set/add fuzzy flag comment + const fuzzy_comment = 'fuzzy'; + if (!new_po_object.translations[ ctxt ][ msgid ].comments) { + new_po_object.translations[ ctxt ][ msgid ].comments = {}; + } + if (!new_po_object.translations[ ctxt ][ msgid ].comments.flag) { + new_po_object.translations[ ctxt ][ msgid ].comments.flag = fuzzy_comment; + } else { + new_po_object.translations[ ctxt ][ msgid ].comments.flag = ( + fuzzy_comment + ', ' + new_po_object.translations[ ctxt ][ msgid ].comments.flag + ); + } + + // Set translator comment to flag re-usage in case of deprecation + // NOTE: comment set on PO object, so it's only included if appended as deprecated. + const reusage_comment = `NOTE: re-used for same message, but with context '${ctxt}'`; + if (!po_object.translations[''][ msgid ].comments) { + po_object.translations[''][ msgid ].comments = {}; + } + if (!po_object.translations[''][ msgid ].comments.translator) { + po_object.translations[''][ msgid ].comments.translator = reusage_comment; + } else { + po_object.translations[''][ msgid ].comments.translator = ( + reusage_comment + '\n' + po_object.translations[''][ msgid ].comments.translator + ); + } + } + + // TODO: move undublicate to POT generator + // Extra - Remove duplicate translator comments + // Fix for gulp-wp-pot's POT generation + if ( entry.comments?.extracted ) { + let comments = entry.comments.extracted.split('\n'); + new_po_object.translations[ ctxt ][ msgid ].comments.extracted = comments.reduce((ar, v) => { + if (0 > ar.indexOf(v)) { + ar.push(v); + } + return ar; + }, []).join('\n'); + } + } + } + + if (options.appendNonIncludedFromPO) { + // Append all strings from PO that are not present in POT + for (const [ctxt, entries] of Object.entries(po_object.translations)) { + // Add context + if (!new_po_object.translations[ ctxt ]) { + new_po_object.translations[ ctxt ] = {}; + } + + for (const [msgid, entry] of Object.entries(entries)) { + // Add entry + if (!new_po_object.translations[ ctxt ][ msgid ]) { + new_po_object.translations[ ctxt ][ msgid ] = entry; + + // Add translator comment "DEPRECATED" + if (!new_po_object.translations[ ctxt ][ msgid ].comments) { + new_po_object.translations[ ctxt ][ msgid ].comments = {}; + } + if (!new_po_object.translations[ ctxt ][ msgid ].comments.translator) { + new_po_object.translations[ ctxt ][ msgid ].comments.translator = 'DEPRECATED'; + } else if (!entry.comments.translator.match(/^DEPRECATED$/gm)) { + new_po_object.translations[ ctxt ][ msgid ].comments.translator = ( + 'DEPRECATED\n' + new_po_object.translations[ ctxt ][ msgid ].comments.translator + ); + } + } + } + } + } + + if (options.includePORevisionDate) { + const d = new Date(); + const po_rev_date_string = [ + `${d.getUTCFullYear()}`, + `-${String(d.getUTCMonth() + 1).padStart(2, '0')}`, + `-${String(d.getUTCDate()).padStart(2, '0')}`, + ` ${String(d.getUTCHours()).padStart(2, '0')}`, + `:${String(d.getUTCMinutes()).padStart(2, '0')}`, + '+0000' + ].join(''); + new_po_object.headers['po-revision-date'] = po_rev_date_string; + } + + new_po_object.headers['X-Generator'] = `${packageJSON.name}/${packageJSON.version}`; + + return new_po_object; +} + +/** + * Compiles PO object to content. + * + * Also sorts the translation string by reference file and line number. + * Optional deprecated strings are sorted at the end of the file. + * + * @param {object} new_po_object + * @param {object} options + * + * @return {string} Compiled PO file content + */ +function compilePO(new_po_object, options) { + return gettextParser.po.compile(new_po_object, { + foldLength: options.wrapLength, + // Sort entries by first reference filepath and line number. + sort: (a, b) => { + // Entries with DEPRECATED translator comment are put last (but sorted as usual there). + const b_deprecated = b.comments?.translator?.match(/^DEPRECATED$/gm); + const a_deprecated = a.comments?.translator?.match(/^DEPRECATED$/gm); + if (!a_deprecated && b_deprecated) return -1; + if (a_deprecated && !b_deprecated) return 1; + + // Entries without reference(s) are put last. + if (!b.comments?.reference) return -1; + if (!a.comments?.reference) return 1; + + a = a.comments.reference.trim().split(/\s+/)[0]; + b = b.comments.reference.trim().split(/\s+/)[0]; + return pathLineSort(a, b); + }, + }); +} + +/** + * Writes PO content to file. + * + * If needed, creates the containing directory as well. + * + * @param {string} new_po_filepath + * @param {string} new_po_output + * + * @return {void} + */ +function writePO(new_po_filepath, new_po_output) { + const new_po_dir = dirname(new_po_filepath); + if (!existsSync(new_po_dir)) { + mkdirSync(new_po_dir, {recursive: true}); + } + writeFileSync(new_po_filepath, new_po_output); +} + +/** + * Log results to console. + * + * Optionally, logs input and output PO files per processed POT file. + * + * @param {array} pots POT filepaths + * @param {array} pos_in Array of arrays with source PO filepaths + * @param {array} pos_out Array of arrays with output PO filepaths + * @param {string} dest Destination directory path (for writing files) + * + * @return {void} + */ +function logResults(pots, pos_in, pos_out, dest) { + pots.forEach((pot, i) => { + const pot_filepath = basename(pot); + const po_filepaths_in = pos_in[i]?.map(po => po); + const po_filepaths_out = pos_out[i]?.map(po => po[0]); + const max_length_in = po_filepaths_in.reduce((p, c) => (Math.max(c.length, p)), 0); + + console.log(''); + + if (po_filepaths_out && po_filepaths_out.length) { + console.log(` ${c.bold.green('■')} ${c.white(pot_filepath)}`); + + po_filepaths_out.forEach((po_filepath_out, pi) => { + console.log([ + ' ', + `${c.cyan(po_filepaths_in[pi].padEnd(max_length_in, ' '))}`, + ` ${c.gray('—►')} `, + `${c.yellow(dest)}${c.yellow(po_filepath_out)}` + ].join('')); + }); + } else { + console.log(` ${c.gray('■')} ${c.white(pot_filepath)}`); + console.log(` ${c.gray('No PO files found.')}`); + } + }); + console.log(''); +} + +module.exports = { + resolvePOTFilepaths, + getPOFilepaths, + generatePO, + logResults +}; diff --git a/src/sync.js b/src/sync.js new file mode 100644 index 0000000..fe7b1fa --- /dev/null +++ b/src/sync.js @@ -0,0 +1,97 @@ +'use strict'; + +const Vinyl = require('vinyl'); +const { readFileSync } = require('fs'); +const gettextParser = require('gettext-parser'); + +const prepareOptions = require('./options'); +const { resolvePOTFilepaths, getPOFilepaths, generatePO, logResults } = require('./shared'); + +let po_input_files = []; +let po_output_files = []; + +/** + * Process all PO files for POT file. + * + * - Reads and parses each PO file + * - Generates new PO file + * - Adds result to collection + * + * @param {array} po_filepaths + * @param {object} pot_object + * @param {object} options + * + * @return {void} + */ +function processPOs(po_filepaths, pot_object, options) { + const pos = []; + // Parse PO files + for (const po_filepath of po_filepaths) { + // Sync - Read and parse PO file + const po_content = readFileSync(po_filepath).toString(); + const po_object = gettextParser.po.parse(po_content); + + // Generate PO and add to collection + pos.push( generatePO(pot_object, po_object, po_filepath, options) ); + } + po_output_files.push(pos); +} + +/** + * Process POT file. + * + * - Finds the PO filepaths + * - Reads and parses the POT file + * - Process all PO files + * + * @param {string} pot_filepath + * @param {object} options + * + * @return {void} + */ +function processPOT(pot_file, options) { + const isVinyl = Vinyl.isVinyl(pot_file); + const pot_filepath = isVinyl ? pot_file.path : pot_file; + + // Get filepaths of POs + const po_filepaths = getPOFilepaths(pot_filepath, options); + po_input_files.push(po_filepaths); + + if (po_filepaths.length) { + let pot_content = ''; + if (isVinyl) { + pot_content = pot_file.contents; + } else { + // Sync - Read and parse POT file + pot_content = readFileSync(pot_filepath).toString(); + } + const pot_object = gettextParser.po.parse(pot_content); + processPOs(po_filepaths, pot_object, options); + } else { + po_output_files.push([]); + } +} + +function fillPotPoSync(options) { + // Reset + po_output_files = []; + + // Set options + options = prepareOptions(options); + options = resolvePOTFilepaths(options); + + // Process all POT files + options.potSources.forEach(pot_file => { + processPOT(pot_file, options); + }); + + if (options.logResults) { + logResults(options._potFiles, po_input_files, po_output_files, options.destDir); + } + + // Flatten into array with all PO files + po_output_files = [].concat(...po_output_files); + return po_output_files; +} + +module.exports = fillPotPoSync; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..dd622a1 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,156 @@ +'use strict'; + +/** + * Escape string for literal match in regex. + * + * @link https://stackoverflow.com/a/6969486/2142071 + * + * @param {string} string + * + * @return {string} + */ +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} +/** + * Determine if `ar` is an array or not. + * + * @param {mixed} ar + * + * @return {boolean} + */ +function isArray(ar) { + return Object.prototype.toString.call(ar) === '[object Array]'; +} + +/** + * Determine if `obj` is a object or not. + * + * @param {mixed} obj + * + * @return {boolean} + */ +function isObject(obj) { + return Object.prototype.toString.call(obj) === '[object Object]'; +} + +/** + * Determine if `str` is a string or not. + * + * @param {mixed} str + * + * @return {boolean} + */ +function isString(str) { + return Object.prototype.toString.call(str) === '[object String]'; +} + +/** + * Determine if `bl` is a boolean or not. + * + * @param {mixed} bl + * + * @return {boolean} + */ +function isBool(bl) { + return Object.prototype.toString.call(bl) === '[object Boolean]'; +} + +/** + * Determine if `ar` is an array containing only strings or not. + * + * @param {mixed} ar + * + * @return {boolean} + */ +function isArrayOfStrings(ar) { + if (!isArray(ar)) return false; + return ar.reduce((r, v) => (isString(v) && r), true); +} + +/** + * Array sort callback for file reference strings (optionally with line numbers). + * e.g. strings like 'some/file_path.js:123' + * + * @param {string} filepath (optionally with ':' + line number) + * @param {string} filepath (optionally with ':' + line number) + * + * @return {number} -1, 0 or 1 + */ +function pathLineSort(a, b) { + // Wrapper mode + if (isArray(a) && typeof b === 'undefined') { + return a.sort(pathLineSort); + } + + if (!isString(a) || !isString(b)) { + throw new Error('pathLineSort: a or b not a string'); + } + + // split line numbers + let a_line, b_line; + [a, a_line] = a.split(':'); + [b, b_line] = b.split(':'); + + // trim leading/trailing slashes + a = a.replaceAll(/(^\/|\/$)/g, ''); + b = b.replaceAll(/(^\/|\/$)/g, ''); + + // line numbers ascending for same paths + if (a == b) { + // no line number first + if ((!a_line || '' === a_line) && b_line.length) return -1; + if ((!b_line || '' === b_line) && a_line.length) return +1; + + return (parseInt(a_line) - parseInt(b_line)); + } + + // split by directory + a = a.split('/'); + b = b.split('/'); + + const a_len = a.length; + const b_len = b.length; + + // Based on: path-sort package + const l = Math.max(a_len, b_len); + for (let i = 0; i < l; i++) { + // less deep paths at the end + if (i >= b_len || '' === b[i]) return -1; + if (i >= a_len || '' === a[i]) return +1; + + // split extension + let a_part, b_part; + let a_ext, b_ext; + [a_part, a_ext] = a[i].split(/\.(?=[^.]*$)/g); + [b_part, b_ext] = b[i].split(/\.(?=[^.]*$)/g); + const a_is_file = (a_ext && '' !== a_ext); + const b_is_file = (b_ext && '' !== b_ext); + + // files after folders + if (!a_is_file && b_is_file) return -1; + if (a_is_file && !b_is_file) return +1; + + // file/folder name - alphabetical ascending + if (a_part.toUpperCase() > b_part.toUpperCase()) return +1; + if (a_part.toUpperCase() < b_part.toUpperCase()) return -1; + + // file extension - alphabetical ascending + if (a_ext && b_ext) { + if (a_ext.toUpperCase() > b_ext.toUpperCase()) return +1; + if (a_ext.toUpperCase() < b_ext.toUpperCase()) return -1; + } + } + + return 0; +} + +module.exports = { + escapeRegExp, + isArray, + isObject, + isString, + isBool, + isArrayOfStrings, + pathLineSort +}; diff --git a/test/async.test.js b/test/async.test.js new file mode 100644 index 0000000..ee3bd22 --- /dev/null +++ b/test/async.test.js @@ -0,0 +1,343 @@ +'use strict'; + +const fillPotPo = require('../src/async'); + +const { sync: matchedSync } = require('matched'); +const { rmSync, existsSync, mkdirSync, readFileSync } = require('fs'); + +const test_dir = 'test/examples/output/fa'; + +function clearOutputFolder() { + let files = matchedSync([ + `${test_dir}*`, + ]); + files.sort((a, b) => { + const a_len = a.split(/\//); + const b_len = b.split(/\//); + return b_len - a_len; + }); + for (const file of files) { + rmSync(file, { recursive: true }); + } +} + +const potSource = './test/examples/text-domain.pot'; +// const potSources = './test/examples/*.pot'; +const poSources = './test/examples/input/*.po'; +// const poSource = './test/examples/input/nl_NL.po'; + +beforeAll(() => { + clearOutputFolder(); +}); + +afterAll(() => { + clearOutputFolder(); +}); + +describe('async.js - single POT', () => { + + let folder_i = 0; + + /* eslint-disable jest/no-done-callback */ + + test('auto domain PO - no write', done => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + writeFiles: false, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + function cb(result_array) { + try { + // Errorless execution + expect(result_array).toHaveLength(2); + const [was_success, result] = result_array; + expect(was_success).toBe(true); + + // Check returned array + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('text-domain-nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(0); + + // Check contents + expect(result[0][1]) + .toEqual(readFileSync('test/examples/output_correct/text-domain-nl_NL.po')); + + done(); + } catch (error) { + done(error); + } + } + + fillPotPo(cb, options); + }); + + test('auto no-domain PO - no write', done => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + writeFiles: false, + domainInPOPath: false, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + function cb(result_array) { + try { + // Errorless execution + expect(result_array).toHaveLength(2); + const [was_success, result] = result_array; + expect(was_success).toBe(true); + + // Check returned array + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(0); + + // Check contents + expect(result[0][1]) + .toEqual(readFileSync('test/examples/output_correct/nl_NL.po')); + + done(); + } catch (error) { + done(error); + } + } + + fillPotPo(cb, options); + }); + + test('manual multiple PO - no write', done => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + poSources: [ poSources ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + writeFiles: false, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + function cb(result_array) { + try { + // Errorless execution + expect(result_array).toHaveLength(2); + const [was_success, result] = result_array; + expect(was_success).toBe(true); + + // Check returned array + expect(result).toHaveLength(2); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + expect(result[1]).toHaveLength(2); + expect(result[1][0]).toEqual('text-domain-nl_NL.po'); + expect(result[1][1]).toBeInstanceOf(Buffer); + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(0); + + // Check contents + expect(result[0][1]) + .toEqual(readFileSync('test/examples/output_correct/nl_NL.po')); + expect(result[1][1]) + .toEqual(readFileSync('test/examples/output_correct/text-domain-nl_NL.po')); + + done(); + } catch (error) { + done(error); + } + } + + fillPotPo(cb, options); + }); + + test('auto domain PO - write', done => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + function cb(result_array) { + try { + // Errorless execution + expect(result_array).toHaveLength(2); + const [was_success, result] = result_array; + expect(was_success).toBe(true); + + // Check returned array + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('text-domain-nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + + // Check if file exist + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(1); + expect(files).toEqual([ + folder_path + '/text-domain-nl_NL.po' + ]); + + // Check contents of file + expect(readFileSync(folder_path + '/text-domain-nl_NL.po', 'utf-8')) + .toEqual(readFileSync('test/examples/output_correct/text-domain-nl_NL.po', 'utf-8')); + + done(); + } catch (error) { + done(error); + } + } + + fillPotPo(cb, options); + }); + + test('auto no-domain PO - write', done => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + domainInPOPath: false, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + function cb(result_array) { + try { + // Errorless execution + expect(result_array).toHaveLength(2); + const [was_success, result] = result_array; + expect(was_success).toBe(true); + + // Check returned array + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + + // Check if file exist + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(1); + expect(files).toEqual([ + folder_path + '/nl_NL.po' + ]); + + // Check contents of file + expect(readFileSync(folder_path + '/nl_NL.po', 'utf-8')) + .toEqual(readFileSync('test/examples/output_correct/nl_NL.po', 'utf-8')); + + done(); + } catch (error) { + done(error); + } + } + + fillPotPo(cb, options); + }); + + test('manual multiple PO - write', done => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + poSources: [ poSources ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + function cb(result_array) { + try { + // Errorless execution + expect(result_array).toHaveLength(2); + const [was_success, result] = result_array; + expect(was_success).toBe(true); + + // Check returned array + expect(result).toHaveLength(2); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + expect(result[1]).toHaveLength(2); + expect(result[1][0]).toEqual('text-domain-nl_NL.po'); + expect(result[1][1]).toBeInstanceOf(Buffer); + + // Check if files exist + const files = matchedSync([folder_path + '/*']); + expect(files).toEqual([ + folder_path + '/nl_NL.po', + folder_path + '/text-domain-nl_NL.po', + ]); + + // Check contents of files + expect(readFileSync(folder_path + '/nl_NL.po', 'utf-8')) + .toEqual(readFileSync('test/examples/output_correct/nl_NL.po', 'utf-8')); + expect(readFileSync(folder_path + '/text-domain-nl_NL.po', 'utf-8')) + .toEqual(readFileSync('test/examples/output_correct/text-domain-nl_NL.po', 'utf-8')); + + done(); + } catch (error) { + done(error); + } + } + + fillPotPo(cb, options); + }); + + /* eslint-enable jest/no-done-callback */ + +}); + +// TODO: multiple POT files? diff --git a/test/examples/input/nl_NL.po b/test/examples/input/nl_NL.po new file mode 100644 index 0000000..54a4835 --- /dev/null +++ b/test/examples/input/nl_NL.po @@ -0,0 +1,149 @@ +# Copyright (C) 2022 fill-pot-po +# This file is distributed under the same license as the fill-pot-po package. +msgid "" +msgstr "" +"Project-Id-Version: fill-pot-po-test - X.x.x\n" +"POT-Creation-Date: 2022-05-28 12:34+0000\n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: nl_NL\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Poedit-Basepath: ..\n" +"X-Poedit-KeywordsList: __;_e;_ex:1,2c;_n:1,2;_n_noop:1,2;_nx:1,2,4c;_nx_noop:1,2,3c;_x:1,2c;esc_attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n" +"X-Poedit-SourceCharset: UTF-8\n" +"X-Domain: text-domain\n" +"X-Generator: Poedit 3.0.1\n" +"X-Poedit-SearchPath-0: .\n" +"X-Poedit-SearchPathExcluded-0: *.js\n" + +msgid "String 1" +msgstr "String 1 - without domain" + +# Basic single string +#: some/path/file.php:10 some/path/file.php:20 some/path/file.php:30 +msgid "String 3" +msgstr "String 3" + +# Basic plural string +#: some/other/path/file.php:40 +msgid "item" +msgid_plural "items" +msgstr[0] "plural item" +msgstr[1] "plural items" + +# Replacement single string +#. translators: %s: category +#: some/other/path/file.php:50 +msgid "the %s item" +msgstr "the %s replacement item" + +# Replacement plural string +#. translators: %s: count +#: some/other/path/file.php:60 +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "%s replacement plural item" +msgstr[1] "%s replacement plural items" + +# Basic context single string +#: some/context/file.php:10 +msgctxt "The context" +msgid "The string" +msgstr "The context string" + +# Basic context single string 2 +#: some/context/file.php:20 +msgctxt "Other context" +msgid "The string" +msgstr "The other context string" + +# Replacement context single string +#. translators: %s: item +#: some/context/file.php:30 +msgctxt "The context" +msgid "The %s" +msgstr "The context replacement %s" + +# Replacement context plural string +#. translators: %s: count +#: some/context/file.php:40 +msgctxt "The context" +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "%s context replacement plural item" +msgstr[1] "%s context replacement plural items" + +# Basic single string with flag +#: some/flagged/file.php:10 +msgid "String 4" +msgstr "String 4" + +# Basic single string with flag 2 +#. translators: 1: item +#: some/flagged/file.php:10 +#, php-format +msgid "%1$s" +msgstr "%1$s" + +# Basic single string with flag 3 +#: some/flagged/file.php:10 +msgid "String 5" +msgstr "String 5" + +# Basic single string with flag 4 +#: some/flagged/file.php:10 +msgid "String 6" +msgstr "String 6" + +# Basic single string with single quotes +#: some/quoted/file.php:10 +msgid "Some 'string'" +msgstr "Some single-quoted 'string'" + +# Basic single string with double quotes +#: some/quoted/file.php:20 +msgid "Some other \"string\"" +msgstr "Some other double-quoted \"string\"" + +# Multiline single string +#: some/multiline/file.php:10 +msgid "" +"This string\n" +"\n" +"has multiple lines." +msgstr "" +"This string\n" +"\n" +"also has multiple lines." + +# Basic single string with HTML +#: some/html/file.php:10 +msgid "Some string with HTML" +msgstr "Some string still with HTML" + +# Basic single string with multiple comments +#. translators: %d: key ID. +#. translators: %d: item ID. +#. translators: %d: other ID. +#. translators: 1: ID +#: some/misc/string.php:10 some/misc/string.php:20 some/misc/string.php:30 +#: some/misc/string.php:40 +msgid "ID: %d" +msgstr "ID: %d" + +# Basic context single string with no-context translation +#: some/context/file.php:11 +msgid "String with context missing, but no-context present" +msgstr "String with no-context fallback" + +#: some/extra/string.php:10 +msgid "Extra string 1" +msgstr "Extra string 1" + +#: some/extra/string.php:20 +msgid "Extra string 2" +msgstr "Extra string 2" diff --git a/test/examples/input/text-domain-nl_NL.po b/test/examples/input/text-domain-nl_NL.po new file mode 100644 index 0000000..205fd35 --- /dev/null +++ b/test/examples/input/text-domain-nl_NL.po @@ -0,0 +1,149 @@ +# Copyright (C) 2022 fill-pot-po +# This file is distributed under the same license as the fill-pot-po package. +msgid "" +msgstr "" +"Project-Id-Version: fill-pot-po-test - X.x.x\n" +"POT-Creation-Date: 2022-05-28 12:34+0000\n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: nl_NL\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Poedit-Basepath: ..\n" +"X-Poedit-KeywordsList: __;_e;_ex:1,2c;_n:1,2;_n_noop:1,2;_nx:1,2,4c;_nx_noop:1,2,3c;_x:1,2c;esc_attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n" +"X-Poedit-SourceCharset: UTF-8\n" +"X-Poedit-SearchPath-0: .\n" +"X-Poedit-SearchPathExcluded-0: *.js\n" +"X-Domain: text-domain\n" +"X-Generator: Poedit 3.0.1\n" + +msgid "String 1" +msgstr "String 1 - with domain" + +# Basic single string +#: some/path/file.php:10 some/path/file.php:20 some/path/file.php:30 +msgid "String 3" +msgstr "String 3" + +# Basic plural string +#: some/other/path/file.php:40 +msgid "item" +msgid_plural "items" +msgstr[0] "plural item" +msgstr[1] "plural items" + +# Replacement single string +#. translators: %s: category +#: some/other/path/file.php:50 +msgid "the %s item" +msgstr "the %s replacement item" + +# Replacement plural string +#. translators: %s: count +#: some/other/path/file.php:60 +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "%s replacement plural item" +msgstr[1] "%s replacement plural items" + +# Basic context single string +#: some/context/file.php:10 +msgctxt "The context" +msgid "The string" +msgstr "The context string" + +# Basic context single string 2 +#: some/context/file.php:20 +msgctxt "Other context" +msgid "The string" +msgstr "The other context string" + +# Replacement context single string +#. translators: %s: item +#: some/context/file.php:30 +msgctxt "The context" +msgid "The %s" +msgstr "The context replacement %s" + +# Replacement context plural string +#. translators: %s: count +#: some/context/file.php:40 +msgctxt "The context" +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "%s context replacement plural item" +msgstr[1] "%s context replacement plural items" + +# Basic single string with flag +#: some/flagged/file.php:10 +msgid "String 4" +msgstr "String 4" + +# Basic single string with flag 2 +#. translators: 1: item +#: some/flagged/file.php:10 +#, php-format +msgid "%1$s" +msgstr "%1$s" + +# Basic single string with flag 3 +#: some/flagged/file.php:10 +msgid "String 5" +msgstr "String 5" + +# Basic single string with flag 4 +#: some/flagged/file.php:10 +msgid "String 6" +msgstr "String 6" + +# Basic single string with single quotes +#: some/quoted/file.php:10 +msgid "Some 'string'" +msgstr "Some single-quoted 'string'" + +# Basic single string with double quotes +#: some/quoted/file.php:20 +msgid "Some other \"string\"" +msgstr "Some other double-quoted \"string\"" + +# Multiline single string +#: some/multiline/file.php:10 +msgid "" +"This string\n" +"\n" +"has multiple lines." +msgstr "" +"This string\n" +"\n" +"also has multiple lines." + +# Basic single string with HTML +#: some/html/file.php:10 +msgid "Some string with HTML" +msgstr "Some string still with HTML" + +# Basic single string with multiple comments +#. translators: %d: key ID. +#. translators: %d: item ID. +#. translators: %d: other ID. +#. translators: 1: ID +#: some/misc/string.php:10 some/misc/string.php:20 some/misc/string.php:30 +#: some/misc/string.php:40 +msgid "ID: %d" +msgstr "ID: %d" + +# Basic context single string with no-context translation +#: some/context/file.php:11 +msgid "String with context missing, but no-context present" +msgstr "String with no-context fallback" + +#: some/extra/string.php:10 +msgid "Extra string 1" +msgstr "Extra string 1" + +#: some/extra/string.php:20 +msgid "Extra string 2" +msgstr "Extra string 2" diff --git a/test/examples/output_correct/nl_NL.po b/test/examples/output_correct/nl_NL.po new file mode 100644 index 0000000..787fced --- /dev/null +++ b/test/examples/output_correct/nl_NL.po @@ -0,0 +1,168 @@ +# Copyright (C) 2022 fill-pot-po +# This file is distributed under the same license as the fill-pot-po package. +msgid "" +msgstr "" +"Project-Id-Version: fill-pot-po-test - X.x.x\n" +"POT-Creation-Date: 2022-05-28 12:34+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Poedit-Basepath: ..\n" +"X-Poedit-KeywordsList: " +"__;_e;_ex:1,2c;_n:1,2;_n_noop:1,2;_nx:1,2,4c;_nx_noop:1,2,3c;_x:1,2c;esc_" +"attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n" +"X-Poedit-SearchPath-0: .\n" +"X-Poedit-SearchPathExcluded-0: *.js\n" +"X-Poedit-SourceCharset: UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Domain: text-domain\n" +"X-Generator: fill-pot-po/0.3.0\n" + +# Basic context single string +#: some/context/file.php:10 +msgctxt "The context" +msgid "The string" +msgstr "The context string" + +# Basic context single string with no-context translation +#: some/context/file.php:11 +#, fuzzy +msgctxt "The context" +msgid "String with context missing, but no-context present" +msgstr "String with no-context fallback" + +# Basic context single string 2 +#: some/context/file.php:20 +msgctxt "Other context" +msgid "The string" +msgstr "The other context string" + +# Replacement context single string +#: some/context/file.php:30 +#. translators: %s: item +msgctxt "The context" +msgid "The %s" +msgstr "The context replacement %s" + +# Replacement context plural string +#: some/context/file.php:40 +#. translators: %s: count +msgctxt "The context" +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "%s context replacement plural item" +msgstr[1] "%s context replacement plural items" + +# Basic single string with flag +#: some/flagged/file.php:10 +#, fuzzy +msgid "String 4" +msgstr "String 4" + +# Basic single string with flag 2 +#: some/flagged/file.php:10 +#. translators: 1: item +#, php-format +msgid "%1$s" +msgstr "%1$s" + +# Basic single string with flag 3 +#: some/flagged/file.php:10 +#, gp-priority: high +msgid "String 5" +msgstr "String 5" + +# Basic single string with flag 4 +#: some/flagged/file.php:10 +#, gp-priority: normal +msgid "String 6" +msgstr "String 6" + +# Basic single string with HTML +#: some/html/file.php:10 +msgid "Some string with HTML" +msgstr "Some string still with HTML" + +# Basic single string with multiple comments +#: some/misc/string.php:10 +#: some/misc/string.php:20 +#: some/misc/string.php:30 +#: some/misc/string.php:40 +#. translators: %d: key ID. +#. translators: %d: item ID. +#. translators: %d: other ID. +#. translators: 1: ID +msgid "ID: %d" +msgstr "ID: %d" + +# Multiline single string +#: some/multiline/file.php:10 +msgid "" +"This string\n" +"\n" +"has multiple lines." +msgstr "" +"This string\n" +"\n" +"also has multiple lines." + +# Basic plural string +#: some/other/path/file.php:40 +msgid "item" +msgid_plural "items" +msgstr[0] "plural item" +msgstr[1] "plural items" + +# Replacement single string +#: some/other/path/file.php:50 +#. translators: %s: category +msgid "the %s item" +msgstr "the %s replacement item" + +# Replacement plural string +#: some/other/path/file.php:60 +#. translators: %s: count +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "%s replacement plural item" +msgstr[1] "%s replacement plural items" + +# Basic single string +#: some/path/file.php:10 +#: some/path/file.php:20 +#: some/path/file.php:30 +msgid "String 3" +msgstr "String 3" + +# Basic single string with single quotes +#: some/quoted/file.php:10 +msgid "Some 'string'" +msgstr "Some single-quoted 'string'" + +# Basic single string with double quotes +#: some/quoted/file.php:20 +msgid "Some other \"string\"" +msgstr "Some other double-quoted \"string\"" + +msgid "Missing string 2" +msgstr "" + +msgid "String 1" +msgstr "String 1 - without domain" + +# DEPRECATED +# NOTE: re-used for same message, but with context 'The context' +# Basic context single string with no-context translation +#: some/context/file.php:11 +msgid "String with context missing, but no-context present" +msgstr "String with no-context fallback" + +# DEPRECATED +#: some/extra/string.php:10 +msgid "Extra string 1" +msgstr "Extra string 1" + +# DEPRECATED +#: some/extra/string.php:20 +msgid "Extra string 2" +msgstr "Extra string 2" \ No newline at end of file diff --git a/test/examples/output_correct/text-domain-nl_NL.po b/test/examples/output_correct/text-domain-nl_NL.po new file mode 100644 index 0000000..28f6423 --- /dev/null +++ b/test/examples/output_correct/text-domain-nl_NL.po @@ -0,0 +1,168 @@ +# Copyright (C) 2022 fill-pot-po +# This file is distributed under the same license as the fill-pot-po package. +msgid "" +msgstr "" +"Project-Id-Version: fill-pot-po-test - X.x.x\n" +"POT-Creation-Date: 2022-05-28 12:34+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Poedit-Basepath: ..\n" +"X-Poedit-KeywordsList: " +"__;_e;_ex:1,2c;_n:1,2;_n_noop:1,2;_nx:1,2,4c;_nx_noop:1,2,3c;_x:1,2c;esc_" +"attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n" +"X-Poedit-SearchPath-0: .\n" +"X-Poedit-SearchPathExcluded-0: *.js\n" +"X-Poedit-SourceCharset: UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Domain: text-domain\n" +"X-Generator: fill-pot-po/0.3.0\n" + +# Basic context single string +#: some/context/file.php:10 +msgctxt "The context" +msgid "The string" +msgstr "The context string" + +# Basic context single string with no-context translation +#: some/context/file.php:11 +#, fuzzy +msgctxt "The context" +msgid "String with context missing, but no-context present" +msgstr "String with no-context fallback" + +# Basic context single string 2 +#: some/context/file.php:20 +msgctxt "Other context" +msgid "The string" +msgstr "The other context string" + +# Replacement context single string +#: some/context/file.php:30 +#. translators: %s: item +msgctxt "The context" +msgid "The %s" +msgstr "The context replacement %s" + +# Replacement context plural string +#: some/context/file.php:40 +#. translators: %s: count +msgctxt "The context" +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "%s context replacement plural item" +msgstr[1] "%s context replacement plural items" + +# Basic single string with flag +#: some/flagged/file.php:10 +#, fuzzy +msgid "String 4" +msgstr "String 4" + +# Basic single string with flag 2 +#: some/flagged/file.php:10 +#. translators: 1: item +#, php-format +msgid "%1$s" +msgstr "%1$s" + +# Basic single string with flag 3 +#: some/flagged/file.php:10 +#, gp-priority: high +msgid "String 5" +msgstr "String 5" + +# Basic single string with flag 4 +#: some/flagged/file.php:10 +#, gp-priority: normal +msgid "String 6" +msgstr "String 6" + +# Basic single string with HTML +#: some/html/file.php:10 +msgid "Some string with HTML" +msgstr "Some string still with HTML" + +# Basic single string with multiple comments +#: some/misc/string.php:10 +#: some/misc/string.php:20 +#: some/misc/string.php:30 +#: some/misc/string.php:40 +#. translators: %d: key ID. +#. translators: %d: item ID. +#. translators: %d: other ID. +#. translators: 1: ID +msgid "ID: %d" +msgstr "ID: %d" + +# Multiline single string +#: some/multiline/file.php:10 +msgid "" +"This string\n" +"\n" +"has multiple lines." +msgstr "" +"This string\n" +"\n" +"also has multiple lines." + +# Basic plural string +#: some/other/path/file.php:40 +msgid "item" +msgid_plural "items" +msgstr[0] "plural item" +msgstr[1] "plural items" + +# Replacement single string +#: some/other/path/file.php:50 +#. translators: %s: category +msgid "the %s item" +msgstr "the %s replacement item" + +# Replacement plural string +#: some/other/path/file.php:60 +#. translators: %s: count +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "%s replacement plural item" +msgstr[1] "%s replacement plural items" + +# Basic single string +#: some/path/file.php:10 +#: some/path/file.php:20 +#: some/path/file.php:30 +msgid "String 3" +msgstr "String 3" + +# Basic single string with single quotes +#: some/quoted/file.php:10 +msgid "Some 'string'" +msgstr "Some single-quoted 'string'" + +# Basic single string with double quotes +#: some/quoted/file.php:20 +msgid "Some other \"string\"" +msgstr "Some other double-quoted \"string\"" + +msgid "Missing string 2" +msgstr "" + +msgid "String 1" +msgstr "String 1 - with domain" + +# DEPRECATED +# NOTE: re-used for same message, but with context 'The context' +# Basic context single string with no-context translation +#: some/context/file.php:11 +msgid "String with context missing, but no-context present" +msgstr "String with no-context fallback" + +# DEPRECATED +#: some/extra/string.php:10 +msgid "Extra string 1" +msgstr "Extra string 1" + +# DEPRECATED +#: some/extra/string.php:20 +msgid "Extra string 2" +msgstr "Extra string 2" \ No newline at end of file diff --git a/test/examples/text-domain.pot b/test/examples/text-domain.pot new file mode 100644 index 0000000..ef58b9f --- /dev/null +++ b/test/examples/text-domain.pot @@ -0,0 +1,152 @@ +# Copyright (C) 2022 fill-pot-po +# This file is distributed under the same license as the fill-pot-po package. +msgid "" +msgstr "" +"Project-Id-Version: fill-pot-po-test - X.x.x\n" +"POT-Creation-Date: 2022-05-28 12:34+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Poedit-Basepath: ..\n" +"X-Poedit-KeywordsList: __;_e;_ex:1,2c;_n:1,2;_n_noop:1,2;_nx:1,2,4c;_nx_noop:1,2,3c;_x:1,2c;esc_attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n" +"X-Poedit-SearchPath-0: .\n" +"X-Poedit-SearchPathExcluded-0: *.js\n" +"X-Poedit-SourceCharset: UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Domain: text-domain\n" + +msgid "String 1" +msgstr "" + +msgid "Missing string 2" +msgstr "" + + +# Basic single string +#: some/path/file.php:10 +#: some/path/file.php:20 +#: some/path/file.php:30 +msgid "String 3" +msgstr "" + +# Basic plural string +#: some/other/path/file.php:40 +msgid "item" +msgid_plural "items" +msgstr[0] "" +msgstr[1] "" + + +# Replacement single string +#. translators: %s: category +#: some/other/path/file.php:50 +msgid "the %s item" +msgstr "" + +# Replacement plural string +#. translators: %s: count +#: some/other/path/file.php:60 +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "" +msgstr[1] "" + + +# Basic context single string +#: some/context/file.php:10 +msgctxt "The context" +msgid "The string" +msgstr "" + +# Basic context single string 2 +#: some/context/file.php:20 +msgctxt "Other context" +msgid "The string" +msgstr "" + +# Replacement context single string +#. translators: %s: item +#: some/context/file.php:30 +msgctxt "The context" +msgid "The %s" +msgstr "" + +# Replacement context plural string +#. translators: %s: count +#: some/context/file.php:40 +msgctxt "The context" +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "" +msgstr[1] "" + + +# Basic single string with flag +#: some/flagged/file.php:10 +#, fuzzy +msgid "String 4" +msgstr "" + +# Basic single string with flag 2 +#. translators: 1: item +#: some/flagged/file.php:10 +#, php-format +msgid "%1$s" +msgstr "" + +# Basic single string with flag 3 +#: some/flagged/file.php:10 +#, gp-priority: high +msgid "String 5" +msgstr "" + +# Basic single string with flag 4 +#: some/flagged/file.php:10 +#, gp-priority: normal +msgid "String 6" +msgstr "" + + +# Basic single string with single quotes +#: some/quoted/file.php:10 +msgid "Some 'string'" +msgstr "" + +# Basic single string with double quotes +#: some/quoted/file.php:20 +msgid "Some other \"string\"" +msgstr "" + + +# Multiline single string +#: some/multiline/file.php:10 +msgid "" +"This string\n" +"\n" +"has multiple lines." +msgstr "" + + +# Basic single string with HTML +#: some/html/file.php:10 +msgid "Some string with HTML" +msgstr "" + + +# Basic single string with multiple comments +#. translators: %d: key ID. +#. translators: %d: item ID. +#. translators: %d: other ID. +#. translators: 1: ID +#: some/misc/string.php:10 +#: some/misc/string.php:20 +#: some/misc/string.php:30 +#: some/misc/string.php:40 +msgid "ID: %d" +msgstr "ID: %d" + +# Basic context single string with no-context translation +#: some/context/file.php:11 +msgctxt "The context" +msgid "String with context missing, but no-context present" +msgstr "" diff --git a/test/examples/text-domain2.pot b/test/examples/text-domain2.pot new file mode 100644 index 0000000..ef58b9f --- /dev/null +++ b/test/examples/text-domain2.pot @@ -0,0 +1,152 @@ +# Copyright (C) 2022 fill-pot-po +# This file is distributed under the same license as the fill-pot-po package. +msgid "" +msgstr "" +"Project-Id-Version: fill-pot-po-test - X.x.x\n" +"POT-Creation-Date: 2022-05-28 12:34+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Poedit-Basepath: ..\n" +"X-Poedit-KeywordsList: __;_e;_ex:1,2c;_n:1,2;_n_noop:1,2;_nx:1,2,4c;_nx_noop:1,2,3c;_x:1,2c;esc_attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n" +"X-Poedit-SearchPath-0: .\n" +"X-Poedit-SearchPathExcluded-0: *.js\n" +"X-Poedit-SourceCharset: UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Domain: text-domain\n" + +msgid "String 1" +msgstr "" + +msgid "Missing string 2" +msgstr "" + + +# Basic single string +#: some/path/file.php:10 +#: some/path/file.php:20 +#: some/path/file.php:30 +msgid "String 3" +msgstr "" + +# Basic plural string +#: some/other/path/file.php:40 +msgid "item" +msgid_plural "items" +msgstr[0] "" +msgstr[1] "" + + +# Replacement single string +#. translators: %s: category +#: some/other/path/file.php:50 +msgid "the %s item" +msgstr "" + +# Replacement plural string +#. translators: %s: count +#: some/other/path/file.php:60 +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "" +msgstr[1] "" + + +# Basic context single string +#: some/context/file.php:10 +msgctxt "The context" +msgid "The string" +msgstr "" + +# Basic context single string 2 +#: some/context/file.php:20 +msgctxt "Other context" +msgid "The string" +msgstr "" + +# Replacement context single string +#. translators: %s: item +#: some/context/file.php:30 +msgctxt "The context" +msgid "The %s" +msgstr "" + +# Replacement context plural string +#. translators: %s: count +#: some/context/file.php:40 +msgctxt "The context" +msgid "%s item" +msgid_plural "%s items" +msgstr[0] "" +msgstr[1] "" + + +# Basic single string with flag +#: some/flagged/file.php:10 +#, fuzzy +msgid "String 4" +msgstr "" + +# Basic single string with flag 2 +#. translators: 1: item +#: some/flagged/file.php:10 +#, php-format +msgid "%1$s" +msgstr "" + +# Basic single string with flag 3 +#: some/flagged/file.php:10 +#, gp-priority: high +msgid "String 5" +msgstr "" + +# Basic single string with flag 4 +#: some/flagged/file.php:10 +#, gp-priority: normal +msgid "String 6" +msgstr "" + + +# Basic single string with single quotes +#: some/quoted/file.php:10 +msgid "Some 'string'" +msgstr "" + +# Basic single string with double quotes +#: some/quoted/file.php:20 +msgid "Some other \"string\"" +msgstr "" + + +# Multiline single string +#: some/multiline/file.php:10 +msgid "" +"This string\n" +"\n" +"has multiple lines." +msgstr "" + + +# Basic single string with HTML +#: some/html/file.php:10 +msgid "Some string with HTML" +msgstr "" + + +# Basic single string with multiple comments +#. translators: %d: key ID. +#. translators: %d: item ID. +#. translators: %d: other ID. +#. translators: 1: ID +#: some/misc/string.php:10 +#: some/misc/string.php:20 +#: some/misc/string.php:30 +#: some/misc/string.php:40 +msgid "ID: %d" +msgstr "ID: %d" + +# Basic context single string with no-context translation +#: some/context/file.php:11 +msgctxt "The context" +msgid "String with context missing, but no-context present" +msgstr "" diff --git a/test/options.test.js b/test/options.test.js new file mode 100644 index 0000000..b29883c --- /dev/null +++ b/test/options.test.js @@ -0,0 +1,366 @@ +'use strict'; + +const prepareOptions = require('../src/options'); + +const { escapeRegExp } = require('../src/utils'); + +function reOptionError(k) { + return new RegExp(`option ${escapeRegExp(k)}`, 'i'); +} + +const potSource = ['./test/examples/text-domain.pot']; + +describe('options.js - validate', () => { + // String only + const string_only = [ 'srcDir', 'destDir', 'domain' ]; + string_only.forEach(k => { + test(`${k} - only string`, () => { + expect(() => { + prepareOptions({ [k]: null }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: false }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: true }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: 1 }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: '' }); + }).not.toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: [] }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: {} }); + }).toThrow(reOptionError(k)); + }); + }); + + // Boolean only + const bool_only = [ + 'writeFiles', + 'domainFromPOTPath', + 'domainInPOPath', + 'defaultContextAsFallback', + 'appendNonIncludedFromPO', + 'includePORevisionDate', + 'logResults', + ]; + bool_only.forEach(k => { + test(`${k} - only boolean`, () => { + expect(() => { + prepareOptions({ [k]: null }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: false }); + }).not.toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: true }); + }).not.toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: 1 }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: '' }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: [] }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: {} }); + }).toThrow(reOptionError(k)); + }); + }); + + // Number only + const number_only = [ 'wrapLength' ]; + number_only.forEach(k => { + test(`${k} - only number`, () => { + expect(() => { + prepareOptions({ [k]: null }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: false }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: true }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: 1 }); + }).not.toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: '' }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: [] }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: {} }); + }).toThrow(reOptionError(k)); + }); + }); + + // Array only + const array_only = [ 'potSources' ]; + array_only.forEach(k => { + test(`${k} - only array`, () => { + expect(() => { + prepareOptions({ [k]: null }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: false }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: true }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: 1 }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: '' }); + }).toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: potSource }); + }).not.toThrow(reOptionError(k)); + expect(() => { + prepareOptions({ [k]: {} }); + }).toThrow(reOptionError(k)); + }); + }); + + test('options - only absent, object, glob string or glob array', () => { + const re = /Options should be an object/; + + expect(() => { + prepareOptions(); + }).not.toThrow(re); + + expect(() => { + prepareOptions(undefined); + }).not.toThrow(re); + + expect(() => { + prepareOptions(null); + }).toThrow(re); + + expect(() => { + prepareOptions(false); + }).toThrow(re); + + expect(() => { + prepareOptions(true); + }).toThrow(re); + + expect(() => { + prepareOptions(1); + }).toThrow(re); + + expect(() => { + prepareOptions('**/*.po'); + }).not.toThrow(re); + + expect(() => { + prepareOptions([]); + }).not.toThrow(re); + + expect(() => { + prepareOptions({}); + }).not.toThrow(re); + + expect(() => { + prepareOptions([1]); + }).toThrow(re); + + expect(() => { + prepareOptions([true]); + }).toThrow(re); + + expect(() => { + prepareOptions(['**/*.po']); + }).not.toThrow(re); + + expect(() => { + prepareOptions([[]]); + }).toThrow(re); + + expect(() => { + prepareOptions([{}]); + }).toThrow(re); + }); + + test('poSources - if truthy, only string or array of strings', () => { + const re = reOptionError('poSources'); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: null }); + }).not.toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: false }); + }).not.toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: true }); + }).toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: 1 }); + }).toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: '**/*.po' }); + }).not.toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: ['**/*.po'] }); + }).not.toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: {} }); + }).toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: [1] }); + }).toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: [true] }); + }).toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: [[]] }); + }).toThrow(re); + + expect(() => { + prepareOptions({ potSources: potSource, poSources: [{}] }); + }).toThrow(re); + }); + + test('srcDir - no newlines', () => { + expect(() => { + prepareOptions({ srcDir: 'some\npath' }); + }).toThrow(reOptionError('srcDir')); + }); + + test('destDir - no newlines', () => { + expect(() => { + prepareOptions({ destDir: 'some\npath' }); + }).toThrow(reOptionError('destDir')); + }); + + test('wrapLength - only positive integer', () => { + const re = reOptionError('wrapLength'); + + expect(() => { + prepareOptions({ wrapLength: -1 }); + }).toThrow(re); + + expect(() => { + prepareOptions({ wrapLength: 0 }); + }).toThrow(re); + }); +}); + +describe('options.js - clean & standardize', () => { + test('poSources - is array of only non-empty strings', () => { + // wrap string + expect( prepareOptions({ potSources: potSource, poSources: '**/*.po' }) ).toHaveProperty('poSources', ['**/*.po']); + + // filter non-strings and (trimmed) empty strings + const all = [ + 'first', + ' ', + 'second', + '', + ' \t third\t ', + ]; + const expected = [ + 'first', + 'second', + 'third', + ]; + expect( prepareOptions({ potSources: potSource, poSources: all }) ).toHaveProperty('poSources', expected); + }); + + const directories = [ 'srcDir', 'destDir' ]; + directories.forEach(k => { + test(`${k} - trimmed whitespace`, () => { + expect( prepareOptions({ [k]: ' ' }) ).toHaveProperty(k, ''); + expect( prepareOptions({ [k]: ' some ' }) ).toHaveProperty(k, 'some/'); + }); + + test(`${k} - only forward slashes`, () => { + expect( prepareOptions({ [k]: 'some\\' }) ).toHaveProperty(k, 'some/'); + expect( prepareOptions({ [k]: 'some\\path\\' }) ).toHaveProperty(k, 'some/path/'); + }); + + test(`${k} - only single slashes`, () => { + expect( prepareOptions({ [k]: 'some\\' }) ).toHaveProperty(k, 'some/'); + expect( prepareOptions({ [k]: 'some\\\\' }) ).toHaveProperty(k, 'some/'); + expect( prepareOptions({ [k]: 'some\\/\\/' }) ).toHaveProperty(k, 'some/'); + expect( prepareOptions({ [k]: 'some\\//\\\\' }) ).toHaveProperty(k, 'some/'); + }); + + test(`${k} - without current directory parts`, () => { + expect( prepareOptions({ [k]: './some/' }) ).toHaveProperty(k, 'some/'); + expect( prepareOptions({ [k]: 'some/././.\\path' }) ).toHaveProperty(k, 'some/path/'); + expect( prepareOptions({ [k]: './../some/.\\path' }) ).toHaveProperty(k, '../some/path/'); + }); + + test(`${k} - trailing slash`, () => { + expect( prepareOptions({ [k]: 'some' }) ).toHaveProperty(k, 'some/'); + expect( prepareOptions({ [k]: 'some/' }) ).toHaveProperty(k, 'some/'); + expect( prepareOptions({ [k]: 'some/path' }) ).toHaveProperty(k, 'some/path/'); + }); + + test(`${k} - resolve to minimal path`, () => { + // basic + expect( prepareOptions({ [k]: 'some/../path' }) ).toHaveProperty(k, 'path/'); + // nested + expect( prepareOptions({ [k]: 'some/other/../../path' }) ).toHaveProperty(k, 'path/'); + // concurrent + expect( prepareOptions({ [k]: 'some/other/../path/../' }) ).toHaveProperty(k, 'some/'); + // nested and concurrent + expect( prepareOptions({ [k]: 'some/other/../path/../../' }) ).toHaveProperty(k, ''); + // leave non-solvables + expect( prepareOptions({ [k]: './../some/../path' }) ).toHaveProperty(k, '../path/'); + // empty if result would be '/' + expect( prepareOptions({ [k]: ' ./\\//.\\/.\\ ' }) ).toHaveProperty(k, ''); + // combination + expect( prepareOptions({ [k]: ' ./some\\other/.\\path ' }) ).toHaveProperty(k, 'some/other/path/'); + }); + }); + + test('wrapLength - ceiled integer', () => { + expect( prepareOptions({ wrapLength: 0.001 }) ).toHaveProperty('wrapLength', 1); + expect( prepareOptions({ wrapLength: 1.2 }) ).toHaveProperty('wrapLength', 2); + }); +}); + +describe('options.js - logic', () => { + test('provide domain if domainInPOPath and not domainFromPOTPath', () => { + const re = new RegExp('domain should be a non-empty string'); + expect(() => { + prepareOptions({ domainFromPOTPath: false }); + }).toThrow(re); + + expect(() => { + prepareOptions({ domainFromPOTPath: false, domain: '' }); + }).toThrow(re); + + expect(() => { + prepareOptions({ domainFromPOTPath: false, domain: 'some' }); + }).not.toThrow(re); + + expect(() => { + prepareOptions({ domainFromPOTPath: false, domainInPOPath: true }); + }).toThrow(re); + + expect(() => { + prepareOptions({ domainFromPOTPath: false, domainInPOPath: false }); + }).not.toThrow(re); + }); +}); diff --git a/test/shared.test.js b/test/shared.test.js new file mode 100644 index 0000000..8d28fe5 --- /dev/null +++ b/test/shared.test.js @@ -0,0 +1,57 @@ +'use strict'; + +const { resolvePOTFilepaths } = require('../src/shared'); +const prepareOptions = require('../src/options'); + +const potSource = ['./test/examples/text-domain.pot']; + +describe('shared.js - logic', () => { + test('leave poSources empty if multiple potSources', () => { + const re = new RegExp('leave option poSources empty'); + expect(() => { + resolvePOTFilepaths( + prepareOptions({ poSources: '' }) + ); + }).not.toThrow(re); + expect(() => { + resolvePOTFilepaths( + prepareOptions({ poSources: '*.po' }) + ); + }).toThrow(re); + expect(() => { + resolvePOTFilepaths( + prepareOptions({ poSources: ['*.po'] }) + ); + }).toThrow(re); + + expect(() => { + resolvePOTFilepaths( + prepareOptions({ potSources: potSource, poSources: '' }) + ); + }).not.toThrow(re); + expect(() => { + resolvePOTFilepaths( + prepareOptions({ potSources: potSource, poSources: '*.po' }) + ); + }).not.toThrow(re); + expect(() => { + resolvePOTFilepaths( + prepareOptions({ potSources: potSource, poSources: ['*.po'] }) + ); + }).not.toThrow(re); + }); + + test('potSources has files to process', () => { + const re = new RegExp('No POT files found'); + expect(() => { + resolvePOTFilepaths( + prepareOptions({ potSources: [] }) + ); + }).toThrow(re); + expect(() => { + resolvePOTFilepaths( + prepareOptions({ potSources: ['NON_EXISTING.pot'] }) + ); + }).toThrow(re); + }); +}); diff --git a/test/sync.test.js b/test/sync.test.js new file mode 100644 index 0000000..3a78a91 --- /dev/null +++ b/test/sync.test.js @@ -0,0 +1,282 @@ +'use strict'; + +const fillPotPo = require('../src/sync'); + +const { sync: matchedSync } = require('matched'); +const { rmSync, existsSync, mkdirSync, readFileSync } = require('fs'); + +const test_dir = 'test/examples/output/fs'; + +function clearOutputFolder() { + let files = matchedSync([ + `${test_dir}*`, + ]); + files.sort((a, b) => { + const a_len = a.split(/\//); + const b_len = b.split(/\//); + return b_len - a_len; + }); + for (const file of files) { + rmSync(file, { recursive: true }); + } +} + +const potSource = './test/examples/text-domain.pot'; +// const potSources = './test/examples/*.pot'; +const poSources = './test/examples/input/*.po'; +// const poSource = './test/examples/input/nl_NL.po'; + +beforeAll(() => { + clearOutputFolder(); +}); + +afterAll(() => { + clearOutputFolder(); +}); + +describe('sync.js - single POT', () => { + + let folder_i = 0; + + test('auto domain PO - no write', () => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + writeFiles: false, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + // Errorless execution + let result; + expect(() => { + result = fillPotPo(options); + }).not.toThrow(); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('text-domain-nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(0); + + // Check contents + expect(result[0][1]) + .toEqual(readFileSync('test/examples/output_correct/text-domain-nl_NL.po')); + }); + + test('auto no-domain PO - no write', () => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + writeFiles: false, + domainInPOPath: false, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + // Errorless execution + let result; + expect(() => { + result = fillPotPo(options); + }).not.toThrow(); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(0); + + // Check contents + expect(result[0][1]) + .toEqual(readFileSync('test/examples/output_correct/nl_NL.po')); + }); + + test('manual multiple PO - no write', () => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + poSources: [ poSources ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + writeFiles: false, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + // Errorless execution + let result; + expect(() => { + result = fillPotPo(options); + }).not.toThrow(); + + expect(result).toHaveLength(2); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + expect(result[1]).toHaveLength(2); + expect(result[1][0]).toEqual('text-domain-nl_NL.po'); + expect(result[1][1]).toBeInstanceOf(Buffer); + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(0); + + // Check contents + expect(result[0][1]) + .toEqual(readFileSync('test/examples/output_correct/nl_NL.po')); + expect(result[1][1]) + .toEqual(readFileSync('test/examples/output_correct/text-domain-nl_NL.po')); + }); + + test('auto domain PO - write', () => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + // Errorless execution + let result; + expect(() => { + result = fillPotPo(options); + }).not.toThrow(); + + // Check returned array + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('text-domain-nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + + // Check if file exist + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(1); + expect(files).toEqual([ + folder_path + '/text-domain-nl_NL.po' + ]); + + // Check contents of file + expect(readFileSync(folder_path + '/text-domain-nl_NL.po', 'utf-8')) + .toEqual(readFileSync('test/examples/output_correct/text-domain-nl_NL.po', 'utf-8')); + }); + + test('auto no-domain PO - write', () => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + domainInPOPath: false, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + // Errorless execution + let result; + expect(() => { + result = fillPotPo(options); + }).not.toThrow(); + + // Check returned array + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + + // Check if file exist + const files = matchedSync([folder_path + '/*']); + expect(files).toHaveLength(1); + expect(files).toEqual([ + folder_path + '/nl_NL.po' + ]); + + // Check contents of file + expect(readFileSync(folder_path + '/nl_NL.po', 'utf-8')) + .toEqual(readFileSync('test/examples/output_correct/nl_NL.po', 'utf-8')); + }); + + test('manual multiple PO - write', () => { + folder_i++; + let folder_path = `${test_dir}${folder_i}`; + if (!existsSync(folder_path)) { + mkdirSync(folder_path); + } + + const options = { + potSources: [ potSource ], + poSources: [ poSources ], + srcDir: './test/examples/input/', // Only used for auto-find POs. Default is POT file directory. + destDir: folder_path, + defaultContextAsFallback: true, + appendNonIncludedFromPO: true, + includePORevisionDate: false, + }; + + // Errorless execution + let result; + expect(() => { + result = fillPotPo(options); + }).not.toThrow(); + + // Check returned array + expect(result).toHaveLength(2); + expect(result[0]).toHaveLength(2); + expect(result[0][0]).toEqual('nl_NL.po'); + expect(result[0][1]).toBeInstanceOf(Buffer); + expect(result[1]).toHaveLength(2); + expect(result[1][0]).toEqual('text-domain-nl_NL.po'); + expect(result[1][1]).toBeInstanceOf(Buffer); + + // Check if files exist + const files = matchedSync([folder_path + '/*']); + expect(files).toEqual([ + folder_path + '/nl_NL.po', + folder_path + '/text-domain-nl_NL.po', + ]); + + // Check contents of files + expect(readFileSync(folder_path + '/nl_NL.po', 'utf-8')) + .toEqual(readFileSync('test/examples/output_correct/nl_NL.po', 'utf-8')); + expect(readFileSync(folder_path + '/text-domain-nl_NL.po', 'utf-8')) + .toEqual(readFileSync('test/examples/output_correct/text-domain-nl_NL.po', 'utf-8')); + }); + +}); + +// TODO: multiple POT files? diff --git a/test/utils.test.js b/test/utils.test.js new file mode 100644 index 0000000..e10dcef --- /dev/null +++ b/test/utils.test.js @@ -0,0 +1,527 @@ +'use strict'; + +const { isArray, isObject, isString, isBool, isArrayOfStrings, pathLineSort } = require('../src/utils'); + +class SomeClass { + constructor() {} +} + +function someFunction() {} + +/** + * Helper function to create extensive test filepaths list. + * + * @return {array} Filepaths + */ +/* eslint-disable-next-line no-unused-vars */ +function generatePaths() { + const dirs = [ + '', + 'some/other/path/', + 'some/other/folder/', + 'some/', + 'some/path/', + 'some/quoted/', + ]; + + const files = [ + 'file', + 'file.ext', + 'file.oth', + 'different_file.ext', + '.ext', + ]; + + const lines = [ + '', + ':10', + ':20', + ':100', + ]; + + let filepaths = [].concat( + ...dirs.map(d => + [d].concat( + ...files.map(f => + lines.map(l => + `${d}${f}${l}` + ) + ) + ) + ) + ); + + return filepaths; +} + +/** + * Shuffle array in-place. + * + * @param {array} array + * @return {array} + */ +function shuffle(array) { + let currentIndex = array.length, randomIndex; + + // While there remain elements to shuffle. + while (currentIndex != 0) { + + // Pick a remaining element. + randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex--; + + // And swap it with the current element. + [ + array[currentIndex], array[randomIndex] + ] = [ + array[randomIndex], array[currentIndex] + ]; + } + + return array; +} + +describe('utils.js - isArray', () => { + test('false on null', () => { + expect( isArray(null) ).toEqual(false); + }); + + test('false on undefined', () => { + expect( isArray(undefined) ).toEqual(false); + }); + + test('false on number', () => { + expect( isArray(1) ).toEqual(false); + }); + + test('false on boolean true', () => { + expect( isArray(true) ).toEqual(false); + }); + + test('false on boolean false', () => { + expect( isArray(false) ).toEqual(false); + }); + + test('false on string', () => { + expect( isArray('string') ).toEqual(false); + }); + + test('false on class instance', () => { + expect( isArray(new SomeClass()) ).toEqual(false); + }); + + test('false on class', () => { + expect( isArray(SomeClass) ).toEqual(false); + }); + + test('false on arrow function', () => { + expect( isArray(() => {}) ).toEqual(false); + }); + + test('false on function', () => { + expect( isArray(someFunction) ).toEqual(false); + }); + + test('false on object', () => { + expect( isArray({}) ).toEqual(false); + }); + + test('true on array', () => { + expect( isArray([]) ).toEqual(true); + }); +}); + +describe('utils.js - isObject', () => { + test('false on null', () => { + expect( isObject(null) ).toEqual(false); + }); + + test('false on undefined', () => { + expect( isObject(undefined) ).toEqual(false); + }); + + test('false on number', () => { + expect( isObject(1) ).toEqual(false); + }); + + test('false on boolean true', () => { + expect( isObject(true) ).toEqual(false); + }); + + test('false on boolean false', () => { + expect( isObject(false) ).toEqual(false); + }); + + test('false on string', () => { + expect( isObject('string') ).toEqual(false); + }); + + test('true on class instance', () => { + expect( isObject(new SomeClass()) ).toEqual(true); + }); + + test('false on class', () => { + expect( isObject(SomeClass) ).toEqual(false); + }); + + test('false on arrow function', () => { + expect( isObject(() => {}) ).toEqual(false); + }); + + test('false on function', () => { + expect( isObject(someFunction) ).toEqual(false); + }); + + test('true on object', () => { + expect( isObject({}) ).toEqual(true); + }); + + test('false on array', () => { + expect( isObject([]) ).toEqual(false); + }); +}); + +describe('utils.js - isString', () => { + test('false on null', () => { + expect( isString(null) ).toEqual(false); + }); + + test('false on undefined', () => { + expect( isString(undefined) ).toEqual(false); + }); + + test('false on number', () => { + expect( isString(1) ).toEqual(false); + }); + + test('false on boolean true', () => { + expect( isString(true) ).toEqual(false); + }); + + test('false on boolean false', () => { + expect( isString(false) ).toEqual(false); + }); + + test('true on string', () => { + expect( isString('string') ).toEqual(true); + }); + + test('false on class instance', () => { + expect( isString(new SomeClass()) ).toEqual(false); + }); + + test('false on class', () => { + expect( isString(SomeClass) ).toEqual(false); + }); + + test('false on arrow function', () => { + expect( isString(() => {}) ).toEqual(false); + }); + + test('false on function', () => { + expect( isString(someFunction) ).toEqual(false); + }); + + test('false on object', () => { + expect( isString({}) ).toEqual(false); + }); + + test('false on array', () => { + expect( isString([]) ).toEqual(false); + }); +}); + +describe('utils.js - isBool', () => { + test('false on null', () => { + expect( isBool(null) ).toEqual(false); + }); + + test('false on undefined', () => { + expect( isBool(undefined) ).toEqual(false); + }); + + test('false on number', () => { + expect( isBool(1) ).toEqual(false); + }); + + test('true on boolean true', () => { + expect( isBool(true) ).toEqual(true); + }); + + test('true on boolean false', () => { + expect( isBool(false) ).toEqual(true); + }); + + test('false on string', () => { + expect( isBool('string') ).toEqual(false); + }); + + test('false on class instance', () => { + expect( isBool(new SomeClass()) ).toEqual(false); + }); + + test('false on class', () => { + expect( isBool(SomeClass) ).toEqual(false); + }); + + test('false on arrow function', () => { + expect( isBool(() => {}) ).toEqual(false); + }); + + test('false on function', () => { + expect( isBool(someFunction) ).toEqual(false); + }); + + test('false on object', () => { + expect( isBool({}) ).toEqual(false); + }); + + test('false on array', () => { + expect( isBool([]) ).toEqual(false); + }); +}); + +describe('utils.js - isArrayOfStrings', () => { + test('false on null', () => { + expect( isArrayOfStrings(null) ).toEqual(false); + }); + + test('false on undefined', () => { + expect( isArrayOfStrings(undefined) ).toEqual(false); + }); + + test('false on number', () => { + expect( isArrayOfStrings(1) ).toEqual(false); + }); + + test('false on boolean true', () => { + expect( isArrayOfStrings(true) ).toEqual(false); + }); + + test('false on boolean false', () => { + expect( isArrayOfStrings(false) ).toEqual(false); + }); + + test('false on string', () => { + expect( isArrayOfStrings('string') ).toEqual(false); + }); + + test('false on class instance', () => { + expect( isArrayOfStrings(new SomeClass()) ).toEqual(false); + }); + + test('false on class', () => { + expect( isArrayOfStrings(SomeClass) ).toEqual(false); + }); + + test('false on arrow function', () => { + expect( isArrayOfStrings(() => {}) ).toEqual(false); + }); + + test('false on function', () => { + expect( isArrayOfStrings(someFunction) ).toEqual(false); + }); + + test('false on object', () => { + expect( isArrayOfStrings({}) ).toEqual(false); + }); + + test('true on array', () => { + expect( isArrayOfStrings([]) ).toEqual(true); + }); + + test('false on array w/ null', () => { + expect( isArrayOfStrings([null]) ).toEqual(false); + }); + + test('false on array w/ undefined', () => { + expect( isArrayOfStrings([undefined]) ).toEqual(false); + }); + + test('false on array w/ boolean true', () => { + expect( isArrayOfStrings([true]) ).toEqual(false); + }); + + test('false on array w/ boolean false', () => { + expect( isArrayOfStrings([false]) ).toEqual(false); + }); + + test('false on array w/ number', () => { + expect( isArrayOfStrings([1]) ).toEqual(false); + }); + + test('true on array w/ string', () => { + expect( isArrayOfStrings(['']) ).toEqual(true); + }); + + test('false on array w/ class instance', () => { + expect( isArrayOfStrings([new SomeClass()]) ).toEqual(false); + }); + + test('false on array w/ class', () => { + expect( isArrayOfStrings([SomeClass]) ).toEqual(false); + }); + + test('false on array w/ arrow function', () => { + expect( isArrayOfStrings([() => {}]) ).toEqual(false); + }); + + test('false on array w/ function', () => { + expect( isArrayOfStrings([someFunction]) ).toEqual(false); + }); + + test('false on array w/ object', () => { + expect( isArrayOfStrings([{}]) ).toEqual(false); + }); + + test('false on array w/ array', () => { + expect( isArrayOfStrings([[]]) ).toEqual(false); + }); +}); + +describe('utils.js - pathLineSort', () => { + // NOTE: no-extension files are treated as folder + // 1) folders first, files and empty last + // 2) folder/file names sort alphabetically + // (per depth; first by name, then by extension) + // 3) no line number first + // 4) line numbers ascending numerically + const sorted = [ + 'file', + 'file:10', + 'file:20', + 'file:100', + 'some/file', + 'some/file:10', + 'some/file:20', + 'some/file:100', + 'some/other/folder/file', + 'some/other/folder/file:10', + 'some/other/folder/file:20', + 'some/other/folder/file:100', + 'some/other/folder/.ext', + 'some/other/folder/.ext:10', + 'some/other/folder/.ext:20', + 'some/other/folder/.ext:100', + 'some/other/folder/different_file.ext', + 'some/other/folder/different_file.ext:10', + 'some/other/folder/different_file.ext:20', + 'some/other/folder/different_file.ext:100', + 'some/other/folder/file.ext', + 'some/other/folder/file.ext:10', + 'some/other/folder/file.ext:20', + 'some/other/folder/file.ext:100', + 'some/other/folder/file.oth', + 'some/other/folder/file.oth:10', + 'some/other/folder/file.oth:20', + 'some/other/folder/file.oth:100', + 'some/other/folder/', + 'some/other/path/file', + 'some/other/path/file:10', + 'some/other/path/file:20', + 'some/other/path/file:100', + 'some/other/path/.ext', + 'some/other/path/.ext:10', + 'some/other/path/.ext:20', + 'some/other/path/.ext:100', + 'some/other/path/different_file.ext', + 'some/other/path/different_file.ext:10', + 'some/other/path/different_file.ext:20', + 'some/other/path/different_file.ext:100', + 'some/other/path/file.ext', + 'some/other/path/file.ext:10', + 'some/other/path/file.ext:20', + 'some/other/path/file.ext:100', + 'some/other/path/file.oth', + 'some/other/path/file.oth:10', + 'some/other/path/file.oth:20', + 'some/other/path/file.oth:100', + 'some/other/path/', + 'some/path/file', + 'some/path/file:10', + 'some/path/file:20', + 'some/path/file:100', + 'some/path/.ext', + 'some/path/.ext:10', + 'some/path/.ext:20', + 'some/path/.ext:100', + 'some/path/different_file.ext', + 'some/path/different_file.ext:10', + 'some/path/different_file.ext:20', + 'some/path/different_file.ext:100', + 'some/path/file.ext', + 'some/path/file.ext:10', + 'some/path/file.ext:20', + 'some/path/file.ext:100', + 'some/path/file.oth', + 'some/path/file.oth:10', + 'some/path/file.oth:20', + 'some/path/file.oth:100', + 'some/path/', + 'some/quoted/file', + 'some/quoted/file:10', + 'some/quoted/file:20', + 'some/quoted/file:100', + 'some/quoted/.ext', + 'some/quoted/.ext:10', + 'some/quoted/.ext:20', + 'some/quoted/.ext:100', + 'some/quoted/different_file.ext', + 'some/quoted/different_file.ext:10', + 'some/quoted/different_file.ext:20', + 'some/quoted/different_file.ext:100', + 'some/quoted/file.ext', + 'some/quoted/file.ext:10', + 'some/quoted/file.ext:20', + 'some/quoted/file.ext:100', + 'some/quoted/file.oth', + 'some/quoted/file.oth:10', + 'some/quoted/file.oth:20', + 'some/quoted/file.oth:100', + 'some/quoted/', + 'some/.ext', + 'some/.ext:10', + 'some/.ext:20', + 'some/.ext:100', + 'some/different_file.ext', + 'some/different_file.ext:10', + 'some/different_file.ext:20', + 'some/different_file.ext:100', + 'some/file.ext', + 'some/file.ext:10', + 'some/file.ext:20', + 'some/file.ext:100', + 'some/file.oth', + 'some/file.oth:10', + 'some/file.oth:20', + 'some/file.oth:100', + 'some/', + '.ext', + '.ext:10', + '.ext:20', + '.ext:100', + 'different_file.ext', + 'different_file.ext:10', + 'different_file.ext:20', + 'different_file.ext:100', + 'file.ext', + 'file.ext:10', + 'file.ext:20', + 'file.ext:100', + 'file.oth', + 'file.oth:10', + 'file.oth:20', + 'file.oth:100', + '' + ]; + + let shuffled = shuffle( sorted.slice() ); + + test('used as function', () => { + expect( pathLineSort(shuffled) ).toEqual(sorted); + }); + + test('used as callback for array.sort()', () => { + expect( shuffled.sort(pathLineSort) ).toEqual(sorted); + }); +});