diff --git a/.github/workflows/i18n-automerge.yml b/.github/workflows/i18n-automerge.yml
new file mode 100644
index 000000000..6ba6df40a
--- /dev/null
+++ b/.github/workflows/i18n-automerge.yml
@@ -0,0 +1,63 @@
+name: i18n PR auto-merge
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, labeled]
+ branches:
+ - main
+
+jobs:
+ run-and-merge:
+ if: contains(github.event.pull_request.labels.*.name, 'i18n') &&
+ github.event.pull_request.base.ref == 'main' &&
+ github.event.pull_request.head.ref == 'l10n_main'
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - run: sleep 15
+
+ - name: Check if the branch is dirty
+ run: |
+ git fetch origin ${{ github.event.pull_request.head.ref }}
+ if [ $(git rev-parse HEAD) != $(git rev-parse origin/${{ github.event.pull_request.head.ref }}) ]; then
+ echo "Branch is dirty. Exiting..."
+ exit 0
+ fi
+
+ - name: Check auto-merge conditions
+ run: |
+ BASE_SHA="${{ github.event.pull_request.base.sha }}"
+ HEAD_SHA="${{ github.event.pull_request.head.sha }}"
+
+ # Debug: Show the base and head SHA
+ echo "Base SHA: $BASE_SHA"
+ echo "Head SHA: $HEAD_SHA"
+
+ # Check if the commits exist
+ if ! git cat-file -e $BASE_SHA || ! git cat-file -e $HEAD_SHA; then
+ echo "ERROR: One or both of the commits are not available."
+ exit 1
+ fi
+
+ # Calculate the total number of lines changed (added, removed, or modified)
+ LINES_CHANGED=$(git diff --shortstat $BASE_SHA $HEAD_SHA | awk '{print $4 + $6 + $8}')
+
+ if [ -z "$LINES_CHANGED" ]; then
+ LINES_CHANGED=0
+ fi
+
+ echo "Total lines changed: $LINES_CHANGED"
+
+ # Check if the number of lines changed is more than 50
+ if [ "$LINES_CHANGED" -le 50 ]; then
+ exit 0
+ else
+ echo "More than 50 lines have been changed. Merging pull request."
+ PR_NUMBER=$(echo ${{ github.event.pull_request.number }})
+ gh pr merge $PR_NUMBER --auto --squash || true
+ fi
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/update-catalogs.yml b/.github/workflows/update-catalogs.yml
new file mode 100644
index 000000000..5a635c653
--- /dev/null
+++ b/.github/workflows/update-catalogs.yml
@@ -0,0 +1,32 @@
+name: Update Catalogs
+
+on:
+ push:
+ branches:
+ - l10n_main
+ workflow_dispatch:
+
+jobs:
+ update-catalogs:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: l10n_main
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ - run: npm ci
+ - name: Update catalogs.json
+ run: |
+ node scripts/catalogs.js
+ if git diff --quiet src/data/catalogs.json; then
+ echo "No changes to catalogs.json"
+ else
+ echo "Changes to catalogs.json"
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
+ git config --global user.name "github-actions[bot]"
+ git add src/data/catalogs.json
+ git commit -m "Update catalogs.json"
+ git push origin HEAD:l10n_main || true
+ fi
diff --git a/.gitignore b/.gitignore
index 0b8599124..c8f96d5f4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,4 +26,7 @@ dist-ssr
# Custom
.env.dev
phanpy-dist.zip
-phanpy-dist.tar.gz
\ No newline at end of file
+phanpy-dist.tar.gz
+
+# Compiled locale files
+src/locales/*.js
\ No newline at end of file
diff --git a/README.md b/README.md
index 0dc9ea979..7cbf99880 100644
--- a/README.md
+++ b/README.md
@@ -100,11 +100,12 @@ Everything is designed and engineered following my taste and vision. This is a p
Prerequisites: Node.js 18+
- `npm install` - Install dependencies
-- `npm run dev` - Start development server
+- `npm run dev` - Start development server and `messages:extract` (`clean` + ``watch`) in parallel
- `npm run build` - Build for production
- `npm run preview` - Preview the production build
- `npm run fetch-instances` - Fetch instances list from [joinmastodon.org/servers](https://joinmastodon.org/servers), save it to `src/data/instances.json`
- `npm run sourcemap` - Run `source-map-explorer` on the production build
+- `npm run messages:extract` - Extract messages from source files and update the locale message catalogs
## Tech stack
@@ -115,10 +116,65 @@ Prerequisites: Node.js 18+
- [masto.js](https://github.com/neet/masto.js/) - Mastodon API client
- [Iconify](https://iconify.design/) - Icon library
- [MingCute icons](https://www.mingcute.com/)
+- [Lingui](https://lingui.dev/) - Internationalization
- Vanilla CSS - _Yes, I'm old school._
Some of these may change in the future. The front-end world is ever-changing.
+## Internationalization
+
+All translations are available as [gettext](https://en.wikipedia.org/wiki/Gettext) `.po` files in the `src/locales` folder. The default language is English (`en`). [CLDR Plural Rules](https://cldr.unicode.org/index/cldr-spec/plural-rules) are used for pluralization. RTL (right-to-left) languages are also supported with proper text direction, icon rendering and layout.
+
+On page load, default language is detected via these methods, in order (first match is used):
+
+1. URL parameter `lang` e.g. `/?lang=zh-Hant`
+2. `localStorage` key `lang`
+3. Browser's `navigator.language`
+
+Users can change the language in the settings, which sets the `localStorage` key `lang`.
+
+### Guide for translators
+
+*Inspired by [Translate WordPress Handbook](https://make.wordpress.org/polyglots/handbook/):
+
+- [Don’t translate literally, translate organically](https://make.wordpress.org/polyglots/handbook/translating/expectations/#dont-translate-literally-translate-organically).
+- [Try to keep the same level of formality (or informality)](https://make.wordpress.org/polyglots/handbook/translating/expectations/#try-to-keep-the-same-level-of-formality-or-informality)
+- [Don’t use slang or audience-specific terms](https://make.wordpress.org/polyglots/handbook/translating/expectations/#try-to-keep-the-same-level-of-formality-or-informality)
+- Be attentive to placeholders for variables. Many strings have placesholders e.g. `{account}` (variable), `<0>{name}0>` (tag with variable) and `#` (number placeholder).
+- [Ellipsis](https://en.wikipedia.org/wiki/Ellipsis) (…) is intentional. Don't remove it.
+ - Nielsen Norman Group: ["Include Ellipses in Command Text to Indicate When More Information Is Required"](https://www.nngroup.com/articles/ui-copy/)
+ - Apple Human Interface Guidelines: ["Append an ellipsis to a menu item’s label when the action requires more information before it can complete. The ellipsis character (…) signals that people need to input information or make additional choices, typically within another view."](https://developer.apple.com/design/human-interface-guidelines/menus)
+ - Windows App Development: ["Ellipses mean incompleteness."](https://learn.microsoft.com/en-us/windows/win32/uxguide/text-ui)
+- Date timestamps, date ranges, numbers, language names and text segmentation are handled by the [ECMAScript Internationalization API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl).
+ - [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) - e.g. "8 Aug", "08/08/2024"
+ - [`Intl.RelativeTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat) - e.g. "2 days ago", "in 2 days"
+ - [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) - e.g. "1,000", "10K"
+ - [`Intl.DisplayNames`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DisplayNames) - e.g. "English" (`en`) in Traditional Chinese (`zh-Hant`) is "英文"
+ - [`Intl.Locale`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) (with polyfill for older browsers)
+ - [`Intl.Segmenter`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter) (with polyfill for older browsers)
+
+### Technical notes
+
+- IDs for strings are auto-generated instead of explicitly defined. Some of the [benefits](https://lingui.dev/tutorials/explicit-vs-generated-ids#benefits-of-generated-ids) are avoiding the "naming things" problem and avoiding duplicates.
+ - Explicit IDs might be introduced in the future when requirements and priorities change. The library (Lingui) allows both.
+ - Please report issues if certain strings are translated differently based on context, culture or region.
+- There are no strings for push notifications. The language is set on the instance server.
+- Native HTML date pickers, e.g. `` will always follow the system's locale and not the user's set locale.
+- "ALT" in ALT badge is not translated. It serves as a a recognizable standard across languages.
+- Custom emoji names are not localized, therefore searches don't work for non-English languages.
+- GIPHY API supports [a list of languages for searches](https://developers.giphy.com/docs/optional-settings/#language-support).
+- Unicode Right-to-left mark (RLM) (`U+200F`, ``) may need to be used for mixed RTL/LTR text, especially for [`
{
states.showGenericAccounts = {
id: 'followers',
- heading: 'Followers',
+ heading: t`Followers`,
fetchAccounts: fetchFollowers,
instance,
excludeRelationshipAttrs: isSelf
? ['followedBy']
: [],
blankCopy: hideCollections
- ? 'This user has chosen to not make this information available.'
+ ? t`This user has chosen to not make this information available.`
: undefined,
};
}, 0);
@@ -705,7 +729,7 @@ function AccountInfo({
{shortenNumber(followersCount)}
{' '}
- Followers
+ Followers {
states.showGenericAccounts = {
- heading: 'Following',
+ heading: t`Following`,
fetchAccounts: fetchFollowing,
instance,
excludeRelationshipAttrs: isSelf ? ['following'] : [],
blankCopy: hideCollections
- ? 'This user has chosen to not make this information available.'
+ ? t`This user has chosen to not make this information available.`
: undefined,
};
}, 0);
@@ -729,7 +753,7 @@ function AccountInfo({
{shortenNumber(followingCount)}
{' '}
- Following
+ Following
{shortenNumber(statusesCount)}
{' '}
- Posts
+ Posts
{!!createdAt && (
- Joined{' '}
-
+
+ Joined{' '}
+
+
)}
@@ -773,25 +799,39 @@ function AccountInfo({
{hasPostingStats ? (
{postingStats.daysSinceLastPost < 365
- ? `Last ${postingStats.total} post${
- postingStats.total > 1 ? 's' : ''
- } in the past
- ${postingStats.daysSinceLastPost} day${
- postingStats.daysSinceLastPost > 1 ? 's' : ''
- }`
- : `
- Last ${postingStats.total} posts in the past year(s)
- `}
+ ? plural(postingStats.total, {
+ one: plural(postingStats.daysSinceLastPost, {
+ one: `Last 1 post in the past 1 day`,
+ other: `Last 1 post in the past ${postingStats.daysSinceLastPost} days`,
+ }),
+ other: plural(
+ postingStats.daysSinceLastPost,
+ {
+ one: `Last ${postingStats.total} posts in the past 1 day`,
+ other: `Last ${postingStats.total} posts in the past ${postingStats.daysSinceLastPost} days`,
+ },
+ ),
+ })
+ : plural(postingStats.total, {
+ one: 'Last 1 post in the past year(s)',
+ other: `Last ${postingStats.total} posts in the past year(s)`,
+ })}