├── .editorconfig ├── .gitignore ├── .jshintrc ├── .travis.yml ├── LICENSE.md ├── README.md ├── bin ├── php │ ├── add-textdomain.php │ ├── extract.php │ ├── makepot.php │ ├── node-add-textdomain.php │ ├── node-makepot.php │ ├── not-gettexted.php │ ├── pomo │ │ ├── entry.php │ │ ├── mo.php │ │ ├── plural-forms.php │ │ ├── po.php │ │ ├── streams.php │ │ └── translations.php │ └── pot-ext-meta.php └── wpi18n ├── docs └── cli.txt ├── index.js ├── lib ├── addtextdomain.js ├── makepot.js ├── msgmerge.js ├── package.js ├── pot.js └── util.js ├── package.json └── test ├── addtextdomain.js ├── fixtures ├── makepot │ ├── basic-plugin │ │ └── basic-plugin.php │ ├── nested-theme │ │ ├── include │ │ │ └── include.php │ │ └── subdir │ │ │ ├── index.php │ │ │ └── style.css │ └── plugin-with-pot │ │ ├── plugin-with-pot.php │ │ └── plugin-with-pot.pot ├── msgmerge │ ├── msgmerge-en_GB.po │ ├── msgmerge-nl_NL.po │ └── msgmerge.pot ├── packages │ ├── plugins │ │ ├── basic-plugin │ │ │ └── basic-plugin.php │ │ ├── different-slug │ │ │ └── plugin.php │ │ ├── invalid-plugin │ │ │ └── invalid-plugin.php │ │ └── plugin-headers │ │ │ └── plugin-headers.php │ └── themes │ │ ├── basic-theme │ │ ├── index.php │ │ └── style.css │ │ ├── nested-theme │ │ └── src │ │ │ ├── index.php │ │ │ └── style.css │ │ └── svn-theme │ │ └── tags │ │ └── 1.0.0 │ │ ├── index.php │ │ └── style.css └── pot │ ├── basic.pot │ └── save.pot ├── makepot.js ├── msgmerge.js ├── package.js ├── pot.js └── util.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 2 10 | 11 | [bin/php/**.php] 12 | indent_style = tab 13 | indent_size = 4 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .nyc_output 3 | coverage 4 | node_modules 5 | npm-debug.log 6 | package-lock.json 7 | tmp 8 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "boss": true, 3 | "curly": true, 4 | "eqeqeq": true, 5 | "eqnull": true, 6 | "immed": true, 7 | "latedef": "nofunc", 8 | "newcap": true, 9 | "noarg": true, 10 | "quotmark": "single", 11 | "sub": true, 12 | "undef": true, 13 | "unused": true, 14 | 15 | "node": true 16 | } 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 5.6 5 | - 7.1 6 | - 7.2 7 | 8 | env: 9 | - TRAVIS_NODE_VERSION="6.9.1" 10 | - TRAVIS_NODE_VERSION="8" 11 | 12 | install: 13 | - rm -rf ~/.nvm && git clone https://github.com/creationix/nvm.git ~/.nvm && (cd ~/.nvm && git checkout `git describe --abbrev=0 --tags`) && source ~/.nvm/nvm.sh && nvm install $TRAVIS_NODE_VERSION 14 | - npm install 15 | 16 | script: npm test 17 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2015 Cedaro, LLC http://www.cedaro.com/ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-wp-i18n [![Build Status](https://travis-ci.org/cedaro/node-wp-i18n.png?branch=develop)](https://travis-ci.org/cedaro/node-wp-i18n) 2 | 3 | > Internationalize WordPress plugins and themes. 4 | 5 | WordPress has a robust suite of tools to help internationalize plugins and themes. This plugin brings the power of those existing tools to Node.js in order to make it easy for you to automate the i18n process and make your projects more accessible to an international audience. 6 | 7 | If you're not familiar with i18n concepts, read the Internationalization entries in the [Plugin Developer Handbook](https://developer.wordpress.org/plugins/internationalization/) or [Theme Developer Handbook](https://developer.wordpress.org/themes/functionality/internationalization/). 8 | 9 | node-wp-i18n started as the core of the [grunt-wp-i18n](https://github.com/cedaro/grunt-wp-i18n) plugin, but has been extracted and rewritten to be more useful as a standalone module and with other tools. 10 | 11 | 12 | ## Getting Started 13 | 14 | `node-wp-i18n` includes a basic CLI tool to help generate POT file or add text domains to i18n functions in WordPress plugins or themes. Installing this module globally will allow you to access the `wpi18n` command: 15 | 16 | ```sh 17 | npm install -g node-wp-i18n 18 | ``` 19 | 20 | Once installed, run this command from a plugin or theme to see the available options: 21 | 22 | ```sh 23 | wpi18n -h 24 | ``` 25 | 26 | Running `wpi18n info` in a plugin or theme directory will show you information about that package. 27 | 28 | 29 | ### Requirements 30 | 31 | * [PHP CLI](http://www.php.net/manual/en/features.commandline.introduction.php) must be in your system path. 32 | -------------------------------------------------------------------------------- /bin/php/add-textdomain.php: -------------------------------------------------------------------------------- 1 | funcs = array_keys( $makepot->rules ); 19 | } 20 | 21 | public function usage() { 22 | $usage = "Usage: php add-textdomain.php [-i] \n\nAdds the string as a last argument to all i18n function calls in \nand prints the modified php file on standard output.\n\nOptions:\n -i Modifies the PHP file in place, instead of printing it to standard output.\n"; 23 | fwrite( STDERR, $usage ); 24 | exit( 1 ); 25 | } 26 | 27 | /** 28 | * Add textdomain to a single file. 29 | * 30 | * @see AddTextdomain::process_string() 31 | * 32 | * @param string $domain Text domain. 33 | * @param string $source_filename Filename with optional path. 34 | * @param bool $inplace True to modifies the PHP file in place. False to print to standard output. 35 | */ 36 | public function process_file( $domain, $source_filename, $inplace ) { 37 | $old_source = file_get_contents( $source_filename ); 38 | $new_source = $this->process_string( $domain, $old_source ); 39 | 40 | if ($inplace) { 41 | if ($old_source !== $new_source) { 42 | $f = fopen( $source_filename, 'w' ); 43 | fwrite( $f, $new_source ); 44 | fclose( $f ); 45 | } 46 | } else { 47 | echo $new_source; 48 | } 49 | } 50 | 51 | /** 52 | * Add textdomain to a string of PHP. 53 | * 54 | * Functions calls should be wrapped in opening and closing PHP delimiters as usual. 55 | * 56 | * @see AddTextdomain::process_tokens() 57 | * 58 | * @param string $domain Text domain. 59 | * @param string $string PHP code to parse. 60 | * 61 | * @return string Modified source. 62 | */ 63 | public function process_string( $domain, $string ) { 64 | $tokens = token_get_all( $string ); 65 | return $this->process_tokens( $domain, $tokens ); 66 | } 67 | 68 | /** 69 | * Add textdomain to a set of PHP tokens. 70 | * 71 | * @param string $domain Text domain. 72 | * @param array $tokens PHP tokens. An array of token identifiers. Each individual token identifier is either a 73 | * single character (i.e.: ;, ., >, !, etc.), or a three element array containing the token 74 | * index in element 0, the string content of the original token in element 1 and the line 75 | * number in element 2. 76 | * 77 | * @return string Modified source. 78 | */ 79 | public function process_tokens( $domain, $tokens ) { 80 | $this->modified_contents = ''; 81 | $domain = addslashes( $domain ); 82 | 83 | $in_func = false; 84 | $args_started = false; 85 | $parens_balance = 0; 86 | $found_domain = false; 87 | 88 | foreach( $tokens as $index => $token ) { 89 | $string_success = false; 90 | if ( is_array( $token ) ) { 91 | list( $id, $text ) = $token; 92 | if ( T_STRING == $id && in_array($text, $this->funcs ) ) { 93 | $in_func = true; 94 | $parens_balance = 0; 95 | $args_started = false; 96 | $found_domain = false; 97 | } elseif ( T_CONSTANT_ENCAPSED_STRING == $id && ( "'$domain'" == $text || "\"$domain\"" == $text ) ) { 98 | if ( $in_func && $args_started ) { 99 | $found_domain = true; 100 | } 101 | } 102 | $token = $text; 103 | } elseif ( '(' == $token ) { 104 | $args_started = true; 105 | ++$parens_balance; 106 | } elseif ( ')' == $token ) { 107 | --$parens_balance; 108 | if ( $in_func && 0 == $parens_balance ) { 109 | if ( ! $found_domain ) { 110 | $token = ", '$domain'"; 111 | if ( T_WHITESPACE == $tokens[ $index - 1 ][0] ) { 112 | $token .= ' '; // Maintain code standards if previously present 113 | // Remove previous whitespace token to account for it. 114 | $this->modified_contents = trim( $this->modified_contents ); 115 | } 116 | $token .= ')'; 117 | } 118 | $in_func = false; 119 | $args_started = false; 120 | $found_domain = false; 121 | } 122 | } 123 | $this->modified_contents .= $token; 124 | } 125 | 126 | return $this->modified_contents; 127 | } 128 | } 129 | 130 | // Run the CLI only if the file wasn't included. 131 | $included_files = get_included_files(); 132 | if ( __FILE__ == $included_files[0] ) { 133 | $adddomain = new AddTextdomain; 134 | 135 | if ( ! isset( $argv[1] ) || ! isset( $argv[2] ) ) { 136 | $adddomain->usage(); 137 | } 138 | 139 | $inplace = false; 140 | if ( '-i' == $argv[1] ) { 141 | $inplace = true; 142 | if ( !isset( $argv[3] ) ) { 143 | $adddomain->usage(); 144 | } 145 | array_shift( $argv ); 146 | } 147 | 148 | $adddomain->process_file( $argv[1], $argv[2], $inplace ); 149 | } 150 | -------------------------------------------------------------------------------- /bin/php/extract.php: -------------------------------------------------------------------------------- 1 | array( 'string' ), 14 | '_e' => array( 'string' ), 15 | '_n' => array( 'singular', 'plural' ), 16 | ); 17 | 18 | public $comment_prefix = 'translators:'; 19 | 20 | public function __construct( $rules = array() ) { 21 | $this->rules = $rules; 22 | } 23 | 24 | public function extract_from_directory( $dir, $excludes = array(), $includes = array(), $prefix = '' ) { 25 | $old_cwd = getcwd(); 26 | chdir( $dir ); 27 | $translations = new Translations; 28 | $file_names = (array) scandir( '.' ); 29 | foreach ( $file_names as $file_name ) { 30 | if ( '.' == $file_name || '..' == $file_name ) { 31 | continue; 32 | } 33 | if ( preg_match( '/\.php$/', $file_name ) && $this->does_file_name_match( $prefix . $file_name, $excludes, $includes ) ) { 34 | $extracted = $this->extract_from_file( $file_name, $prefix ); 35 | $translations->merge_originals_with( $extracted ); 36 | } 37 | if ( is_dir( $file_name ) && ! $this->is_directory_excluded( $prefix . $file_name, $excludes ) ) { 38 | $extracted = $this->extract_from_directory( $file_name, $excludes, $includes, $prefix . $file_name . '/' ); 39 | $translations->merge_originals_with( $extracted ); 40 | } 41 | } 42 | chdir( $old_cwd ); 43 | return $translations; 44 | } 45 | 46 | public function extract_from_file( $file_name, $prefix ) { 47 | $code = file_get_contents( $file_name ); 48 | return $this->extract_from_code( $code, $prefix . $file_name ); 49 | } 50 | 51 | public function does_file_name_match( $path, $excludes, $includes ) { 52 | if ( $includes ) { 53 | $matched_any_include = false; 54 | foreach( $includes as $include ) { 55 | if ( preg_match( '|^' . $include . '$|', $path ) ) { 56 | $matched_any_include = true; 57 | break; 58 | } 59 | } 60 | if ( ! $matched_any_include ) { 61 | return false; 62 | } 63 | } 64 | if ( $excludes ) { 65 | foreach( $excludes as $exclude ) { 66 | if ( preg_match( '|^' . $exclude . '$|', $path ) ) { 67 | return false; 68 | } 69 | } 70 | } 71 | return true; 72 | } 73 | 74 | public function is_directory_excluded( $directory, $excludes ) { 75 | if ( $excludes ) { 76 | foreach( $excludes as $exclude ) { 77 | if ( 78 | preg_match( '|^' . $exclude . '$|', $directory ) || 79 | preg_match( '|^' . $exclude . '$|', $directory . '/' ) 80 | ) { 81 | return true; 82 | } 83 | } 84 | } 85 | return false; 86 | } 87 | 88 | public function entry_from_call( $call, $file_name ) { 89 | $rule = isset( $this->rules[ $call['name'] ] )? $this->rules[ $call['name'] ] : null; 90 | if ( ! $rule ) { 91 | return null; 92 | } 93 | $entry = new Translation_Entry; 94 | $multiple = array(); 95 | $complete = false; 96 | for( $i = 0; $i < count( $rule ); ++$i ) { 97 | if ( $rule[ $i ] && ( ! isset( $call['args'][ $i ] ) || ! is_string( $call['args'][ $i ] ) || '' == $call['args'][ $i ] ) ) { 98 | return false; 99 | } 100 | switch( $rule[ $i ] ) { 101 | case 'string': 102 | if ( $complete ) { 103 | $multiple[] = $entry; 104 | $entry = new Translation_Entry; 105 | $complete = false; 106 | } 107 | $entry->singular = $call['args'][ $i ]; 108 | $complete = true; 109 | break; 110 | case 'singular': 111 | if ( $complete ) { 112 | $multiple[] = $entry; 113 | $entry = new Translation_Entry; 114 | $complete = false; 115 | } 116 | $entry->singular = $call['args'][ $i ]; 117 | $entry->is_plural = true; 118 | break; 119 | case 'plural': 120 | $entry->plural = $call['args'][ $i ]; 121 | $entry->is_plural = true; 122 | $complete = true; 123 | break; 124 | case 'context': 125 | $entry->context = $call['args'][ $i ]; 126 | foreach( $multiple as &$single_entry ) { 127 | $single_entry->context = $entry->context; 128 | } 129 | break; 130 | } 131 | } 132 | if ( isset( $call['line'] ) && $call['line'] ) { 133 | $references = array( $file_name . ':' . $call['line'] ); 134 | $entry->references = $references; 135 | foreach( $multiple as &$single_entry ) { 136 | $single_entry->references = $references; 137 | } 138 | } 139 | if ( isset( $call['comment'] ) && $call['comment'] ) { 140 | $comments = rtrim( $call['comment'] ) . "\n"; 141 | $entry->extracted_comments = $comments; 142 | foreach( $multiple as &$single_entry ) { 143 | $single_entry->extracted_comments = $comments; 144 | } 145 | } 146 | if ( $multiple && $entry ) { 147 | $multiple[] = $entry; 148 | return $multiple; 149 | } 150 | 151 | return $entry; 152 | } 153 | 154 | public function extract_from_code( $code, $file_name ) { 155 | $translations = new Translations; 156 | $function_calls = $this->find_function_calls( array_keys( $this->rules ), $code ); 157 | foreach( $function_calls as $call ) { 158 | $entry = $this->entry_from_call( $call, $file_name ); 159 | if ( is_array( $entry ) ) { 160 | foreach( $entry as $single_entry ) { 161 | $translations->add_entry_or_merge( $single_entry ); 162 | } 163 | } elseif ( $entry ) { 164 | $translations->add_entry_or_merge( $entry ); 165 | } 166 | } 167 | return $translations; 168 | } 169 | 170 | /** 171 | * Finds all function calls in $code and returns an array with an associative array for each function: 172 | * - name - name of the function 173 | * - args - array for the function arguments. Each string literal is represented by itself, other arguments are represented by null. 174 | * - line - line number 175 | */ 176 | public function find_function_calls( $function_names, $code ) { 177 | $tokens = token_get_all( $code ); 178 | $function_calls = array(); 179 | $latest_comment = false; 180 | $in_func = false; 181 | foreach( $tokens as $token ) { 182 | $id = $text = null; 183 | if ( is_array( $token ) ) { 184 | list( $id, $text, $line ) = $token; 185 | } 186 | if ( T_WHITESPACE == $id ) { 187 | continue; 188 | } 189 | if ( T_STRING == $id && in_array( $text, $function_names ) && ! $in_func ) { 190 | $in_func = true; 191 | $paren_level = -1; 192 | $args = array(); 193 | $func_name = $text; 194 | $func_line = $line; 195 | $func_comment = $latest_comment? $latest_comment : ''; 196 | 197 | $just_got_into_func = true; 198 | $latest_comment = false; 199 | continue; 200 | } 201 | if ( T_COMMENT == $id ) { 202 | $text = preg_replace( '%^\s+\*\s%m', '', $text ); 203 | $text = str_replace( array( "\r\n", "\n" ), ' ', $text ); 204 | $text = trim( preg_replace( '%^/\*|//%', '', preg_replace( '%\*/$%', '', $text ) ) ); 205 | if ( 0 === stripos( $text, $this->comment_prefix ) ) { 206 | $latest_comment = $text; 207 | } 208 | } 209 | if ( ! $in_func ) { 210 | continue; 211 | } 212 | if ( '(' == $token ) { 213 | $paren_level++; 214 | if ( 0 == $paren_level ) { // Start of first argument. 215 | $just_got_into_func = false; 216 | $current_argument = null; 217 | $current_argument_is_just_literal = true; 218 | } 219 | continue; 220 | } 221 | if ( $just_got_into_func ) { 222 | // There wasn't an opening paren just after the function name -- this means it is not a function. 223 | $in_func = false; 224 | $just_got_into_func = false; 225 | } 226 | if ( ')' == $token ) { 227 | if ( 0 == $paren_level ) { 228 | $in_func = false; 229 | $args[] = $current_argument; 230 | $call = array( 'name' => $func_name, 'args' => $args, 'line' => $func_line ); 231 | if ( $func_comment ) { 232 | $call['comment'] = $func_comment; 233 | } 234 | $function_calls[] = $call; 235 | } 236 | $paren_level--; 237 | continue; 238 | } 239 | if ( ',' == $token && 0 == $paren_level ) { 240 | $args[] = $current_argument; 241 | $current_argument = null; 242 | $current_argument_is_just_literal = true; 243 | continue; 244 | } 245 | if ( T_CONSTANT_ENCAPSED_STRING == $id && $current_argument_is_just_literal ) { 246 | // We can use eval safely, because we are sure $text is just a string literal. 247 | eval( '$current_argument = ' . $text . ';' ); 248 | continue; 249 | } 250 | $current_argument_is_just_literal = false; 251 | $current_argument = null; 252 | } 253 | return $function_calls; 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /bin/php/makepot.php: -------------------------------------------------------------------------------- 1 | array( 'string' ), 41 | '__' => array( 'string' ), 42 | '_e' => array( 'string' ), 43 | '_c' => array( 'string' ), 44 | '_n' => array( 'singular', 'plural' ), 45 | '_n_noop' => array( 'singular', 'plural' ), 46 | '_nc' => array( 'singular', 'plural' ), 47 | '__ngettext' => array( 'singular', 'plural' ), 48 | '__ngettext_noop' => array( 'singular', 'plural' ), 49 | '_x' => array( 'string', 'context' ), 50 | '_ex' => array( 'string', 'context' ), 51 | '_nx' => array( 'singular', 'plural', null, 'context' ), 52 | '_nx_noop' => array( 'singular', 'plural', 'context' ), 53 | '_n_js' => array( 'singular', 'plural' ), 54 | '_nx_js' => array( 'singular', 'plural', 'context' ), 55 | 'esc_attr__' => array( 'string' ), 56 | 'esc_html__' => array( 'string' ), 57 | 'esc_attr_e' => array( 'string' ), 58 | 'esc_html_e' => array( 'string' ), 59 | 'esc_attr_x' => array( 'string', 'context' ), 60 | 'esc_html_x' => array( 'string', 'context' ), 61 | 'comments_number_link' => array( 'string', 'singular', 'plural' ), 62 | ); 63 | 64 | public $ms_files = array( 65 | 'ms-.*', 66 | '.*/ms-.*', 67 | '.*/my-.*', 68 | 'wp-activate\.php', 69 | 'wp-signup\.php', 70 | 'wp-admin/network\.php', 71 | 'wp-admin/includes/ms\.php', 72 | 'wp-admin/network/.*\.php', 73 | 'wp-admin/includes/class-wp-ms.*', 74 | ); 75 | 76 | public $temp_files = array(); 77 | 78 | public $meta = array( 79 | 'default' => array( 80 | 'from-code' => 'utf-8', 81 | 'msgid-bugs-address' => 'https://make.wordpress.org/polyglots', 82 | 'language' => 'php', 83 | 'add-comments' => 'translators', 84 | 'comments' => "Copyright (C) {year} {package-name}\nThis file is distributed under the same license as the {package-name} package.", 85 | ), 86 | 'generic' => array(), 87 | 'wp-frontend' => array( 88 | 'description' => 'Translation of frontend strings in WordPress {version}', 89 | 'copyright-holder' => 'WordPress', 90 | 'package-name' => 'WordPress', 91 | 'package-version' => '{version}', 92 | ), 93 | 'wp-admin' => array( 94 | 'description' => 'Translation of site admin strings in WordPress {version}', 95 | 'copyright-holder' => 'WordPress', 96 | 'package-name' => 'WordPress', 97 | 'package-version' => '{version}', 98 | ), 99 | 'wp-network-admin' => array( 100 | 'description' => 'Translation of network admin strings in WordPress {version}', 101 | 'copyright-holder' => 'WordPress', 102 | 'package-name' => 'WordPress', 103 | 'package-version' => '{version}', 104 | ), 105 | 'wp-core' => array( 106 | 'description' => 'Translation of WordPress {version}', 107 | 'copyright-holder' => 'WordPress', 108 | 'package-name' => 'WordPress', 109 | 'package-version' => '{version}', 110 | ), 111 | 'wp-ms' => array( 112 | 'description' => 'Translation of multisite strings in WordPress {version}', 113 | 'copyright-holder' => 'WordPress', 114 | 'package-name' => 'WordPress', 115 | 'package-version' => '{version}', 116 | ), 117 | 'wp-tz' => array( 118 | 'description' => 'Translation of timezone strings in WordPress {version}', 119 | 'copyright-holder' => 'WordPress', 120 | 'package-name' => 'WordPress', 121 | 'package-version' => '{version}', 122 | ), 123 | 'bb' => array( 124 | 'description' => 'Translation of bbPress', 125 | 'copyright-holder' => 'bbPress', 126 | 'package-name' => 'bbPress', 127 | ), 128 | 'wp-plugin' => array( 129 | 'description' => 'Translation of the WordPress plugin {name} {version} by {author}', 130 | 'msgid-bugs-address' => 'https://wordpress.org/support/plugin/{slug}', 131 | 'copyright-holder' => '{author}', 132 | 'package-name' => '{name}', 133 | 'package-version' => '{version}', 134 | ), 135 | 'wp-theme' => array( 136 | 'description' => 'Translation of the WordPress theme {name} {version} by {author}', 137 | 'msgid-bugs-address' => 'https://wordpress.org/support/theme/{slug}', 138 | 'copyright-holder' => '{author}', 139 | 'package-name' => '{name}', 140 | 'package-version' => '{version}', 141 | 'comments' => 'Copyright (C) {year} {author}\nThis file is distributed under the same license as the {package-name} package.', 142 | ), 143 | 'bp' => array( 144 | 'description' => 'Translation of BuddyPress', 145 | 'copyright-holder' => 'BuddyPress', 146 | 'package-name' => 'BuddyPress', 147 | ), 148 | 'glotpress' => array( 149 | 'description' => 'Translation of GlotPress', 150 | 'copyright-holder' => 'GlotPress', 151 | 'package-name' => 'GlotPress', 152 | ), 153 | 'wporg-bb-forums' => array( 154 | 'description' => 'WordPress.org International Forums', 155 | 'copyright-holder' => 'WordPress', 156 | 'package-name' => 'WordPress.org International Forums', 157 | ), 158 | 'rosetta' => array( 159 | 'description' => 'Rosetta (.wordpress.org locale sites)', 160 | 'copyright-holder' => 'WordPress', 161 | 'package-name' => 'Rosetta', 162 | ), 163 | ); 164 | 165 | public function __construct( $deprecated = true ) { 166 | $this->extractor = new StringExtractor( $this->rules ); 167 | } 168 | 169 | public function __destruct() { 170 | foreach ( $this->temp_files as $temp_file ) { 171 | unlink( $temp_file ); 172 | } 173 | } 174 | 175 | public function tempnam( $file ) { 176 | $tempnam = tempnam( sys_get_temp_dir(), $file ); 177 | $this->temp_files[] = $tempnam; 178 | return $tempnam; 179 | } 180 | 181 | public function realpath_missing( $path ) { 182 | return realpath( dirname( $path ) ) . DIRECTORY_SEPARATOR . basename( $path ); 183 | } 184 | 185 | public function xgettext( $project, $dir, $output_file, $placeholders = array(), $excludes = array(), $includes = array() ) { 186 | $meta = array_merge( $this->meta['default'], $this->meta[ $project ] ); 187 | $placeholders = array_merge( $meta, $placeholders ); 188 | $meta['output'] = $this->realpath_missing( $output_file ); 189 | $placeholders['year'] = gmdate( 'Y' ); 190 | $placeholder_keys = array_map( function( $x ) { return "{" . $x . "}"; }, array_keys( $placeholders ) ); 191 | $placeholder_values = array_values( $placeholders ); 192 | foreach( $meta as $key => $value ) { 193 | $meta[ $key ] = str_replace( $placeholder_keys, $placeholder_values, $value ); 194 | } 195 | 196 | $originals = $this->extractor->extract_from_directory( $dir, $excludes, $includes ); 197 | $pot = new PO; 198 | $pot->entries = $originals->entries; 199 | 200 | $pot->set_header( 'Project-Id-Version', $meta['package-name'].' '.$meta['package-version'] ); 201 | $pot->set_header( 'Report-Msgid-Bugs-To', $meta['msgid-bugs-address'] ); 202 | $pot->set_header( 'POT-Creation-Date', gmdate( 'Y-m-d H:i:s+00:00' ) ); 203 | $pot->set_header( 'MIME-Version', '1.0' ); 204 | $pot->set_header( 'Content-Type', 'text/plain; charset=UTF-8' ); 205 | $pot->set_header( 'Content-Transfer-Encoding', '8bit' ); 206 | $pot->set_header( 'PO-Revision-Date', gmdate( 'Y' ) . '-MO-DA HO:MI+ZONE' ); 207 | $pot->set_header( 'Last-Translator', 'FULL NAME ' ); 208 | $pot->set_header( 'Language-Team', 'LANGUAGE ' ); 209 | $pot->set_comment_before_headers( $meta['comments'] ); 210 | $pot->export_to_file( $output_file ); 211 | return true; 212 | } 213 | 214 | public function wp_generic( $dir, $args ) { 215 | $defaults = array( 216 | 'project' => 'wp-core', 217 | 'output' => null, 218 | 'default_output' => 'wordpress.pot', 219 | 'includes' => array(), 220 | 'excludes' => array_merge( 221 | array( 'wp-admin/includes/continents-cities\.php', 'wp-content/themes/twenty.*' ), 222 | $this->ms_files 223 | ), 224 | 'extract_not_gettexted' => false, 225 | 'not_gettexted_files_filter' => false, 226 | ); 227 | $args = array_merge( $defaults, $args ); 228 | extract( $args ); 229 | $placeholders = array(); 230 | if ( $wp_version = $this->wp_version( $dir ) ) { 231 | $placeholders['version'] = $wp_version; 232 | } else { 233 | return false; 234 | } 235 | $output = is_null( $output )? $default_output : $output; 236 | $res = $this->xgettext( $project, $dir, $output, $placeholders, $excludes, $includes ); 237 | if ( ! $res ) { 238 | return false; 239 | } 240 | 241 | if ( $extract_not_gettexted ) { 242 | $old_dir = getcwd(); 243 | $output = realpath( $output ); 244 | chdir( $dir ); 245 | $php_files = NotGettexted::list_php_files( '.' ); 246 | $php_files = array_filter( $php_files, $not_gettexted_files_filter ); 247 | $not_gettexted = new NotGettexted; 248 | $res = $not_gettexted->command_extract( $output, $php_files ); 249 | chdir( $old_dir ); 250 | // Adding non-gettexted strings can repeat some phrases. 251 | $output_shell = escapeshellarg( $output ); 252 | system( "msguniq --use-first $output_shell -o $output_shell" ); 253 | } 254 | return $res; 255 | } 256 | 257 | public function wp_core( $dir, $output ) { 258 | if ( file_exists( "$dir/wp-admin/user/about.php" ) ) { 259 | return false; 260 | } 261 | 262 | return $this->wp_generic( 263 | $dir, 264 | array( 265 | 'project' => 'wp-core', 266 | 'output' => $output, 267 | 'extract_not_gettexted' => true, 268 | 'not_gettexted_files_filter' => array( $this, 'is_not_ms_file' ), 269 | ) 270 | ); 271 | } 272 | 273 | public function wp_frontend( $dir, $output ) { 274 | if ( ! file_exists( "$dir/wp-admin/user/about.php" ) ) { 275 | return false; 276 | } 277 | 278 | return $this->wp_generic( 279 | $dir, 280 | array( 281 | 'project' => 'wp-frontend', 282 | 'output' => $output, 283 | 'includes' => array(), 284 | 'excludes' => array( 'wp-admin/.*', 'wp-content/themes/.*' ), 285 | 'default_output' => 'wordpress.pot', 286 | ) 287 | ); 288 | } 289 | 290 | public function wp_admin( $dir, $output ) { 291 | if ( ! file_exists( "$dir/wp-admin/user/about.php" ) ) { 292 | return false; 293 | } 294 | 295 | $frontend_pot = $this->tempnam( 'frontend.pot' ); 296 | if ( false === $frontend_pot ) { 297 | return false; 298 | } 299 | 300 | $frontend_result = $this->wp_frontend( $dir, $frontend_pot ); 301 | if ( ! $frontend_result ) { 302 | return false; 303 | } 304 | 305 | $result = $this->wp_generic( 306 | $dir, 307 | array( 308 | 'project' => 'wp-admin', 309 | 'output' => $output, 310 | 'includes' => array( 'wp-admin/.*' ), 311 | 'excludes' => array( 'wp-admin/includes/continents-cities\.php', 'wp-admin/network/.*', 'wp-admin/network.php' ), 312 | 'default_output' => 'wordpress-admin.pot', 313 | ) 314 | ); 315 | 316 | if ( ! $result ) { 317 | return false; 318 | } 319 | 320 | $potextmeta = new PotExtMeta; 321 | $result = $potextmeta->append( "$dir/wp-content/plugins/akismet/akismet.php", $output ); 322 | if ( ! $result ) { 323 | return false; 324 | } 325 | 326 | $result = $potextmeta->append( "$dir/wp-content/plugins/hello.php", $output ); 327 | if ( ! $result ) { 328 | return false; 329 | } 330 | 331 | // Adding non-gettexted strings can repeat some phrases. 332 | $output_shell = escapeshellarg( $output ); 333 | system( "msguniq $output_shell -o $output_shell" ); 334 | 335 | $common_pot = $this->tempnam( 'common.pot' ); 336 | if ( ! $common_pot ) { 337 | return false; 338 | } 339 | $admin_pot = realpath( is_null( $output ) ? 'wordpress-admin.pot' : $output ); 340 | system( "msgcat --more-than=1 --use-first $frontend_pot $admin_pot > $common_pot" ); 341 | system( "msgcat -u --use-first $admin_pot $common_pot -o $admin_pot" ); 342 | return true; 343 | } 344 | 345 | public function wp_network_admin( $dir, $output ) { 346 | if ( ! file_exists( "$dir/wp-admin/user/about.php" ) ) { 347 | return false; 348 | } 349 | 350 | $frontend_pot = $this->tempnam( 'frontend.pot' ); 351 | if ( false === $frontend_pot ) { 352 | return false; 353 | } 354 | 355 | $frontend_result = $this->wp_frontend( $dir, $frontend_pot ); 356 | if ( ! $frontend_result ) { 357 | return false; 358 | } 359 | 360 | $admin_pot = $this->tempnam( 'admin.pot' ); 361 | if ( false === $admin_pot ) { 362 | return false; 363 | } 364 | 365 | $admin_result = $this->wp_admin( $dir, $admin_pot ); 366 | if ( ! $admin_result ) { 367 | return false; 368 | } 369 | 370 | $result = $this->wp_generic( 371 | $dir, 372 | array( 373 | 'project' => 'wp-network-admin', 374 | 'output' => $output, 375 | 'includes' => array( 'wp-admin/network/.*', 'wp-admin/network.php' ), 376 | 'excludes' => array(), 377 | 'default_output' => 'wordpress-admin-network.pot', 378 | ) 379 | ); 380 | 381 | if ( ! $result ) { 382 | return false; 383 | } 384 | 385 | $common_pot = $this->tempnam( 'common.pot' ); 386 | if ( ! $common_pot ) { 387 | return false; 388 | } 389 | 390 | $net_admin_pot = realpath( is_null( $output ) ? 'wordpress-network-admin.pot' : $output ); 391 | system( "msgcat --more-than=1 --use-first $frontend_pot $admin_pot $net_admin_pot > $common_pot" ); 392 | system( "msgcat -u --use-first $net_admin_pot $common_pot -o $net_admin_pot" ); 393 | return true; 394 | } 395 | 396 | public function wp_ms( $dir, $output ) { 397 | if ( file_exists( "$dir/wp-admin/user/about.php" ) ) { 398 | return false; 399 | } 400 | if ( !is_file("$dir/wp-admin/ms-users.php") ) { 401 | return false; 402 | } 403 | $core_pot = $this->tempnam( 'wordpress.pot' ); 404 | if ( false === $core_pot ) { 405 | return false; 406 | } 407 | $core_result = $this->wp_core( $dir, $core_pot ); 408 | if ( ! $core_result ) { 409 | return false; 410 | } 411 | $ms_result = $this->wp_generic( 412 | $dir, 413 | array( 414 | 'project' => 'wp-ms', 415 | 'output' => $output, 416 | 'includes' => $this->ms_files, 417 | 'excludes' => array(), 418 | 'default_output' => 'wordpress-ms.pot', 419 | 'extract_not_gettexted' => true, 420 | 'not_gettexted_files_filter' => array( $this, 'is_ms_file' ), 421 | ) 422 | ); 423 | if ( !$ms_result ) { 424 | return false; 425 | } 426 | $common_pot = $this->tempnam( 'common.pot' ); 427 | if ( ! $common_pot ) { 428 | return false; 429 | } 430 | $ms_pot = realpath( is_null( $output )? 'wordpress-ms.pot' : $output ); 431 | system( "msgcat --more-than=1 --use-first $core_pot $ms_pot > $common_pot" ); 432 | system( "msgcat -u --use-first $ms_pot $common_pot -o $ms_pot" ); 433 | return true; 434 | } 435 | 436 | public function wp_tz( $dir, $output ) { 437 | $continents_path = 'wp-admin/includes/continents-cities.php'; 438 | if ( ! file_exists( "$dir/$continents_path" ) ) { 439 | return false; 440 | } 441 | return $this->wp_generic( 442 | $dir, 443 | array( 444 | 'project' => 'wp-tz', 445 | 'output' => $output, 446 | 'includes' => array( $continents_path ), 447 | 'excludes' => array(), 448 | 'default_output' => 'wordpress-continents-cities.pot', 449 | ) 450 | ); 451 | } 452 | 453 | public function wp_version( $dir ) { 454 | $version_php = $dir . '/wp-includes/version.php'; 455 | if ( ! is_readable( $version_php ) ) { 456 | return false; 457 | } 458 | return preg_match( '/\$wp_version\s*=\s*\'(.*?)\';/', file_get_contents( $version_php ), $matches )? $matches[1] : false; 459 | } 460 | 461 | public function mu( $dir, $output ) { 462 | $placeholders = array(); 463 | if ( preg_match( '/\$wpmu_version\s*=\s*\'(.*?)\';/', file_get_contents( $dir . '/wp-includes/version.php' ), $matches ) ) { 464 | $placeholders['version'] = $matches[1]; 465 | } 466 | $output = is_null( $output )? 'wordpress.pot' : $output; 467 | return $this->xgettext( 'wp', $dir, $output, $placeholders ); 468 | } 469 | 470 | public function bb( $dir, $output ) { 471 | $placeholders = array(); 472 | $output = is_null( $output )? 'bbpress.pot' : $output; 473 | return $this->xgettext( 'bb', $dir, $output, $placeholders ); 474 | 475 | } 476 | 477 | public static function get_first_lines( $filename, $lines = 30 ) { 478 | $extf = fopen( $filename, 'r' ); 479 | if ( ! $extf ) { 480 | return false; 481 | } 482 | $first_lines = ''; 483 | foreach( range( 1, $lines ) as $x ) { 484 | $line = fgets( $extf ); 485 | if ( feof( $extf ) ) { 486 | break; 487 | } 488 | if ( false === $line ) { 489 | return false; 490 | } 491 | $first_lines .= $line; 492 | } 493 | return $first_lines; 494 | } 495 | 496 | public static function get_addon_header( $header, $source ) { 497 | if ( preg_match( '|' . $header . ':(.*)$|mi', $source, $matches ) ) { 498 | return trim( $matches[1] ); 499 | } else { 500 | return false; 501 | } 502 | } 503 | 504 | public function generic( $dir, $output ) { 505 | $output = is_null( $output )? 'generic.pot' : $output; 506 | return $this->xgettext( 'generic', $dir, $output, array() ); 507 | } 508 | 509 | public function guess_plugin_slug( $dir ) { 510 | if ( 'trunk' == basename( $dir ) ) { 511 | $slug = basename( dirname( $dir ) ); 512 | } elseif ( in_array( basename( dirname( $dir ) ), array( 'branches', 'tags' ) ) ) { 513 | $slug = basename( dirname( dirname( $dir ) ) ); 514 | } else { 515 | $slug = basename( $dir ); 516 | } 517 | return $slug; 518 | } 519 | 520 | public function wp_plugin( $dir, $output, $slug = null ) { 521 | $placeholders = array(); 522 | // Guess plugin slug. 523 | if ( is_null( $slug ) ) { 524 | $slug = $this->guess_plugin_slug( $dir ); 525 | } 526 | 527 | $plugins_dir = @opendir( $dir ); 528 | $plugin_files = array(); 529 | if ( $plugins_dir ) { 530 | while ( ( $file = readdir( $plugins_dir ) ) !== false ) { 531 | if ( '.' === substr( $file, 0, 1 ) ) { 532 | continue; 533 | } 534 | 535 | if ( '.php' === substr( $file, -4 ) ) { 536 | $plugin_files[] = $file; 537 | } 538 | } 539 | closedir( $plugins_dir ); 540 | } 541 | 542 | if ( empty( $plugin_files ) ) { 543 | return false; 544 | } 545 | 546 | $main_file = ''; 547 | foreach ( $plugin_files as $plugin_file ) { 548 | if ( ! is_readable( "$dir/$plugin_file" ) ) { 549 | continue; 550 | } 551 | 552 | $source = $this->get_first_lines( "$dir/$plugin_file", $this->max_header_lines ); 553 | 554 | // Stop when we find a file with a plugin name header in it. 555 | if ( $this->get_addon_header( 'Plugin Name', $source ) != false ) { 556 | $main_file = "$dir/$plugin_file"; 557 | break; 558 | } 559 | } 560 | 561 | if ( empty( $main_file ) ) { 562 | return false; 563 | } 564 | 565 | $placeholders['version'] = $this->get_addon_header( 'Version', $source ); 566 | $placeholders['author'] = $this->get_addon_header( 'Author', $source ); 567 | $placeholders['name'] = $this->get_addon_header( 'Plugin Name', $source ); 568 | $placeholders['slug'] = $slug; 569 | 570 | $output = is_null( $output )? "$slug.pot" : $output; 571 | $res = $this->xgettext( 'wp-plugin', $dir, $output, $placeholders ); 572 | if ( ! $res ) { 573 | return false; 574 | } 575 | $potextmeta = new PotExtMeta; 576 | $res = $potextmeta->append( $main_file, $output ); 577 | /* Adding non-gettexted strings can repeat some phrases */ 578 | $output_shell = escapeshellarg( $output ); 579 | system( "msguniq $output_shell -o $output_shell" ); 580 | return $res; 581 | } 582 | 583 | public function wp_theme( $dir, $output, $slug = null ) { 584 | $placeholders = array(); 585 | // Guess theme slug. 586 | if ( is_null( $slug ) ) { 587 | $slug = $this->guess_plugin_slug( $dir ); 588 | } 589 | $main_file = $dir . '/style.css'; 590 | $source = $this->get_first_lines( $main_file, $this->max_header_lines ); 591 | 592 | $placeholders['version'] = $this->get_addon_header( 'Version', $source ); 593 | $placeholders['author'] = $this->get_addon_header( 'Author', $source ); 594 | $placeholders['name'] = $this->get_addon_header( 'Theme Name', $source ); 595 | $placeholders['slug'] = $slug; 596 | 597 | $license = $this->get_addon_header( 'License', $source ); 598 | if ( $license ) { 599 | $this->meta['wp-theme']['comments'] = "Copyright (C) {year} {author}\nThis file is distributed under the {$license}."; 600 | } else { 601 | $this->meta['wp-theme']['comments'] = "Copyright (C) {year} {author}\nThis file is distributed under the same license as the {package-name} package."; 602 | } 603 | 604 | $output = is_null( $output )? "$slug.pot" : $output; 605 | $res = $this->xgettext( 'wp-theme', $dir, $output, $placeholders ); 606 | if ( ! $res ) { 607 | return false; 608 | } 609 | $potextmeta = new PotExtMeta; 610 | $res = $potextmeta->append( $main_file, $output, array( 'Theme Name', 'Theme URI', 'Description', 'Author', 'Author URI' ) ); 611 | if ( ! $res ) { 612 | return false; 613 | } 614 | // If we're dealing with a pre-3.4 default theme, don't extract page templates before 3.4. 615 | $extract_templates = ! in_array( $slug, array( 'twentyten', 'twentyeleven', 'default', 'classic' ) ); 616 | if ( ! $extract_templates ) { 617 | $wp_dir = dirname( dirname( dirname( $dir ) ) ); 618 | $extract_templates = file_exists( "$wp_dir/wp-admin/user/about.php" ) || ! file_exists( "$wp_dir/wp-load.php" ); 619 | } 620 | if ( $extract_templates ) { 621 | $res = $potextmeta->append( $dir, $output, array( 'Template Name' ) ); 622 | if ( ! $res ) { 623 | return false; 624 | } 625 | $files = scandir( $dir ); 626 | foreach ( $files as $file ) { 627 | if ( '.' == $file[0] || 'CVS' == $file ) { 628 | continue; 629 | } 630 | if ( is_dir( $dir . '/' . $file ) ) { 631 | $res = $potextmeta->append( $dir . '/' . $file, $output, array( 'Template Name' ) ); 632 | if ( ! $res ) { 633 | return false; 634 | } 635 | } 636 | } 637 | } 638 | /* Adding non-gettexted strings can repeat some phrases */ 639 | $output_shell = escapeshellarg( $output ); 640 | system( "msguniq $output_shell -o $output_shell" ); 641 | return $res; 642 | } 643 | 644 | public function bp( $dir, $output ) { 645 | $output = is_null( $output )? "buddypress.pot" : $output; 646 | return $this->xgettext( 'bp', $dir, $output, array(), array( 'bp-forums/bbpress/.*' ) ); 647 | } 648 | 649 | public function glotpress( $dir, $output ) { 650 | $output = is_null( $output ) ? "glotpress.pot" : $output; 651 | return $this->xgettext( 'glotpress', $dir, $output ); 652 | } 653 | 654 | public function wporg_bb_forums( $dir, $output ) { 655 | $output = is_null( $output ) ? 'wporg.pot' : $output; 656 | return $this->xgettext( 'wporg-bb-forums', $dir, $output, array(), array( 657 | 'bb-plugins/elfakismet/.*', 658 | 'bb-plugins/support-forum/.*', 659 | ) ); 660 | } 661 | 662 | public function rosetta( $dir, $output ) { 663 | $output = is_null( $output )? 'rosetta.pot' : $output; 664 | return $this->xgettext( 'rosetta', $dir, $output, array(), array(), array( 665 | 'mu-plugins/rosetta.*\.php', 666 | 'mu-plugins/rosetta/[^/]+\.php', 667 | 'mu-plugins/rosetta/tmpl/.*\.php', 668 | 'themes/rosetta/.*\.php', 669 | ) ); 670 | } 671 | 672 | public function is_ms_file( $file_name ) { 673 | $is_ms_file = false; 674 | $prefix = substr( $file_name, 0, 2 ) === './' ? '\./' : ''; 675 | foreach( $this->ms_files as $ms_file ) { 676 | if ( preg_match( '|^' . $prefix . $ms_file . '$|', $file_name ) ) { 677 | $is_ms_file = true; 678 | break; 679 | } 680 | } 681 | return $is_ms_file; 682 | } 683 | 684 | public function is_not_ms_file( $file_name ) { 685 | return ! $this->is_ms_file( $file_name ); 686 | } 687 | } 688 | 689 | // Run the CLI only if the file wasn't included. 690 | $included_files = get_included_files(); 691 | if ( __FILE__ == $included_files[0] ) { 692 | $makepot = new MakePOT; 693 | if ( ( 3 == count( $argv ) || 4 == count( $argv ) ) && in_array( $method = str_replace( '-', '_', $argv[1] ), get_class_methods( $makepot ) ) ) { 694 | $res = call_user_func( 695 | array( $makepot, $method ), // Method 696 | realpath( $argv[2] ), // Directory 697 | isset( $argv[3] ) ? $argv[3] : null // Output 698 | ); 699 | if ( false === $res ) { 700 | fwrite( STDERR, "Couldn't generate POT file!\n" ); 701 | } 702 | } else { 703 | $usage = "Usage: php makepot.php PROJECT DIRECTORY [OUTPUT]\n\n"; 704 | $usage .= "Generate POT file from the files in DIRECTORY [OUTPUT]\n"; 705 | $usage .= "Available projects: " . implode( ', ', $makepot->projects ) . "\n"; 706 | fwrite( STDERR, $usage ); 707 | exit( 1 ); 708 | } 709 | } 710 | -------------------------------------------------------------------------------- /bin/php/node-add-textdomain.php: -------------------------------------------------------------------------------- 1 | 2, 26 | '_e' => 2, 27 | '_x' => 3, 28 | '_ex' => 3, 29 | '_n' => 4, 30 | '_nx' => 5, 31 | '_n_noop' => 3, 32 | '_nx_noop' => 4, 33 | 'esc_attr__' => 2, 34 | 'esc_html__' => 2, 35 | 'esc_attr_e' => 2, 36 | 'esc_html_e' => 2, 37 | 'esc_attr_x' => 3, 38 | 'esc_html_x' => 3, 39 | ); 40 | 41 | /** 42 | * Add textdomain to a set of PHP tokens. 43 | * 44 | * @param string $domain Text domain. 45 | * @param array $tokens PHP tokens. An array of token identifiers. Each individual token identifier is either a 46 | * single character (i.e.: ;, ., >, !, etc.), or a three element array containing the token 47 | * index in element 0, the string content of the original token in element 1 and the line 48 | * number in element 2. 49 | * 50 | * @return string Modified source. 51 | */ 52 | public function process_tokens( $domain, $tokens ) { 53 | $this->modified_contents = ''; 54 | $domain = addslashes( $domain ); 55 | 56 | $in_func = false; 57 | $current_func = null; 58 | $args_started = false; 59 | $arg_position = 0; 60 | $parens_balance = 0; 61 | $found_domain = false; 62 | 63 | foreach( $tokens as $index => $token ) { 64 | $string_success = false; 65 | if ( is_array( $token ) ) { 66 | list( $id, $text ) = $token; 67 | if ( T_STRING == $id && in_array( $text, $this->funcs ) ) { 68 | $in_func = true; 69 | $current_func = $text; 70 | $parens_balance = 0; 71 | $args_started = false; 72 | $found_domain = false; 73 | } elseif ( T_CONSTANT_ENCAPSED_STRING == $id && ( "'$domain'" == $text || "\"$domain\"" == $text ) ) { 74 | if ( $in_func && $args_started ) { 75 | $found_domain = true; 76 | } 77 | } elseif ( T_CONSTANT_ENCAPSED_STRING == $id && ! empty( $current_func ) && isset( $this->domain_positions[ $current_func ] ) ) { 78 | $is_domain_match = in_array( trim( $text, '\'"' ), $this->domains_to_update ); 79 | $is_domain_arg = $this->update_all_domains && $arg_position === $this->domain_positions[ $current_func ] - 1; 80 | 81 | if ( $in_func && $args_started && ( $is_domain_match || $is_domain_arg ) ) { 82 | $text = preg_replace( '/([\'"]).+[\'"]$/', '$1' . $domain . '$1', $text ); 83 | $found_domain = true; 84 | } 85 | } 86 | $token = $text; 87 | } elseif ( '(' == $token ) { 88 | $args_started = true; 89 | ++$parens_balance; 90 | } elseif ( $in_func && ',' == $token ) { 91 | ++$arg_position; 92 | } elseif ( ')' == $token ) { 93 | --$parens_balance; 94 | if ( $in_func && 0 == $parens_balance ) { 95 | if ( ! $found_domain ) { 96 | $token = ", '$domain'"; 97 | if ( T_WHITESPACE == $tokens[ $index - 1 ][0] ) { 98 | $token .= ' '; // Maintain code standards if previously present 99 | // Remove previous whitespace token to account for it. 100 | $this->modified_contents = trim( $this->modified_contents ); 101 | } 102 | $token .= ')'; 103 | } 104 | $in_func = false; 105 | $current_func = null; 106 | $args_started = false; 107 | $arg_position = 0; 108 | $found_domain = false; 109 | } 110 | } 111 | $this->modified_contents .= $token; 112 | } 113 | 114 | return $this->modified_contents; 115 | } 116 | 117 | /** 118 | * Normalize and set a list of text domains to update. 119 | * 120 | * @param string|array $domains Comma-separated string or array of domains to update. 121 | */ 122 | public function set_domains_to_update( $domains = array() ) { 123 | if ( is_string( $domains ) ) { 124 | $domains = explode( ',', $domains ); 125 | } 126 | 127 | if ( in_array( 'all', $domains ) ) { 128 | $this->update_all_domains = true; 129 | $domains = array(); 130 | } 131 | 132 | // Remove empty items and non-strings. 133 | $this->domains_to_update = array_filter( array_filter( (array) $domains ), 'is_string' ); 134 | } 135 | } 136 | 137 | /** 138 | * CLI interface. 139 | * 140 | * Run the CLI only if the file wasn't included. 141 | */ 142 | $included_files = get_included_files(); 143 | if ( __FILE__ == $included_files[0] ) { 144 | $args = json_decode( file_get_contents( $argv[1] ), true ); 145 | 146 | $adddomain = new NodeAddTextdomain; 147 | $adddomain->set_domains_to_update( $args['update-domains'] ); 148 | 149 | foreach ( $args['files'] as $file ) { 150 | $adddomain->process_file( $args['textdomain'], $file, ! $args['dry-run'] ); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /bin/php/node-makepot.php: -------------------------------------------------------------------------------- 1 | get_first_lines( $main_file, $this->max_header_lines ); 32 | $excludes = $this->normalize_patterns( $excludes ); 33 | $includes = $this->normalize_patterns( $includes ); 34 | 35 | $placeholders = array(); 36 | $placeholders['version'] = $this->get_addon_header( 'Version', $source ); 37 | $placeholders['author'] = $this->get_addon_header( 'Author', $source ); 38 | $placeholders['name'] = $this->get_addon_header( 'Plugin Name', $source ); 39 | $placeholders['slug'] = $slug; 40 | 41 | $license = $this->get_addon_header( 'License', $source ); 42 | if ( $license ) { 43 | $this->meta['wp-plugin']['comments'] = "Copyright (C) {year} {author}\nThis file is distributed under the {$license}."; 44 | } else { 45 | $this->meta['wp-plugin']['comments'] = "Copyright (C) {year} {author}\nThis file is distributed under the same license as the {package-name} package."; 46 | } 47 | 48 | $result = $this->xgettext( 'wp-plugin', $dir, $output, $placeholders, $excludes, $includes ); 49 | if ( ! $result ) { 50 | return false; 51 | } 52 | 53 | $potextmeta = new PotExtMeta; 54 | $result = $potextmeta->append( $main_file, $output ); 55 | return $result; 56 | } 57 | 58 | /** 59 | * Generate a POT file for a theme. 60 | * 61 | * @param string $dir Directory to search for gettext calls. 62 | * @param string $output POT file name. 63 | * @param string $slug Optional. Theme slug. 64 | * @param string $main_file Optional. Theme main style.css file path. 65 | * @param string $excludes Optional. Comma-separated list of exclusion patterns. 66 | * @param string $includes Optional. Comma-separated list of inclusion patterns. 67 | * @return bool 68 | */ 69 | public function wp_theme( $dir, $output, $slug = null, $main_file = null, $excludes = '', $includes = '' ) { 70 | $main_file = $dir . '/' . $main_file; 71 | $source = $this->get_first_lines( $main_file, $this->max_header_lines ); 72 | $excludes = $this->normalize_patterns( $excludes ); 73 | $includes = $this->normalize_patterns( $includes ); 74 | 75 | $placeholders = array(); 76 | $placeholders['version'] = $this->get_addon_header( 'Version', $source ); 77 | $placeholders['author'] = $this->get_addon_header( 'Author', $source ); 78 | $placeholders['name'] = $this->get_addon_header( 'Theme Name', $source ); 79 | $placeholders['slug'] = $slug; 80 | 81 | $license = $this->get_addon_header( 'License', $source ); 82 | if ( $license ) { 83 | $this->meta['wp-theme']['comments'] = ""; 84 | } else { 85 | $this->meta['wp-theme']['comments'] = ""; 86 | } 87 | 88 | $result = $this->xgettext( 'wp-theme', $dir, $output, $placeholders, $excludes, $includes ); 89 | if ( ! $result ) { 90 | return false; 91 | } 92 | 93 | $potextmeta = new PotExtMeta; 94 | $result = $potextmeta->append( $main_file, $output, array( 'Theme Name', 'Theme URI', 'Description', 'Author', 'Author URI' ) ); 95 | if ( ! $result ) { 96 | return false; 97 | } 98 | 99 | // If we're dealing with a pre-3.4 default theme, don't extract page templates before 3.4. 100 | $extract_templates = ! in_array( $slug, array( 'twentyten', 'twentyeleven', 'default', 'classic' ) ); 101 | if ( ! $extract_templates ) { 102 | $wp_dir = dirname( dirname( dirname( $dir ) ) ); 103 | $extract_templates = file_exists( "$wp_dir/wp-admin/user/about.php" ) || ! file_exists( "$wp_dir/wp-load.php" ); 104 | } 105 | 106 | if ( $extract_templates ) { 107 | $result = $potextmeta->append( $dir, $output, array( 'Template Name' ) ); 108 | if ( ! $result ) { 109 | return false; 110 | } 111 | 112 | $files = scandir( $dir ); 113 | foreach ( $files as $file ) { 114 | if ( in_array( $file, array( '.', '..', '.git', 'CVS', 'node_modules' ) ) ) { 115 | continue; 116 | } 117 | 118 | if ( is_dir( $dir . '/' . $file ) ) { 119 | $result = $potextmeta->append( $dir . '/' . $file, $output, array( 'Template Name' ) ); 120 | if ( ! $result ) { 121 | return false; 122 | } 123 | } 124 | } 125 | } 126 | 127 | return $result; 128 | } 129 | 130 | /** 131 | * Convert a string or array of exclusion/inclusion patterns into an array. 132 | * 133 | * @param string|array $patterns Comma-separated string or array of exclusion/inclusion patterns. 134 | * @return array 135 | */ 136 | protected function normalize_patterns( $patterns ) { 137 | if ( is_string( $patterns ) ) { 138 | $patterns = explode( ',', $patterns ); 139 | } 140 | 141 | // Remove empty items and non-strings. 142 | return array_filter( array_filter( (array) $patterns ), 'is_string' ); 143 | } 144 | } 145 | 146 | /** 147 | * CLI interface. 148 | */ 149 | $makepot = new NodeMakePOT; 150 | $method = str_replace( '-', '_', $argv[1] ); 151 | 152 | if ( in_array( count( $argv ), range( 3, 8 ) ) && in_array( $method, get_class_methods( $makepot ) ) ) { 153 | $res = call_user_func( 154 | array( $makepot, $method ), // Method 155 | realpath( $argv[2] ), // Directory 156 | $argv[3], // Output 157 | $argv[4], // Slug 158 | $argv[5], // Main File 159 | $argv[6], // Excludes 160 | $argv[7] // Includes 161 | ); 162 | 163 | if ( false === $res ) { 164 | fwrite( STDERR, "Could not generate POT file!\n" ); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /bin/php/not-gettexted.php: -------------------------------------------------------------------------------- 1 | 'command_extract', 'replace' => 'command_replace' ); 32 | 33 | public function logmsg() { 34 | $args = func_get_args(); 35 | if ( $this->enable_logging ) { 36 | error_log( implode( ' ', $args ) ); 37 | } 38 | } 39 | 40 | public function stderr( $msg, $nl=true ) { 41 | fwrite( STDERR, $msg . ( $nl ? "\n" : "" ) ); 42 | } 43 | 44 | public function cli_die( $msg ) { 45 | $this->stderr( $msg ); 46 | exit( 1 ); 47 | } 48 | 49 | public function unchanged_token( $token, $s = '' ) { 50 | return is_array( $token )? $token[1] : $token; 51 | } 52 | 53 | public function ignore_token( $token, $s = '' ) { 54 | return ''; 55 | } 56 | 57 | public function list_php_files( $dir ) { 58 | $files = array(); 59 | $items = scandir( $dir ); 60 | foreach ( (array) $items as $item ) { 61 | $full_item = $dir . '/' . $item; 62 | if ( '.' == $item || '..' == $item ) { 63 | continue; 64 | } 65 | if ( '.php' == substr( $item, -4 ) ) { 66 | $files[] = $full_item; 67 | } 68 | if ( is_dir( $full_item ) ) { 69 | $files += array_merge( $files, NotGettexted::list_php_files( $full_item, $files ) ); 70 | } 71 | } 72 | return $files; 73 | } 74 | 75 | public function walk_tokens( &$tokens, $string_action, $other_action, $register_action = null ) { 76 | 77 | $current_comment_id = ''; 78 | $current_string = ''; 79 | $current_string_line = 0; 80 | 81 | $result = ''; 82 | $line = 1; 83 | 84 | foreach( $tokens as $token ) { 85 | if ( is_array( $token ) ) { 86 | list( $id, $text ) = $token; 87 | $line += substr_count( $text, "\n" ); 88 | if ( ( T_ML_COMMENT == $id || T_COMMENT == $id ) && preg_match( '|/\*\s*(/?WP_I18N_[a-z_]+)\s*\*/|i', $text, $matches ) ) { 89 | if ( $this->STAGE_OUTSIDE == $stage ) { 90 | $stage = $this->STAGE_START_COMMENT; 91 | $current_comment_id = $matches[1]; 92 | $this->logmsg( 'start comment', $current_comment_id ); 93 | $result .= call_user_func( $other_action, $token ); 94 | continue; 95 | } 96 | if ( $this->STAGE_START_COMMENT <= $stage && $stage <= $this->STAGE_WHITESPACE_AFTER && '/' . $current_comment_id == $matches[1] ) { 97 | $stage = $this->STAGE_END_COMMENT; 98 | $this->logmsg( 'end comment', $current_comment_id ); 99 | $result .= call_user_func( $other_action, $token ); 100 | if ( ! is_null( $register_action ) ) { 101 | call_user_func( $register_action, $current_string, $current_comment_id, $current_string_line ); 102 | } 103 | continue; 104 | } 105 | } elseif ( T_CONSTANT_ENCAPSED_STRING == $id ) { 106 | if ( $this->STAGE_START_COMMENT <= $stage && $stage < $this->STAGE_WHITESPACE_AFTER ) { 107 | eval( '$current_string=' . $text . ';' ); 108 | $this->logmsg( 'string', $current_string ); 109 | $current_string_line = $line; 110 | $result .= call_user_func( $string_action, $token, $current_string ); 111 | continue; 112 | } 113 | } elseif ( T_WHITESPACE == $id ) { 114 | if ( $this->STAGE_START_COMMENT <= $stage && $stage < $this->STAGE_STRING ) { 115 | $stage = $this->STAGE_WHITESPACE_BEFORE; 116 | $this->logmsg( 'whitespace before' ); 117 | $result .= call_user_func( $other_action, $token ); 118 | continue; 119 | } 120 | if ( $this->STAGE_STRING < $stage && $stage < $this->STAGE_END_COMMENT ) { 121 | $stage = $this->STAGE_WHITESPACE_AFTER; 122 | $this->logmsg( 'whitespace after' ); 123 | $result .= call_user_func( $other_action, $token ); 124 | continue; 125 | } 126 | } 127 | } 128 | $result .= call_user_func( $other_action, $token ); 129 | $stage = $this->STAGE_OUTSIDE; 130 | $current_comment_id = ''; 131 | $current_string = ''; 132 | $current_string_line = 0; 133 | } 134 | return $result; 135 | } 136 | 137 | public function command_extract() { 138 | $args = func_get_args(); 139 | $pot_filename = $args[0]; 140 | if ( isset( $args[1] ) && is_array( $args[1] ) ) { 141 | $filenames = $args[1]; 142 | } else { 143 | $filenames = array_slice($args, 1); 144 | } 145 | 146 | $global_name = '__entries_' . mt_rand( 1, 1000 ); 147 | $GLOBALS[ $global_name ] = array(); 148 | 149 | foreach( $filenames as $filename ) { 150 | $tokens = token_get_all( file_get_contents( $filename ) ); 151 | $aggregator = function( $string, $comment_id, $line_number ) use ( $global_name, $filename ) { 152 | $GLOBALS[ $global_name ][] = array( $string, $comment_id, var_export( $filename, true ), $line_number ); 153 | }; 154 | $this->walk_tokens( $tokens, array( $this, 'ignore_token' ), array( $this, 'ignore_token' ), $aggregator ); 155 | } 156 | 157 | $potf = '-' == $pot_filename ? STDOUT : @fopen( $pot_filename, 'a' ); 158 | if ( false === $potf ) { 159 | $this->cli_die( "Couldn't open pot file: $pot_filename" ); 160 | } 161 | 162 | foreach( $GLOBALS[ $global_name ] as $item ) { 163 | @list( $string, $comment_id, $filename, $line_number ) = $item; 164 | $filename = isset( $filename ) ? preg_replace( '|^\./|', '', $filename ) : ''; 165 | $ref_line_number = isset( $line_number ) ? ":$line_number" : ''; 166 | $args = array( 167 | 'singular' => $string, 168 | 'extracted_comments' => "Not gettexted string $comment_id", 169 | 'references' => array( "$filename$ref_line_number" ), 170 | ); 171 | $entry = new Translation_Entry( $args ); 172 | fwrite( $potf, "\n" . PO::export_entry( $entry ) . "\n"); 173 | } 174 | if ( '-' != $pot_filename ) { 175 | fclose($potf); 176 | } 177 | return true; 178 | } 179 | 180 | public function command_replace() { 181 | $args = func_get_args(); 182 | $mo_filename = $args[0]; 183 | if ( isset( $args[1] ) && is_array( $args[1] ) ) { 184 | $filenames = $args[1]; 185 | } else { 186 | $filenames = array_slice( $args, 1 ); 187 | } 188 | 189 | $global_name = '__mo_' . mt_rand( 1, 1000 ); 190 | $mo = $GLOBALS[ $global_name ] = new MO(); 191 | $replacer = function( $token, $string ) use ( $mo ) { 192 | return var_export( $mo->translate( $string ), true ); 193 | }; 194 | 195 | $res = $GLOBALS[ $global_name ]->import_from_file( $mo_filename ); 196 | if ( false === $res ) { 197 | $this->cli_die("Couldn't read MO file '$mo_filename'!"); 198 | } 199 | foreach( $filenames as $filename ) { 200 | $source = file_get_contents( $filename ); 201 | if ( strlen( $source ) > 150000 ) { 202 | continue; 203 | } 204 | $tokens = token_get_all( $source ); 205 | $new_file = $this->walk_tokens( $tokens, $replacer, array( $this, 'unchanged_token' ) ); 206 | $f = fopen( $filename, 'w' ); 207 | fwrite( $f, $new_file ); 208 | fclose( $f ); 209 | } 210 | return true; 211 | } 212 | 213 | public function usage() { 214 | $this->stderr( 'php i18n-comments.php COMMAND OUTPUTFILE INPUTFILES' ); 215 | $this->stderr( 'Extracts and replaces strings, which cannot be gettexted' ); 216 | $this->stderr( 'Commands:' ); 217 | $this->stderr( ' extract POTFILE PHPFILES appends the strings to POTFILE' ); 218 | $this->stderr( ' replace MOFILE PHPFILES replaces strings in PHPFILES with translations from MOFILE' ); 219 | } 220 | 221 | public function cli() { 222 | global $argv, $commands; 223 | if ( count( $argv ) < 4 || ! in_array( $argv[1], array_keys( $this->commands ) ) ) { 224 | $this->usage(); 225 | exit( 1 ); 226 | } 227 | call_user_func_array( array( $this, $this->commands[ $argv[1] ] ), array_slice( $argv, 2 ) ); 228 | } 229 | } 230 | 231 | // Run the CLI only if the file wasn't included. 232 | $included_files = get_included_files(); 233 | if ( __FILE__ == $included_files[0] ) { 234 | error_reporting( E_ALL ); 235 | $not_gettexted = new NotGettexted; 236 | $not_gettexted->cli(); 237 | } 238 | -------------------------------------------------------------------------------- /bin/php/pomo/entry.php: -------------------------------------------------------------------------------- 1 | $value ) { 59 | $this->$varname = $value; 60 | } 61 | if ( isset( $args['plural'] ) && $args['plural'] ) { 62 | $this->is_plural = true; 63 | } 64 | if ( ! is_array( $this->translations ) ) { 65 | $this->translations = array(); 66 | } 67 | if ( ! is_array( $this->references ) ) { 68 | $this->references = array(); 69 | } 70 | if ( ! is_array( $this->flags ) ) { 71 | $this->flags = array(); 72 | } 73 | } 74 | 75 | /** 76 | * PHP4 constructor. 77 | * 78 | * @deprecated 5.4.0 Use __construct() instead. 79 | * 80 | * @see Translation_Entry::__construct() 81 | */ 82 | public function Translation_Entry( $args = array() ) { 83 | _deprecated_constructor( self::class, '5.4.0', static::class ); 84 | self::__construct( $args ); 85 | } 86 | 87 | /** 88 | * Generates a unique key for this entry. 89 | * 90 | * @return string|false The key or false if the entry is null. 91 | */ 92 | public function key() { 93 | if ( null === $this->singular ) { 94 | return false; 95 | } 96 | 97 | // Prepend context and EOT, like in MO files. 98 | $key = ! $this->context ? $this->singular : $this->context . "\4" . $this->singular; 99 | // Standardize on \n line endings. 100 | $key = str_replace( array( "\r\n", "\r" ), "\n", $key ); 101 | 102 | return $key; 103 | } 104 | 105 | /** 106 | * @param object $other 107 | */ 108 | public function merge_with( &$other ) { 109 | $this->flags = array_unique( array_merge( $this->flags, $other->flags ) ); 110 | $this->references = array_unique( array_merge( $this->references, $other->references ) ); 111 | if ( $this->extracted_comments != $other->extracted_comments ) { 112 | $this->extracted_comments .= $other->extracted_comments; 113 | } 114 | 115 | } 116 | } 117 | endif; 118 | -------------------------------------------------------------------------------- /bin/php/pomo/mo.php: -------------------------------------------------------------------------------- 1 | filename; 37 | } 38 | 39 | /** 40 | * Fills up with the entries from MO file $filename 41 | * 42 | * @param string $filename MO file to load 43 | * @return bool True if the import from file was successful, otherwise false. 44 | */ 45 | public function import_from_file( $filename ) { 46 | $reader = new POMO_FileReader( $filename ); 47 | 48 | if ( ! $reader->is_resource() ) { 49 | return false; 50 | } 51 | 52 | $this->filename = (string) $filename; 53 | 54 | return $this->import_from_reader( $reader ); 55 | } 56 | 57 | /** 58 | * @param string $filename 59 | * @return bool 60 | */ 61 | public function export_to_file( $filename ) { 62 | $fh = fopen( $filename, 'wb' ); 63 | if ( ! $fh ) { 64 | return false; 65 | } 66 | $res = $this->export_to_file_handle( $fh ); 67 | fclose( $fh ); 68 | return $res; 69 | } 70 | 71 | /** 72 | * @return string|false 73 | */ 74 | public function export() { 75 | $tmp_fh = fopen( 'php://temp', 'r+' ); 76 | if ( ! $tmp_fh ) { 77 | return false; 78 | } 79 | $this->export_to_file_handle( $tmp_fh ); 80 | rewind( $tmp_fh ); 81 | return stream_get_contents( $tmp_fh ); 82 | } 83 | 84 | /** 85 | * @param Translation_Entry $entry 86 | * @return bool 87 | */ 88 | public function is_entry_good_for_export( $entry ) { 89 | if ( empty( $entry->translations ) ) { 90 | return false; 91 | } 92 | 93 | if ( ! array_filter( $entry->translations ) ) { 94 | return false; 95 | } 96 | 97 | return true; 98 | } 99 | 100 | /** 101 | * @param resource $fh 102 | * @return true 103 | */ 104 | public function export_to_file_handle( $fh ) { 105 | $entries = array_filter( $this->entries, array( $this, 'is_entry_good_for_export' ) ); 106 | ksort( $entries ); 107 | $magic = 0x950412de; 108 | $revision = 0; 109 | $total = count( $entries ) + 1; // All the headers are one entry. 110 | $originals_lengths_addr = 28; 111 | $translations_lengths_addr = $originals_lengths_addr + 8 * $total; 112 | $size_of_hash = 0; 113 | $hash_addr = $translations_lengths_addr + 8 * $total; 114 | $current_addr = $hash_addr; 115 | fwrite( 116 | $fh, 117 | pack( 118 | 'V*', 119 | $magic, 120 | $revision, 121 | $total, 122 | $originals_lengths_addr, 123 | $translations_lengths_addr, 124 | $size_of_hash, 125 | $hash_addr 126 | ) 127 | ); 128 | fseek( $fh, $originals_lengths_addr ); 129 | 130 | // Headers' msgid is an empty string. 131 | fwrite( $fh, pack( 'VV', 0, $current_addr ) ); 132 | $current_addr++; 133 | $originals_table = "\0"; 134 | 135 | $reader = new POMO_Reader(); 136 | 137 | foreach ( $entries as $entry ) { 138 | $originals_table .= $this->export_original( $entry ) . "\0"; 139 | $length = $reader->strlen( $this->export_original( $entry ) ); 140 | fwrite( $fh, pack( 'VV', $length, $current_addr ) ); 141 | $current_addr += $length + 1; // Account for the NULL byte after. 142 | } 143 | 144 | $exported_headers = $this->export_headers(); 145 | fwrite( $fh, pack( 'VV', $reader->strlen( $exported_headers ), $current_addr ) ); 146 | $current_addr += strlen( $exported_headers ) + 1; 147 | $translations_table = $exported_headers . "\0"; 148 | 149 | foreach ( $entries as $entry ) { 150 | $translations_table .= $this->export_translations( $entry ) . "\0"; 151 | $length = $reader->strlen( $this->export_translations( $entry ) ); 152 | fwrite( $fh, pack( 'VV', $length, $current_addr ) ); 153 | $current_addr += $length + 1; 154 | } 155 | 156 | fwrite( $fh, $originals_table ); 157 | fwrite( $fh, $translations_table ); 158 | return true; 159 | } 160 | 161 | /** 162 | * @param Translation_Entry $entry 163 | * @return string 164 | */ 165 | public function export_original( $entry ) { 166 | // TODO: Warnings for control characters. 167 | $exported = $entry->singular; 168 | if ( $entry->is_plural ) { 169 | $exported .= "\0" . $entry->plural; 170 | } 171 | if ( $entry->context ) { 172 | $exported = $entry->context . "\4" . $exported; 173 | } 174 | return $exported; 175 | } 176 | 177 | /** 178 | * @param Translation_Entry $entry 179 | * @return string 180 | */ 181 | public function export_translations( $entry ) { 182 | // TODO: Warnings for control characters. 183 | return $entry->is_plural ? implode( "\0", $entry->translations ) : $entry->translations[0]; 184 | } 185 | 186 | /** 187 | * @return string 188 | */ 189 | public function export_headers() { 190 | $exported = ''; 191 | foreach ( $this->headers as $header => $value ) { 192 | $exported .= "$header: $value\n"; 193 | } 194 | return $exported; 195 | } 196 | 197 | /** 198 | * @param int $magic 199 | * @return string|false 200 | */ 201 | public function get_byteorder( $magic ) { 202 | // The magic is 0x950412de. 203 | 204 | // bug in PHP 5.0.2, see https://savannah.nongnu.org/bugs/?func=detailitem&item_id=10565 205 | $magic_little = (int) - 1794895138; 206 | $magic_little_64 = (int) 2500072158; 207 | // 0xde120495 208 | $magic_big = ( (int) - 569244523 ) & 0xFFFFFFFF; 209 | if ( $magic_little == $magic || $magic_little_64 == $magic ) { 210 | return 'little'; 211 | } elseif ( $magic_big == $magic ) { 212 | return 'big'; 213 | } else { 214 | return false; 215 | } 216 | } 217 | 218 | /** 219 | * @param POMO_FileReader $reader 220 | * @return bool True if the import was successful, otherwise false. 221 | */ 222 | public function import_from_reader( $reader ) { 223 | $endian_string = MO::get_byteorder( $reader->readint32() ); 224 | if ( false === $endian_string ) { 225 | return false; 226 | } 227 | $reader->setEndian( $endian_string ); 228 | 229 | $endian = ( 'big' === $endian_string ) ? 'N' : 'V'; 230 | 231 | $header = $reader->read( 24 ); 232 | if ( $reader->strlen( $header ) != 24 ) { 233 | return false; 234 | } 235 | 236 | // Parse header. 237 | $header = unpack( "{$endian}revision/{$endian}total/{$endian}originals_lengths_addr/{$endian}translations_lengths_addr/{$endian}hash_length/{$endian}hash_addr", $header ); 238 | if ( ! is_array( $header ) ) { 239 | return false; 240 | } 241 | 242 | // Support revision 0 of MO format specs, only. 243 | if ( 0 != $header['revision'] ) { 244 | return false; 245 | } 246 | 247 | // Seek to data blocks. 248 | $reader->seekto( $header['originals_lengths_addr'] ); 249 | 250 | // Read originals' indices. 251 | $originals_lengths_length = $header['translations_lengths_addr'] - $header['originals_lengths_addr']; 252 | if ( $originals_lengths_length != $header['total'] * 8 ) { 253 | return false; 254 | } 255 | 256 | $originals = $reader->read( $originals_lengths_length ); 257 | if ( $reader->strlen( $originals ) != $originals_lengths_length ) { 258 | return false; 259 | } 260 | 261 | // Read translations' indices. 262 | $translations_lengths_length = $header['hash_addr'] - $header['translations_lengths_addr']; 263 | if ( $translations_lengths_length != $header['total'] * 8 ) { 264 | return false; 265 | } 266 | 267 | $translations = $reader->read( $translations_lengths_length ); 268 | if ( $reader->strlen( $translations ) != $translations_lengths_length ) { 269 | return false; 270 | } 271 | 272 | // Transform raw data into set of indices. 273 | $originals = $reader->str_split( $originals, 8 ); 274 | $translations = $reader->str_split( $translations, 8 ); 275 | 276 | // Skip hash table. 277 | $strings_addr = $header['hash_addr'] + $header['hash_length'] * 4; 278 | 279 | $reader->seekto( $strings_addr ); 280 | 281 | $strings = $reader->read_all(); 282 | $reader->close(); 283 | 284 | for ( $i = 0; $i < $header['total']; $i++ ) { 285 | $o = unpack( "{$endian}length/{$endian}pos", $originals[ $i ] ); 286 | $t = unpack( "{$endian}length/{$endian}pos", $translations[ $i ] ); 287 | if ( ! $o || ! $t ) { 288 | return false; 289 | } 290 | 291 | // Adjust offset due to reading strings to separate space before. 292 | $o['pos'] -= $strings_addr; 293 | $t['pos'] -= $strings_addr; 294 | 295 | $original = $reader->substr( $strings, $o['pos'], $o['length'] ); 296 | $translation = $reader->substr( $strings, $t['pos'], $t['length'] ); 297 | 298 | if ( '' === $original ) { 299 | $this->set_headers( $this->make_headers( $translation ) ); 300 | } else { 301 | $entry = &$this->make_entry( $original, $translation ); 302 | $this->entries[ $entry->key() ] = &$entry; 303 | } 304 | } 305 | return true; 306 | } 307 | 308 | /** 309 | * Build a Translation_Entry from original string and translation strings, 310 | * found in a MO file 311 | * 312 | * @static 313 | * @param string $original original string to translate from MO file. Might contain 314 | * 0x04 as context separator or 0x00 as singular/plural separator 315 | * @param string $translation translation string from MO file. Might contain 316 | * 0x00 as a plural translations separator 317 | * @return Translation_Entry Entry instance. 318 | */ 319 | public function &make_entry( $original, $translation ) { 320 | $entry = new Translation_Entry(); 321 | // Look for context, separated by \4. 322 | $parts = explode( "\4", $original ); 323 | if ( isset( $parts[1] ) ) { 324 | $original = $parts[1]; 325 | $entry->context = $parts[0]; 326 | } 327 | // Look for plural original. 328 | $parts = explode( "\0", $original ); 329 | $entry->singular = $parts[0]; 330 | if ( isset( $parts[1] ) ) { 331 | $entry->is_plural = true; 332 | $entry->plural = $parts[1]; 333 | } 334 | // Plural translations are also separated by \0. 335 | $entry->translations = explode( "\0", $translation ); 336 | return $entry; 337 | } 338 | 339 | /** 340 | * @param int $count 341 | * @return string 342 | */ 343 | public function select_plural_form( $count ) { 344 | return $this->gettext_select_plural_form( $count ); 345 | } 346 | 347 | /** 348 | * @return int 349 | */ 350 | public function get_plural_forms_count() { 351 | return $this->_nplurals; 352 | } 353 | } 354 | endif; 355 | -------------------------------------------------------------------------------- /bin/php/pomo/plural-forms.php: -------------------------------------------------------------------------------- 1 | 6, 40 | 41 | '<' => 5, 42 | '<=' => 5, 43 | '>' => 5, 44 | '>=' => 5, 45 | 46 | '==' => 4, 47 | '!=' => 4, 48 | 49 | '&&' => 3, 50 | 51 | '||' => 2, 52 | 53 | '?:' => 1, 54 | '?' => 1, 55 | 56 | '(' => 0, 57 | ')' => 0, 58 | ); 59 | 60 | /** 61 | * Tokens generated from the string. 62 | * 63 | * @since 4.9.0 64 | * @var array $tokens List of tokens. 65 | */ 66 | protected $tokens = array(); 67 | 68 | /** 69 | * Cache for repeated calls to the function. 70 | * 71 | * @since 4.9.0 72 | * @var array $cache Map of $n => $result 73 | */ 74 | protected $cache = array(); 75 | 76 | /** 77 | * Constructor. 78 | * 79 | * @since 4.9.0 80 | * 81 | * @param string $str Plural function (just the bit after `plural=` from Plural-Forms) 82 | */ 83 | public function __construct( $str ) { 84 | $this->parse( $str ); 85 | } 86 | 87 | /** 88 | * Parse a Plural-Forms string into tokens. 89 | * 90 | * Uses the shunting-yard algorithm to convert the string to Reverse Polish 91 | * Notation tokens. 92 | * 93 | * @since 4.9.0 94 | * 95 | * @throws Exception If there is a syntax or parsing error with the string. 96 | * 97 | * @param string $str String to parse. 98 | */ 99 | protected function parse( $str ) { 100 | $pos = 0; 101 | $len = strlen( $str ); 102 | 103 | // Convert infix operators to postfix using the shunting-yard algorithm. 104 | $output = array(); 105 | $stack = array(); 106 | while ( $pos < $len ) { 107 | $next = substr( $str, $pos, 1 ); 108 | 109 | switch ( $next ) { 110 | // Ignore whitespace. 111 | case ' ': 112 | case "\t": 113 | $pos++; 114 | break; 115 | 116 | // Variable (n). 117 | case 'n': 118 | $output[] = array( 'var' ); 119 | $pos++; 120 | break; 121 | 122 | // Parentheses. 123 | case '(': 124 | $stack[] = $next; 125 | $pos++; 126 | break; 127 | 128 | case ')': 129 | $found = false; 130 | while ( ! empty( $stack ) ) { 131 | $o2 = $stack[ count( $stack ) - 1 ]; 132 | if ( '(' !== $o2 ) { 133 | $output[] = array( 'op', array_pop( $stack ) ); 134 | continue; 135 | } 136 | 137 | // Discard open paren. 138 | array_pop( $stack ); 139 | $found = true; 140 | break; 141 | } 142 | 143 | if ( ! $found ) { 144 | throw new Exception( 'Mismatched parentheses' ); 145 | } 146 | 147 | $pos++; 148 | break; 149 | 150 | // Operators. 151 | case '|': 152 | case '&': 153 | case '>': 154 | case '<': 155 | case '!': 156 | case '=': 157 | case '%': 158 | case '?': 159 | $end_operator = strspn( $str, self::OP_CHARS, $pos ); 160 | $operator = substr( $str, $pos, $end_operator ); 161 | if ( ! array_key_exists( $operator, self::$op_precedence ) ) { 162 | throw new Exception( sprintf( 'Unknown operator "%s"', $operator ) ); 163 | } 164 | 165 | while ( ! empty( $stack ) ) { 166 | $o2 = $stack[ count( $stack ) - 1 ]; 167 | 168 | // Ternary is right-associative in C. 169 | if ( '?:' === $operator || '?' === $operator ) { 170 | if ( self::$op_precedence[ $operator ] >= self::$op_precedence[ $o2 ] ) { 171 | break; 172 | } 173 | } elseif ( self::$op_precedence[ $operator ] > self::$op_precedence[ $o2 ] ) { 174 | break; 175 | } 176 | 177 | $output[] = array( 'op', array_pop( $stack ) ); 178 | } 179 | $stack[] = $operator; 180 | 181 | $pos += $end_operator; 182 | break; 183 | 184 | // Ternary "else". 185 | case ':': 186 | $found = false; 187 | $s_pos = count( $stack ) - 1; 188 | while ( $s_pos >= 0 ) { 189 | $o2 = $stack[ $s_pos ]; 190 | if ( '?' !== $o2 ) { 191 | $output[] = array( 'op', array_pop( $stack ) ); 192 | $s_pos--; 193 | continue; 194 | } 195 | 196 | // Replace. 197 | $stack[ $s_pos ] = '?:'; 198 | $found = true; 199 | break; 200 | } 201 | 202 | if ( ! $found ) { 203 | throw new Exception( 'Missing starting "?" ternary operator' ); 204 | } 205 | $pos++; 206 | break; 207 | 208 | // Default - number or invalid. 209 | default: 210 | if ( $next >= '0' && $next <= '9' ) { 211 | $span = strspn( $str, self::NUM_CHARS, $pos ); 212 | $output[] = array( 'value', intval( substr( $str, $pos, $span ) ) ); 213 | $pos += $span; 214 | break; 215 | } 216 | 217 | throw new Exception( sprintf( 'Unknown symbol "%s"', $next ) ); 218 | } 219 | } 220 | 221 | while ( ! empty( $stack ) ) { 222 | $o2 = array_pop( $stack ); 223 | if ( '(' === $o2 || ')' === $o2 ) { 224 | throw new Exception( 'Mismatched parentheses' ); 225 | } 226 | 227 | $output[] = array( 'op', $o2 ); 228 | } 229 | 230 | $this->tokens = $output; 231 | } 232 | 233 | /** 234 | * Get the plural form for a number. 235 | * 236 | * Caches the value for repeated calls. 237 | * 238 | * @since 4.9.0 239 | * 240 | * @param int $num Number to get plural form for. 241 | * @return int Plural form value. 242 | */ 243 | public function get( $num ) { 244 | if ( isset( $this->cache[ $num ] ) ) { 245 | return $this->cache[ $num ]; 246 | } 247 | $this->cache[ $num ] = $this->execute( $num ); 248 | return $this->cache[ $num ]; 249 | } 250 | 251 | /** 252 | * Execute the plural form function. 253 | * 254 | * @since 4.9.0 255 | * 256 | * @throws Exception If the plural form value cannot be calculated. 257 | * 258 | * @param int $n Variable "n" to substitute. 259 | * @return int Plural form value. 260 | */ 261 | public function execute( $n ) { 262 | $stack = array(); 263 | $i = 0; 264 | $total = count( $this->tokens ); 265 | while ( $i < $total ) { 266 | $next = $this->tokens[ $i ]; 267 | $i++; 268 | if ( 'var' === $next[0] ) { 269 | $stack[] = $n; 270 | continue; 271 | } elseif ( 'value' === $next[0] ) { 272 | $stack[] = $next[1]; 273 | continue; 274 | } 275 | 276 | // Only operators left. 277 | switch ( $next[1] ) { 278 | case '%': 279 | $v2 = array_pop( $stack ); 280 | $v1 = array_pop( $stack ); 281 | $stack[] = $v1 % $v2; 282 | break; 283 | 284 | case '||': 285 | $v2 = array_pop( $stack ); 286 | $v1 = array_pop( $stack ); 287 | $stack[] = $v1 || $v2; 288 | break; 289 | 290 | case '&&': 291 | $v2 = array_pop( $stack ); 292 | $v1 = array_pop( $stack ); 293 | $stack[] = $v1 && $v2; 294 | break; 295 | 296 | case '<': 297 | $v2 = array_pop( $stack ); 298 | $v1 = array_pop( $stack ); 299 | $stack[] = $v1 < $v2; 300 | break; 301 | 302 | case '<=': 303 | $v2 = array_pop( $stack ); 304 | $v1 = array_pop( $stack ); 305 | $stack[] = $v1 <= $v2; 306 | break; 307 | 308 | case '>': 309 | $v2 = array_pop( $stack ); 310 | $v1 = array_pop( $stack ); 311 | $stack[] = $v1 > $v2; 312 | break; 313 | 314 | case '>=': 315 | $v2 = array_pop( $stack ); 316 | $v1 = array_pop( $stack ); 317 | $stack[] = $v1 >= $v2; 318 | break; 319 | 320 | case '!=': 321 | $v2 = array_pop( $stack ); 322 | $v1 = array_pop( $stack ); 323 | $stack[] = $v1 != $v2; 324 | break; 325 | 326 | case '==': 327 | $v2 = array_pop( $stack ); 328 | $v1 = array_pop( $stack ); 329 | $stack[] = $v1 == $v2; 330 | break; 331 | 332 | case '?:': 333 | $v3 = array_pop( $stack ); 334 | $v2 = array_pop( $stack ); 335 | $v1 = array_pop( $stack ); 336 | $stack[] = $v1 ? $v2 : $v3; 337 | break; 338 | 339 | default: 340 | throw new Exception( sprintf( 'Unknown operator "%s"', $next[1] ) ); 341 | } 342 | } 343 | 344 | if ( count( $stack ) !== 1 ) { 345 | throw new Exception( 'Too many values remaining on the stack' ); 346 | } 347 | 348 | return (int) $stack[0]; 349 | } 350 | } 351 | endif; 352 | -------------------------------------------------------------------------------- /bin/php/pomo/po.php: -------------------------------------------------------------------------------- 1 | headers as $header => $value ) { 42 | $header_string .= "$header: $value\n"; 43 | } 44 | $poified = PO::poify( $header_string ); 45 | if ( $this->comments_before_headers ) { 46 | $before_headers = $this->prepend_each_line( rtrim( $this->comments_before_headers ) . "\n", '# ' ); 47 | } else { 48 | $before_headers = ''; 49 | } 50 | return rtrim( "{$before_headers}msgid \"\"\nmsgstr $poified" ); 51 | } 52 | 53 | /** 54 | * Exports all entries to PO format 55 | * 56 | * @return string sequence of mgsgid/msgstr PO strings, doesn't containt newline at the end 57 | */ 58 | public function export_entries() { 59 | // TODO: Sorting. 60 | return implode( "\n\n", array_map( array( 'PO', 'export_entry' ), $this->entries ) ); 61 | } 62 | 63 | /** 64 | * Exports the whole PO file as a string 65 | * 66 | * @param bool $include_headers whether to include the headers in the export 67 | * @return string ready for inclusion in PO file string for headers and all the enrtries 68 | */ 69 | public function export( $include_headers = true ) { 70 | $res = ''; 71 | if ( $include_headers ) { 72 | $res .= $this->export_headers(); 73 | $res .= "\n\n"; 74 | } 75 | $res .= $this->export_entries(); 76 | return $res; 77 | } 78 | 79 | /** 80 | * Same as {@link export}, but writes the result to a file 81 | * 82 | * @param string $filename Where to write the PO string. 83 | * @param bool $include_headers Whether to include the headers in the export. 84 | * @return bool true on success, false on error 85 | */ 86 | public function export_to_file( $filename, $include_headers = true ) { 87 | $fh = fopen( $filename, 'w' ); 88 | if ( false === $fh ) { 89 | return false; 90 | } 91 | $export = $this->export( $include_headers ); 92 | $res = fwrite( $fh, $export ); 93 | if ( false === $res ) { 94 | return false; 95 | } 96 | return fclose( $fh ); 97 | } 98 | 99 | /** 100 | * Text to include as a comment before the start of the PO contents 101 | * 102 | * Doesn't need to include # in the beginning of lines, these are added automatically 103 | * 104 | * @param string $text Text to include as a comment. 105 | */ 106 | public function set_comment_before_headers( $text ) { 107 | $this->comments_before_headers = $text; 108 | } 109 | 110 | /** 111 | * Formats a string in PO-style 112 | * 113 | * @param string $string the string to format 114 | * @return string the poified string 115 | */ 116 | public static function poify( $string ) { 117 | $quote = '"'; 118 | $slash = '\\'; 119 | $newline = "\n"; 120 | 121 | $replaces = array( 122 | "$slash" => "$slash$slash", 123 | "$quote" => "$slash$quote", 124 | "\t" => '\t', 125 | ); 126 | 127 | $string = str_replace( array_keys( $replaces ), array_values( $replaces ), $string ); 128 | 129 | $po = $quote . implode( "{$slash}n{$quote}{$newline}{$quote}", explode( $newline, $string ) ) . $quote; 130 | // Add empty string on first line for readbility. 131 | if ( false !== strpos( $string, $newline ) && 132 | ( substr_count( $string, $newline ) > 1 || substr( $string, -strlen( $newline ) ) !== $newline ) ) { 133 | $po = "$quote$quote$newline$po"; 134 | } 135 | // Remove empty strings. 136 | $po = str_replace( "$newline$quote$quote", '', $po ); 137 | return $po; 138 | } 139 | 140 | /** 141 | * Gives back the original string from a PO-formatted string 142 | * 143 | * @param string $string PO-formatted string 144 | * @return string enascaped string 145 | */ 146 | public static function unpoify( $string ) { 147 | $escapes = array( 148 | 't' => "\t", 149 | 'n' => "\n", 150 | 'r' => "\r", 151 | '\\' => '\\', 152 | ); 153 | $lines = array_map( 'trim', explode( "\n", $string ) ); 154 | $lines = array_map( array( 'PO', 'trim_quotes' ), $lines ); 155 | $unpoified = ''; 156 | $previous_is_backslash = false; 157 | foreach ( $lines as $line ) { 158 | preg_match_all( '/./u', $line, $chars ); 159 | $chars = $chars[0]; 160 | foreach ( $chars as $char ) { 161 | if ( ! $previous_is_backslash ) { 162 | if ( '\\' === $char ) { 163 | $previous_is_backslash = true; 164 | } else { 165 | $unpoified .= $char; 166 | } 167 | } else { 168 | $previous_is_backslash = false; 169 | $unpoified .= isset( $escapes[ $char ] ) ? $escapes[ $char ] : $char; 170 | } 171 | } 172 | } 173 | 174 | // Standardize the line endings on imported content, technically PO files shouldn't contain \r. 175 | $unpoified = str_replace( array( "\r\n", "\r" ), "\n", $unpoified ); 176 | 177 | return $unpoified; 178 | } 179 | 180 | /** 181 | * Inserts $with in the beginning of every new line of $string and 182 | * returns the modified string 183 | * 184 | * @param string $string prepend lines in this string 185 | * @param string $with prepend lines with this string 186 | */ 187 | public static function prepend_each_line( $string, $with ) { 188 | $lines = explode( "\n", $string ); 189 | $append = ''; 190 | if ( "\n" === substr( $string, -1 ) && '' === end( $lines ) ) { 191 | /* 192 | * Last line might be empty because $string was terminated 193 | * with a newline, remove it from the $lines array, 194 | * we'll restore state by re-terminating the string at the end. 195 | */ 196 | array_pop( $lines ); 197 | $append = "\n"; 198 | } 199 | foreach ( $lines as &$line ) { 200 | $line = $with . $line; 201 | } 202 | unset( $line ); 203 | return implode( "\n", $lines ) . $append; 204 | } 205 | 206 | /** 207 | * Prepare a text as a comment -- wraps the lines and prepends # 208 | * and a special character to each line 209 | * 210 | * @access private 211 | * @param string $text the comment text 212 | * @param string $char character to denote a special PO comment, 213 | * like :, default is a space 214 | */ 215 | public static function comment_block( $text, $char = ' ' ) { 216 | $text = wordwrap( $text, PO_MAX_LINE_LEN - 3 ); 217 | return PO::prepend_each_line( $text, "#$char " ); 218 | } 219 | 220 | /** 221 | * Builds a string from the entry for inclusion in PO file 222 | * 223 | * @param Translation_Entry $entry the entry to convert to po string. 224 | * @return string|false PO-style formatted string for the entry or 225 | * false if the entry is empty 226 | */ 227 | public static function export_entry( $entry ) { 228 | if ( null === $entry->singular || '' === $entry->singular ) { 229 | return false; 230 | } 231 | $po = array(); 232 | if ( ! empty( $entry->translator_comments ) ) { 233 | $po[] = PO::comment_block( $entry->translator_comments ); 234 | } 235 | if ( ! empty( $entry->extracted_comments ) ) { 236 | $po[] = PO::comment_block( $entry->extracted_comments, '.' ); 237 | } 238 | if ( ! empty( $entry->references ) ) { 239 | $po[] = PO::comment_block( implode( ' ', $entry->references ), ':' ); 240 | } 241 | if ( ! empty( $entry->flags ) ) { 242 | $po[] = PO::comment_block( implode( ', ', $entry->flags ), ',' ); 243 | } 244 | if ( $entry->context ) { 245 | $po[] = 'msgctxt ' . PO::poify( $entry->context ); 246 | } 247 | $po[] = 'msgid ' . PO::poify( $entry->singular ); 248 | if ( ! $entry->is_plural ) { 249 | $translation = empty( $entry->translations ) ? '' : $entry->translations[0]; 250 | $translation = PO::match_begin_and_end_newlines( $translation, $entry->singular ); 251 | $po[] = 'msgstr ' . PO::poify( $translation ); 252 | } else { 253 | $po[] = 'msgid_plural ' . PO::poify( $entry->plural ); 254 | $translations = empty( $entry->translations ) ? array( '', '' ) : $entry->translations; 255 | foreach ( $translations as $i => $translation ) { 256 | $translation = PO::match_begin_and_end_newlines( $translation, $entry->plural ); 257 | $po[] = "msgstr[$i] " . PO::poify( $translation ); 258 | } 259 | } 260 | return implode( "\n", $po ); 261 | } 262 | 263 | public static function match_begin_and_end_newlines( $translation, $original ) { 264 | if ( '' === $translation ) { 265 | return $translation; 266 | } 267 | 268 | $original_begin = "\n" === substr( $original, 0, 1 ); 269 | $original_end = "\n" === substr( $original, -1 ); 270 | $translation_begin = "\n" === substr( $translation, 0, 1 ); 271 | $translation_end = "\n" === substr( $translation, -1 ); 272 | 273 | if ( $original_begin ) { 274 | if ( ! $translation_begin ) { 275 | $translation = "\n" . $translation; 276 | } 277 | } elseif ( $translation_begin ) { 278 | $translation = ltrim( $translation, "\n" ); 279 | } 280 | 281 | if ( $original_end ) { 282 | if ( ! $translation_end ) { 283 | $translation .= "\n"; 284 | } 285 | } elseif ( $translation_end ) { 286 | $translation = rtrim( $translation, "\n" ); 287 | } 288 | 289 | return $translation; 290 | } 291 | 292 | /** 293 | * @param string $filename 294 | * @return bool 295 | */ 296 | public function import_from_file( $filename ) { 297 | $f = fopen( $filename, 'r' ); 298 | if ( ! $f ) { 299 | return false; 300 | } 301 | $lineno = 0; 302 | while ( true ) { 303 | $res = $this->read_entry( $f, $lineno ); 304 | if ( ! $res ) { 305 | break; 306 | } 307 | if ( '' === $res['entry']->singular ) { 308 | $this->set_headers( $this->make_headers( $res['entry']->translations[0] ) ); 309 | } else { 310 | $this->add_entry( $res['entry'] ); 311 | } 312 | } 313 | PO::read_line( $f, 'clear' ); 314 | if ( false === $res ) { 315 | return false; 316 | } 317 | if ( ! $this->headers && ! $this->entries ) { 318 | return false; 319 | } 320 | return true; 321 | } 322 | 323 | /** 324 | * Helper function for read_entry 325 | * 326 | * @param string $context 327 | * @return bool 328 | */ 329 | protected static function is_final( $context ) { 330 | return ( 'msgstr' === $context ) || ( 'msgstr_plural' === $context ); 331 | } 332 | 333 | /** 334 | * @param resource $f 335 | * @param int $lineno 336 | * @return null|false|array 337 | */ 338 | public function read_entry( $f, $lineno = 0 ) { 339 | $entry = new Translation_Entry(); 340 | // Where were we in the last step. 341 | // Can be: comment, msgctxt, msgid, msgid_plural, msgstr, msgstr_plural. 342 | $context = ''; 343 | $msgstr_index = 0; 344 | while ( true ) { 345 | $lineno++; 346 | $line = PO::read_line( $f ); 347 | if ( ! $line ) { 348 | if ( feof( $f ) ) { 349 | if ( self::is_final( $context ) ) { 350 | break; 351 | } elseif ( ! $context ) { // We haven't read a line and EOF came. 352 | return null; 353 | } else { 354 | return false; 355 | } 356 | } else { 357 | return false; 358 | } 359 | } 360 | if ( "\n" === $line ) { 361 | continue; 362 | } 363 | $line = trim( $line ); 364 | if ( preg_match( '/^#/', $line, $m ) ) { 365 | // The comment is the start of a new entry. 366 | if ( self::is_final( $context ) ) { 367 | PO::read_line( $f, 'put-back' ); 368 | $lineno--; 369 | break; 370 | } 371 | // Comments have to be at the beginning. 372 | if ( $context && 'comment' !== $context ) { 373 | return false; 374 | } 375 | // Add comment. 376 | $this->add_comment_to_entry( $entry, $line ); 377 | } elseif ( preg_match( '/^msgctxt\s+(".*")/', $line, $m ) ) { 378 | if ( self::is_final( $context ) ) { 379 | PO::read_line( $f, 'put-back' ); 380 | $lineno--; 381 | break; 382 | } 383 | if ( $context && 'comment' !== $context ) { 384 | return false; 385 | } 386 | $context = 'msgctxt'; 387 | $entry->context .= PO::unpoify( $m[1] ); 388 | } elseif ( preg_match( '/^msgid\s+(".*")/', $line, $m ) ) { 389 | if ( self::is_final( $context ) ) { 390 | PO::read_line( $f, 'put-back' ); 391 | $lineno--; 392 | break; 393 | } 394 | if ( $context && 'msgctxt' !== $context && 'comment' !== $context ) { 395 | return false; 396 | } 397 | $context = 'msgid'; 398 | $entry->singular .= PO::unpoify( $m[1] ); 399 | } elseif ( preg_match( '/^msgid_plural\s+(".*")/', $line, $m ) ) { 400 | if ( 'msgid' !== $context ) { 401 | return false; 402 | } 403 | $context = 'msgid_plural'; 404 | $entry->is_plural = true; 405 | $entry->plural .= PO::unpoify( $m[1] ); 406 | } elseif ( preg_match( '/^msgstr\s+(".*")/', $line, $m ) ) { 407 | if ( 'msgid' !== $context ) { 408 | return false; 409 | } 410 | $context = 'msgstr'; 411 | $entry->translations = array( PO::unpoify( $m[1] ) ); 412 | } elseif ( preg_match( '/^msgstr\[(\d+)\]\s+(".*")/', $line, $m ) ) { 413 | if ( 'msgid_plural' !== $context && 'msgstr_plural' !== $context ) { 414 | return false; 415 | } 416 | $context = 'msgstr_plural'; 417 | $msgstr_index = $m[1]; 418 | $entry->translations[ $m[1] ] = PO::unpoify( $m[2] ); 419 | } elseif ( preg_match( '/^".*"$/', $line ) ) { 420 | $unpoified = PO::unpoify( $line ); 421 | switch ( $context ) { 422 | case 'msgid': 423 | $entry->singular .= $unpoified; 424 | break; 425 | case 'msgctxt': 426 | $entry->context .= $unpoified; 427 | break; 428 | case 'msgid_plural': 429 | $entry->plural .= $unpoified; 430 | break; 431 | case 'msgstr': 432 | $entry->translations[0] .= $unpoified; 433 | break; 434 | case 'msgstr_plural': 435 | $entry->translations[ $msgstr_index ] .= $unpoified; 436 | break; 437 | default: 438 | return false; 439 | } 440 | } else { 441 | return false; 442 | } 443 | } 444 | 445 | $have_translations = false; 446 | foreach ( $entry->translations as $t ) { 447 | if ( $t || ( '0' === $t ) ) { 448 | $have_translations = true; 449 | break; 450 | } 451 | } 452 | if ( false === $have_translations ) { 453 | $entry->translations = array(); 454 | } 455 | 456 | return array( 457 | 'entry' => $entry, 458 | 'lineno' => $lineno, 459 | ); 460 | } 461 | 462 | /** 463 | * @param resource $f 464 | * @param string $action 465 | * @return bool 466 | */ 467 | public function read_line( $f, $action = 'read' ) { 468 | static $last_line = ''; 469 | static $use_last_line = false; 470 | if ( 'clear' === $action ) { 471 | $last_line = ''; 472 | return true; 473 | } 474 | if ( 'put-back' === $action ) { 475 | $use_last_line = true; 476 | return true; 477 | } 478 | $line = $use_last_line ? $last_line : fgets( $f ); 479 | $line = ( "\r\n" === substr( $line, -2 ) ) ? rtrim( $line, "\r\n" ) . "\n" : $line; 480 | $last_line = $line; 481 | $use_last_line = false; 482 | return $line; 483 | } 484 | 485 | /** 486 | * @param Translation_Entry $entry 487 | * @param string $po_comment_line 488 | */ 489 | public function add_comment_to_entry( &$entry, $po_comment_line ) { 490 | $first_two = substr( $po_comment_line, 0, 2 ); 491 | $comment = trim( substr( $po_comment_line, 2 ) ); 492 | if ( '#:' === $first_two ) { 493 | $entry->references = array_merge( $entry->references, preg_split( '/\s+/', $comment ) ); 494 | } elseif ( '#.' === $first_two ) { 495 | $entry->extracted_comments = trim( $entry->extracted_comments . "\n" . $comment ); 496 | } elseif ( '#,' === $first_two ) { 497 | $entry->flags = array_merge( $entry->flags, preg_split( '/,\s*/', $comment ) ); 498 | } else { 499 | $entry->translator_comments = trim( $entry->translator_comments . "\n" . $comment ); 500 | } 501 | } 502 | 503 | /** 504 | * @param string $s 505 | * @return string 506 | */ 507 | public static function trim_quotes( $s ) { 508 | if ( '"' === substr( $s, 0, 1 ) ) { 509 | $s = substr( $s, 1 ); 510 | } 511 | if ( '"' === substr( $s, -1, 1 ) ) { 512 | $s = substr( $s, 0, -1 ); 513 | } 514 | return $s; 515 | } 516 | } 517 | endif; 518 | -------------------------------------------------------------------------------- /bin/php/pomo/streams.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * @version $Id: streams.php 1157 2015-11-20 04:30:11Z dd32 $ 7 | * @package pomo 8 | * @subpackage streams 9 | */ 10 | 11 | if ( ! class_exists( 'POMO_Reader', false ) ) : 12 | #[AllowDynamicProperties] 13 | class POMO_Reader { 14 | 15 | public $endian = 'little'; 16 | public $_pos; 17 | public $is_overloaded; 18 | 19 | /** 20 | * PHP5 constructor. 21 | */ 22 | public function __construct() { 23 | if ( function_exists( 'mb_substr' ) 24 | && ( (int) ini_get( 'mbstring.func_overload' ) & 2 ) // phpcs:ignore PHPCompatibility.IniDirectives.RemovedIniDirectives.mbstring_func_overloadDeprecated 25 | ) { 26 | $this->is_overloaded = true; 27 | } else { 28 | $this->is_overloaded = false; 29 | } 30 | 31 | $this->_pos = 0; 32 | } 33 | 34 | /** 35 | * PHP4 constructor. 36 | * 37 | * @deprecated 5.4.0 Use __construct() instead. 38 | * 39 | * @see POMO_Reader::__construct() 40 | */ 41 | public function POMO_Reader() { 42 | _deprecated_constructor( self::class, '5.4.0', static::class ); 43 | self::__construct(); 44 | } 45 | 46 | /** 47 | * Sets the endianness of the file. 48 | * 49 | * @param string $endian Set the endianness of the file. Accepts 'big', or 'little'. 50 | */ 51 | public function setEndian( $endian ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid 52 | $this->endian = $endian; 53 | } 54 | 55 | /** 56 | * Reads a 32bit Integer from the Stream 57 | * 58 | * @return mixed The integer, corresponding to the next 32 bits from 59 | * the stream of false if there are not enough bytes or on error 60 | */ 61 | public function readint32() { 62 | $bytes = $this->read( 4 ); 63 | if ( 4 != $this->strlen( $bytes ) ) { 64 | return false; 65 | } 66 | $endian_letter = ( 'big' === $this->endian ) ? 'N' : 'V'; 67 | $int = unpack( $endian_letter, $bytes ); 68 | return reset( $int ); 69 | } 70 | 71 | /** 72 | * Reads an array of 32-bit Integers from the Stream 73 | * 74 | * @param int $count How many elements should be read 75 | * @return mixed Array of integers or false if there isn't 76 | * enough data or on error 77 | */ 78 | public function readint32array( $count ) { 79 | $bytes = $this->read( 4 * $count ); 80 | if ( 4 * $count != $this->strlen( $bytes ) ) { 81 | return false; 82 | } 83 | $endian_letter = ( 'big' === $this->endian ) ? 'N' : 'V'; 84 | return unpack( $endian_letter . $count, $bytes ); 85 | } 86 | 87 | /** 88 | * @param string $string 89 | * @param int $start 90 | * @param int $length 91 | * @return string 92 | */ 93 | public function substr( $string, $start, $length ) { 94 | if ( $this->is_overloaded ) { 95 | return mb_substr( $string, $start, $length, 'ascii' ); 96 | } else { 97 | return substr( $string, $start, $length ); 98 | } 99 | } 100 | 101 | /** 102 | * @param string $string 103 | * @return int 104 | */ 105 | public function strlen( $string ) { 106 | if ( $this->is_overloaded ) { 107 | return mb_strlen( $string, 'ascii' ); 108 | } else { 109 | return strlen( $string ); 110 | } 111 | } 112 | 113 | /** 114 | * @param string $string 115 | * @param int $chunk_size 116 | * @return array 117 | */ 118 | public function str_split( $string, $chunk_size ) { 119 | if ( ! function_exists( 'str_split' ) ) { 120 | $length = $this->strlen( $string ); 121 | $out = array(); 122 | for ( $i = 0; $i < $length; $i += $chunk_size ) { 123 | $out[] = $this->substr( $string, $i, $chunk_size ); 124 | } 125 | return $out; 126 | } else { 127 | return str_split( $string, $chunk_size ); 128 | } 129 | } 130 | 131 | /** 132 | * @return int 133 | */ 134 | public function pos() { 135 | return $this->_pos; 136 | } 137 | 138 | /** 139 | * @return true 140 | */ 141 | public function is_resource() { 142 | return true; 143 | } 144 | 145 | /** 146 | * @return true 147 | */ 148 | public function close() { 149 | return true; 150 | } 151 | } 152 | endif; 153 | 154 | if ( ! class_exists( 'POMO_FileReader', false ) ) : 155 | class POMO_FileReader extends POMO_Reader { 156 | 157 | /** 158 | * File pointer resource. 159 | * 160 | * @var resource|false 161 | */ 162 | public $_f; 163 | 164 | /** 165 | * @param string $filename 166 | */ 167 | public function __construct( $filename ) { 168 | parent::__construct(); 169 | $this->_f = fopen( $filename, 'rb' ); 170 | } 171 | 172 | /** 173 | * PHP4 constructor. 174 | * 175 | * @deprecated 5.4.0 Use __construct() instead. 176 | * 177 | * @see POMO_FileReader::__construct() 178 | */ 179 | public function POMO_FileReader( $filename ) { 180 | _deprecated_constructor( self::class, '5.4.0', static::class ); 181 | self::__construct( $filename ); 182 | } 183 | 184 | /** 185 | * @param int $bytes 186 | * @return string|false Returns read string, otherwise false. 187 | */ 188 | public function read( $bytes ) { 189 | return fread( $this->_f, $bytes ); 190 | } 191 | 192 | /** 193 | * @param int $pos 194 | * @return bool 195 | */ 196 | public function seekto( $pos ) { 197 | if ( -1 == fseek( $this->_f, $pos, SEEK_SET ) ) { 198 | return false; 199 | } 200 | $this->_pos = $pos; 201 | return true; 202 | } 203 | 204 | /** 205 | * @return bool 206 | */ 207 | public function is_resource() { 208 | return is_resource( $this->_f ); 209 | } 210 | 211 | /** 212 | * @return bool 213 | */ 214 | public function feof() { 215 | return feof( $this->_f ); 216 | } 217 | 218 | /** 219 | * @return bool 220 | */ 221 | public function close() { 222 | return fclose( $this->_f ); 223 | } 224 | 225 | /** 226 | * @return string 227 | */ 228 | public function read_all() { 229 | return stream_get_contents( $this->_f ); 230 | } 231 | } 232 | endif; 233 | 234 | if ( ! class_exists( 'POMO_StringReader', false ) ) : 235 | /** 236 | * Provides file-like methods for manipulating a string instead 237 | * of a physical file. 238 | */ 239 | class POMO_StringReader extends POMO_Reader { 240 | 241 | public $_str = ''; 242 | 243 | /** 244 | * PHP5 constructor. 245 | */ 246 | public function __construct( $str = '' ) { 247 | parent::__construct(); 248 | $this->_str = $str; 249 | $this->_pos = 0; 250 | } 251 | 252 | /** 253 | * PHP4 constructor. 254 | * 255 | * @deprecated 5.4.0 Use __construct() instead. 256 | * 257 | * @see POMO_StringReader::__construct() 258 | */ 259 | public function POMO_StringReader( $str = '' ) { 260 | _deprecated_constructor( self::class, '5.4.0', static::class ); 261 | self::__construct( $str ); 262 | } 263 | 264 | /** 265 | * @param string $bytes 266 | * @return string 267 | */ 268 | public function read( $bytes ) { 269 | $data = $this->substr( $this->_str, $this->_pos, $bytes ); 270 | $this->_pos += $bytes; 271 | if ( $this->strlen( $this->_str ) < $this->_pos ) { 272 | $this->_pos = $this->strlen( $this->_str ); 273 | } 274 | return $data; 275 | } 276 | 277 | /** 278 | * @param int $pos 279 | * @return int 280 | */ 281 | public function seekto( $pos ) { 282 | $this->_pos = $pos; 283 | if ( $this->strlen( $this->_str ) < $this->_pos ) { 284 | $this->_pos = $this->strlen( $this->_str ); 285 | } 286 | return $this->_pos; 287 | } 288 | 289 | /** 290 | * @return int 291 | */ 292 | public function length() { 293 | return $this->strlen( $this->_str ); 294 | } 295 | 296 | /** 297 | * @return string 298 | */ 299 | public function read_all() { 300 | return $this->substr( $this->_str, $this->_pos, $this->strlen( $this->_str ) ); 301 | } 302 | 303 | } 304 | endif; 305 | 306 | if ( ! class_exists( 'POMO_CachedFileReader', false ) ) : 307 | /** 308 | * Reads the contents of the file in the beginning. 309 | */ 310 | class POMO_CachedFileReader extends POMO_StringReader { 311 | /** 312 | * PHP5 constructor. 313 | */ 314 | public function __construct( $filename ) { 315 | parent::__construct(); 316 | $this->_str = file_get_contents( $filename ); 317 | if ( false === $this->_str ) { 318 | return false; 319 | } 320 | $this->_pos = 0; 321 | } 322 | 323 | /** 324 | * PHP4 constructor. 325 | * 326 | * @deprecated 5.4.0 Use __construct() instead. 327 | * 328 | * @see POMO_CachedFileReader::__construct() 329 | */ 330 | public function POMO_CachedFileReader( $filename ) { 331 | _deprecated_constructor( self::class, '5.4.0', static::class ); 332 | self::__construct( $filename ); 333 | } 334 | } 335 | endif; 336 | 337 | if ( ! class_exists( 'POMO_CachedIntFileReader', false ) ) : 338 | /** 339 | * Reads the contents of the file in the beginning. 340 | */ 341 | class POMO_CachedIntFileReader extends POMO_CachedFileReader { 342 | /** 343 | * PHP5 constructor. 344 | */ 345 | public function __construct( $filename ) { 346 | parent::__construct( $filename ); 347 | } 348 | 349 | /** 350 | * PHP4 constructor. 351 | * 352 | * @deprecated 5.4.0 Use __construct() instead. 353 | * 354 | * @see POMO_CachedIntFileReader::__construct() 355 | */ 356 | public function POMO_CachedIntFileReader( $filename ) { 357 | _deprecated_constructor( self::class, '5.4.0', static::class ); 358 | self::__construct( $filename ); 359 | } 360 | } 361 | endif; 362 | 363 | -------------------------------------------------------------------------------- /bin/php/pomo/translations.php: -------------------------------------------------------------------------------- 1 | key(); 30 | if ( false === $key ) { 31 | return false; 32 | } 33 | $this->entries[ $key ] = &$entry; 34 | return true; 35 | } 36 | 37 | /** 38 | * @param array|Translation_Entry $entry 39 | * @return bool 40 | */ 41 | public function add_entry_or_merge( $entry ) { 42 | if ( is_array( $entry ) ) { 43 | $entry = new Translation_Entry( $entry ); 44 | } 45 | $key = $entry->key(); 46 | if ( false === $key ) { 47 | return false; 48 | } 49 | if ( isset( $this->entries[ $key ] ) ) { 50 | $this->entries[ $key ]->merge_with( $entry ); 51 | } else { 52 | $this->entries[ $key ] = &$entry; 53 | } 54 | return true; 55 | } 56 | 57 | /** 58 | * Sets $header PO header to $value 59 | * 60 | * If the header already exists, it will be overwritten 61 | * 62 | * TODO: this should be out of this class, it is gettext specific 63 | * 64 | * @param string $header header name, without trailing : 65 | * @param string $value header value, without trailing \n 66 | */ 67 | public function set_header( $header, $value ) { 68 | $this->headers[ $header ] = $value; 69 | } 70 | 71 | /** 72 | * @param array $headers 73 | */ 74 | public function set_headers( $headers ) { 75 | foreach ( $headers as $header => $value ) { 76 | $this->set_header( $header, $value ); 77 | } 78 | } 79 | 80 | /** 81 | * @param string $header 82 | */ 83 | public function get_header( $header ) { 84 | return isset( $this->headers[ $header ] ) ? $this->headers[ $header ] : false; 85 | } 86 | 87 | /** 88 | * @param Translation_Entry $entry 89 | */ 90 | public function translate_entry( &$entry ) { 91 | $key = $entry->key(); 92 | return isset( $this->entries[ $key ] ) ? $this->entries[ $key ] : false; 93 | } 94 | 95 | /** 96 | * @param string $singular 97 | * @param string $context 98 | * @return string 99 | */ 100 | public function translate( $singular, $context = null ) { 101 | $entry = new Translation_Entry( 102 | array( 103 | 'singular' => $singular, 104 | 'context' => $context, 105 | ) 106 | ); 107 | $translated = $this->translate_entry( $entry ); 108 | return ( $translated && ! empty( $translated->translations ) ) ? $translated->translations[0] : $singular; 109 | } 110 | 111 | /** 112 | * Given the number of items, returns the 0-based index of the plural form to use 113 | * 114 | * Here, in the base Translations class, the common logic for English is implemented: 115 | * 0 if there is one element, 1 otherwise 116 | * 117 | * This function should be overridden by the subclasses. For example MO/PO can derive the logic 118 | * from their headers. 119 | * 120 | * @param int $count number of items 121 | */ 122 | public function select_plural_form( $count ) { 123 | return 1 == $count ? 0 : 1; 124 | } 125 | 126 | /** 127 | * @return int 128 | */ 129 | public function get_plural_forms_count() { 130 | return 2; 131 | } 132 | 133 | /** 134 | * @param string $singular 135 | * @param string $plural 136 | * @param int $count 137 | * @param string $context 138 | */ 139 | public function translate_plural( $singular, $plural, $count, $context = null ) { 140 | $entry = new Translation_Entry( 141 | array( 142 | 'singular' => $singular, 143 | 'plural' => $plural, 144 | 'context' => $context, 145 | ) 146 | ); 147 | $translated = $this->translate_entry( $entry ); 148 | $index = $this->select_plural_form( $count ); 149 | $total_plural_forms = $this->get_plural_forms_count(); 150 | if ( $translated && 0 <= $index && $index < $total_plural_forms && 151 | is_array( $translated->translations ) && 152 | isset( $translated->translations[ $index ] ) ) { 153 | return $translated->translations[ $index ]; 154 | } else { 155 | return 1 == $count ? $singular : $plural; 156 | } 157 | } 158 | 159 | /** 160 | * Merge $other in the current object. 161 | * 162 | * @param Object $other Another Translation object, whose translations will be merged in this one (passed by reference). 163 | */ 164 | public function merge_with( &$other ) { 165 | foreach ( $other->entries as $entry ) { 166 | $this->entries[ $entry->key() ] = $entry; 167 | } 168 | } 169 | 170 | /** 171 | * @param object $other 172 | */ 173 | public function merge_originals_with( &$other ) { 174 | foreach ( $other->entries as $entry ) { 175 | if ( ! isset( $this->entries[ $entry->key() ] ) ) { 176 | $this->entries[ $entry->key() ] = $entry; 177 | } else { 178 | $this->entries[ $entry->key() ]->merge_with( $entry ); 179 | } 180 | } 181 | } 182 | } 183 | 184 | class Gettext_Translations extends Translations { 185 | 186 | /** 187 | * Number of plural forms. 188 | * 189 | * @var int 190 | */ 191 | public $_nplurals; 192 | 193 | /** 194 | * Callback to retrieve the plural form. 195 | * 196 | * @var callable 197 | */ 198 | public $_gettext_select_plural_form; 199 | 200 | /** 201 | * The gettext implementation of select_plural_form. 202 | * 203 | * It lives in this class, because there are more than one descendand, which will use it and 204 | * they can't share it effectively. 205 | * 206 | * @param int $count 207 | */ 208 | public function gettext_select_plural_form( $count ) { 209 | if ( ! isset( $this->_gettext_select_plural_form ) || is_null( $this->_gettext_select_plural_form ) ) { 210 | list( $nplurals, $expression ) = $this->nplurals_and_expression_from_header( $this->get_header( 'Plural-Forms' ) ); 211 | $this->_nplurals = $nplurals; 212 | $this->_gettext_select_plural_form = $this->make_plural_form_function( $nplurals, $expression ); 213 | } 214 | return call_user_func( $this->_gettext_select_plural_form, $count ); 215 | } 216 | 217 | /** 218 | * @param string $header 219 | * @return array 220 | */ 221 | public function nplurals_and_expression_from_header( $header ) { 222 | if ( preg_match( '/^\s*nplurals\s*=\s*(\d+)\s*;\s+plural\s*=\s*(.+)$/', $header, $matches ) ) { 223 | $nplurals = (int) $matches[1]; 224 | $expression = trim( $matches[2] ); 225 | return array( $nplurals, $expression ); 226 | } else { 227 | return array( 2, 'n != 1' ); 228 | } 229 | } 230 | 231 | /** 232 | * Makes a function, which will return the right translation index, according to the 233 | * plural forms header 234 | * 235 | * @param int $nplurals 236 | * @param string $expression 237 | */ 238 | public function make_plural_form_function( $nplurals, $expression ) { 239 | try { 240 | $handler = new Plural_Forms( rtrim( $expression, ';' ) ); 241 | return array( $handler, 'get' ); 242 | } catch ( Exception $e ) { 243 | // Fall back to default plural-form function. 244 | return $this->make_plural_form_function( 2, 'n != 1' ); 245 | } 246 | } 247 | 248 | /** 249 | * Adds parentheses to the inner parts of ternary operators in 250 | * plural expressions, because PHP evaluates ternary oerators from left to right 251 | * 252 | * @param string $expression the expression without parentheses 253 | * @return string the expression with parentheses added 254 | */ 255 | public function parenthesize_plural_exression( $expression ) { 256 | $expression .= ';'; 257 | $res = ''; 258 | $depth = 0; 259 | for ( $i = 0; $i < strlen( $expression ); ++$i ) { 260 | $char = $expression[ $i ]; 261 | switch ( $char ) { 262 | case '?': 263 | $res .= ' ? ('; 264 | $depth++; 265 | break; 266 | case ':': 267 | $res .= ') : ('; 268 | break; 269 | case ';': 270 | $res .= str_repeat( ')', $depth ) . ';'; 271 | $depth = 0; 272 | break; 273 | default: 274 | $res .= $char; 275 | } 276 | } 277 | return rtrim( $res, ';' ); 278 | } 279 | 280 | /** 281 | * @param string $translation 282 | * @return array 283 | */ 284 | public function make_headers( $translation ) { 285 | $headers = array(); 286 | // Sometimes \n's are used instead of real new lines. 287 | $translation = str_replace( '\n', "\n", $translation ); 288 | $lines = explode( "\n", $translation ); 289 | foreach ( $lines as $line ) { 290 | $parts = explode( ':', $line, 2 ); 291 | if ( ! isset( $parts[1] ) ) { 292 | continue; 293 | } 294 | $headers[ trim( $parts[0] ) ] = trim( $parts[1] ); 295 | } 296 | return $headers; 297 | } 298 | 299 | /** 300 | * @param string $header 301 | * @param string $value 302 | */ 303 | public function set_header( $header, $value ) { 304 | parent::set_header( $header, $value ); 305 | if ( 'Plural-Forms' === $header ) { 306 | list( $nplurals, $expression ) = $this->nplurals_and_expression_from_header( $this->get_header( 'Plural-Forms' ) ); 307 | $this->_nplurals = $nplurals; 308 | $this->_gettext_select_plural_form = $this->make_plural_form_function( $nplurals, $expression ); 309 | } 310 | } 311 | } 312 | endif; 313 | 314 | if ( ! class_exists( 'NOOP_Translations', false ) ) : 315 | /** 316 | * Provides the same interface as Translations, but doesn't do anything 317 | */ 318 | #[AllowDynamicProperties] 319 | class NOOP_Translations { 320 | public $entries = array(); 321 | public $headers = array(); 322 | 323 | public function add_entry( $entry ) { 324 | return true; 325 | } 326 | 327 | /** 328 | * @param string $header 329 | * @param string $value 330 | */ 331 | public function set_header( $header, $value ) { 332 | } 333 | 334 | /** 335 | * @param array $headers 336 | */ 337 | public function set_headers( $headers ) { 338 | } 339 | 340 | /** 341 | * @param string $header 342 | * @return false 343 | */ 344 | public function get_header( $header ) { 345 | return false; 346 | } 347 | 348 | /** 349 | * @param Translation_Entry $entry 350 | * @return false 351 | */ 352 | public function translate_entry( &$entry ) { 353 | return false; 354 | } 355 | 356 | /** 357 | * @param string $singular 358 | * @param string $context 359 | */ 360 | public function translate( $singular, $context = null ) { 361 | return $singular; 362 | } 363 | 364 | /** 365 | * @param int $count 366 | * @return bool 367 | */ 368 | public function select_plural_form( $count ) { 369 | return 1 == $count ? 0 : 1; 370 | } 371 | 372 | /** 373 | * @return int 374 | */ 375 | public function get_plural_forms_count() { 376 | return 2; 377 | } 378 | 379 | /** 380 | * @param string $singular 381 | * @param string $plural 382 | * @param int $count 383 | * @param string $context 384 | */ 385 | public function translate_plural( $singular, $plural, $count, $context = null ) { 386 | return 1 == $count ? $singular : $plural; 387 | } 388 | 389 | /** 390 | * @param object $other 391 | */ 392 | public function merge_with( &$other ) { 393 | } 394 | } 395 | endif; 396 | -------------------------------------------------------------------------------- /bin/php/pot-ext-meta.php: -------------------------------------------------------------------------------- 1 | headers as $header ) { 36 | $string = MakePOT::get_addon_header( $header, $source ); 37 | if ( ! $string ) { 38 | continue; 39 | } 40 | $args = array( 41 | 'singular' => $string, 42 | 'extracted_comments' => $header.' of the plugin/theme', 43 | ); 44 | $entry = new Translation_Entry( $args ); 45 | $pot .= "\n" . PO::export_entry( $entry ) . "\n"; 46 | } 47 | return $pot; 48 | } 49 | 50 | public function append( $ext_filename, $pot_filename, $headers = null ) { 51 | if ( $headers ) { 52 | $this->headers = (array) $headers; 53 | } 54 | if ( is_dir( $ext_filename ) ) { 55 | $pot = implode( '', array_map( array( $this, 'load_from_file' ), glob( "$ext_filename/*.php" ) ) ); 56 | } else { 57 | $pot = $this->load_from_file( $ext_filename ); 58 | } 59 | $potf = '-' == $pot_filename ? STDOUT : fopen( $pot_filename, 'a' ); 60 | if ( ! $potf ) { 61 | return false; 62 | } 63 | fwrite( $potf, $pot ); 64 | if ( '-' != $pot_filename ) { 65 | fclose( $potf ); 66 | } 67 | return true; 68 | } 69 | } 70 | 71 | $included_files = get_included_files(); 72 | if ( __FILE__ == $included_files[0] ) { 73 | ini_set( 'display_errors', 1 ); 74 | $potextmeta = new PotExtMeta; 75 | if ( ! isset( $argv[1] ) ) { 76 | $potextmeta->usage(); 77 | } 78 | $potextmeta->append( $argv[1], isset( $argv[2] ) ? $argv[2] : '-', isset( $argv[3] ) ? $argv[3] : null ); 79 | } 80 | -------------------------------------------------------------------------------- /bin/wpi18n: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | var fs = require('fs'); 6 | var glob = require('glob'); 7 | var path = require('path'); 8 | var wpi18n = require('../'); 9 | var WPPackage = require('../lib/package'); 10 | 11 | var argv = require('minimist')(process.argv.slice(2), { 12 | string: [ 13 | 'domain-path', 14 | 'exclude', 15 | 'glob-pattern', 16 | 'main-file', 17 | 'pot-file', 18 | 'textdomain', 19 | 'type' 20 | ], 21 | boolean: [ 22 | 'dry-run', 23 | 'help', 24 | 'poedit', 25 | 'version', 26 | 'update-po-files' 27 | ], 28 | alias: { 29 | h: 'help', 30 | v: 'version' 31 | } 32 | }); 33 | 34 | if (argv.help) { 35 | var help = fs.readFileSync(path.join(__dirname, '../docs/cli.txt')).toString(); 36 | console.log(help); 37 | process.exit(0); 38 | } 39 | 40 | if (argv.version) { 41 | var pkg = require('../package.json'); 42 | console.log('node-wp-i18n v' + pkg.version); 43 | process.exit(0); 44 | } 45 | 46 | if (-1 !== argv._.indexOf('addtextdomain')) { 47 | var pattern = argv['glob-pattern'] ? argv['glob-pattern'] : '**/*.php'; 48 | var files = glob.sync(pattern); 49 | 50 | if (argv['exclude']) { 51 | var excludePattern = argv['exclude'] 52 | .split(',') 53 | .map(function(string) { 54 | // Escape special characters. 55 | // @link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions 56 | return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 57 | }) 58 | .join('|'); 59 | 60 | var excludeRegExp = new RegExp('^('+excludePattern+')'); 61 | 62 | files = files.filter(function(file) { 63 | return ! excludeRegExp.test(file); 64 | }); 65 | } 66 | 67 | wpi18n.addtextdomain(files, { 68 | dryRun: argv['dry-run'], 69 | textdomain: argv.textdomain || '', 70 | updateDomains: [ 'all' ] 71 | }).then(function() { 72 | 73 | }); 74 | } 75 | 76 | if (-1 !== argv._.indexOf('info')) { 77 | var wpPackage = new WPPackage(process.cwd()); 78 | var nameHeader = 'wp-plugin' === wpPackage.getType() ? 'Plugin Name' : 'Theme Name'; 79 | 80 | console.log('Name: ' + wpPackage.getHeader(nameHeader)); 81 | console.log('Path: ' + wpPackage.getPath()); 82 | console.log('Type: ' + wpPackage.getType()); 83 | console.log('Main File: ' + wpPackage.getMainFile()); 84 | console.log('Domain Path: ' + wpPackage.getHeader('Domain Path')); 85 | console.log('Text Domain: ' + wpPackage.getHeader('Text Domain')); 86 | console.log('Pot File: ' + wpPackage.getPotFile()); 87 | } 88 | 89 | if (-1 !== argv._.indexOf('makepot')) { 90 | var pkg = require('../package.json'); 91 | 92 | var headers = { 93 | 'x-generator': 'node-wp-i18n ' + pkg.version 94 | }; 95 | 96 | if (argv.poedit) { 97 | headers.poedit = true; 98 | } 99 | 100 | wpi18n.makepot({ 101 | domainPath: argv['domain-path'] ? argv['domain-path'] : '', 102 | exclude: argv['exclude'] ? argv['exclude'].split(',') : [], 103 | mainFile: argv['main-file'] ? argv['main-file'] : '', 104 | potHeaders: headers, 105 | potFile: argv['pot-file'] ? argv['pot-file'] : '', 106 | type: argv.type ? 'wp-' + argv.type.replace('wp-', '') : '', 107 | updateTimestamp: false, 108 | updatePoFiles: argv['update-po-files'] 109 | }).then(function() { 110 | 111 | }); 112 | } 113 | -------------------------------------------------------------------------------- /docs/cli.txt: -------------------------------------------------------------------------------- 1 | Usage: wpi18n [options] 2 | 3 | Back up your project before using! This is still experimental and may change. 4 | 5 | Commands: 6 | addtextdomain Add a text domain to gettext functions. 7 | info Display information about a package. 8 | makepot Generate a POT file. 9 | 10 | Options: 11 | -v, --version Display the current version. 12 | -h, --help Display help and usage details. 13 | --domain-path Relative path to the POT file directory. 14 | --exclude               Exclude directories. Separate multiple directories by comma. 15 | --main-file             Name of the main package file. 16 | --pot-file Name of the POT file. 17 | --type Type of package (plugin or theme). 18 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * node-wp-i18n 3 | * https://github.com/cedaro/node-wp-i18n 4 | * 5 | * @copyright Copyright (c) 2015 Cedaro, LLC 6 | * @license MIT 7 | */ 8 | 9 | 'use strict'; 10 | 11 | exports.addtextdomain = require( './lib/addtextdomain' ); 12 | exports.makepot = require('./lib/makepot'); 13 | -------------------------------------------------------------------------------- /lib/addtextdomain.js: -------------------------------------------------------------------------------- 1 | /** 2 | * node-wp-i18n 3 | * https://github.com/cedaro/node-wp-i18n 4 | * 5 | * @copyright Copyright (c) 2015 Cedaro, LLC 6 | * @license MIT 7 | */ 8 | 9 | 'use strict'; 10 | 11 | var _ = require('lodash'); 12 | var fs = require('fs'); 13 | var path = require('path'); 14 | var Promise = require('bluebird'); 15 | var tmp = require('tmp'); 16 | var util = require('./util'); 17 | var WPPackage = require('./package'); 18 | 19 | var toolsPath = path.resolve(__dirname, '../bin/php/'); 20 | 21 | /** 22 | * Add a text domain to gettext functions in PHP files. 23 | * 24 | * @param {Array} files List of files. 25 | * @param {Array} options 26 | * @returns {Promise} 27 | */ 28 | module.exports = function(files, options) { 29 | options = _.merge({ 30 | cwd: process.cwd(), 31 | dryRun: false, 32 | textdomain: '', 33 | updateDomains: [] 34 | }, options); 35 | 36 | options.cwd = path.resolve(process.cwd(), options.cwd); 37 | var wpPackage = new WPPackage(options.cwd); 38 | 39 | if ('' === options.textdomain) { 40 | options.textdomain = wpPackage.getHeader('Text Domain'); 41 | } 42 | 43 | if (true === options.updateDomains) { 44 | options.updateDomains = ['all']; 45 | } 46 | 47 | var args = { 48 | 'dry-run': options.dryRun, 49 | files: files.map(function(file) { 50 | return path.resolve(options.cwd, file); 51 | }), 52 | textdomain: options.textdomain, 53 | 'update-domains': options.updateDomains 54 | }; 55 | 56 | var argsFile = tmp.tmpNameSync({ prefix: 'arguments-', postfix: '.json' }); 57 | fs.writeFileSync(argsFile, JSON.stringify(args)); 58 | 59 | return util.spawn('php', [ 60 | path.resolve(toolsPath, 'node-add-textdomain.php'), 61 | argsFile 62 | ]).finally(function() { 63 | fs.unlinkSync(argsFile); 64 | }); 65 | }; 66 | -------------------------------------------------------------------------------- /lib/makepot.js: -------------------------------------------------------------------------------- 1 | /** 2 | * node-wp-i18n 3 | * https://github.com/cedaro/node-wp-i18n 4 | * 5 | * @copyright Copyright (c) 2015 Cedaro, LLC 6 | * @license MIT 7 | */ 8 | 9 | 'use strict'; 10 | 11 | var _ = require('lodash'); 12 | var mkdirp = require('mkdirp'); 13 | var msgMerge = require('./msgmerge'); 14 | var path = require('path'); 15 | var Promise = require('bluebird'); 16 | var util = require('./util'); 17 | var WPPackage = require('./package'); 18 | 19 | var toolsPath = path.resolve(__dirname, '../bin/php/'); 20 | 21 | /** 22 | * Create a POT file. 23 | * 24 | * @param {Array} options 25 | * @returns {Promise} 26 | */ 27 | module.exports = function(options) { 28 | options = _.merge({ 29 | cwd: process.cwd(), 30 | domainPath: '', 31 | exclude: [], 32 | include: [], 33 | mainFile: '', 34 | potComments: '', 35 | potFile: '', 36 | potHeaders: {}, 37 | processPot: null, 38 | type: '', 39 | updateTimestamp: true, 40 | updatePoFiles: false 41 | }, options); 42 | 43 | var wpPackage = new WPPackage(options.cwd, options.type); 44 | 45 | if ('' !== options.mainFile) { 46 | wpPackage.setMainFile(options.mainFile); 47 | } 48 | 49 | if ('' !== options.domainPath) { 50 | wpPackage.setDomainPath(options.domainPath); 51 | } 52 | 53 | if ('' !== options.potFile) { 54 | wpPackage.setPotFile(options.potFile); 55 | } 56 | 57 | // Create the domain path directory if it doesn't exist. 58 | mkdirp.sync(wpPackage.getPath(wpPackage.getDomainPath())); 59 | 60 | // Exclude the node_modules directory by default. 61 | options.exclude.push('node_modules/.*'); 62 | 63 | var originalPot = wpPackage.getPot(); 64 | 65 | if (originalPot.fileExists()) { 66 | originalPot.parse(); 67 | } 68 | 69 | return util.execFile('php', [ 70 | path.resolve(toolsPath, 'node-makepot.php'), 71 | wpPackage.getType(), 72 | wpPackage.getPath(), 73 | wpPackage.getPotFilename(), 74 | wpPackage.getSlug(), 75 | wpPackage.getMainFile(), 76 | options.exclude.join(','), 77 | options.include.join(',') 78 | ]) 79 | .then(function() { 80 | var pot = wpPackage.getPot(); 81 | 82 | if (pot.fileExists()) { 83 | pot.parse() 84 | .setFileComment(options.potComments) 85 | .setHeaders(options.potHeaders); 86 | 87 | // Allow the POT file to be modified with a callback. 88 | if ('function' === typeof options.processPot) { 89 | pot.contents = options.processPot.call(pot, pot.contents, options); 90 | } 91 | 92 | // Determine if the creation date is the only thing that changed. 93 | if (!options.updateTimestamp && pot.sameAs(originalPot)) { 94 | pot.setHeader('pot-creation-date', originalPot.initialDate); 95 | } 96 | 97 | pot.save(); 98 | } 99 | 100 | return Promise.resolve(wpPackage); 101 | }) 102 | .then(function maybeUpdatePoFiles(wpPackage) { 103 | if (options.updatePoFiles) { 104 | return msgMerge 105 | .updatePoFiles(wpPackage.getPotFilename()) 106 | .return(wpPackage); 107 | } 108 | 109 | return Promise.resolve(wpPackage); 110 | }) 111 | .catch(function(error) { 112 | console.log(error); 113 | }); 114 | }; 115 | -------------------------------------------------------------------------------- /lib/msgmerge.js: -------------------------------------------------------------------------------- 1 | /** 2 | * node-wp-i18n 3 | * https://github.com/cedaro/node-wp-i18n 4 | * 5 | * @copyright Copyright (c) 2015 Cedaro, LLC 6 | * @license MIT 7 | */ 8 | 9 | 'use strict'; 10 | 11 | var glob = require('glob'); 12 | var path = require('path'); 13 | var Promise = require('bluebird'); 14 | var util = require('./util'); 15 | 16 | module.exports = { 17 | merge: mergeFiles, 18 | updatePoFiles: updatePoFiles 19 | }; 20 | 21 | /** 22 | * Uses gettext msgmerge to merge a .pot file into a .po. 23 | * 24 | * @param {string} from File to merge from (generally a .pot file). 25 | * @param {string} to File to merge to (generally a .po file). 26 | * @returns {Promise} 27 | */ 28 | function mergeFiles(from, to) { 29 | return util.execFile('msgmerge', [ '--update', '--backup=none', to, from ]); 30 | } 31 | 32 | /** 33 | * Set multiple headers at once. 34 | * 35 | * Magically expands certain values to add Poedit headers. 36 | * 37 | * @param {string} filename Full path to a POT file. 38 | * @param {string} pattern Optional. Glob pattern of PO files to update. 39 | * @returns {Promise} 40 | */ 41 | function updatePoFiles(filename, pattern) { 42 | var merged = []; 43 | var searchPath = path.dirname(filename); 44 | 45 | pattern = pattern || '*.po'; 46 | 47 | glob.sync(pattern, { 48 | cwd: path.dirname(filename) 49 | }).forEach(function(file) { 50 | var poFile = path.join(searchPath, file); 51 | merged.push(mergeFiles(filename, poFile)); 52 | }); 53 | 54 | return Promise.all(merged); 55 | } 56 | -------------------------------------------------------------------------------- /lib/package.js: -------------------------------------------------------------------------------- 1 | /** 2 | * node-wp-i18n 3 | * https://github.com/cedaro/node-wp-i18n 4 | * 5 | * @copyright Copyright (c) 2015 Cedaro, LLC 6 | * @license MIT 7 | */ 8 | 9 | 'use strict'; 10 | 11 | var fs = require('fs'); 12 | var glob = require('glob'); 13 | var path = require('path'); 14 | var Pot = require('./pot'); 15 | var util = require('./util'); 16 | 17 | module.exports = WPPackage; 18 | 19 | /** 20 | * Guess the wpPackage slug. 21 | * 22 | * See MakePOT::guess_plugin_slug() in makepot.php 23 | * 24 | * @returns {string} 25 | */ 26 | function guessSlug(wpPackage) { 27 | var directory = wpPackage.getPath(); 28 | var slug = path.basename(directory); 29 | var slug2 = path.basename(path.dirname(directory)); 30 | 31 | if ('trunk' === slug || 'src' === slug) { 32 | slug = slug2; 33 | } else if (-1 !== ['branches', 'tags'].indexOf(slug2)) { 34 | slug = path.basename(path.dirname(path.dirname(directory))); 35 | } 36 | 37 | return slug; 38 | } 39 | 40 | /** 41 | * Discover the main package file. 42 | * 43 | * For themes, the main file will be style.css. The main file for plugins 44 | * contains the plugin headers. 45 | * 46 | * @param {WPPackage} wpPackage Package object. 47 | * @returns {string} 48 | */ 49 | function findMainFile(wpPackage) { 50 | if (wpPackage.isType('wp-theme')) { 51 | return 'style.css'; 52 | } 53 | 54 | var found = ''; 55 | var pluginFile = guessSlug(wpPackage) + '.php'; 56 | var filename = wpPackage.getPath(pluginFile); 57 | 58 | // Check if the main file exists. 59 | if (util.fileExists(filename) && wpPackage.getHeader('Plugin Name', filename)) { 60 | return pluginFile; 61 | } 62 | 63 | // Search for plugin headers in php files in the main directory. 64 | glob.sync('*.php', { 65 | cwd: wpPackage.getPath() 66 | }).forEach(function(file) { 67 | var filename = wpPackage.getPath(file); 68 | 69 | if (wpPackage.getHeader('Plugin Name', filename)) { 70 | found = file; 71 | } 72 | }); 73 | 74 | return found; 75 | } 76 | 77 | /** 78 | * Create a new package. 79 | * 80 | * @class WPPackage 81 | */ 82 | function WPPackage(directory, type) { 83 | if (!(this instanceof WPPackage)) { 84 | return new WPPackage(directory, type); 85 | } 86 | 87 | this.directory = null; 88 | this.domainPath = null; 89 | this.mainFile = null; 90 | this.potFile = null; 91 | this.type = 'wp-plugin'; 92 | 93 | this.initialize(directory, type); 94 | } 95 | 96 | /** 97 | * Initialize the package. 98 | * 99 | * @param {string} directory Full path to the package root directory. 100 | * @param {string} type Optional. Package type. 101 | * @returns {this} 102 | */ 103 | WPPackage.prototype.initialize = function(directory, type) { 104 | return this.setDirectory(directory) 105 | .setType(type) 106 | .setMainFile(findMainFile(this)) 107 | .setDomainPath(this.getHeader('Domain Path')) 108 | .setPotFile(this.getHeader('Text Domain') + '.pot'); 109 | }; 110 | 111 | /** 112 | * Set the package directory. 113 | * 114 | * @param {string} directory Full path to the package root directory. 115 | * @returns {this} 116 | */ 117 | WPPackage.prototype.setDirectory = function(directory) { 118 | this.directory = directory; 119 | 120 | if (!path.isAbsolute(directory)) { 121 | this.directory = path.resolve(process.cwd(), directory); 122 | } 123 | 124 | return this; 125 | }; 126 | 127 | /** 128 | * Retrieve the domain path. 129 | * 130 | * @returns {string} Relative directory to the language files from the package directory. 131 | */ 132 | WPPackage.prototype.getDomainPath = function() { 133 | return this.domainPath.replace(/^(\/|\\)/, ''); // Strip leading slashes. 134 | }; 135 | 136 | /** 137 | * Set the domain path. 138 | * 139 | * @param {string} domainPath Relative directory to the language files from the package directory. 140 | * @returns {this} 141 | */ 142 | WPPackage.prototype.setDomainPath = function(domainPath) { 143 | this.domainPath = domainPath; 144 | return this; 145 | }; 146 | 147 | /** 148 | * Get the value of a plugin or theme header. 149 | * 150 | * @param {string} name Name of the header. 151 | * @param {string} filename Optional. Absolute path to the main file. 152 | * @returns {string} 153 | */ 154 | WPPackage.prototype.getHeader = function(name, filename) { 155 | if ('undefined' === typeof filename) { 156 | filename = this.getPath(this.getMainFile()); 157 | } else if (!path.isAbsolute(filename)) { 158 | filename = path.resolve(this.getPath(), filename); 159 | } 160 | 161 | if (filename && util.fileExists(filename)) { 162 | var pattern = new RegExp(name + ':(.*)$', 'mi'); 163 | var matches = fs.readFileSync(filename, {encoding: 'utf-8'}).match(pattern); 164 | 165 | if (matches) { 166 | return matches.pop().trim(); 167 | } 168 | } 169 | 170 | if ('Text Domain' === name) { 171 | return guessSlug(this); 172 | } 173 | 174 | return ''; 175 | }; 176 | 177 | /** 178 | * Retrieve the main file. 179 | * 180 | * The main file contains the packager headers. 181 | * 182 | * @returns {string} Name of the main file relative to the package directory. 183 | */ 184 | WPPackage.prototype.getMainFile = function() { 185 | return this.mainFile; 186 | }; 187 | 188 | /** 189 | * Set the main file. 190 | * 191 | * @param {string} mainFile Name of the main file relative to the package directory. 192 | * @returns {this} 193 | */ 194 | WPPackage.prototype.setMainFile = function(mainFile) { 195 | this.mainFile = mainFile; 196 | return this; 197 | }; 198 | 199 | /** 200 | * Retrieve the full path to the package or a file within it. 201 | * 202 | * @param {string} file Optional. Name of a file relative to the package directory. 203 | * @returns {string} Full path to the package directory a file within it. 204 | */ 205 | WPPackage.prototype.getPath = function(file) { 206 | if ('undefined' === typeof file) { 207 | return this.directory; 208 | } 209 | 210 | return path.join(this.directory, file); 211 | }; 212 | 213 | /** 214 | * Retrieve the package POT object. 215 | * 216 | * @returns {Pot} 217 | */ 218 | WPPackage.prototype.getPot = function() { 219 | return new Pot(this.getPotFilename()); 220 | }; 221 | 222 | /** 223 | * Retrieve the name of the POT file. 224 | * 225 | * @returns {string} 226 | */ 227 | WPPackage.prototype.getPotFile = function() { 228 | return this.potFile; 229 | }; 230 | 231 | /** 232 | * Set the name of the POT file. 233 | * 234 | * @param {string} potFile Name of the pot file. 235 | * @returns {this} 236 | */ 237 | WPPackage.prototype.setPotFile = function(potFile) { 238 | this.potFile = potFile; 239 | return this; 240 | }; 241 | 242 | /** 243 | * Retrieve the full path to the POT file. 244 | * 245 | * @returns {string} 246 | */ 247 | WPPackage.prototype.getPotFilename = function() { 248 | return path.join(this.getPath(), this.getDomainPath(), this.potFile); 249 | }; 250 | 251 | /** 252 | * Retrieve the package slug. 253 | * 254 | * @returns {string} 255 | */ 256 | WPPackage.prototype.getSlug = function() { 257 | return guessSlug(this); 258 | }; 259 | 260 | /** 261 | * Retrieve the package type. 262 | * 263 | * @returns {string} 264 | */ 265 | WPPackage.prototype.getType = function() { 266 | return this.type; 267 | }; 268 | 269 | /** 270 | * Whether a package is a certain type. 271 | * 272 | * @param {string} type Package type. 273 | * @returns {boolean}} 274 | */ 275 | WPPackage.prototype.isType = function(type) { 276 | return this.getType() === type; 277 | }; 278 | 279 | /** 280 | * Set the package type. 281 | * 282 | * @param {string} type Optional. Defaults to 'wp-plugin' if the package doesn't have a style.css file. 283 | * @returns {this} 284 | */ 285 | WPPackage.prototype.setType = function(type) { 286 | if ('wp-theme' === type) { 287 | this.type = 'wp-theme'; 288 | } else if (('undefined' === typeof type || '' === type) && util.fileExists(this.getPath('style.css'))) { 289 | this.type = 'wp-theme'; 290 | } else { 291 | this.type = 'wp-plugin'; 292 | } 293 | 294 | return this; 295 | }; 296 | -------------------------------------------------------------------------------- /lib/pot.js: -------------------------------------------------------------------------------- 1 | /** 2 | * node-wp-i18n 3 | * https://github.com/cedaro/node-wp-i18n 4 | * 5 | * @copyright Copyright (c) 2015 Cedaro, LLC 6 | * @license MIT 7 | */ 8 | 9 | 'use strict'; 10 | 11 | var _ = require('lodash'); 12 | var crypto = require('crypto'); 13 | var fs = require('fs'); 14 | var gettext = require('gettext-parser'); 15 | var util = require('./util'); 16 | 17 | module.exports = Pot; 18 | 19 | /** 20 | * Fix POT file headers. 21 | * 22 | * Updates case-sensitive Poedit headers. 23 | * 24 | * @param {string} pot POT file contents. 25 | * @returns {string} 26 | */ 27 | function fixHeaders(contents) { 28 | contents = contents.replace(/x-poedit-keywordslist:/i, 'X-Poedit-KeywordsList:'); 29 | contents = contents.replace(/x-poedit-searchpath-/ig, 'X-Poedit-SearchPath-'); 30 | contents = contents.replace(/x-poedit-searchpathexcluded-/ig, 'X-Poedit-SearchPathExcluded-'); 31 | contents = contents.replace(/x-poedit-sourcecharset:/i, 'X-Poedit-SourceCharset:'); 32 | return contents; 33 | } 34 | 35 | function generateHash(content) { 36 | return crypto.createHash('md5').update(JSON.stringify(content)).digest('hex'); 37 | } 38 | 39 | /** 40 | * Normalize Pot contents created by gettext-parser. 41 | * 42 | * This normalizes dynamic strings in a POT file in order to compare them and 43 | * determine if anything has changed. 44 | * 45 | * Headers are stored in two locations. 46 | * 47 | * @param {Object} pot Pot contents created by gettext-parser. 48 | * @returns {Object} 49 | */ 50 | function normalizeForComparison(pot) { 51 | var clone = _.cloneDeep(pot); 52 | 53 | if (!pot) { 54 | return pot; 55 | } 56 | 57 | // Normalize the content type case. 58 | clone.headers['content-type'] = clone.headers['content-type'].toLowerCase(); 59 | 60 | // Blank out the dates. 61 | clone.headers['pot-creation-date'] = ''; 62 | clone.headers['po-revision-date'] = ''; 63 | 64 | // Blank out the headers in the translations object. These are used for 65 | // reference only and won't be compiled, so they shouldn't be used when 66 | // comparing POT objects. 67 | clone.translations['']['']['msgstr'] = ''; 68 | 69 | return clone; 70 | } 71 | 72 | /** 73 | * Create a new Pot object. 74 | * 75 | * @class Pot 76 | */ 77 | function Pot(filename) { 78 | if (! (this instanceof Pot)) { 79 | return new Pot(filename); 80 | } 81 | 82 | this.isOpen = false; 83 | this.filename = filename; 84 | this.contents = ''; 85 | this.initialDate = ''; 86 | this.fingerprint = ''; 87 | } 88 | 89 | /** 90 | * Whether the POT file exists. 91 | * 92 | * @returns {boolean} 93 | */ 94 | Pot.prototype.fileExists = function() { 95 | return util.fileExists(this.filename); 96 | }; 97 | 98 | /** 99 | * Parse the POT file using gettext-parser. 100 | * 101 | * Initializes default properties to determine if the file has changed. 102 | * 103 | * Parsing the file removes duplicates, replacing the need for the msguniq binary. 104 | * 105 | * @param {string} Full path to the package root directory. 106 | * @returns {this} 107 | */ 108 | Pot.prototype.parse = function() { 109 | if (!this.isOpen) { 110 | this.contents = fs.readFileSync(this.filename, 'utf8'); 111 | this.contents = gettext.po.parse(this.contents); 112 | this.initialDate = this.contents.headers['pot-creation-date']; 113 | this.fingerprint = generateHash(normalizeForComparison(this.contents)); 114 | this.isOpen = true; 115 | } 116 | 117 | return this; 118 | }; 119 | 120 | /** 121 | * Save the POT file. 122 | * 123 | * Writes the POT contents to a file. 124 | * 125 | * @returns {this} 126 | */ 127 | Pot.prototype.save = function() { 128 | var contents; 129 | 130 | if (this.isOpen) { 131 | contents = gettext.po.compile(this.contents).toString(); 132 | contents = fixHeaders(contents); 133 | 134 | fs.writeFileSync(this.filename, contents); 135 | this.isOpen = false; 136 | } 137 | 138 | return this; 139 | }; 140 | 141 | /** 142 | * Whether the contents have changed. 143 | * 144 | * @returns {boolean} 145 | */ 146 | Pot.prototype.hasChanged = function() { 147 | return generateHash(normalizeForComparison(this.contents)) !== this.fingerprint; 148 | }; 149 | 150 | /** 151 | * Reset the creation date header. 152 | * 153 | * Useful when strings haven't changed in the package and don't want to commit 154 | * an unnecessary change to a repository. 155 | * 156 | * @returns {this} 157 | */ 158 | Pot.prototype.resetCreationDate = function() { 159 | this.contents.headers['pot-creation-date'] = this.initialDate; 160 | return this; 161 | }; 162 | 163 | /** 164 | * Whether two POT files have the same content regardless of creation date header. 165 | * 166 | * @param {Pot} 167 | * @returns {boolean} 168 | */ 169 | Pot.prototype.sameAs = function(pot) { 170 | var fingerprint = generateHash(normalizeForComparison(this.contents)); 171 | 172 | var compareHash = -1; 173 | if (pot.fileExists()) { 174 | compareHash = generateHash(normalizeForComparison(pot.contents)); 175 | } 176 | 177 | return fingerprint === compareHash; 178 | }; 179 | 180 | /** 181 | * Set the comment that shows at the beginning of the POT file. 182 | * 183 | * @param {string} Comment text. 184 | * @returns {this} 185 | */ 186 | Pot.prototype.setFileComment = function(comment) { 187 | if ('' === comment) { 188 | return this; 189 | } 190 | 191 | comment = comment.replace('{year}', new Date().getFullYear()); 192 | this.contents.translations[''][''].comments.translator = comment; 193 | 194 | return this; 195 | }; 196 | 197 | /** 198 | * Set a header value. 199 | * 200 | * Magically expands certain values to add Poedit headers. 201 | * 202 | * @param {string} name Name of the header. 203 | * @param {string} value Value of the header. 204 | * @returns {this} 205 | */ 206 | Pot.prototype.setHeader = function(name, value) { 207 | var key = name.toLowerCase(); 208 | 209 | var poedit = { 210 | 'language': 'en', 211 | 'plural-forms': 'nplurals=2; plural=(n != 1);', 212 | 'x-poedit-country': 'United States', 213 | 'x-poedit-sourcecharset': 'UTF-8', 214 | 'x-poedit-keywordslist': true, 215 | 'x-poedit-basepath': '../', 216 | 'x-poedit-searchpath-0': '.', 217 | 'x-poedit-bookmarks': '', 218 | 'x-textdomain-support': 'yes' 219 | }; 220 | 221 | // Add default Poedit headers. 222 | if ('poedit' === key && true === value) { 223 | var self = this; 224 | _.forOwn(poedit, function(value, name) { 225 | if (!_.has(self.contents.headers, name)) { 226 | self.setHeader(name, value); 227 | } 228 | }); 229 | return this; 230 | } 231 | 232 | // Add the the Poedit keywordslist header. 233 | if ('x-poedit-keywordslist' === key && true === value) { 234 | value = '__;_e;_x:1,2c;_ex:1,2c;_n:1,2;_nx:1,2,4c;_n_noop:1,2;_nx_noop:1,2,3c;esc_attr__;esc_html__;esc_attr_e;esc_html_e;esc_attr_x:1,2c;esc_html_x:1,2c;'; 235 | } 236 | 237 | this.contents.headers[ key ] = value; 238 | return this; 239 | }; 240 | 241 | /** 242 | * Set multiple headers at once. 243 | * 244 | * @param {object} Headers object. 245 | * @returns {this} 246 | */ 247 | Pot.prototype.setHeaders = function(headers) { 248 | var self = this; 249 | 250 | _.forOwn(headers, function(value, name) { 251 | self.setHeader(name, value); 252 | }); 253 | 254 | return this; 255 | }; 256 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * node-wp-i18n 3 | * https://github.com/cedaro/node-wp-i18n 4 | * 5 | * @copyright Copyright (c) 2015 Cedaro, LLC 6 | * @license MIT 7 | */ 8 | 9 | 'use strict'; 10 | 11 | var execFile = require('child_process').execFile; 12 | var fs = require('fs'); 13 | var Promise = require('bluebird'); 14 | var spawn = require('child_process').spawn; 15 | 16 | module.exports = { 17 | /** 18 | * Execute a file and return a promise. 19 | * 20 | * @param {string} file Filename of the program to run. 21 | * @param {string[]} args List of string arguments. 22 | * @returns {Promise} 23 | */ 24 | execFile: function(file, args) { 25 | return new Promise(function(resolve, reject) { 26 | execFile(file, args, function(error, stdout) { 27 | console.log(stdout); 28 | 29 | if (error) { 30 | reject(error); 31 | } else { 32 | resolve(); 33 | } 34 | }); 35 | }); 36 | }, 37 | 38 | /** 39 | * Whether a file exists. 40 | * 41 | * @param {string} filename Full path to a file. 42 | * @returns {boolean} 43 | */ 44 | fileExists: function(filename) { 45 | try { 46 | var stat = fs.statSync(filename); 47 | } catch (ex) { 48 | return false; 49 | } 50 | 51 | return stat.isFile(); 52 | }, 53 | 54 | /** 55 | * Spawn a process and return a promise. 56 | * 57 | * @param {string} file Filename of the program to run. 58 | * @param {string[]} args List of string arguments. 59 | * @returns {Promise} 60 | */ 61 | spawn: function(file, args) { 62 | return new Promise(function(resolve, reject) { 63 | var child = spawn(file, args, { stdio: 'inherit' }); 64 | child.on('error', reject); 65 | child.on('close', resolve); 66 | }); 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-wp-i18n", 3 | "version": "1.2.7", 4 | "description": "Internationalize WordPress themes and plugins.", 5 | "license": "MIT", 6 | "homepage": "https://github.com/cedaro/node-wp-i18n", 7 | "repository": "cedaro/node-wp-i18n", 8 | "author": { 9 | "name": "Brady Vercher", 10 | "email": "brady@blazersix.com", 11 | "url": "http://www.cedaro.com/" 12 | }, 13 | "bin": { 14 | "wpi18n": "./bin/wpi18n" 15 | }, 16 | "main": "index.js", 17 | "scripts": { 18 | "lint": "jshint index.js lib/*.js", 19 | "pretest": "rm -rf tmp && cp -R test/fixtures tmp", 20 | "test": "tap test/*.js --cov" 21 | }, 22 | "dependencies": { 23 | "bluebird": "^3.4.1", 24 | "gettext-parser": "^3.1.0", 25 | "glob": "^7.0.5", 26 | "lodash": "^4.14.2", 27 | "minimist": "^1.2.5", 28 | "mkdirp": "^1.0.4", 29 | "tmp": "^0.2.1" 30 | }, 31 | "keywords": [ 32 | "wordpress", 33 | "i18n", 34 | "translation" 35 | ], 36 | "devDependencies": { 37 | "async": "^3.2.0", 38 | "jshint": "^2.9.2", 39 | "tap": "^14.11.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/addtextdomain.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var addtextdomain = require('../lib/addtextdomain'); 3 | var path = require('path'); 4 | var test = require('tap').test; 5 | -------------------------------------------------------------------------------- /test/fixtures/makepot/basic-plugin/basic-plugin.php: -------------------------------------------------------------------------------- 1 | \n" 12 | "Language-Team: LANGUAGE \n" 13 | "X-Generator: node-wp-i18n\n" 14 | 15 | #. Plugin Name of the plugin/theme 16 | msgid "Example Plugin" 17 | msgstr "" 18 | -------------------------------------------------------------------------------- /test/fixtures/msgmerge/msgmerge-en_GB.po: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2014 2 | # This file is distributed under the same license as the Example Plugin package. 3 | msgid "" 4 | msgstr "" 5 | "Project-Id-Version: Example Plugin\n" 6 | "Report-Msgid-Bugs-To: http://wordpress.org/support/plugin/basic-plugin\n" 7 | "POT-Creation-Date: 2014-03-20 19:54:59+00:00\n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=utf-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "PO-Revision-Date: 2014-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "X-Generator: grunt-wp-i18n 0.4.9\n" 15 | 16 | #. Test translation 17 | msgid "Color" 18 | msgstr "Colours" -------------------------------------------------------------------------------- /test/fixtures/msgmerge/msgmerge-nl_NL.po: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2014 2 | # This file is distributed under the same license as the Example Plugin package. 3 | msgid "" 4 | msgstr "" 5 | "Project-Id-Version: Example Plugin\n" 6 | "Report-Msgid-Bugs-To: http://wordpress.org/support/plugin/basic-plugin\n" 7 | "POT-Creation-Date: 2014-03-20 19:54:59+00:00\n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=utf-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "PO-Revision-Date: 2014-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "X-Generator: grunt-wp-i18n 0.4.9\n" 15 | 16 | #. Test translation 17 | msgid "Color" 18 | msgstr "Kleur" -------------------------------------------------------------------------------- /test/fixtures/msgmerge/msgmerge.pot: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2014 2 | # This file is distributed under the same license as the Example Plugin package. 3 | msgid "" 4 | msgstr "" 5 | "Project-Id-Version: Example Plugin\n" 6 | "Report-Msgid-Bugs-To: http://wordpress.org/support/plugin/basic-plugin\n" 7 | "POT-Creation-Date: 2014-03-20 19:54:59+00:00\n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=utf-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "PO-Revision-Date: 2014-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "X-Generator: grunt-wp-i18n 0.4.9\n" 15 | 16 | #. Test translation 17 | msgid "Colors" 18 | msgstr "" -------------------------------------------------------------------------------- /test/fixtures/packages/plugins/basic-plugin/basic-plugin.php: -------------------------------------------------------------------------------- 1 | \n" 13 | "Language-Team: LANGUAGE \n" 14 | 15 | #. Plugin Name of the plugin/theme 16 | msgid "Example Plugin" 17 | msgstr "" 18 | -------------------------------------------------------------------------------- /test/fixtures/pot/save.pot: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2016 2 | # This file is distributed under the same license as the Example Plugin package. 3 | msgid "" 4 | msgstr "" 5 | "Project-Id-Version: Example Plugin\n" 6 | "POT-Creation-Date: 2014-03-20 19:54:59+00:00\n" 7 | "MIME-Version: 1.0\n" 8 | "Content-Type: text/plain; charset=utf-8\n" 9 | "Content-Transfer-Encoding: 8bit\n" 10 | "PO-Revision-Date: 2016-MO-DA HO:MI+ZONE\n" 11 | "Last-Translator: FULL NAME \n" 12 | "Language-Team: LANGUAGE \n" 13 | 14 | #. Plugin Name of the plugin/theme 15 | msgid "Example Plugin" 16 | msgstr "" 17 | -------------------------------------------------------------------------------- /test/makepot.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var gettext = require('gettext-parser'); 3 | var makepot = require('../lib/makepot'); 4 | var path = require('path'); 5 | var test = require('tap').test; 6 | 7 | test('makepot default', function(t) { 8 | t.plan(3); 9 | 10 | makepot({ 11 | cwd: path.resolve('tmp/makepot/basic-plugin') 12 | }).then(function() { 13 | var potFilename = path.resolve('tmp/makepot/basic-plugin/basic-plugin.pot'); 14 | t.ok(fs.statSync(potFilename)); 15 | 16 | var pot = gettext.po.parse(fs.readFileSync(potFilename, 'utf8')); 17 | var pluginName = 'Example Plugin'; 18 | t.equal(pot.headers['project-id-version'], pluginName, 'the plugin name should be the project id in the pot file'); 19 | t.equal(pot.translations[''][ pluginName ]['msgid'], pluginName, 'the plugin name should be included as a string in the pot file'); 20 | }); 21 | }); 22 | 23 | test('makepot custom pot file', function(t) { 24 | t.plan(1); 25 | 26 | makepot({ 27 | cwd: path.resolve('tmp/makepot/basic-plugin'), 28 | potFile: 'custom.pot' 29 | }).then(function() { 30 | var potFilename = path.resolve('tmp/makepot/basic-plugin/custom.pot'); 31 | t.ok(fs.statSync(potFilename)); 32 | }); 33 | }); 34 | 35 | test('makepot no changes', function(t) { 36 | t.plan(2); 37 | 38 | var potFilename = path.resolve('tmp/makepot/plugin-with-pot/plugin-with-pot.pot'); 39 | var pot = gettext.po.parse(fs.readFileSync(potFilename, 'utf8')); 40 | var creationDate = pot.headers['pot-creation-date']; 41 | 42 | makepot({ 43 | cwd: path.resolve('tmp/makepot/plugin-with-pot'), 44 | potComments: 'Copyright', 45 | potHeaders: { 46 | 'x-generator': 'node-wp-i18n' 47 | }, 48 | updateTimestamp: false 49 | }).then(function() { 50 | t.ok(fs.statSync(potFilename)); 51 | 52 | var pot = gettext.po.parse(fs.readFileSync(potFilename, 'utf8')); 53 | t.equal(pot.headers['pot-creation-date'], creationDate, 'the creation date should not change'); 54 | }); 55 | }); 56 | 57 | /** 58 | * @link https://github.com/cedaro/node-wp-i18n/issues/16 59 | */ 60 | test('makepot when working directory is not package root', function(t) { 61 | t.plan(3); 62 | 63 | makepot({ 64 | cwd: path.resolve('tmp/makepot/nested-theme'), 65 | domainPath: '/languages', 66 | mainFile: 'subdir/style.css', 67 | potFile: 'nested-theme.pot', 68 | type: 'wp-theme' 69 | }).then(function() { 70 | var potFilename = path.resolve('tmp/makepot/nested-theme/languages/nested-theme.pot'); 71 | t.ok(fs.statSync(potFilename)); 72 | 73 | var pot = gettext.po.parse(fs.readFileSync(potFilename, 'utf8')); 74 | var themeName = 'Example Theme'; 75 | t.equal(pot.headers['project-id-version'], themeName, 'the theme name should be the project id in the pot file'); 76 | t.equal(pot.translations[''][ themeName ]['msgid'], themeName, 'the theme name should be included as a string in the pot file'); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /test/msgmerge.js: -------------------------------------------------------------------------------- 1 | var execFileSync = require('child_process').execFileSync; 2 | var fs = require('fs'); 3 | var gettext = require('gettext-parser'); 4 | var path = require('path'); 5 | var test = require('tap').test; 6 | var msgmerge = require('../lib/msgmerge'); 7 | 8 | var hasMsgMerge = (function() { 9 | try { 10 | execFileSync('msgmerge', ['--version']); 11 | } catch (ex) { 12 | return false; 13 | } 14 | return true; 15 | })(); 16 | 17 | test('update po files', { skip: ! hasMsgMerge }, function(t) { 18 | t.plan(4); 19 | 20 | var potFilename = path.resolve('tmp/msgmerge/msgmerge.pot'); 21 | msgmerge.updatePoFiles(potFilename) 22 | .then(function() { 23 | var en_GB = gettext.po.parse(fs.readFileSync('tmp/msgmerge/msgmerge-en_GB.po', 'utf8')); 24 | var nl_NL = gettext.po.parse(fs.readFileSync('tmp/msgmerge/msgmerge-nl_NL.po', 'utf8')); 25 | 26 | t.ok(en_GB.translations['']['Colors'], '"Colors" string should exist'); 27 | t.ok(nl_NL.translations['']['Colors'], '"Colors" string should exist'); 28 | 29 | t.equal(en_GB.translations['']['Colors']['comments']['flag'], 'fuzzy', 'a changed translation should be fuzzy after msgmerge'); 30 | t.equal(nl_NL.translations['']['Colors']['comments']['flag'], 'fuzzy', 'a changed translation should be fuzzy after msgmerge'); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /test/package.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var test = require('tap').test; 3 | var WPPackage = require('../lib/package'); 4 | 5 | test('plugin package instance', function(t) { 6 | t.plan(7); 7 | 8 | var directory = path.resolve('tmp/packages/plugins/basic-plugin'); 9 | var type = 'wp-plugin'; 10 | var wpPackage = WPPackage(directory, type); 11 | t.type(wpPackage, 'WPPackage'); 12 | 13 | 14 | var wpPackage = new WPPackage(directory, type); 15 | t.type(wpPackage, 'WPPackage'); 16 | t.equal(wpPackage.getType(), type); 17 | t.equal(wpPackage.isType(type), true); 18 | t.equal(wpPackage.isType('wp-theme'), false); 19 | t.equal(wpPackage.getDomainPath(), ''); 20 | t.equal(wpPackage.getPotFile(), 'basic-plugin.pot'); 21 | }); 22 | 23 | test('theme package instance', function(t) { 24 | t.plan(6); 25 | 26 | var directory = path.resolve('tmp/packages/themes/basic-theme'); 27 | var type = 'wp-theme'; 28 | var wpPackage = new WPPackage(directory, type); 29 | 30 | t.type(wpPackage, 'WPPackage'); 31 | t.equal(wpPackage.getType(), type); 32 | t.equal(wpPackage.isType(type), true); 33 | t.equal(wpPackage.isType('wp-plugin'), false); 34 | t.equal(wpPackage.getDomainPath(), ''); 35 | t.equal(wpPackage.getPotFile(), 'basic-theme.pot'); 36 | }); 37 | 38 | test('determine package type without type parameter', function(t) { 39 | t.plan(3); 40 | 41 | var directory = path.resolve('tmp/packages/plugins/basic-plugin'); 42 | var wpPackage = new WPPackage(directory); 43 | t.equal('wp-plugin', wpPackage.getType()); 44 | 45 | var directory = path.resolve('tmp/packages/plugins/different-slug'); 46 | var wpPackage = new WPPackage(directory); 47 | t.equal('wp-plugin', wpPackage.getType()); 48 | 49 | var directory = path.resolve('tmp/packages/themes/basic-theme'); 50 | var wpPackage = new WPPackage(directory); 51 | t.equal('wp-theme', wpPackage.getType()); 52 | }); 53 | 54 | test('set package directory', function(t) { 55 | t.plan(2); 56 | 57 | var directory = path.resolve('tmp/packages/plugins/basic-plugin'); 58 | var wpPackage = new WPPackage(directory); 59 | t.equal(wpPackage.directory, directory); 60 | 61 | var newDirectory = 'tmp/packages/plugins/basic-plugin2'; 62 | wpPackage.setDirectory(newDirectory); 63 | t.equal(wpPackage.directory, path.resolve(newDirectory)); 64 | }); 65 | 66 | test('set package domain path', function(t) { 67 | t.plan(1); 68 | 69 | var directory = path.resolve('tmp/packages/plugins/basic-plugin'); 70 | var wpPackage = new WPPackage(directory); 71 | wpPackage.setDomainPath('/languages'); 72 | t.equal(wpPackage.getDomainPath(), 'languages'); 73 | }); 74 | 75 | test('set main package file', function(t) { 76 | t.plan(1); 77 | 78 | var directory = path.resolve('tmp/packages/plugins/basic-plugin'); 79 | var mainFile = 'basic-plugin.php'; 80 | var wpPackage = new WPPackage(directory); 81 | wpPackage.setMainFile(mainFile); 82 | t.equal(wpPackage.getMainFile(), mainFile); 83 | }); 84 | 85 | test('get package path', function(t) { 86 | t.plan(2); 87 | 88 | var directory = path.resolve('tmp/packages/themes/basic-theme'); 89 | var wpPackage = new WPPackage(directory); 90 | t.equal(wpPackage.getPath(), directory); 91 | t.equal(wpPackage.getPath('style.css'), path.resolve(directory, 'style.css')); 92 | }); 93 | 94 | test('set package pot file', function(t) { 95 | t.plan(1); 96 | 97 | var directory = path.resolve('tmp/packages/plugins/basic-plugin'); 98 | var potFile = 'basic-plugin.pot'; 99 | var wpPackage = new WPPackage(directory); 100 | wpPackage.setPotFile(potFile); 101 | t.equal(wpPackage.getPotFile(), potFile); 102 | }); 103 | 104 | test('get package pot filename', function(t) { 105 | t.plan(2); 106 | 107 | var directory = path.resolve('tmp/packages/plugins/basic-plugin'); 108 | var potFile = 'basic-plugin.pot'; 109 | var wpPackage = new WPPackage(directory); 110 | wpPackage.setPotFile(potFile); 111 | t.equal(wpPackage.getPotFilename(), path.resolve(directory, potFile)); 112 | 113 | wpPackage.setDomainPath('/languages'); 114 | t.equal(wpPackage.getPotFilename(), path.resolve(directory, 'languages', potFile)); 115 | }); 116 | 117 | test('get package pot', function(t) { 118 | t.plan(1); 119 | 120 | var directory = path.resolve('tmp/packages/plugins/basic-plugin'); 121 | var wpPackage = new WPPackage(directory); 122 | t.type(wpPackage.getPot(), 'Pot'); 123 | }); 124 | 125 | test('get package header', function(t) { 126 | t.plan(5); 127 | 128 | var directory = path.resolve('tmp/packages/plugins/basic-plugin'); 129 | var wpPackage = new WPPackage(directory); 130 | t.equal(wpPackage.getHeader('Text Domain'), 'basic-plugin'); 131 | t.equal(wpPackage.getHeader('Plugin Name', 'basic-plugin.php'), 'Example Plugin'); 132 | 133 | var directory = path.resolve('tmp/packages/plugins/plugin-headers'); 134 | var wpPackage = new WPPackage(directory); 135 | t.equal(wpPackage.getHeader('Plugin Name'), 'Example Plugin'); 136 | t.equal(wpPackage.getHeader('Domain Path'), '/languages'); 137 | t.equal(wpPackage.getHeader('Text Domain'), 'example-plugin'); 138 | }); 139 | 140 | test('get package text domain without header', function(t) { 141 | t.plan(4); 142 | 143 | var directory = path.resolve('tmp/packages/plugins/basic-plugin'); 144 | var wpPackage = new WPPackage(directory); 145 | t.equal(wpPackage.getHeader('Text Domain'), 'basic-plugin'); 146 | 147 | // Packages default to the `wp-plugin` type. 148 | var directory = path.resolve('tmp/packages/plugins/invalid-plugin'); 149 | var wpPackage = new WPPackage(directory); 150 | t.equal(wpPackage.getHeader('Text Domain'), 'invalid-plugin'); 151 | 152 | var directory = path.resolve('tmp/packages/themes/nested-theme/src'); 153 | var wpPackage = new WPPackage(directory); 154 | t.equal(wpPackage.getHeader('Text Domain'), 'nested-theme'); 155 | 156 | var directory = path.resolve('tmp/packages/themes/svn-theme/tags/1.0.0'); 157 | var wpPackage = new WPPackage(directory); 158 | t.equal(wpPackage.getHeader('Text Domain'), 'svn-theme'); 159 | }); 160 | -------------------------------------------------------------------------------- /test/pot.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var Pot = require('../lib/pot'); 3 | var test = require('tap').test; 4 | 5 | test('pot instance', function(t) { 6 | t.plan(1); 7 | 8 | var pot = Pot(); 9 | t.type(pot, 'Pot'); 10 | }); 11 | 12 | test('pot file does not exist', function(t) { 13 | t.plan(2); 14 | 15 | var filename = path.resolve('tmp/pot/fake.pot'); 16 | var pot = new Pot(filename); 17 | t.type(pot, 'Pot'); 18 | t.equal(pot.fileExists(), false); 19 | }); 20 | 21 | test('pot file exists', function(t) { 22 | t.plan(1); 23 | 24 | var filename = path.resolve('tmp/pot/basic.pot'); 25 | var pot = new Pot(filename); 26 | t.equal(pot.fileExists(), true); 27 | }); 28 | 29 | test('parse pot file', function(t) { 30 | t.plan(6); 31 | 32 | var filename = path.resolve('tmp/pot/basic.pot'); 33 | var pot = new Pot(filename); 34 | pot = pot.parse().parse(); 35 | 36 | t.type(pot, 'Pot'); 37 | t.equal(pot.isOpen, true); 38 | t.type(pot.contents, 'object'); 39 | t.ok(pot.initialDate); 40 | t.ok(pot.fingerprint); 41 | t.equal(pot.hasChanged(), false); 42 | }); 43 | 44 | test('has changed on post creation header update', function(t) { 45 | t.plan(1); 46 | 47 | var filename = path.resolve('tmp/pot/basic.pot'); 48 | var pot = new Pot(filename); 49 | 50 | pot.parse() 51 | .setHeader('pot-creation-date', '2003-04-01 14:12:34+00:00'); 52 | 53 | t.equal(pot.hasChanged(), false); 54 | }); 55 | 56 | test('compare pot file with non-existent pot file', function(t) { 57 | t.plan(1); 58 | 59 | var filename = path.resolve('tmp/pot/basic.pot'); 60 | var pot = new Pot(filename); 61 | var fake = new Pot(path.resolve('tmp/pot/fake.pot')); 62 | 63 | pot.parse(); 64 | 65 | t.equal(pot.sameAs(fake), false); 66 | }); 67 | 68 | test('compare same pot files with different creation date headers', function(t) { 69 | t.plan(1); 70 | 71 | var filename = path.resolve('tmp/pot/basic.pot'); 72 | var pot = new Pot(filename); 73 | var pot2 = new Pot(filename); 74 | 75 | pot.parse().setHeader('pot-creation-date', '2003-04-01 14:12:34+00:00'); 76 | pot2.parse() 77 | 78 | t.equal(pot.sameAs(pot2), true); 79 | }); 80 | 81 | test('reset pot creation date', function(t) { 82 | t.plan(1); 83 | 84 | var filename = path.resolve('tmp/pot/basic.pot'); 85 | var pot = new Pot(filename); 86 | 87 | pot.parse() 88 | .setHeader('pot-creation-date', '2003-04-01 14:12:34+00:00') 89 | .resetCreationDate(); 90 | 91 | t.equal(pot.contents.headers['pot-creation-date'], '2014-03-20 19:54:59+00:00'); 92 | }); 93 | 94 | test('set pot file comment', function(t) { 95 | t.plan(2); 96 | 97 | var filename = path.resolve('tmp/pot/basic.pot'); 98 | var pot = new Pot(filename); 99 | var comment = 'a file comment'; 100 | 101 | pot.parse() 102 | .setFileComment(comment); 103 | 104 | t.equal(pot.contents.translations[''][''].comments.translator, comment); 105 | 106 | pot.setFileComment(''); 107 | t.equal(pot.contents.translations[''][''].comments.translator, comment); 108 | }); 109 | 110 | test('set pot header', function(t) { 111 | t.plan(1); 112 | 113 | var filename = path.resolve('tmp/pot/basic.pot'); 114 | var pot = new Pot(filename); 115 | var key = 'report-msgid-bugs-to'; 116 | var value = 'https://example.com'; 117 | 118 | pot.parse() 119 | .setHeader(key, value); 120 | 121 | t.equal(pot.contents.headers[ key ], value); 122 | }); 123 | 124 | test('set pot headers', function(t) { 125 | t.plan(2); 126 | 127 | var filename = path.resolve('tmp/pot/basic.pot'); 128 | var pot = new Pot(filename); 129 | 130 | pot.parse() 131 | .setHeaders({ 132 | 'last-translator': 'Firstus Lastus', 133 | 'language-team': 'translate@example.com' 134 | }); 135 | 136 | t.equal(pot.contents.headers['last-translator'], 'Firstus Lastus'); 137 | t.equal(pot.contents.headers['language-team'], 'translate@example.com'); 138 | }); 139 | 140 | test('set poedit headers', function(t) { 141 | t.plan(9); 142 | 143 | var filename = path.resolve('tmp/pot/basic.pot'); 144 | var pot = new Pot(filename); 145 | 146 | pot.parse() 147 | .setHeader('poedit', true); 148 | 149 | var headers = pot.contents.headers; 150 | t.equal(headers['language'], 'en'); 151 | t.equal(headers['plural-forms'], 'nplurals=2; plural=(n != 1);'); 152 | t.equal(headers['x-poedit-country'], 'United States'); 153 | t.equal(headers['x-poedit-sourcecharset'], 'UTF-8'); 154 | t.equal(headers['x-poedit-basepath'], '../'); 155 | t.equal(headers['x-poedit-searchpath-0'], '.'); 156 | t.equal(headers['x-poedit-bookmarks'], ''); 157 | t.equal(headers['x-textdomain-support'], 'yes'); 158 | t.equal(headers['x-poedit-keywordslist'], '__;_e;_x:1,2c;_ex:1,2c;_n:1,2;_nx:1,2,4c;_n_noop:1,2;_nx_noop:1,2,3c;esc_attr__;esc_html__;esc_attr_e;esc_html_e;esc_attr_x:1,2c;esc_html_x:1,2c;'); 159 | }); 160 | 161 | test('set poedit headers without overriding existing header values', function(t) { 162 | t.plan(1); 163 | 164 | var filename = path.resolve('tmp/pot/basic.pot'); 165 | var pot = new Pot(filename); 166 | 167 | pot.parse() 168 | .setHeader('x-poedit-country', 'Spain') 169 | .setHeader('poedit', true); 170 | 171 | var headers = pot.contents.headers; 172 | t.equal(headers['x-poedit-country'], 'Spain'); 173 | }); 174 | 175 | test('save pot file', function(t) { 176 | t.plan(2); 177 | 178 | var filename = path.resolve('tmp/pot/save.pot'); 179 | var pot = new Pot(filename); 180 | var comment = 'a file comment'; 181 | 182 | pot.parse() 183 | .setFileComment(comment) 184 | .save().save(); 185 | 186 | t.equal(pot.isOpen, false); 187 | 188 | var pot = new Pot(filename); 189 | pot.parse(); 190 | 191 | t.equal(pot.contents.translations[''][''].comments.translator, 'a file comment'); 192 | }); 193 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var test = require('tap').test; 3 | var util = require('../lib/util'); 4 | 5 | test('execute file', function(t) { 6 | t.plan(2); 7 | 8 | util.execFile('node', ['-v']) 9 | .then(t.pass) 10 | .catch(t.threw); 11 | 12 | util.execFile('unknown') 13 | .then(t.fail) 14 | .catch(t.pass); 15 | }); 16 | 17 | test('file exists', function(t) { 18 | t.plan(1); 19 | 20 | var filename = path.resolve(__dirname, 'fixtures/pot/basic.pot'); 21 | t.equal(util.fileExists(filename), true); 22 | }); 23 | 24 | test('spawn a process', function(t) { 25 | t.plan(2); 26 | 27 | util.spawn('node', ['-v']) 28 | .then(t.pass) 29 | .catch(t.threw); 30 | 31 | util.spawn('unknown') 32 | .then(t.fail) 33 | .catch(t.pass); 34 | }); 35 | --------------------------------------------------------------------------------