Skip to content
This repository
Browse code

API CHANGE Removed $priority arguments from _t(), use module prioriti…

…es instead.

ENHANCEMENT Refactored i18nTextCollector collection logic alongside $priority removal, from regex to (slightly more maintainable) PHP tokenizer. Using var_export() for generating PHP, which auto-escapes strings more robustly.
ENHANCEMENT Refactored i18nTextCollector into pluggable writers (in preparation of new YML output format)
  • Loading branch information...
commit fca2c205b71e6bf304d1e085df7485a8dd2c8453 1 parent d44f6b3
Ingo Schommer authored April 14, 2012
62  docs/en/topics/i18n.md
Source Rendered
@@ -118,7 +118,7 @@ The field tries to translate the date formats and locales into a format compatib
118 118
 	$field->setConfig('jslocale', 'de'); // jQuery UI only has a generic German localization
119 119
 	$field->setConfig('dateformat', 'dd. MMMM YYYY'); // will be transformed to 'dd. MM yy' for jQuery
120 120
 
121  
-## Adapting modules for i18n
  121
+## Translating text
122 122
 
123 123
 Adapting a module to make it localizable is easy with SilverStripe. You just need to avoid hardcoding strings that are
124 124
 language-dependent and use a translator function call instead.
@@ -130,59 +130,24 @@ language-dependent and use a translator function call instead.
130 130
 	echo _t("Namespace.Entity","This is a string");
131 131
 
132 132
 
133  
-All strings passed through the _t() function will be collected in a separate language table (see "Collecting entities"
  133
+All strings passed through the `_t()` function will be collected in a separate language table (see "Collecting entities"
134 134
 below), which is the starting point for translations.
135 135
 
136 136
 ### The _t() function
137 137
 
138  
-Here is the function prototype of this translator function
  138
+The `_t()` function is the main gateway to localized text, and takes four parameters, all but the first being optional.
139 139
 
140  
-	:::php
141  
-	public function _t(string $entity [, string $string [, int $priority [, string $context]]]) {
142  
-
143  
-
144  
-**$entity:** The first parameter is the identifier, and is composed by a namespace and an entity name, with a dot separating them. 
145  
-The main class name (i.e. the same one that the php name file) should usually be used as the namespace. This means that
146  
-if we are coding in the file LeftAndMain.php, the namespace should be 'LeftAndMain', and therefore the complete first
147  
-parameter would be 'LeftAndMain.ENTITY'. There is an exception to this rule. If you are using the same exactly string in two different files, for example in
148  
-A.php and B.php, and the string in B.php will always be the same string that in A.php, then you can 'declare' this
149  
-string in A.php with `_t('A.ENTITY','String that is used in A and B');`{php} and then in B.php simply write:
150  
-`_t('A.ENTITY');`{php} In this way if somewhere in the future you need to modify this string, you just need to edit it
151  
-in one file (A.php). Translators will also have to translate this string just once. Entity names are by convention written in uppercase. They have to be unique within their namespace, and its purpose is
152  
-to serve as an identificator to this string, together with the namespace. Having an unique identificator for each string
153  
-allows some features like change tracking. Therefore, a meaningful name is always welcomed, although not required. And
154  
-also, that's why you shouldn't change an existing entity name in the code, unless you have a good reason to do it.
155  
-
156  
-**$string:** The second parameter is the string itself. It's not mandatory if you have set this same string in another place before
157  
-(using the same class and entity). So you could write `_t('ClassName.HELLO',"Hello")` and later `_t('ClassName.HELLO')`.
158  
-In fact, if you write the string in this second case, a warning will be issued when text-collecting to alert that you
159  
-are redeclaring an entity.
160  
-
161  
-**$priority:** Priority parameter is an optional parameter and it can be used to set a translation priority. If a string is widely
162  
-used, it should have a high priority (PR_HIGH), in this way translators will be able to prioritise the translation of
163  
-this strings. If a string is extremely rarely shown, use PR_LOW. You can use PR_MEDIUM as well. Leaving this field blank
164  
-will be interpretated as a "normal" priority (some less than PR_MEDIUM). Using priorities allows translators to benefit from the 80/20 rule when translating, since typically there is a reduced
165  
-set of strings that are widely displayed, and a lot of more specific strings. Therefore, in a module with a considerable
166  
-amount of strings, where partial translations can be expected, priorities will help to have translated the most
167  
-displayed strings. If a string is in a class is inheritable, it's not recommended to establish a priority (we don't know about child
168  
-behavior a priori).
169  
-
170  
-### Context
171  
-
172  
-Last parameter is context, it's also optional. Sometimes short phrases or words can have several translations depending
173  
-upon where they are used, and Context serves as a way to tell translators more information about the string in these
174  
-cases where translating can be difficult, due to lack of context or ambiguity.
175  
-
176  
-This context param can also be used with other situations where translation may need to know more than the original
177  
-string, for example with sprintf '%' params inside the string, since you can tell translators about the meaning of this
178  
-parameters.
  140
+ * **$entity:** Unique identifier, composed by a namespace and an entity name, with a dot separating them. Both are arbitrary names, although by convention we use the name of the containing class or template. Use this identifier to reference the same translation elsewhere in your code. 
  141
+ * **$string:** (optional) The original language string to be translated. Only needs to be declared once, and gets picked up the [text collector](#collecting-text).
  142
+ * **$string:** (optional) Natural language (particularly short phrases and individual words)
  143
+are very context dependent. This parameter allows the developer to convey this information
  144
+to the translator. Can also be used to explain `sprintf()` placeholders.
179 145
 
180 146
 	:::php
181 147
 	//Example 4: Using context to hint information about a parameter
182 148
 	sprintf(
183 149
 		_t('CMSMain.RESTORED', 
184 150
 			"Restored '%s' successfully", 
185  
-			PR_MEDIUM, 
186 151
 			'Param %s is a title'
187 152
 		),
188 153
 		$title
@@ -255,10 +220,14 @@ If you want to run the text collector for just one module you can use the 'modul
255 220
 **Note**: You'll need to install PHPUnit to run the text collector (see [testing-guide](/topics/testing)).
256 221
 </div>
257 222
 
258  
-## Language tables in PHP
  223
+## Language definitions
  224
+
  225
+Each module can have one language table per locale, stored by convention in the `lang/` subfolder.
  226
+The translation is powered by `[Zend_Translate](http://framework.zend.com/manual/en/zend.translate.html)`,
  227
+which supports different translation adapters, dealing with different storage formats.
259 228
 
260  
-Each module can have one language table per locale. These tables are just PHP files with array notations. By convention,
261  
-the files are stored in the /lang subfolder, and are named after their locale value, e.g. "en_US.php".
  229
+In SilverStripe 2.x, there tables are just PHP files with array notations,
  230
+stored based on their locale name (e.g. "en_US.php").
262 231
 
263 232
 Example: framework/lang/en_US.php (extract)
264 233
 
@@ -266,7 +235,6 @@ Example: framework/lang/en_US.php (extract)
266 235
 	// ...
267 236
 	$lang['en_US']['ImageUploader']['ATTACH'] = array(
268 237
 		'Attach %s',
269  
-		PR_MEDIUM,
270 238
 		'Attach image/file'
271 239
 	);
272 240
 	$lang['en_US']['FileIFrameField']['NOTEADDFILES'] = 'You can add files once you have saved for the first time.';
13  i18n/i18n.php
@@ -1452,14 +1452,17 @@ public static function get_time_format() {
1452 1452
 	 * 						 the class name where this string is used and Entity identifies the string inside the namespace.
1453 1453
 	 * @param string $string The original string itself. In a usual call this is a mandatory parameter, but if you are reusing a string which
1454 1454
 	 *				 has already been "declared" (using another call to this function, with the same class and entity), you can omit it.
1455  
-	 * @param string $priority Optional parameter to set a translation priority. If a string is widely used, should have a high priority (PR_HIGH),
1456  
-	 * 				    in this way translators will be able to prioritise this strings. If a string is rarely shown, you should use PR_LOW.
1457  
-	 *				    You can use PR_MEDIUM as well. Leaving this field blank will be interpretated as a "normal" priority (less than PR_MEDIUM).
1458 1455
 	 * @param string $context If the string can be difficult to translate by any reason, you can help translators with some more info using this param
1459  
-	 *
1460 1456
 	 * @return string The translated string, according to the currently set locale {@link i18n::set_locale()}
1461 1457
 	 */
1462  
-	static function _t($entity, $string = "", $priority = 40, $context = "") {
  1458
+	static function _t($entity, $string = "", $context = "") {
  1459
+		if(is_numeric($context) && in_array($context, array(PR_LOW, PR_MEDIUM, PR_HIGH))) {
  1460
+			$context = func_get_arg(4);
  1461
+			Deprecation::notice(
  1462
+				'3.0', 
  1463
+				'The $priority argument to _t() is deprecated, please use module inclusion priorities instead'
  1464
+			);
  1465
+		}
1463 1466
 		// get current locale (either default or user preference)
1464 1467
 		$locale = i18n::get_locale();
1465 1468
 		$lang = i18n::get_lang_from_locale($locale);
348  i18n/i18nTextCollector.php
@@ -37,25 +37,33 @@ class i18nTextCollector extends Object {
37 37
 	 * @todo Fully support changing of basePath through {@link SSViewer} and {@link ManifestBuilder}
38 38
 	 */
39 39
 	public $basePath;
40  
-	
  40
+
  41
+	public $baseSavePath;
  42
+
41 43
 	/**
42  
-	 * @var string $baseSavePath The directory base on which the collector should create new lang folders and files.
43  
-	 * Usually the webroot set through {@link Director::baseFolder()}.
44  
-	 * Can be overwritten for testing or export purposes.
45  
-	 * @todo Fully support changing of baseSavePath through {@link SSViewer} and {@link ManifestBuilder}
  44
+	 * @var i18nTextCollector_Writer
46 45
 	 */
47  
-	public $baseSavePath;
  46
+	protected $writer;
48 47
 	
49 48
 	/**
50 49
 	 * @param $locale
51 50
 	 */
52 51
 	function __construct($locale = null) {
53  
-		$this->defaultLocale = ($locale) ? $locale : i18n::default_locale();
  52
+		$this->defaultLocale = ($locale) ? $locale : i18n::get_lang_from_locale(i18n::default_locale());
54 53
 		$this->basePath = Director::baseFolder();
55 54
 		$this->baseSavePath = Director::baseFolder();
56 55
 		
57 56
 		parent::__construct();
58 57
 	}
  58
+
  59
+	public function setWriter($writer) {
  60
+		$this->writer = $writer;
  61
+	}
  62
+
  63
+	public function getWriter() {
  64
+		if(!$this->writer) $this->writer = new i18nTextCollector_Writer_Php();
  65
+		return $this->writer;
  66
+	}
59 67
 	
60 68
 	/**
61 69
 	 * This is the main method to build the master string tables with the original strings.
@@ -139,10 +147,10 @@ public function run($restrictToModules = null) {
139 147
 			}			
140 148
 		}
141 149
 
142  
-		// Write the generated master string tables
143  
-		$this->writeMasterStringFile($entitiesByModule);
144  
-		
145  
-		//Debug::message("Done!", false);
  150
+		// Write each module language file
  151
+		if($entitiesByModule) foreach($entitiesByModule as $module => $entities) {
  152
+			$this->getWriter()->write($entities, $this->defaultLocale, $this->baseSavePath . '/' . $module);
  153
+		}
146 154
 	}
147 155
 	
148 156
 	/**
@@ -151,7 +159,7 @@ public function run($restrictToModules = null) {
151 159
 	 * @param string $module Module's name or 'themes'
152 160
 	 */
153 161
 	protected function processModule($module) {	
154  
-		$entitiesArr = array();
  162
+		$entities = array();
155 163
 
156 164
 		//Debug::message("Processing Module '{$module}'", false);
157 165
 
@@ -166,8 +174,8 @@ protected function processModule($module) {
166 174
 			// exclude ss-templates, they're scanned separately
167 175
 			if(substr($filePath,-3) == 'php') {
168 176
 				$content = file_get_contents($filePath);
169  
-				$entitiesArr = array_merge($entitiesArr,(array)$this->collectFromCode($content, $module));
170  
-				$entitiesArr = array_merge($entitiesArr, (array)$this->collectFromEntityProviders($filePath, $module));
  177
+				$entities = array_merge($entities,(array)$this->collectFromCode($content, $module));
  178
+				$entities = array_merge($entities, (array)$this->collectFromEntityProviders($filePath, $module));
171 179
 			}
172 180
 		}
173 181
 		
@@ -178,41 +186,69 @@ protected function processModule($module) {
178 186
 				$content = file_get_contents($filePath);
179 187
 				// templates use their filename as a namespace
180 188
 				$namespace = basename($filePath);
181  
-				$entitiesArr = array_merge($entitiesArr, (array)$this->collectFromTemplate($content, $module, $namespace));
  189
+				$entities = array_merge($entities, (array)$this->collectFromTemplate($content, $module, $namespace));
182 190
 			}
183 191
 		}
184 192
 
185 193
 		// sort for easier lookup and comparison with translated files
186  
-		ksort($entitiesArr);
  194
+		ksort($entities);
187 195
 
188  
-		return $entitiesArr;
  196
+		return $entities;
189 197
 	}
190 198
 	
191 199
 	public function collectFromCode($content, $module) {
192  
-		$entitiesArr = array();
193  
-		
194  
-		$regexRule = '#_t[[:space:]]*\(' .
195  
-			'[[:space:]]*("[^"]*"|\\\'[^\']*\\\')[[:space:]]*,' . // namespace.entity
196  
-			'[[:space:]]*(("([^"]|\\\")*"|\'([^\']|\\\\\')*\')' .  // value
197  
-			'([[:space:]]*\\.[[:space:]]*("([^"]|\\\")*"|\'([^\']|\\\\\')*\'))*)' . // concatenations
198  
-			'([[:space:]]*,[[:space:]]*[^,)]*)?([[:space:]]*,' . // priority (optional)
199  
-			'[[:space:]]*("([^"]|\\\")*"|\'([^\']|\\\\\')*\'))?[[:space:]]*' . // comment (optional)
200  
-		'\)#';
  200
+		$entities = array();
201 201
 
202  
-		while (preg_match($regexRule, $content, $regs)) {
203  
-			$entitiesArr = array_merge($entitiesArr, (array)$this->entitySpecFromRegexMatches($regs));
204  
-			
205  
-			// remove parsed content to continue while() loop
206  
-			$content = str_replace($regs[0],"",$content);
  202
+		$tokens = token_get_all("<?php\n" . $content);
  203
+		$inTransFn = false;
  204
+		$inConcat = false;
  205
+		$currentEntity = array();
  206
+		foreach($tokens as $token) {
  207
+			if(is_array($token)) {
  208
+				list($id, $text) = $token;
  209
+				if($id == T_STRING && $text == '_t') {
  210
+					// start definition
  211
+					$inTransFn = true;
  212
+				} elseif($inTransFn && $id == T_CONSTANT_ENCAPSED_STRING) {
  213
+					// Fixed quoting escapes, and remove leading/trailing quotes
  214
+					if(preg_match('/^\'/', $text)) {
  215
+						$text = str_replace("\'", "'", $text);
  216
+						$text = preg_replace('/^\'/', '', $text);
  217
+						$text = preg_replace('/\'$/', '', $text);
  218
+					} else {
  219
+						$text = str_replace('\"', '"', $text);
  220
+						$text = preg_replace('/^"/', '', $text);
  221
+						$text = preg_replace('/"$/', '', $text);
  222
+					}
  223
+					
  224
+					if($inConcat) $currentEntity[count($currentEntity)-1] .= $text;
  225
+					else $currentEntity[] = $text;
  226
+				} 
  227
+			} elseif($inTransFn && $token == '.') {
  228
+				$inConcat = true;	
  229
+			} elseif($inTransFn && $token == ',') {
  230
+				$inConcat = false;	
  231
+			} elseif($inTransFn && $token == ')') {
  232
+				// finalize definition
  233
+				$inTransFn = false;
  234
+				$inConcat = false;
  235
+				$entity = array_shift($currentEntity);
  236
+				$entities[$entity] = $currentEntity;
  237
+				$currentEntity = array();
  238
+			}
207 239
 		}
208 240
 		
209  
-		ksort($entitiesArr);
  241
+		foreach($entities as $entity => $spec) {
  242
+			unset($entities[$entity]);
  243
+			$entities[$this->normalizeEntity($entity, $module)] = $spec;
  244
+		}
  245
+		ksort($entities);
210 246
 		
211  
-		return $entitiesArr;
  247
+		return $entities;
212 248
 	}
213 249
 
214  
-	public function collectFromTemplate($content, $module, $fileName) {
215  
-		$entitiesArr = array();
  250
+	public function collectFromTemplate($content, $fileName, $module) {
  251
+		$entities = array();
216 252
 		
217 253
 		// Search for included templates
218 254
 		preg_match_all('/<' . '% include +([A-Za-z0-9_]+) +%' . '>/', $content, $regs, PREG_SET_ORDER);
@@ -223,36 +259,32 @@ public function collectFromTemplate($content, $module, $fileName) {
223 259
 			if(!$filePath) $filePath = SSViewer::getTemplateFileByType($includeName, 'main');
224 260
 			if($filePath) {
225 261
 				$includeContent = file_get_contents($filePath);
226  
-				$entitiesArr = array_merge($entitiesArr,(array)$this->collectFromTemplate($includeContent, $module, $includeFileName));
  262
+				$entities = array_merge($entities,(array)$this->collectFromTemplate($includeContent, $module, $includeFileName));
227 263
 			}
228 264
 			// @todo Will get massively confused if you include the includer -> infinite loop
229 265
 		}
230 266
 
231  
-		// @todo respect template tags (< % _t() % > instead of _t())
232  
-		$regexRule = '#_t[[:space:]]*\(' .
233  
-			'[[:space:]]*("[^"]*"|\\\'[^\']*\\\')[[:space:]]*,' . // namespace.entity
234  
-			'[[:space:]]*(("([^"]|\\\")*"|\'([^\']|\\\\\')*\')' .  // value
235  
-			'([[:space:]]*\\.[[:space:]]*("([^"]|\\\")*"|\'([^\']|\\\\\')*\'))*)' . // concatenations
236  
-			'([[:space:]]*,[[:space:]]*[^,)]*)?([[:space:]]*,' . // priority (optional)
237  
-			'[[:space:]]*("([^"]|\\\")*"|\'([^\']|\\\\\')*\'))?[[:space:]]*' . // comment (optional)
238  
-		'\)#';
  267
+		// Collect in actual template
  268
+		if(preg_match_all('/<%\s*(_t\(.*)%>/ms', $content, $matches)) {
  269
+			foreach($matches as $match) {
  270
+				$entities = array_merge($entities, $this->collectFromCode($match[0], $module));
  271
+			}
  272
+		}
239 273
 
240  
-		while (preg_match($regexRule,$content,$regs)) {
241  
-			$entitiesArr = array_merge($entitiesArr,(array)$this->entitySpecFromRegexMatches($regs, $fileName));
242  
-			// remove parsed content to continue while() loop
243  
-			$content = str_replace($regs[0],"",$content);
  274
+		foreach($entities as $entity => $spec) {
  275
+			unset($entities[$entity]);
  276
+			$entities[$this->normalizeEntity($entity, $module)] = $spec;
244 277
 		}
  278
+		ksort($entities);
245 279
 		
246  
-		ksort($entitiesArr);
247  
-		
248  
-		return $entitiesArr;
  280
+		return $entities;
249 281
 	}
250 282
 	
251 283
 	/**
252 284
 	 * @uses i18nEntityProvider
253 285
 	 */
254 286
 	function collectFromEntityProviders($filePath) {
255  
-		$entitiesArr = array();
  287
+		$entities = array();
256 288
 		
257 289
 		$classes = ClassInfo::classes_for_file($filePath);
258 290
 		if($classes) foreach($classes as $class) {
@@ -264,22 +296,20 @@ function collectFromEntityProviders($filePath) {
264 296
 				if($reflectionClass->isAbstract()) continue;
265 297
 
266 298
 				$obj = singleton($class);
267  
-				$entitiesArr = array_merge($entitiesArr,(array)$obj->provideI18nEntities());
  299
+				$entities = array_merge($entities,(array)$obj->provideI18nEntities());
268 300
 			}
269 301
 		}
270 302
 		
271  
-		ksort($entitiesArr);
272  
-		
273  
-		return $entitiesArr;
  303
+		ksort($entities);
  304
+		return $entities;
274 305
 	}
275 306
 	
276 307
 	/**
277  
-	 * @todo Fix regexes so the deletion of quotes, commas and newlines from wrong matches isn't necessary
  308
+	 * @param String $fullName
  309
+	 * @param String $_namespace 
  310
+	 * @return String|FALSE
278 311
 	 */
279  
-	protected function entitySpecFromRegexMatches($regs, $_namespace = null) {
280  
-		// remove wrapping quotes
281  
-		$fullName = substr($regs[1],1,-1);
282  
-		
  312
+	protected function normalizeEntity($fullName, $_namespace = null) {
283 313
 		// split fullname into entity parts
284 314
 		$entityParts = explode('.', $fullName);
285 315
 		if(count($entityParts) > 1) {
@@ -299,117 +329,10 @@ protected function entitySpecFromRegexMatches($regs, $_namespace = null) {
299 329
 		// through $db, which are detected by {@link collectFromEntityProviders}.
300 330
 		if(strpos('$', $entity) !== FALSE) return false;
301 331
 		
302  
-		// remove wrapping quotes
303  
-		$value = !empty($regs[2]) ? substr($regs[2],1,-1) : null;
304  
-
305  
-		$value = preg_replace("#([^\\\\])['\"][[:space:]]*\.[[:space:]]*['\"]#", '\\1', $value);
306  
-
307  
-		// only escape quotes when wrapped in double quotes, to make them safe for insertion
308  
-		// into single-quoted PHP code. If they're wrapped in single quotes, the string should
309  
-		// be properly escaped already
310  
-		if(substr($regs[2],0,1) == '"') {
311  
-			// Double quotes don't need escaping
312  
-			$value = str_replace('\\"','"', $value);
313  
-			// But single quotes do
314  
-			$value = str_replace("'","\\'", $value);
315  
-		}
316  
-
317  
-		// remove starting comma and any newlines
318  
-		$eol = PHP_EOL;
319  
-		$prio = !empty($regs[10]) ? trim(preg_replace("/$eol/", '', substr($regs[10],1))) : null;
320  
-
321  
-		// remove wrapping quotes
322  
-		$comment = !empty($regs[12]) ? substr($regs[12],1,-1) : null;
323  
-
324  
-		return array(
325  
-			"{$namespace}.{$entity}" => array(
326  
-				$value,
327  
-				$prio,
328  
-				$comment
329  
-			)
330  
-		);
  332
+		return "{$namespace}.{$entity}";
331 333
 	}
332 334
 	
333  
-	/**
334  
-	 * Input for langArrayCodeForEntitySpec() should be suitable for insertion
335  
-	 * into single-quoted strings, so needs to be escaped already.
336  
-	 * 
337  
-	 * @param string $entity The entity name, e.g. CMSMain.BUTTONSAVE
338  
-	 */
339  
-	public function langArrayCodeForEntitySpec($entityFullName, $entitySpec) {
340  
-		$php = '';
341  
-		$eol = PHP_EOL;
342  
-		
343  
-		$entityParts = explode('.', $entityFullName);
344  
-		if(count($entityParts) > 1) {
345  
-			// templates don't have a custom namespace
346  
-			$entity = array_pop($entityParts);
347  
-			// namespace might contain dots, so we implode back
348  
-			$namespace = implode('.',$entityParts); 
349  
-		} else {
350  
-			user_error("i18nTextCollector::langArrayCodeForEntitySpec(): Wrong entity format for $entityFullName with values" . var_export($entitySpec, true), E_USER_WARNING);
351  
-			return false;
352  
-		}
353  
-		
354  
-		$value = $entitySpec[0];
355  
-		$prio = (isset($entitySpec[1])) ? addcslashes($entitySpec[1],'\'') : null;
356  
-		$comment = (isset($entitySpec[2])) ? addcslashes($entitySpec[2],'\'') : null;
357  
-		
358  
-		$php .= '$lang[\'' . $this->defaultLocale . '\'][\'' . $namespace . '\'][\'' . $entity . '\'] = ';
359  
-		if ($prio) {
360  
-			$php .= "array($eol\t'" . $value . "',$eol\t" . $prio;
361  
-			if ($comment) {
362  
-				$php .= ",$eol\t'" . $comment . '\''; 
363  
-			}
364  
-			$php .= "$eol);";
365  
-		} else {
366  
-			$php .= '\'' . $value . '\';';
367  
-		}
368  
-		$php .= "$eol";
369  
-		
370  
-		return $php;
371  
-	}
372 335
 	
373  
-	/**
374  
-	 * Write the master string table of every processed module
375  
-	 */
376  
-	protected function writeMasterStringFile($entitiesByModule) {
377  
-		// Write each module language file
378  
-		if($entitiesByModule) foreach($entitiesByModule as $module => $entities) {
379  
-			$php = '';
380  
-			$eol = PHP_EOL;
381  
-			
382  
-			// Create folder for lang files
383  
-			$langFolder = $this->baseSavePath . '/' . $module . '/lang';
384  
-			if(!file_exists($langFolder)) {
385  
-				Filesystem::makeFolder($langFolder, Filesystem::$folder_create_mask);
386  
-				touch($langFolder . '/_manifest_exclude');
387  
-			}
388  
-
389  
-			// Open the English file and write the Master String Table
390  
-			$langFile = $langFolder . '/' . $this->defaultLocale . '.php';
391  
-			if($fh = fopen($langFile, "w")) {
392  
-				if($entities) foreach($entities as $fullName => $spec) {
393  
-					$php .= $this->langArrayCodeForEntitySpec($fullName, $spec);
394  
-				}
395  
-				
396  
-				// test for valid PHP syntax by eval'ing it
397  
-				try{
398  
-					eval($php);
399  
-				} catch(Exception $e) {
400  
-					user_error('i18nTextCollector->writeMasterStringFile(): Invalid PHP language file. Error: ' . $e->toString(), E_USER_ERROR);
401  
-				}
402  
-				
403  
-				fwrite($fh, "<"."?php{$eol}{$eol}global \$lang;{$eol}{$eol}" . $php . "{$eol}");
404  
-				fclose($fh);
405  
-				
406  
-				//Debug::message("Created file: $langFolder/" . $this->defaultLocale . ".php", false);
407  
-			} else {
408  
-				user_error("Cannot write language file! Please check permissions of $langFolder/" . $this->defaultLocale . ".php", E_USER_ERROR);
409  
-			}
410  
-		}
411  
-
412  
-	}
413 336
 	
414 337
 	/**
415 338
 	 * Helper function that searches for potential files to be parsed
@@ -442,3 +365,90 @@ public function setDefaultLocale($locale) {
442 365
 		$this->defaultLocale = $locale;
443 366
 	}
444 367
 }
  368
+
  369
+/**
  370
+ * Allows serialization of entity definitions collected through {@link i18nTextCollector}
  371
+ * into a persistent format, usually on the filesystem.
  372
+ */
  373
+interface i18nTextCollector_Writer {
  374
+	/**
  375
+	 * @param Array $entities Map of entity names (incl. namespace) to an numeric array,
  376
+	 * with at least one element, the original string, and an optional second element, the context.
  377
+	 * @param String $locale
  378
+	 * @param String $path The directory base on which the collector should create new lang folders and files.
  379
+	 * Usually the webroot set through {@link Director::baseFolder()}. Can be overwritten for testing or export purposes.
  380
+	 * @return Boolean success
  381
+	 */
  382
+	function write($entities, $locale, $path);
  383
+}
  384
+
  385
+/**
  386
+ * Legacy writer for 2.x style persistence.
  387
+ */
  388
+class i18nTextCollector_Writer_Php implements i18nTextCollector_Writer {
  389
+
  390
+	public function write($entities, $locale, $path) {
  391
+		$php = '';
  392
+		$eol = PHP_EOL;
  393
+		
  394
+		// Create folder for lang files
  395
+		$langFolder = $path . '/lang';
  396
+		if(!file_exists($langFolder)) {
  397
+			Filesystem::makeFolder($langFolder, Filesystem::$folder_create_mask);
  398
+			touch($langFolder . '/_manifest_exclude');
  399
+		}
  400
+
  401
+		// Open the English file and write the Master String Table
  402
+		$langFile = $langFolder . '/' . $locale . '.php';
  403
+		if($fh = fopen($langFile, "w")) {
  404
+			if($entities) foreach($entities as $fullName => $spec) {
  405
+				$php .= $this->langArrayCodeForEntitySpec($fullName, $spec, $locale);
  406
+			}
  407
+			// test for valid PHP syntax by eval'ing it
  408
+			try{
  409
+				eval($php);
  410
+			} catch(Exception $e) {
  411
+				throw new LogicException('i18nTextCollector->writeMasterStringFile(): Invalid PHP language file. Error: ' . $e->toString());
  412
+			}
  413
+			
  414
+			fwrite($fh, "<"."?php{$eol}{$eol}global \$lang;{$eol}{$eol}" . $php . "{$eol}");
  415
+			fclose($fh);
  416
+			
  417
+		} else {
  418
+			throw new LogicException("Cannot write language file! Please check permissions of $langFolder/" . $locale . ".php");
  419
+		}
  420
+
  421
+		return true;
  422
+	}
  423
+
  424
+	/**
  425
+	 * Input for langArrayCodeForEntitySpec() should be suitable for insertion
  426
+	 * into single-quoted strings, so needs to be escaped already.
  427
+	 * 
  428
+	 * @param string $entity The entity name, e.g. CMSMain.BUTTONSAVE
  429
+	 */
  430
+	public function langArrayCodeForEntitySpec($entityFullName, $entitySpec, $locale) {
  431
+		$php = '';
  432
+		$eol = PHP_EOL;
  433
+		
  434
+		$entityParts = explode('.', $entityFullName);
  435
+		if(count($entityParts) > 1) {
  436
+			// templates don't have a custom namespace
  437
+			$entity = array_pop($entityParts);
  438
+			// namespace might contain dots, so we implode back
  439
+			$namespace = implode('.',$entityParts); 
  440
+		} else {
  441
+			user_error("i18nTextCollector::langArrayCodeForEntitySpec(): Wrong entity format for $entityFullName with values" . var_export($entitySpec, true), E_USER_WARNING);
  442
+			return false;
  443
+		}
  444
+		
  445
+		$value = $entitySpec[0];
  446
+		$comment = (isset($entitySpec[1])) ? addcslashes($entitySpec[1],'\'') : null;
  447
+
  448
+		$php .= '$lang[\'' . $locale . '\'][\'' . $namespace . '\'][\'' . $entity . '\'] = ';
  449
+		$php .= (count($entitySpec) == 1) ? var_export($entitySpec[0], true) : var_export($entitySpec, true);
  450
+		$php .= ";$eol";
  451
+		
  452
+		return $php;
  453
+	}
  454
+}
152  tests/i18n/i18nTextCollectorTest.php
@@ -52,7 +52,7 @@ function testConcatenationInEntityValues() {
52 52
 'Line 1 and ' .
53 53
 'Line \'2\' and ' .
54 54
 'Line "3"',
55  
-PR_MEDIUM,
  55
+
56 56
 'Comment'
57 57
 );
58 58
 
@@ -64,8 +64,8 @@ function testConcatenationInEntityValues() {
64 64
 		$this->assertEquals(
65 65
 			$c->collectFromCode($php, 'mymodule'),
66 66
 			array(
67  
-				'Test.CONCATENATED' => array("Line 1 and Line \\'2\\' and Line \"3\"",'PR_MEDIUM','Comment'),
68  
-				'Test.CONCATENATED2' => array("Line \"4\" and Line 5",null,null)
  67
+				'Test.CONCATENATED' => array("Line 1 and Line '2' and Line \"3\"",'Comment'),
  68
+				'Test.CONCATENATED2' => array("Line \"4\" and Line 5")
69 69
 			)
70 70
 		);
71 71
 	}	
@@ -78,7 +78,7 @@ function testCollectFromTemplateSimple() {
78 78
 		$this->assertEquals(
79 79
 			$c->collectFromTemplate($html, 'mymodule', 'Test'),
80 80
 			array(
81  
-				'Test.SINGLEQUOTE' => array('Single Quote',null,null)
  81
+				'Test.SINGLEQUOTE' => array('Single Quote')
82 82
 			)
83 83
 		);
84 84
 
@@ -88,7 +88,7 @@ function testCollectFromTemplateSimple() {
88 88
 		$this->assertEquals(
89 89
 			$c->collectFromTemplate($html, 'mymodule', 'Test'),
90 90
 			array(
91  
-				'Test.DOUBLEQUOTE' => array("Double Quote and Spaces", null, null)
  91
+				'Test.DOUBLEQUOTE' => array("Double Quote and Spaces")
92 92
 			)
93 93
 		);
94 94
 		
@@ -98,7 +98,7 @@ function testCollectFromTemplateSimple() {
98 98
 		$this->assertEquals(
99 99
 			$c->collectFromTemplate($html, 'mymodule', 'Test'),
100 100
 			array(
101  
-				'Test.NOSEMICOLON' => array("No Semicolon", null, null)
  101
+				'Test.NOSEMICOLON' => array("No Semicolon")
102 102
 			)
103 103
 		);
104 104
 	}
@@ -115,7 +115,7 @@ function testCollectFromTemplateAdvanced() {
115 115
 		$this->assertEquals(
116 116
 			$c->collectFromTemplate($html, 'mymodule', 'Test'),
117 117
 			array(
118  
-				'Test.NEWLINES' => array("New Lines", null, null)
  118
+				'Test.NEWLINES' => array("New Lines")
119 119
 			)
120 120
 		);
121 121
 
@@ -123,14 +123,13 @@ function testCollectFromTemplateAdvanced() {
123 123
 <% _t(
124 124
 	'Test.PRIOANDCOMMENT',
125 125
 	' Prio and Value with "Double Quotes"',
126  
-	PR_MEDIUM,
127 126
 	'Comment with "Double Quotes"'
128 127
 ) %>
129 128
 SS;
130 129
 		$this->assertEquals(
131 130
 			$c->collectFromTemplate($html, 'mymodule', 'Test'),
132 131
 			array(
133  
-				'Test.PRIOANDCOMMENT' => array(' Prio and Value with "Double Quotes"','PR_MEDIUM','Comment with "Double Quotes"')
  132
+				'Test.PRIOANDCOMMENT' => array(' Prio and Value with "Double Quotes"','Comment with "Double Quotes"')
134 133
 			)
135 134
 		);
136 135
 
@@ -138,14 +137,14 @@ function testCollectFromTemplateAdvanced() {
138 137
 <% _t(
139 138
 	'Test.PRIOANDCOMMENT',
140 139
 	" Prio and Value with 'Single Quotes'",
141  
-	PR_MEDIUM,
  140
+	
142 141
 	"Comment with 'Single Quotes'"
143 142
 ) %>
144 143
 SS;
145 144
 		$this->assertEquals(
146 145
 			$c->collectFromTemplate($html, 'mymodule', 'Test'),
147 146
 			array(
148  
-				'Test.PRIOANDCOMMENT' => array(" Prio and Value with \'Single Quotes\'",'PR_MEDIUM',"Comment with 'Single Quotes'")
  147
+				'Test.PRIOANDCOMMENT' => array(" Prio and Value with 'Single Quotes'","Comment with 'Single Quotes'")
149 148
 			)
150 149
 		);
151 150
 	}
@@ -160,7 +159,7 @@ function testCollectFromCodeSimple() {
160 159
 		$this->assertEquals(
161 160
 			$c->collectFromCode($php, 'mymodule'),
162 161
 			array(
163  
-				'Test.SINGLEQUOTE' => array('Single Quote',null,null)
  162
+				'Test.SINGLEQUOTE' => array('Single Quote')
164 163
 			)
165 164
 		);
166 165
 		
@@ -170,7 +169,7 @@ function testCollectFromCodeSimple() {
170 169
 		$this->assertEquals(
171 170
 			$c->collectFromCode($php, 'mymodule'),
172 171
 			array(
173  
-				'Test.DOUBLEQUOTE' => array("Double Quote and Spaces", null, null)
  172
+				'Test.DOUBLEQUOTE' => array("Double Quote and Spaces")
174 173
 			)
175 174
 		);
176 175
 	}
@@ -187,7 +186,7 @@ function testCollectFromCodeAdvanced() {
187 186
 		$this->assertEquals(
188 187
 			$c->collectFromCode($php, 'mymodule'),
189 188
 			array(
190  
-				'Test.NEWLINES' => array("New Lines", null, null)
  189
+				'Test.NEWLINES' => array("New Lines")
191 190
 			)
192 191
 		);
193 192
 		
@@ -195,14 +194,14 @@ function testCollectFromCodeAdvanced() {
195 194
 _t(
196 195
 	'Test.PRIOANDCOMMENT',
197 196
 	' Value with "Double Quotes"',
198  
-	PR_MEDIUM,
  197
+	
199 198
 	'Comment with "Double Quotes"'
200 199
 );
201 200
 PHP;
202 201
 		$this->assertEquals(
203 202
 			$c->collectFromCode($php, 'mymodule'),
204 203
 			array(
205  
-				'Test.PRIOANDCOMMENT' => array(' Value with "Double Quotes"','PR_MEDIUM','Comment with "Double Quotes"')
  204
+				'Test.PRIOANDCOMMENT' => array(' Value with "Double Quotes"','Comment with "Double Quotes"')
206 205
 			)
207 206
 		);
208 207
 		
@@ -210,14 +209,14 @@ function testCollectFromCodeAdvanced() {
210 209
 _t(
211 210
 	'Test.PRIOANDCOMMENT',
212 211
 	" Value with 'Single Quotes'",
213  
-	PR_MEDIUM,
  212
+	
214 213
 	"Comment with 'Single Quotes'"
215 214
 );
216 215
 PHP;
217 216
 		$this->assertEquals(
218 217
 			$c->collectFromCode($php, 'mymodule'),
219 218
 			array(
220  
-				'Test.PRIOANDCOMMENT' => array(" Value with \'Single Quotes\'",'PR_MEDIUM',"Comment with 'Single Quotes'")
  219
+				'Test.PRIOANDCOMMENT' => array(" Value with 'Single Quotes'","Comment with 'Single Quotes'")
221 220
 			)
222 221
 		);
223 222
 		
@@ -230,7 +229,7 @@ function testCollectFromCodeAdvanced() {
230 229
 		$this->assertEquals(
231 230
 			$c->collectFromCode($php, 'mymodule'),
232 231
 			array(
233  
-				'Test.PRIOANDCOMMENT' => array("Value with \'Escaped Single Quotes\'",null,null)
  232
+				'Test.PRIOANDCOMMENT' => array("Value with 'Escaped Single Quotes'")
234 233
 			)
235 234
 		);
236 235
 	
@@ -243,7 +242,7 @@ function testCollectFromCodeAdvanced() {
243 242
 		$this->assertEquals(
244 243
 			$c->collectFromCode($php, 'mymodule'),
245 244
 			array(
246  
-				'Test.PRIOANDCOMMENT' => array("Doublequoted Value with \'Unescaped Single Quotes\'",null,null)
  245
+				'Test.PRIOANDCOMMENT' => array("Doublequoted Value with 'Unescaped Single Quotes'")
247 246
 			)
248 247
 		);
249 248
 	}
@@ -264,7 +263,7 @@ function testNewlinesInEntityValues() {
264 263
 		$this->assertEquals(
265 264
 			$c->collectFromCode($php, 'mymodule'),
266 265
 			array(
267  
-				'Test.NEWLINESINGLEQUOTE' => array("Line 1{$eol}Line 2",null,null)
  266
+				'Test.NEWLINESINGLEQUOTE' => array("Line 1{$eol}Line 2")
268 267
 			)
269 268
 		);
270 269
 
@@ -278,7 +277,7 @@ function testNewlinesInEntityValues() {
278 277
 		$this->assertEquals(
279 278
 			$c->collectFromCode($php, 'mymodule'),
280 279
 			array(
281  
-				'Test.NEWLINEDOUBLEQUOTE' => array("Line 1{$eol}Line 2",null,null)
  280
+				'Test.NEWLINEDOUBLEQUOTE' => array("Line 1{$eol}Line 2")
282 281
 			)
283 282
 		);
284 283
 	}
@@ -287,49 +286,46 @@ function testNewlinesInEntityValues() {
287 286
 	 * Input for langArrayCodeForEntitySpec() should be suitable for insertion
288 287
 	 * into single-quoted strings, so needs to be escaped already.
289 288
 	 */
290  
-	function testLangArrayCodeForEntity() {
291  
-		$c = new i18nTextCollector();
292  
-		$locale = $c->getDefaultLocale();
  289
+	function testPhpWriterLangArrayCodeForEntity() {
  290
+		$c = new i18nTextCollector_Writer_Php();
293 291
 		
294 292
 		$this->assertEquals(
295  
-			$c->langArrayCodeForEntitySpec('Test.SIMPLE', array('Simple Value')),
296  
-			"\$lang['{$locale}']['Test']['SIMPLE'] = 'Simple Value';" . PHP_EOL
  293
+			$c->langArrayCodeForEntitySpec('Test.SIMPLE', array('Simple Value'), 'en_US'),
  294
+			"\$lang['en_US']['Test']['SIMPLE'] = 'Simple Value';" . PHP_EOL
297 295
 		);
298 296
 		
299 297
 		$this->assertEquals(
300 298
 			// single quotes should be properly escaped by the parser already
301  
-			$c->langArrayCodeForEntitySpec('Test.ESCAPEDSINGLEQUOTES', array("Value with \'Escaped Single Quotes\'")),
302  
-			"\$lang['{$locale}']['Test']['ESCAPEDSINGLEQUOTES'] = 'Value with \'Escaped Single Quotes\'';" . PHP_EOL
  299
+			$c->langArrayCodeForEntitySpec('Test.ESCAPEDSINGLEQUOTES', array("Value with 'Escaped Single Quotes'"), 'en_US'),
  300
+			"\$lang['en_US']['Test']['ESCAPEDSINGLEQUOTES'] = 'Value with \'Escaped Single Quotes\'';" . PHP_EOL
303 301
 		);
304 302
 		
305 303
 		$this->assertEquals(
306  
-			$c->langArrayCodeForEntitySpec('Test.DOUBLEQUOTES', array('Value with "Double Quotes"')),
307  
-			"\$lang['{$locale}']['Test']['DOUBLEQUOTES'] = 'Value with \"Double Quotes\"';" . PHP_EOL
  304
+			$c->langArrayCodeForEntitySpec('Test.DOUBLEQUOTES', array('Value with "Double Quotes"'), 'en_US'),
  305
+			"\$lang['en_US']['Test']['DOUBLEQUOTES'] = 'Value with \"Double Quotes\"';" . PHP_EOL
308 306
 		);
309 307
 		
310 308
 		$php = <<<PHP
311  
-\$lang['$locale']['Test']['PRIOANDCOMMENT'] = array(
312  
-	'Value with \'Single Quotes\'',
313  
-	PR_MEDIUM,
314  
-	'Comment with \'Single Quotes\''
  309
+\$lang['en_US']['Test']['PRIOANDCOMMENT'] = array (
  310
+  0 => 'Value with \'Single Quotes\'',
  311
+  1 => 'Comment with \'Single Quotes\'',
315 312
 );
316 313
 
317 314
 PHP;
318 315
 		$this->assertEquals(
319  
-			$c->langArrayCodeForEntitySpec('Test.PRIOANDCOMMENT', array("Value with \'Single Quotes\'",'PR_MEDIUM',"Comment with 'Single Quotes'")),
  316
+			$c->langArrayCodeForEntitySpec('Test.PRIOANDCOMMENT', array("Value with 'Single Quotes'","Comment with 'Single Quotes'"), 'en_US'),
320 317
 			$php
321 318
 		);
322 319
 		
323 320
 		$php = <<<PHP
324  
-\$lang['$locale']['Test']['PRIOANDCOMMENT'] = array(
325  
-	'Value with "Double Quotes"',
326  
-	PR_MEDIUM,
327  
-	'Comment with "Double Quotes"'
  321
+\$lang['en_US']['Test']['PRIOANDCOMMENT'] = array (
  322
+  0 => 'Value with "Double Quotes"',
  323
+  1 => 'Comment with "Double Quotes"',
328 324
 );
329 325
 
330 326
 PHP;
331 327
 		$this->assertEquals(
332  
-			$c->langArrayCodeForEntitySpec('Test.PRIOANDCOMMENT', array('Value with "Double Quotes"','PR_MEDIUM','Comment with "Double Quotes"')),
  328
+			$c->langArrayCodeForEntitySpec('Test.PRIOANDCOMMENT', array('Value with "Double Quotes"','Comment with "Double Quotes"'), 'en_US'),
333 329
 			$php
334 330
 		);
335 331
 	}
@@ -345,43 +341,43 @@ function testCollectFromIncludedTemplates() {
345 341
 		$this->assertArrayHasKey('i18nTestModule.ss.LAYOUTTEMPLATENONAMESPACE', $matches);
346 342
 		$this->assertEquals(
347 343
 			$matches['i18nTestModule.ss.LAYOUTTEMPLATENONAMESPACE'],
348  
-			array('Layout Template no namespace', null, null)
  344
+			array('Layout Template no namespace')
349 345
 		);
350 346
 		*/
351 347
 		$this->assertArrayHasKey('RandomNamespace.SPRINTFNONAMESPACE', $matches);
352 348
 		$this->assertEquals(
353 349
 			$matches['RandomNamespace.SPRINTFNONAMESPACE'],
354  
-			array('My replacement no namespace: %s', null, null)
  350
+			array('My replacement no namespace: %s')
355 351
 		);
356 352
 		$this->assertArrayHasKey('i18nTestModule.LAYOUTTEMPLATE', $matches);
357 353
 		$this->assertEquals(
358 354
 			$matches['i18nTestModule.LAYOUTTEMPLATE'],
359  
-			array('Layout Template', null, null)
  355
+			array('Layout Template')
360 356
 		);
361 357
 		$this->assertArrayHasKey('i18nTestModule.SPRINTFNAMESPACE', $matches);
362 358
 		$this->assertEquals(
363 359
 			$matches['i18nTestModule.SPRINTFNAMESPACE'],
364  
-			array('My replacement: %s', null, null)
  360
+			array('My replacement: %s')
365 361
 		);
366 362
 		$this->assertArrayHasKey('i18nTestModule.WITHNAMESPACE', $matches);
367 363
 		$this->assertEquals(
368 364
 			$matches['i18nTestModule.WITHNAMESPACE'],
369  
-			array('Include Entity with Namespace', null, null)
  365
+			array('Include Entity with Namespace')
370 366
 		);
371 367
 		$this->assertArrayHasKey('i18nTestModuleInclude.ss.NONAMESPACE', $matches);
372 368
 		$this->assertEquals(
373 369
 			$matches['i18nTestModuleInclude.ss.NONAMESPACE'],
374  
-			array('Include Entity without Namespace', null, null)
  370
+			array('Include Entity without Namespace')
375 371
 		);
376 372
 		$this->assertArrayHasKey('i18nTestModuleInclude.ss.SPRINTFINCLUDENAMESPACE', $matches);
377 373
 		$this->assertEquals(
378 374
 			$matches['i18nTestModuleInclude.ss.SPRINTFINCLUDENAMESPACE'],
379  
-			array('My include replacement: %s', null, null)
  375
+			array('My include replacement: %s')
380 376
 		);
381 377
 		$this->assertArrayHasKey('i18nTestModuleInclude.ss.SPRINTFINCLUDENONAMESPACE', $matches);
382 378
 		$this->assertEquals(
383 379
 			$matches['i18nTestModuleInclude.ss.SPRINTFINCLUDENONAMESPACE'],
384  
-			array('My include replacement no namespace: %s', null, null)
  380
+			array('My include replacement no namespace: %s')
385 381
 		);
386 382
 	}
387 383
 	
@@ -397,48 +393,48 @@ function testCollectFromThemesTemplates() {
397 393
 		// all entities from i18nTestTheme1.ss
398 394
 		$this->assertEquals(
399 395
 			$matches['i18nTestTheme1.LAYOUTTEMPLATE'],
400  
-			array('Theme1 Layout Template', null, null)
  396
+			array('Theme1 Layout Template')
401 397
 		);
402 398
 		
403 399
 		$this->assertArrayHasKey('i18nTestTheme1.ss.LAYOUTTEMPLATENONAMESPACE', $matches);
404 400
 		$this->assertEquals(
405 401
 			$matches['i18nTestTheme1.ss.LAYOUTTEMPLATENONAMESPACE'],
406  
-			array('Theme1 Layout Template no namespace', null, null)
  402
+			array('Theme1 Layout Template no namespace')
407 403
 		);
408 404
 		
409 405
 		$this->assertEquals(
410 406
 			$matches['i18nTestTheme1.SPRINTFNAMESPACE'],
411  
-			array('Theme1 My replacement: %s', null, null)
  407
+			array('Theme1 My replacement: %s')
412 408
 		);
413 409
 		
414 410
 		$this->assertArrayHasKey('i18nTestTheme1.ss.SPRINTFNONAMESPACE', $matches);
415 411
 		$this->assertEquals(
416 412
 			$matches['i18nTestTheme1.ss.SPRINTFNONAMESPACE'],
417  
-			array('Theme1 My replacement no namespace: %s', null, null)
  413
+			array('Theme1 My replacement no namespace: %s')
418 414
 		);
419 415
 
420 416
 		// all entities from i18nTestTheme1Include.ss	
421 417
 		$this->assertEquals(
422 418
 			$matches['i18nTestTheme1Include.WITHNAMESPACE'],
423  
-			array('Theme1 Include Entity with Namespace', null, null)
  419
+			array('Theme1 Include Entity with Namespace')
424 420
 		);
425 421
 		
426 422
 		$this->assertArrayHasKey('i18nTestTheme1Include.ss.NONAMESPACE', $matches);
427 423
 		$this->assertEquals(
428 424
 			$matches['i18nTestTheme1Include.ss.NONAMESPACE'],
429  
-			array('Theme1 Include Entity without Namespace', null, null)
  425
+			array('Theme1 Include Entity without Namespace')
430 426
 		);
431 427
 		
432 428
 		
433 429
 		$this->assertEquals(
434 430
 			$matches['i18nTestTheme1Include.SPRINTFINCLUDENAMESPACE'],
435  
-			array('Theme1 My include replacement: %s', null, null)
  431
+			array('Theme1 My include replacement: %s')
436 432
 		);
437 433
 		
438 434
 		$this->assertArrayHasKey('i18nTestTheme1Include.ss.SPRINTFINCLUDENONAMESPACE', $matches);
439 435
 		$this->assertEquals(
440 436
 			$matches['i18nTestTheme1Include.ss.SPRINTFINCLUDENONAMESPACE'],
441  
-			array('Theme1 My include replacement no namespace: %s', null, null)
  437
+			array('Theme1 My include replacement no namespace: %s')
442 438
 		);
443 439
 		
444 440
 		SSViewer::set_theme($theme);
@@ -451,6 +447,7 @@ function testCollectFromFilesystemAndWriteMasterTables() {
451 447
 		i18n::set_default_locale('en_US');
452 448
 
453 449
 		$c = new i18nTextCollector();
  450
+		$c->setWriter(new i18nTextCollector_Writer_Php());
454 451
 		$c->basePath = $this->alternateBasePath;
455 452
 		$c->baseSavePath = $this->alternateBaseSavePath;
456 453
 		
@@ -465,31 +462,30 @@ function testCollectFromFilesystemAndWriteMasterTables() {
465 462
 		
466 463
 		$moduleLangFileContent = file_get_contents($moduleLangFile);
467 464
 		$this->assertContains(
468  
-			"\$lang['en_US']['i18nTestModule']['ADDITION'] = 'Addition';",
  465
+			"\$lang['en']['i18nTestModule']['ADDITION'] = 'Addition';",
469 466
 			$moduleLangFileContent
470 467
 		);
471 468
 		$this->assertContains(
472  
-			"\$lang['en_US']['i18nTestModule']['ENTITY'] = array(
473  
-	'Entity with \"Double Quotes\"',
474  
-	PR_LOW,
475  
-	'Comment for entity'
  469
+			"\$lang['en']['i18nTestModule']['ENTITY'] = array (
  470
+  0 => 'Entity with \"Double Quotes\"',
  471
+  1 => 'Comment for entity',
476 472
 );",
477 473
 			$moduleLangFileContent
478 474
 		);
479 475
 		$this->assertContains(
480  
-			"\$lang['en_US']['i18nTestModule']['MAINTEMPLATE'] = 'Main Template';",
  476
+			"\$lang['en']['i18nTestModule']['MAINTEMPLATE'] = 'Main Template';",
481 477
 			$moduleLangFileContent
482 478
 		);
483 479
 		$this->assertContains(
484  
-			"\$lang['en_US']['i18nTestModule']['OTHERENTITY'] = 'Other Entity';",
  480
+			"\$lang['en']['i18nTestModule']['OTHERENTITY'] = 'Other Entity';",
485 481
 			$moduleLangFileContent
486 482
 		);
487 483
 		$this->assertContains(
488  
-			"\$lang['en_US']['i18nTestModule']['WITHNAMESPACE'] = 'Include Entity with Namespace';",
  484
+			"\$lang['en']['i18nTestModule']['WITHNAMESPACE'] = 'Include Entity with Namespace';",
489 485
 			$moduleLangFileContent
490 486
 		);
491 487
 		$this->assertContains(
492  
-			"\$lang['en_US']['i18nTestModuleInclude.ss']['NONAMESPACE'] = 'Include Entity without Namespace';",
  488
+			"\$lang['en']['i18nTestModuleInclude.ss']['NONAMESPACE'] = 'Include Entity without Namespace';",
493 489
 			$moduleLangFileContent
494 490
 		);
495 491
 		
@@ -501,11 +497,11 @@ function testCollectFromFilesystemAndWriteMasterTables() {
501 497
 		);
502 498
 		$otherModuleLangFileContent = file_get_contents($otherModuleLangFile);
503 499
 		$this->assertContains(
504  
-			"\$lang['en_US']['i18nOtherModule']['ENTITY'] = 'Other Module Entity';",
  500
+			"\$lang['en']['i18nOtherModule']['ENTITY'] = 'Other Module Entity';",
505 501
 			$otherModuleLangFileContent
506 502
 		);
507 503
 		$this->assertContains(
508  
-			"\$lang['en_US']['i18nOtherModule']['MAINTEMPLATE'] = 'Main Template Other Module';",
  504
+			"\$lang['en']['i18nOtherModule']['MAINTEMPLATE'] = 'Main Template Other Module';",
509 505
 			$otherModuleLangFileContent
510 506
 		);
511 507
 		
@@ -517,40 +513,40 @@ function testCollectFromFilesystemAndWriteMasterTables() {
517 513
 		);
518 514
 		$theme1LangFileContent = file_get_contents($theme1LangFile);
519 515
 		$this->assertContains(
520  
-			"\$lang['en_US']['i18nTestTheme1']['MAINTEMPLATE'] = 'Theme1 Main Template';",
  516
+			"\$lang['en']['i18nTestTheme1']['MAINTEMPLATE'] = 'Theme1 Main Template';",
521 517
 			$theme1LangFileContent
522 518
 		);
523 519
 		$this->assertContains(
524  
-			"\$lang['en_US']['i18nTestTheme1']['LAYOUTTEMPLATE'] = 'Theme1 Layout Template';",
  520
+			"\$lang['en']['i18nTestTheme1']['LAYOUTTEMPLATE'] = 'Theme1 Layout Template';",
525 521
 			$theme1LangFileContent
526 522
 		);
527 523
 		$this->assertContains(
528  
-			"\$lang['en_US']['i18nTestTheme1']['SPRINTFNAMESPACE'] = 'Theme1 My replacement: %s';",
  524
+			"\$lang['en']['i18nTestTheme1']['SPRINTFNAMESPACE'] = 'Theme1 My replacement: %s';",
529 525
 			$theme1LangFileContent
530 526
 		);
531 527
 		$this->assertContains(
532  
-			"\$lang['en_US']['i18nTestTheme1.ss']['LAYOUTTEMPLATENONAMESPACE'] = 'Theme1 Layout Template no namespace';",
  528
+			"\$lang['en']['i18nTestTheme1.ss']['LAYOUTTEMPLATENONAMESPACE'] = 'Theme1 Layout Template no namespace';",
533 529
 			$theme1LangFileContent
534 530
 		);
535 531
 		$this->assertContains(
536  
-			"\$lang['en_US']['i18nTestTheme1.ss']['SPRINTFNONAMESPACE'] = 'Theme1 My replacement no namespace: %s';",
  532
+			"\$lang['en']['i18nTestTheme1.ss']['SPRINTFNONAMESPACE'] = 'Theme1 My replacement no namespace: %s';",
537 533
 			$theme1LangFileContent
538 534
 		);
539 535
 		
540 536
 		$this->assertContains(
541  
-			"\$lang['en_US']['i18nTestTheme1Include']['SPRINTFINCLUDENAMESPACE'] = 'Theme1 My include replacement: %s';",
  537
+			"\$lang['en']['i18nTestTheme1Include']['SPRINTFINCLUDENAMESPACE'] = 'Theme1 My include replacement: %s';",
542 538
 			$theme1LangFileContent
543