GSoC 2026: Set up BookBrainz for internationalization - Amr Salahuddin

AI Usage:

I prompted Claude to convert some texts into MD (timeline table) and convert the proposal docx file into MD ( and it automatically added some em dashes not mine :D)
(it has also converted -> into →)
(it fixed some typos)

prompt:
Can you convert this doc into md? preserving the different highlightings, headers...etc? and also if there is a block of code, highlight it.

original doc file link

I removed some parts due to characters limit.

Further stylings will be AI-generated.

BookBrainz SSR Architecture Analysis + react-i18next Integration Plan

Contact Information

Full Name: Amr Salahuddin
Nickname: amr
IRC nick/Matrix handle: amr-salahuddin
Email: ar.slhdn@gmail.com

PRs

BookBrainz:

  1. BB-881
  2. BB-867

Architecture Deep-Dive

The SSR Hydration Pipeline

1. Server Route index.js

  1. Express router that handles GET / and several static pages (/about, /help, etc.)

  2. SSR process for GET /:

    1. Fetches recent revisions from the ORM
    2. Calls generateProps(req, res, { recent, homepage, ... }) merges res.locals (user, alerts, siteRevision, repositoryUrl) with route-specific data
    3. Calls ReactDOMServer.renderToString(<Layout><Index/></Layout>) renders the entire React tree to an HTML string on the server
    4. Sends the response via target({ markup, props, page, script }) the HTML template
  3. Static routes: _createStaticRoute() does the same for About, Help, FAQ, etc. — same pattern but without custom data.


2. Server Template target.js

A function that generates the full HTML document


3. Client Controller controllers/index.js

  1. The client-side entry point for the index bundle (webpack entry index)

  2. Hydration process:

    1. Reads #propsJSON.parse() → same props object the server used
    2. Reads #page → determines which component to render (from pageMap)
    3. Reconstructs the exact same React tree the server rendered
    4. Calls ReactDOM.hydrate(markup, document.getElementById('target')) — React attaches event handlers to the existing server-rendered DOM without re-rendering (if the trees match)

4. Layout layout.js

  1. The shared wrapper component wrapping every page with navbar, footer, alerts, etc.
  2. Used both server-side and client-side Receives layout props, renders the navigation bar, search form, user dropdowns lists, and wraps {children} (the page component)

5. Page Components pages/index.js + pages/statistics.tsx

  1. These are components that render hardcoded English strings
  2. IndexPage is a class component; StatisticsPage is a function component
  3. They receive their data as props and render tables, headings, text, etc.

Statistics Flow (Same Pattern)

The statistics route → statistics controller → StatisticsPage follows the exact same pattern, just with different data (allEntities, last30DaysEntities, topEditors).


Library Integration

Library Selection: react-i18next

After evaluation options like react-intl,Fluent and react-i18next, react-i18next is better for its React integration, plugin ecosystem, scalability and community support (documentation) + more features like lazy-loading, hosting translations on another server if needed…etc.


Why Don’t We Just Use t('') Directly?

The challenge is that this is an SSR app with hydration:

  1. Server renders HTML with renderToString() → translations must be available synchronously on the server at render time
  2. Client hydrates → if the client doesn’t have the same translations available at hydration time, React will see a mismatch (server HTML says “Statistics of BookBrainz” but client says "" or an untranslated key), which causes hydration errors/flicker
  3. Each controller is a separate webpack entry → there’s no single App component; each page has its own entry point that needs its own i18n initialization
  4. Server is multi-user → you can’t use a singleton i18n instance on the server (different users might request different languages simultaneously)

So we need to:

  1. Create a per-request i18n instance on the server
  2. Pre-load translations on the server (read from disk)
  3. Pass the loaded translations to the client via props
  4. On the client, initialize i18n with those same translations (so hydration matches)
  5. Additionally configure an HTTP backend on the client for lazy-loading future namespaces

Namespaces

Instead of hardcoding the translations, we will use namespaces in:

public/locales/{{lng}}/{{ns}}.json

Each page/component will have its own namespace for simplicity plus a shared common namespace.

For Example:

Namespace Content Used by
common Navbar strings, footer, shared UI text Every page (via Layout)
index Index page strings (search placeholder, “The Open Book Database”, etc.) Index page only
statistics “Statistics of BookBrainz”, “Top 10 Editors”, column headers Statistics only

This way, the statistics page never loads index translations and vice versa. Each server route declares which namespaces it needs: ['common', 'statistics'].


Language Change: Dynamic or Full Reload?

Dynamic (change in-place): Smooth UX, no flicker but needs to download new translations, re-render everything, risk hydration issues

Full reload: Simple, guaranteed consistency, SSR renders correct language from scratch but Brief page reload

I have implemented the dynamic option and commented out the full SSR reload endpoint code below until feedback.

We will set a language cookie via POST /set-language, and add a language selector in the navbar (in Layout) that sets a cookie lang=xx and dynamically changes the language in the client.


Set Up

NPM Dependencies

npm install i18next react-i18next i18next-http-backend i18next-fs-backend cookie-parser

Shared Infrastructure

[NEW] i18n.js

import i18n from 'i18next';
import {initReactI18next} from 'react-i18next';

// temporary
const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur'];
const SUPPORTED_LANGUAGES = ['en', 'ar'];

export function isRtlLanguage(lng) {
    return RTL_LANGUAGES.includes(lng);
}

export function resolveLanguage(lng) {
    if (!lng) {
        return 'en';
    }
    const base = lng.split('-')[0].toLowerCase();
    return SUPPORTED_LANGUAGES.includes(base) ? base : 'en';
}

export function createI18nextInstance({lng, ns, resources, backend}) {
    const instance = i18n.createInstance();
    const config = {
        defaultNS: ns[0],
        fallbackLng: 'en',
        initImmediate: false,
        interpolation: {escapeValue: false},
        lng,
        ns,
        partialBundledLanguages: true
    };

    if (backend) {
        instance.use(backend.plugin);
        config.backend = backend.options;
    }

    if (resources) {
        config.resources = resources;
    }

    instance.use(initReactI18next);
    instance.init(config);
    return instance;
}

Shared factory used by both server and client. No Node.js or browser-specific code.

Exports:

  1. isRtlLanguage(lng) — checks if a language code is RTL (ar, he, fa, ur)
  2. resolveLanguage(lng) — maps a raw language code to a supported one (strips region, falls back to 'en')
  3. createI18nextInstance({lng, ns, resources, backend}) — creates and initializes an i18next instance with:
    • initImmediate: false — sync init (fs-backend uses readSync internally to read the JSON files needed for the current language)
    • partialBundledLanguages: true — skips backend loads for pre-bundled namespaces which are sent by the SSR in the props to the client, enabling faster initial SSR while avoiding redundant calls
    • Registers the provided backend.plugin (needed for getting the JSON files either locally from disk by the server or by an HTTP request by the client when changing language dynamically) and pre-seeds resources (passed by the server to the client props)

[NEW] i18nServer.js

import {createI18nextInstance, resolveLanguage} from '../../common/helpers/i18n';
import FsBackend from 'i18next-fs-backend';
import {getAcceptedLanguageCodes} from './i18n';
import path from 'path';

const LOCALES_DIR = path.resolve(__dirname, '../../../public/locales');

export function createServerI18n(lng, namespaces) {
    return createI18nextInstance({
        backend: {
            options: {
                loadPath: path.join(LOCALES_DIR, '{{lng}}/{{ns}}.json')
            },
            plugin: FsBackend
        },
        lng,
        ns: namespaces
    });
}

export function getLoadedResources(i18nInstance, lng, namespaces) {
    const resources = {[lng]: {}};
    if (lng !== 'en') {
        resources.en = {};
    }
    for (const ns of namespaces) {
        resources[lng][ns] = i18nInstance.getResourceBundle(lng, ns) || {};
        if (lng !== 'en') {
            resources.en[ns] = i18nInstance.getResourceBundle('en', ns) || {};
        }
    }
    return resources;
}

export function detectLanguage(req) {
    // Cookie takes priority
    const cookieLang = req.cookies && req.cookies.lang;
    if (cookieLang) {
        return resolveLanguage(cookieLang);
    }
    // fallback to Accept-Language header
    const headerLangs = getAcceptedLanguageCodes(req);
    return resolveLanguage(headerLangs[0]);
}

Server-only helper. Uses i18next-fs-backend library to load translation JSON files from disk.

Exports:

  1. createServerI18n(lng, namespaces) — calls createI18nextInstance with i18next-fs-backend pointed at public/locales/{{lng}}/{{ns}}.json. Returns an initialized instance (per request for each user) with needed translations loaded synchronously.
  2. getLoadedResources(i18nInstance, lng, namespaces) — extracts loaded translations from the instance via getResourceBundle(). Always includes en (fallback) alongside the requested language. Returns { en: { common: {...} }, ar: { common: {...} } }. This object is serialized into props for client hydration.
  3. detectLanguage(req) — reads req.cookies.lang; if not set, reads Accept-Language header; if not set, falls back to 'en'. Uses resolveLanguage() to validate.

[NEW] renderI18nPage.js

import * as propHelpers from '../../client/helpers/props';
import {createServerI18n, detectLanguage, getLoadedResources} from '../helpers/i18nServer';
import {escapeProps, generateProps} from '../helpers/props';
import {I18nextProvider} from 'react-i18next';
import Layout from '../../client/containers/layout';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import {isRtlLanguage} from '../../common/helpers/i18n';
import target from '../templates/target';

export function renderI18nPage({
    req, res, page, script, namespaces,
    title, extraProps, children: ChildComponent, childProps
}) {
    const lng = detectLanguage(req);
    const i18nNamespaces = ['common', ...namespaces.filter((ns) => ns !== 'common')];
    const i18nInstance = createServerI18n(lng, i18nNamespaces);
    const i18nResources = getLoadedResources(i18nInstance, lng, i18nNamespaces);

    const props = generateProps(req, res, {
        ...extraProps,
        i18nLanguage: lng,
        i18nNamespaces: i18nNamespaces,
        i18nResources
    });

    const layoutProps = propHelpers.extractLayoutProps(props);
    const markup = ReactDOMServer.renderToString(
        <I18nextProvider i18n={i18nInstance}>
            <Layout {...layoutProps}>
                {childProps ? <ChildComponent {...childProps}/> : <ChildComponent/>}
            </Layout>
        </I18nextProvider>
    );

    res.send(target({
        dir: isRtlLanguage(lng) ? 'rtl' : 'ltr',
        lang: lng,
        markup,
        page,
        props: escapeProps(props),
        script,
        title
    }));
}

Reusable SSR helper that eliminates i18n boilerplate from all server routes.

renderI18nPage({ req, res, page, script, namespaces, title, extraProps, children, childProps })

Automatically: detects language → prepends 'common' namespace → creates i18n instance → extracts resources for client → generates props → renderToString(<I18nextProvider><Layout><ChildComponent/></Layout></I18nextProvider>) → sends HTML via target().


[NEW] hydrateWithI18n.js

import {createI18nextInstance, isRtlLanguage} from '../../common/helpers/i18n';
import {extractLayoutProps} from '../helpers/props';
import HttpBackend from 'i18next-http-backend';
import {I18nextProvider} from 'react-i18next';
import Layout from '../containers/layout';
import React from 'react';
import ReactDOM from 'react-dom';
import {AppContainer} from 'react-hot-loader';

export function hydrateWithI18n({props, children}) {
    const lng = props.i18nLanguage || 'en';
    const ns = props.i18nNamespaces || ['common'];

    const i18nInstance = createI18nextInstance({
        backend: {
            options: {loadPath: '/locales/{{lng}}/{{ns}}.json'},
            plugin: HttpBackend
        },
        lng,
        ns,
        resources: props.i18nResources
    });

    // Set RTL on document <html/>
    const isRtl = isRtlLanguage(lng);
    document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
    document.documentElement.lang = lng;

    const markup = (
        <AppContainer>
            <I18nextProvider i18n={i18nInstance}>
                <Layout {...extractLayoutProps(props)}>
                    {children}
                </Layout>
            </I18nextProvider>
        </AppContainer>
    );

    ReactDOM.hydrate(markup, document.getElementById('target'));
}

Reusable client hydration helper that eliminates i18n boilerplate from all controllers.

hydrateWithI18n({ props, children })

Reads i18nLanguage/i18nNamespaces/i18nResources from props → creates i18n instance pre-seeded with SSR resources props and backed by i18next-http-backend for lazy-loading on language change → sets document.dir/lang for RTL → ReactDOM.hydrate().


[NEW] Translation Files

public/locales/
├── en/
│   ├── common.json         # Navbar, footer, shared strings
│   ├── index.json          # Index page strings
│   ├── statistics.json     # Statistics page strings
│   └── ...other components
├── ar/
│   ├── common.json
│   ├── index.json
│   └── statistics.json
...

Server Changes

[MODIFY] app.js

import cookieParser from 'cookie-parser';

app.use(cookieParser());
app.use('/locales', express.static(path.join(rootDir, 'public/locales')));

// not longer used now because it causes a reload
/*
// Language switch endpoint sets cookie and redirects back
app.post('/set-language', (req, res) => {
    const {lang} = req.body;
    if (lang) {
        res.cookie('lang', lang, {
            httpOnly: false,
            maxAge: 365 * 24 * 60 * 60 * 1000,
            sameSite: 'lax'
        });
    }
    const referrer = req.headers.referer || '/';
    res.redirect(referrer);
});
*/
  1. Added cookie-parser middleware (for req.cookies.lang)

  2. Added /locales static route (serves translation JSON for client HTTP backend)

  3. Added POST /set-language endpoint (sets lang cookie, redirects back — no-JS fallback)

    :warning: This one is not used now, as the cookie is set client-side for dynamic language change instead in layout.js


[MODIFY] target.js

Added lang and dir attributes on <html> tag for RTL support:

<html lang='${lang || 'en'}' dir='${dir || 'ltr'}'>

And a text-align: start on the <body> tag:

<body style='text-align: start;'>

[MODIFY] routes/index.js

Replaced manual SSR boilerplate with a single renderI18nPage() call:

/* GET home page. */
router.get('/', async (req, res, next) => {
    const {orm} = req.app.locals;
    const numRevisionsOnHomepage = 9;
    try {
        const orderedRevisions = await getOrderedRevisions(0, numRevisionsOnHomepage, orm);
        renderI18nPage({
            childProps: {
                disableSignUp: req.signUpDisabled,
                isLoggedIn: Boolean(req.user),
                recent: orderedRevisions
            },
            children: Index,
            extraProps: {
                disableSignUp: req.signUpDisabled,
                homepage: true,
                isLoggedIn: Boolean(req.user),
                recent: orderedRevisions,
                requireJS: Boolean(res.locals.user)
            },
            namespaces: ['index'],
            page: 'Index',
            req,
            res,
            script: '/js/index.js'
        });
    }
    catch (err) {
        return next(err);
    }
});

Same pattern applied to _createStaticRoute() for About, Help, FAQ, etc.


[MODIFY] routes/statistics.js

Same renderI18nPage() pattern with namespaces: ['statistics'].


Client Changes

[MODIFY] controllers/index.js (as an example)

Replaced manual hydration boilerplate with a single hydrateWithI18n() call:

const propsTarget = document.getElementById('props');
const props = propsTarget ? JSON.parse(propsTarget.innerHTML) : {};

hydrateWithI18n({
    children: <Child {...extractChildProps(props)}/>,
    props
});

[MODIFY] controllers/statistics.js

Same hydrateWithI18n() pattern.


[MODIFY] helpers/props.js

Added i18n props to LAYOUT_PROPS so they get consumed by the hydration helper in the <Layout> and not passed to child components:

const LAYOUT_PROPS = [
    'alerts',
    'hideSearch',
    'homepage',
+   'i18nLanguage',
+   'i18nNamespaces',
+   'i18nResources',
    'mergeQueue',
    'repositoryUrl',
    'requiresJS',
    'siteRevision',
    'user'
];

Components

[MODIFY] pages/index.js

Class component → wrapped with withTranslation('index') HOC:

export default withTranslation('index')(IndexPage);

All hardcoded English strings replaced by t() calls.


[MODIFY] pages/statistics.tsx

Functional component that uses useTranslation('statistics') hook.


[MODIFY] parts/revisions-table.js

Shared part component that uses useTranslation('common').
Locale-aware date formatting with date-fns/locale/ar-SA for Arabic AM/PM (ص/م).
No separate hydration needed (inherits I18nextProvider from parent page).


[MODIFY] layout.js

  1. Wrapped with withTranslation('common') — all navbar strings translated:
export default withTranslation('common')(Layout);
  1. Added language toggle dropdown (:globe_with_meridians: globe icon) with dynamic switching:
renderLanguageDropdown() {
    const {t, i18n} = this.props;
    const currentLang = i18n.language || 'en';

    const langDropdownTitle = (
        <span>
            <FontAwesomeIcon icon={faGlobe}/>
            {` ${t('language')}`}
        </span>
    );

    function handleLanguageChange(lang) {
        // 1. Switch language dynamically — re-renders all translated components in client instead of a reload
        i18n.changeLanguage(lang);

        // 2. Update HTML dir/lang attributes for RTL
        document.documentElement.dir = isRtlLanguage(lang) ? 'rtl' : 'ltr';
        document.documentElement.lang = lang;

        // 3. set cookie client-side for next SSR request
        document.cookie = `lang=${lang};path=/;max-age=${365 * 24 * 60 * 60};SameSite=Lax`;
    }

    return (
        <Nav>
            <NavDropdown
                alignRight
                id="lang-dropdown"
                title={langDropdownTitle}
                onMouseDown={this.handleMouseDown}
            >
                <NavDropdown.Item
                    active={currentLang === 'en'}
                    onClick={() => handleLanguageChange('en')}
                >
                    {t('english')}
                </NavDropdown.Item>
                <NavDropdown.Item
                    active={currentLang === 'ar'}
                    onClick={() => handleLanguageChange('ar')}
                >
                    {t('arabic')}
                </NavDropdown.Item>
            </NavDropdown>
        </Nav>
    );
}

[MODIFY] helpers/utils.tsx

export function formatDate(date: Date, includeTime?: boolean, locale?: Locale) {
    if (!date) {
        return null;
    }
    const options = locale ? {locale} : {};
    if (includeTime) {
        return format(date, 'yyyy-MM-dd hh:mm:ss a', options);
    }
    return format(date, 'yyyy-MM-dd', options);
}

formatDate() now accepts an optional locale parameter (date-fns locale object) for Arabic AM/PM (م/ص) rendering.


[MODIFY] style.scss

// Before
th { text-align: left }

// After
th { text-align: start }  /* CSS logical property that respects RTL direction */

Manual margins (margin-right) used for centering the elements should be also fixed and replaced with flexboxes instead.


Implementation Plan

Parsing (i18next-cli)

We use a library called i18next-cli (i18next-parser is deprecated) that scans all t() and <Trans> calls then creates/updates JSON files in public/locales/.

Run with: npx i18next-cli extract

[NEW] i18next.config.js

export default {
    locales: ['en', 'ar'],
    extract: {
        input: ['src/client/**/*.{js,jsx,ts,tsx}', 'src/common/**/*.{js,jsx,ts,tsx}'],
        output: 'public/locales/{{language}}/{{namespace}}.json',
        defaultNS: 'common',
        keySeparator: false,
        removeUnusedKeys: false,
        sort: true
    }
};

The namespace will be determined manually based on the code. For example:

import { useTranslation } from 'react-i18next';

function UserDashboard() {
    // Object destructuring (creates public/locales/.../dashboard.json)
    const { t: dashboardT } = useTranslation('dashboard');

    // array destructuring also works (creates login.json and profile.json)
    const [loginT] = useTranslation('login');
    const [profileT] = useTranslation('profile');

    return (
        <div>
            <h1>{dashboardT('welcome')}</h1>
            <p>{loginT('signInMessage')}</p>
            <p>{profileT('userName', { name: 'Ahmed' })}</p>
        </div>
    );
}

export default UserDashboard;

Server-side Texts

Error Flow (example)

We simply send a translation key (like 'error_not_found') and any needed values to the page component. Then both the server (during SSR) and the client (after hydration or when changing language) use t(key) to translate it into the correct text for the current language.


Database Texts

Script: Generate Namespace JSONs from DB Types

For the Database texts (like relationshipTypes) we can simply make a JavaScript script file that accesses the ORM just like middleware.ts.

Where in middleware.ts:

function makeLoader(modelName, propName, sortFunc?, relations = []) {
    return async function loaderFunc(req: $Request, res: $Response, next: NextFunction) {
        try {
            const {orm}: any = req.app.locals;
            const model = orm[modelName];
            const results = await model.fetchAll({withRelated: [...relations]});
            const resultsSerial = results.toJSON();
            res.locals[propName] =
                sortFunc ? resultsSerial.sort(sortFunc) : resultsSerial;
        }
        catch (err) {
            return next(err);
        }
        next();
        return null;
    };
}

export const loadAuthorTypes = makeLoader('AuthorType', 'authorTypes');
export const loadEditionFormats = makeLoader('EditionFormat', 'editionFormats');
export const loadEditionStatuses = makeLoader('EditionStatus', 'editionStatuses');
// ...

[NEW] generate-type-translations.js

#!/usr/bin/env node
import BookBrainzData from 'bookbrainz-data';
import config from '../src/common/helpers/config';
import {existsSync, mkdirSync, writeFileSync} from 'fs';
import path from 'path';

const LOCALES_DIR = path.join(__dirname, '..', 'public', 'locales');
const LOCALES = ['en', 'ar']; // hardcoded for now

// Each entry: [ORM model name, namespace filename, and field to use as key]
// some fields are called label and others are called name
const TYPE_MODELS = [
    {field: 'label', model: 'AuthorType', namespace: 'authorTypes'},
    {field: 'label', model: 'EditionFormat', namespace: 'editionFormats'},
    {field: 'label', model: 'EditionGroupType', namespace: 'editionGroupTypes'},
    {field: 'label', model: 'EditionStatus', namespace: 'editionStatuses'},
    {field: 'label', model: 'IdentifierType', namespace: 'identifierTypes'},
    {field: 'label', model: 'PublisherType', namespace: 'publisherTypes'},
    {field: 'label', model: 'RelationshipType', namespace: 'relationshipTypes'},
    {field: 'name', model: 'RelationshipAttributeType', namespace: 'relationshipAttributeTypes'},
    {field: 'label', model: 'SeriesOrderingType', namespace: 'seriesOrderingTypes'},
    {field: 'label', model: 'WorkType', namespace: 'workTypes'},
    {field: 'name', model: 'Gender', namespace: 'genders'},
    {field: 'name', model: 'Language', namespace: 'languages'}
];

function ensureDir(dirPath) {
    if (!existsSync(dirPath)) {
        mkdirSync(dirPath, {recursive: true});
    }
}

async function main() {
    console.log('connecting to database...');
    const orm = BookBrainzData(config.database);

    for (const {model, namespace, field} of TYPE_MODELS) {
        console.log(`fetching ${model}...`);
        const Model = orm[model];
        if (!Model) {
            console.warn(`ERROR!: model "${model}" not found in ORM, skipping..`);
            continue;
        }

        const results = await Model.fetchAll();
        const rows = results.toJSON();

        // Build translation object: { "label_value": "label_value" } for english
        const enTranslations = {};
        const arTranslations = {};
        for (const row of rows) {
            const key = row[field];
            if (key) {
                enTranslations[key] = key;
                arTranslations[key] = '';
            }
        }

        // sort keys alphabetically
        const sortedEn = {};
        const sortedAr = {};
        for (const key of Object.keys(enTranslations).sort()) {
            sortedEn[key] = enTranslations[key];
            sortedAr[key] = arTranslations[key];
        }

        // write to each locale directory
        for (const locale of LOCALES) {
            const localeDir = path.join(LOCALES_DIR, locale);
            ensureDir(localeDir);
            const filePath = path.join(localeDir, `${namespace}.json`);
            const data = locale === 'en' ? sortedEn : sortedAr;
            writeFileSync(filePath, JSON.stringify(data, null, '\t') + '\n');
            console.log(` ✔ ${locale}/${namespace}.json (${Object.keys(data).length} keys)`);
        }
    }

    console.log('translation files generated..');

    // Closes database connection
    if (orm.bookshelf && orm.bookshelf.knex) {
        await orm.bookshelf.knex.destroy();
    }
}

main().catch((err) => {
    console.error('ERROR!:', err);
    process.exit(1);
});

Handling Plurals + Genders + Interpolation

Plurals

Arabic requires all 6 forms for accurate natural phrasing (e.g., different words/forms for 0, 1, 2, 3~10, 11~99, 100+).

Example translation keys (ar.json):

{
    "editions_zero": "لا توجد إصدارات",
    "editions_one": "إصدار واحد",
    "editions_two": "إصداران",
    "editions_few": "{{count}} إصدارات",
    "editions_many": "{{count}} إصدارًا",
    "editions_other": "{{count}} إصدار"
}

Usage in React component:

const { t } = useTranslation('common', { lng: 'ar' });
<p>{t('editions', { count: editionCount })}</p>

// e.g., count=0: "لا توجد إصدارات"
// count=1: "إصدار واحد"
// count=11: "{{count}} إصدارًا" (with 11 inserted)

This automatically selects the correct form based on Arabic CLDR rules. No manual logic needed.


Gender (via context)

Arabic often requires gender-specific verbs/nouns (e.g., “قام/قامت” for “added”). We use context to differentiate masculine/feminine (and neutral/other).

Example translation keys (ar.json):

{
    "addedBy_male": "أضافه {{name}}",
    "addedBy_female": "أضافته {{name}}",
    "addedBy_other": "أضافه/أضافته {{name}}"
}

Usage:

<p dir="rtl">{t('addedBy', { name: creatorName, context: userGender })}</p>

// context: 'male'   -> "أضافه أحمد"
// context: 'female' -> "أضافته فاطمة"

Combined Plural + Gender + Interpolation

For sentences like “He/She/They has/have {{count}} edition(s)”:

Example keys (ar.json):

{
    "hasEditions_male_zero": "ليس لديه أي إصدار",
    "hasEditions_male_one": "لديه إصدار واحد",
    "hasEditions_male_two": "لديه إصداران",
    "hasEditions_male_few": "لديه {{count}} إصدارات",
    "hasEditions_male_many": "لديه {{count}} إصدارًا",
    "hasEditions_male_other": "لديه {{count}} إصدار",
    "hasEditions_female_zero": "ليس لديها أي إصدار",
    "hasEditions_female_one": "لديها إصدار واحد",
    // ... similarly for two/few/many/other
    "hasEditions_other_zero": "ليس لديهم/ليس لديها أي إصدار",
    // ... and so on
}

Usage:

<p dir="rtl">{t('hasEditions', { count: editionCount, context: userGender })}</p>

In languages like English, we only need to set editions_one and editions_other. No need to set redundant keys to match the Arabic files.


Weblate Integration

This part is straightforward. We simply set it up (following its guide) so that it makes a PR request to a separate branch (e.g., translation-branch), but we need to add the Weblate bot as a collaborator. (more about it in the original doc file)

Practical requirements

I plan to work at least 25 hours/week (roughly 5h/day on work days).

No university or job during the summer

I am using Legion 5 pro laptop

Timeline

# Milestone Dates
Community Bonding May 1 – 24
1 Infrastructure May 25 – Jun 8
2 layout.js Jun 9 – 11
3 Homepage + Statistics Jun 12 – 14
4 Static Pages Jun 15 – 17
5 Shared Display Sub-components Jun 18 – 20
6 Author display Jun 21 – 23
7 Work display Jun 24 – 25
8 Edition display Jun 26 – 28
9 Edition Group + Publisher + Series display Jun 29 – Jul 1
10 Editor Profile Jul 2 – 4
11 Search Jul 5 – 7
12 Collections Jul 8 – 10
13 Deletion + Revisions Jul 11 – 12
14 Entity Editor: Shared components Jul 13 – 17
15 Entity Editor: Entity-specific sections Jul 18 – 21
16 Entity Editor: Relationship editor Jul 22 – 24
17 Unified Form Jul 25 – 30
18 Plurals, Gender & Interpolation Jul 31 – Aug 3
19 Weblate + Testing + RTL Audit Aug 4 – 11
20 Final Polish + Documentation Aug 12 – 16

What aspects of BookBrainz interest you the most?

  • being open and having a great community
  • has an API for developers
  • data is free and reusable

Programming precedents

When did you first start programming? High school.

I have made a WebWhatsapp Clone using MERN stack and RTK with socket.io

and Social Media website using MERN and MUI