Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/translation-source-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Translation Source Sync

on:
schedule:
- cron: "0 2 * * *"
workflow_dispatch:

jobs:
extract-and-pr:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.3

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install composer dependencies
run: composer install --no-interaction --no-progress --prefer-dist --no-scripts

- name: Install frontend dependencies
run: npm ci

- name: Build frontend assets
run: npm run dev

- name: Extract source strings
run: composer extract-strings

- name: Create pull request with source string updates
uses: peter-evans/create-pull-request@v6
with:
commit-message: "chore(i18n): refresh source strings"
title: "chore(i18n): refresh source strings"
body: |
Automated update of source strings for Weblate synchronization.

- Ran `composer extract-strings`
- Updated source catalog used by Weblate
branch: chore/i18n-refresh-source-strings
delete-branch: true
add-paths: |
lang/en/main.json
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
# LibreSign Website

## Translations
Help to translate the project on Weblate platform: https://hosted.weblate.org/projects/libresign-coop-site/site/
Help to translate the project on Weblate platform: https://hosted.weblate.org/projects/libresign-coop-site/site/

### Maintainer notes
- `lang/` is managed by Weblate and automation PRs.
- Source strings are refreshed automatically by GitHub Actions daily at `02:00` (cron).
- Normal builds must not modify `lang/`.
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
"post-install-cmd": [
"npm ci"
],
"extract-strings": [
"JIGSAW_EXTRACT_STRINGS=1 ./vendor/bin/jigsaw build local"
],
"dev": [
"npm run watch"
],
Expand Down
102 changes: 52 additions & 50 deletions listeners/AddNewTranslation.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,75 +2,77 @@

namespace App\Listeners;

use Illuminate\Support\Collection;
use TightenCo\Jigsaw\Jigsaw;

/**
* Tracks all translatable strings encountered during a Jigsaw build.
*
* During normal builds this class only collects strings in memory and never
* writes to disk, keeping the lang/ directory clean for Weblate to manage.
*
* When JIGSAW_EXTRACT_STRINGS=1 is set (extraction mode), the afterBuild
* listener PersistExtractedStrings calls persistExtractedStrings() to sync
* lang/{defaultLocale}/main.json with the current set of source strings.
*/
class AddNewTranslation
{
private Jigsaw $jigsaw;
private Collection $localization;
private string $currentLanguage;
public function handle(Jigsaw $jigsaw)
private static array $encounteredStrings = [];

public function handle(Jigsaw $jigsaw): void
{
$this->jigsaw = $jigsaw;
$self = $this;
$this->jigsaw->getSiteData()->macro('addNewTranslation', function(string $currentLanguage, string $text) use ($self) {
$self->addNewTranslation($currentLanguage, $text);
$jigsaw->getSiteData()->macro('addNewTranslation', function (string $currentLanguage, string $text) use ($self): void {
$self->track($text);
});
}

public function addNewTranslation(string $currentLanguage, string $text) {
$this->localization = $this->jigsaw->getSiteData()->localization;
$this->currentLanguage = $currentLanguage;
if (!is_dir('lang/' . $this->currentLanguage)) {
mkdir('lang/' . $this->currentLanguage);
}
// Don't translate tranlated text
if ($this->isTranslatedText($text)) {
return;
}
$this->storeAtGlossaryFile($text);
$this->storeAtTranslationFile($text);
}

private function isTranslatedText(string $text): bool
public function track(string $text): void
{
if ($this->localization[$this->currentLanguage]->has($text)) {
return false;
}
return $this->localization[$this->currentLanguage]->contains($text);
self::$encounteredStrings[$text] = $text;
}

private function storeAtTranslationFile(string $text): void
/**
* Persist the collected strings to lang/{defaultLocale}/main.json.
*
* New strings are added with the source text as the default value.
* Strings no longer present in the templates are removed.
* Existing translations for the default locale are preserved.
*
* Only runs when JIGSAW_EXTRACT_STRINGS env var is set to a truthy value.
*/
public function persistExtractedStrings(): void
{
// Only change the file if haven't the text
if ($this->localization[$this->currentLanguage]->has($text)) {
if (!self::isExtractionMode()) {
return;
}
// Save new texts
$translationFile = 'lang/' . $this->currentLanguage . '/main.json';

$defaultLocale = packageDefaultLocale();
$translationFile = 'lang/' . $defaultLocale . '/main.json';

$existing = [];
if (file_exists($translationFile)) {
$content = file_get_contents($translationFile);
$content = json_decode($content, true);
} else {
$content = [];
$existing = json_decode(file_get_contents($translationFile), true) ?? [];
}

// Rebuild the source file: keep only strings still in use, add new ones.
$updated = [];
foreach (self::$encounteredStrings as $text => $_) {
$updated[$text] = $existing[$text] ?? $text;
}
$content[$text] = $text;
ksort($content);
file_put_contents($translationFile, json_encode($content, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT));
ksort($updated);

if (!is_dir('lang/' . $defaultLocale)) {
mkdir('lang/' . $defaultLocale, 0755, true);
}

file_put_contents(
$translationFile,
json_encode($updated, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL
);
}

private function storeAtGlossaryFile(string $text): void
public static function isExtractionMode(): bool
{
// Store translated texts to be possible update translation files
if (file_exists('lang/to_translate.json')) {
$toTranslate = file_get_contents('lang/to_translate.json');
$toTranslate = json_decode($toTranslate, true);
} else {
$toTranslate = [];
}
$toTranslate[$text] = '';
ksort($toTranslate);
file_put_contents('lang/to_translate.json', json_encode($toTranslate, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT) . PHP_EOL);
return !empty(getenv('JIGSAW_EXTRACT_STRINGS'));
}
}
20 changes: 20 additions & 0 deletions listeners/PersistExtractedStrings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace App\Listeners;

use TightenCo\Jigsaw\Jigsaw;

/**
* afterBuild listener that persists extracted strings to lang/{defaultLocale}/main.json.
*
* Only does work when JIGSAW_EXTRACT_STRINGS=1 is set.
* Delegates to AddNewTranslation which holds the in-memory collection of
* all strings encountered during the build.
*/
class PersistExtractedStrings
{
public function handle(Jigsaw $jigsaw): void
{
(new AddNewTranslation())->persistExtractedStrings();
}
}
31 changes: 0 additions & 31 deletions listeners/RemoveDeletedTranslations.php

This file was deleted.

90 changes: 64 additions & 26 deletions listeners/RemoveTranslationFiles.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

namespace App\Listeners;

use Illuminate\Support\Str;
use TightenCo\Jigsaw\File\Filesystem;
use TightenCo\Jigsaw\Jigsaw;

Expand All @@ -12,36 +11,75 @@ class RemoveTranslationFiles
private Filesystem $filesystem;
private array $langs;

public function handle(Jigsaw $jigsaw)
public function handle(Jigsaw $jigsaw): void
{
$this->filesystem = $jigsaw->getFilesystem();
$this->jigsaw = $jigsaw;
$this->langs = $this->jigsaw->getSiteData()->localization->keys()->all();
$path = $this->jigsaw->getSourcePath();
collect($this->filesystem->directories($path))
->filter(function ($folder) {
foreach ($this->langs as $lang) {
if ($folder->getFilename() === $lang) {
return true;
}
if ($folder->getFilename() === '_tmp') {
return true;
}
}
return false;
})->each(function ($folder) {
$this->filesystem->deleteDirectory($folder->getPathName(), false);
});
collect($this->filesystem->files($path))
->filter(function ($file) {
foreach ($this->langs as $lang) {
if (Str::startsWith($file->getFilename(), $lang . '_')) {
return true;
}

$this->removeTranslatedDirectories($path);
$this->removeTranslatedFiles($path);
}

private function removeTranslatedDirectories(string $sourcePath): void
{
$directoriesToRemove = [];

// Remove only top-level locale folders generated for translated pages.
foreach ($this->langs as $lang) {
$localeDirectory = rtrim($sourcePath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $lang;
if (is_dir($localeDirectory)) {
$directoriesToRemove[] = $localeDirectory;
}
}

$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($sourcePath, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);

foreach ($iterator as $item) {
if (!$item->isDir()) {
continue;
}

$directoryName = $item->getFilename();
if ($directoryName === '_tmp') {
$directoriesToRemove[] = $item->getPathname();
}
}

foreach ($directoriesToRemove as $directoryPath) {
if (is_dir($directoryPath)) {
$this->filesystem->deleteDirectory($directoryPath, false);
}
}
}

private function removeTranslatedFiles(string $sourcePath): void
{
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($sourcePath, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY
);

foreach ($iterator as $item) {
if (!$item->isFile()) {
continue;
}

if (!str_contains($item->getPathname(), DIRECTORY_SEPARATOR . '_tmp' . DIRECTORY_SEPARATOR)) {
continue;
}

$filename = $item->getFilename();
foreach ($this->langs as $lang) {
if (str_starts_with($filename, $lang . '_')) {
$this->filesystem->delete($item->getPathname());
break;
}
return false;
})->each(function ($file) {
$this->filesystem->delete($file->getPathName());
});
}
}
}
}
Loading
Loading