Skip to content
Open
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
126 changes: 126 additions & 0 deletions bin/build-i18n-merge.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php
/**
* Merge per-source-file Jed JSONs produced by `wp i18n make-json` into
* a single per-handle JSON for one locale. Driven by env vars set by
* bin/build-i18n.sh; not intended to run on its own.
*
* Inputs (env):
* HANDLE_MAP_PREFIX Source path prefix that selects which hashed
* JSONs feed this handle, e.g. "src/recycle-bin/".
* Files whose `source` starts with the prefix of an
* earlier (more specific) handle are skipped here,
* so the most specific handle should run first.
* HANDLE_OUT_FILE Final output file path for this handle.
* HANDLE_SOURCES_DIR Directory containing the hashed JSONs from
* `wp i18n make-json`.
* HANDLE_LOCALE Locale slug (e.g. es_ES). Used in the output
* header and revision-date passthrough.
* HANDLE_DOMAIN Text domain (e.g. desktop-mode).
*
* Output: writes HANDLE_OUT_FILE if at least one translation matches the
* prefix, removes a stale file otherwise.
*/

$prefix = getenv( 'HANDLE_MAP_PREFIX' );
$out_file = getenv( 'HANDLE_OUT_FILE' );
$sources_dir = getenv( 'HANDLE_SOURCES_DIR' );
$locale = getenv( 'HANDLE_LOCALE' );
$domain = getenv( 'HANDLE_DOMAIN' );

if ( ! $prefix || ! $out_file || ! $sources_dir || ! $locale || ! $domain ) {
fwrite( STDERR, "build-i18n-merge.php: missing required env vars.\n" );
exit( 1 );
}

// Determine which prefixes are MORE specific than ours, so we can
// exclude their sources from this handle's bundle. We re-derive the
// list from the script's own ordering hint: anything passed via the
// EXCLUDE_PREFIXES env var (newline separated) is skipped.
$exclude = array_filter( preg_split( '/\R/', (string) getenv( 'HANDLE_EXCLUDE_PREFIXES' ) ?: '' ) );

$plurals = '';
$revision_date = '';
$messages = array();

$files = glob( rtrim( $sources_dir, '/' ) . '/*.json' );
if ( ! $files ) {
$files = array();
}

foreach ( $files as $file ) {
$raw = file_get_contents( $file );
if ( false === $raw ) {
continue;
}
$data = json_decode( $raw, true );
if ( ! is_array( $data ) ) {
continue;
}
$source = isset( $data['source'] ) ? (string) $data['source'] : '';
if ( '' === $source || 0 !== strpos( $source, $prefix ) ) {
continue;
}
foreach ( $exclude as $skip ) {
if ( '' !== $skip && 0 === strpos( $source, $skip ) ) {
continue 2;
}
}

if ( '' === $revision_date && ! empty( $data['translation-revision-date'] ) ) {
$revision_date = (string) $data['translation-revision-date'];
}

if (
! empty( $data['locale_data']['messages'][''] ) &&
is_array( $data['locale_data']['messages'][''] )
) {
$header = $data['locale_data']['messages'][''];
if ( ! empty( $header['plural-forms'] ) && '' === $plurals ) {
$plurals = (string) $header['plural-forms'];
}
}

if ( ! empty( $data['locale_data']['messages'] ) && is_array( $data['locale_data']['messages'] ) ) {
foreach ( $data['locale_data']['messages'] as $key => $value ) {
if ( '' === $key ) {
continue;
}
$messages[ $key ] = $value;
}
}
}

if ( empty( $messages ) ) {
if ( file_exists( $out_file ) ) {
unlink( $out_file );
}
exit( 0 );
}

ksort( $messages );

$header = array(
'domain' => 'messages',
'lang' => $locale,
'plural-forms' => '' !== $plurals ? $plurals : 'nplurals=2; plural=(n != 1);',
);

$json = array(
'translation-revision-date' => '' !== $revision_date ? $revision_date : gmdate( 'Y-m-d H:iO' ),
'generator' => 'desktop-mode/build-i18n.sh',
'domain' => 'messages',
'locale_data' => array(
'messages' => array_merge( array( '' => $header ), $messages ),
),
);

$encoded = json_encode( $json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
if ( false === $encoded ) {
fwrite( STDERR, "build-i18n-merge.php: failed to encode JSON for {$out_file}.\n" );
exit( 1 );
}

if ( false === file_put_contents( $out_file, $encoded . "\n" ) ) {
fwrite( STDERR, "build-i18n-merge.php: failed to write {$out_file}.\n" );
exit( 1 );
}
101 changes: 101 additions & 0 deletions bin/build-i18n.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
#
# Build per-handle JSON translation files from PO files.
#
# WordPress's `wp_set_script_translations( $handle, $domain, $path )` expects
# `$path/$domain-$locale-$handle.json`. `wp i18n make-json` only emits one
# JSON per JS source file (named with the md5 of the source path), which
# does not match the lookup performed when a translations path is passed.
#
# This script:
# 1. Runs `wp i18n make-json --extensions=ts` on every PO file in
# languages/ into a temporary directory, producing one hashed JSON
# per .ts source file.
# 2. Merges those hashed JSONs per handle, based on a source-prefix to
# handle map defined below, and writes them as
# `languages/$domain-$locale-$handle.json` so WordPress can find them.
#
# Re-run this whenever the .po files change. PO/POT extraction itself is
# not handled here, that is normally done via `wp i18n make-pot` against
# the plugin root (with `--include='src/*.ts'` appended manually if you
# want JS strings in the POT).

set -euo pipefail

DOMAIN="desktop-mode"
PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LANG_DIR="$PLUGIN_DIR/languages"

if ! command -v wp >/dev/null 2>&1; then
echo "build-i18n.sh: wp-cli is required (https://wp-cli.org/)." >&2
exit 1
fi

if ! command -v php >/dev/null 2>&1; then
echo "build-i18n.sh: php is required." >&2
exit 1
fi

# Source-prefix to script-handle map.
#
# Every script handle that is wired up via `wp_set_script_translations()`
# in includes/assets.php must have an entry here. A PO `#:` reference
# whose path starts with one of these prefixes is bundled into the
# matching handle's JSON. A reference that matches no prefix is dropped
# (PHP-only strings live in the .mo file, not the JSON).
#
# Order matters: the first matching prefix wins, so list the most
# specific paths first.
declare -a HANDLE_MAP=(
"src/recycle-bin/=desktop-mode-recycle-bin"
"src/posts-window/=desktop-mode-posts-window"
"src/=desktop-mode"
)

tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT

shopt -s nullglob
po_files=("$LANG_DIR"/${DOMAIN}-*.po)
shopt -u nullglob

if [ "${#po_files[@]}" -eq 0 ]; then
echo "build-i18n.sh: no PO files found in $LANG_DIR." >&2
exit 0
fi

for po in "${po_files[@]}"; do
locale="$(basename "$po" .po)"
locale="${locale#${DOMAIN}-}"

out_dir="$tmp_dir/$locale"
mkdir -p "$out_dir"

# Extract hashed JSON files (one per .ts source file referenced by
# the PO). --no-purge keeps the PO untouched.
wp i18n make-json "$po" "$out_dir" --extensions=ts --no-purge --pretty-print >/dev/null

# Merge the hashed JSONs into one per-handle JSON, written into
# languages/ with the filename WordPress actually looks for. We
# pass each handle the list of MORE-specific prefixes that come
# before it in HANDLE_MAP, so a catch-all entry like `src/=...`
# does not also pull in strings already claimed by a narrower
# prefix like `src/recycle-bin/=...`.
previous_prefixes=""
for entry in "${HANDLE_MAP[@]}"; do
prefix="${entry%%=*}"
handle="${entry#*=}"

HANDLE_MAP_PREFIX="$prefix" \
HANDLE_OUT_FILE="$LANG_DIR/${DOMAIN}-${locale}-${handle}.json" \
HANDLE_SOURCES_DIR="$out_dir" \
HANDLE_LOCALE="$locale" \
HANDLE_DOMAIN="$DOMAIN" \
HANDLE_EXCLUDE_PREFIXES="$previous_prefixes" \
php "$PLUGIN_DIR/bin/build-i18n-merge.php"

previous_prefixes="${previous_prefixes}${prefix}"$'\n'
done
done

echo "build-i18n.sh: regenerated per-handle JSON files in $LANG_DIR."
27 changes: 27 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,33 @@ interface in `src/desktop.ts`. To add a method:
a browser quirk or a subtle invariant, note it inline. Otherwise
let the code speak.

## i18n

Strings flow through three files per locale in `languages/`:

- `desktop-mode.pot` — extracted from PHP and TS sources. Regenerate with
`wp i18n make-pot . languages/desktop-mode.pot --include='src/*.ts'`
(the `--include` is the bit that picks up TypeScript callers of
`__()`, `_x()`, etc.).
- `desktop-mode-{locale}.po` / `.mo` — translator output, one pair per
shipped locale.
- `desktop-mode-{locale}-{handle}.json` — JS translation bundles.
WordPress's `wp_set_script_translations()` looks up these files by
the script handle, NOT by source-file hash, because we pass a path
argument from `includes/assets.php`. Today the only handle with a
populated bundle is `desktop-mode` (the main shell); see
`bin/build-i18n.sh` for the handle to source-prefix map.

Build the JSON bundles with:

```bash
npm run build:i18n
```

Re-run this after editing any PO file. The script invokes `wp i18n
make-json --extensions=ts` under the hood and merges the per-source
JSONs into one file per script handle.

## Where things are tested

- **Vitest** — `tests/vitest/*.test.ts` + colocated
Expand Down
Loading
Loading