Front Matter & PHP Language Packs
How Docara translates front matter and PHP-based language/settings packs (.lang.php, .settings.php) using the built-in translator.
Where this logic runs
- Entry point (per file):
translateFiles()(core translator) - Helpers:
frontMatterParser(),translateFromMatter(),translateLangFiles(),generateSettingsTranslate(),makeContent(),setByPath() - I/O: Symfony YAML for front matter;
var_export()for PHP arrays
Front matter translation
Front matter is parsed and translated before the Markdown body.
Parsing
private function frontMatterParser($originalMarkdown): array
{
$parser = new FrontMatterParser(new SymfonyYamlFrontMatterParser());
$document = $parser->parse($originalMarkdown);
$front = $document->getFrontMatter();
$content = $document->getContent();
return [$front, $content];
}
Selecting keys to translate
Only keys configured under frontMatter are considered, and only if the value contains letters (/\p{L}/u). Cached entries are reused.
private function translateFromMatter(array $frontMatter, string $lang): array
{
if (empty($this->config['frontMatter']) || !is_array($this->config['frontMatter'])) {
return $frontMatter;
}
[$cachedKeys, $frontMatter] = $this->checkCached($frontMatter, $lang);
$items = $keys = [];
foreach ($frontMatter as $k => $v) {
if (!in_array($k, $cachedKeys, true)
&& in_array($k, $this->config['frontMatter'], true)
&& is_string($v)
&& preg_match('/\p{L}/u', $v)) {
$keys[] = $k;
$items[] = ['Text' => $v];
}
}
return $this->makeContent($items, $frontMatter, $lang, $keys);
}
Writing back (Markdown flow)
Inside translateFiles() for .md files:
[$front, $original] = $this->frontMatterParser($content);
$frontTranslated = $this->translateFromMatter($front, $lang);
$bodyTranslated = $this->generateTranslateContent($original, $lang);
$yamlBlock = "---\n" . Yaml::dump($frontTranslated) . "---\n\n";
$translated = $yamlBlock . $bodyTranslated;
YAML dump preserves arrays/scalars and keeps valid front matter.
PHP language packs (.lang.php)
Language pack files return associative arrays of UI strings. They are loaded, translated per value, and written back.
Loading & caching
$data = include $filePathName; // returns array
[$cachedKeys, $data] = $this->checkCached($data, $lang);
Selecting values
Only string values with letters are translated; cached keys are kept as-is.
$items = $keys = [];
foreach ($data as $k => $v) {
if (!in_array($k, $cachedKeys, true) && is_string($v) && preg_match('/\p{L}/u', $v)) {
$keys[] = $k;
$items[] = ['Text' => $v];
}
}
Translating & writing
makeContent() calls Azure via curlRequest() and writes results back, updating cache. The final PHP file is generated with var_export():
$translated = $this->makeContent($items, $data, $lang, $keys);
$phpOut = "<?php\nreturn " . var_export($translated, true) . ";\n";
file_put_contents($destPath, $phpOut);
Settings packs (.settings.php)
Settings files may have nested translatable values (e.g., a menu array). We collect paths to each translatable string and write them back with setByPath().
Collecting candidates
private function generateSettingsTranslate(array $settings, string $lang): array
{
$paths = [];
$texts = [];
if (isset($settings['title']) && is_string($settings['title']) && preg_match('/\p{L}/u', $settings['title'])) {
$paths[] = ['title'];
$texts[] = $settings['title'];
}
if (!empty($settings['menu']) && is_array($settings['menu'])) {
foreach ($settings['menu'] as $menuKey => $menuVal) {
if (is_string($menuVal) && preg_match('/\p{L}/u', $menuVal)) {
$paths[] = ['menu', $menuKey];
$texts[] = $menuVal;
}
}
}
if (!$paths) return $settings;
[$cachedIdx, $strings] = $this->checkCached($texts, $lang);
// Build translation batch only for misses
$toTranslate = [];
$mapIdx = [];
foreach ($strings as $i => $text) {
if (!in_array($i, $cachedIdx, true) && $text !== '') {
$mapIdx[] = $i;
$toTranslate[] = ['Text' => $text];
}
}
$decoded = $toTranslate ? $this->curlRequest($toTranslate, $lang) : [];
// Stitch results back by original indexes
foreach ($strings as $i => $text) {
$translated = in_array($i, $cachedIdx, true)
? $text
: ($decoded[array_search($i, $mapIdx, true)]['translations'][0]['text'] ?? $text);
$this->setByPath($settings, $paths[$i], $translated, $lang);
}
return $settings;
}
Writing nested values
private function setByPath(array &$arr, array $path, mixed $value, string $lang): void
{
$ref =& $arr;
foreach ($path as $idx => $key) {
if ($idx === count($path) - 1) {
$this->setCached($lang, $value, $ref[$key]); // update cache using original value
$ref[$key] = $value; // write translation
return;
}
if (!isset($ref[$key]) || !is_array($ref[$key])) $ref[$key] = [];
$ref =& $ref[$key];
}
}
Write the resulting array to <?php return ...; via var_export() as for .lang.php.
Destination paths & structure
Destination path is derived by swapping the base locale suffix with the target language:
$srcPath = $file->getPathname();
$destPath = str_replace("_docs-{$this->config['target_lang']}", "_docs-{$lang}", $srcPath);
Create directories on demand:
$dir = dirname($destPath);
if (!is_dir($dir)) mkdir($dir, 0777, true);
Caching behavior
All three flows use the same cache API:
- Keying:
normalize($text)→ SHA-1 over LF-normalized, whitespace-collapsed string. - Read:
[$cachedKeys, $data] = checkCached($data, $lang)marks cached positions and inlines cached translations. - Write:
setCached($lang, $translated, $original)updates in-memory cache. - Persist:
saveCache()writestranslate_<lang>.json,.config.json(locale names viaSymfony\Component\Intl\Languages), andhash.json.
Incremental updates & guards
- Per-file hash:
hashData[$lang][$filePath] = md5(file)— unchanged files are skipped on subsequent runs. - Duplicate language guard: if a target
langis already present in Docaralocales,translateFiles()throws:
if (in_array($lang, array_keys($this->usedLocales), true)) {
throw new Exception('Language "' . $lang . '" is already translated.');
}
Testing checklist
- Front matter keys in
frontMatterare translated; others remain intact. .lang.php: only string values with letters are translated; arrays/numbers untouched..settings.php: nested paths (e.g.,menu.*) are translated; non-string values skipped.- Cache hit: repeated runs avoid API calls; outputs are stable.
- Destination path uses
_docs-<lang>mirroring the base tree.
Tips
- Keep the
frontMatterlist short and intentional (titles, descriptions). - If you need additional nested settings translated (beyond
titleandmenu), extendgenerateSettingsTranslate()with more path collectors. - Consider wrapping
file_put_contents()with an atomic write (tmp file → rename) in CI.