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.
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:
Architecture Deep-Dive
The SSR Hydration Pipeline
1. Server Route index.js
-
Express router that handles
GET /and several static pages (/about,/help, etc.) -
SSR process for
GET /:- Fetches recent revisions from the ORM
- Calls
generateProps(req, res, { recent, homepage, ... })mergesres.locals(user, alerts, siteRevision, repositoryUrl) with route-specific data - Calls
ReactDOMServer.renderToString(<Layout><Index/></Layout>)renders the entire React tree to an HTML string on the server - Sends the response via
target({ markup, props, page, script })the HTML template
-
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
-
The client-side entry point for the index bundle (webpack entry index)
-
Hydration process:
- Reads
#props→JSON.parse()→ same props object the server used - Reads
#page→ determines which component to render (frompageMap) - Reconstructs the exact same React tree the server rendered
- Calls
ReactDOM.hydrate(markup, document.getElementById('target'))— React attaches event handlers to the existing server-rendered DOM without re-rendering (if the trees match)
- Reads
4. Layout layout.js
- The shared wrapper component wrapping every page with navbar, footer, alerts, etc.
- 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
- These are components that render hardcoded English strings
IndexPageis a class component;StatisticsPageis a function component- 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:
- Server renders HTML with
renderToString()→ translations must be available synchronously on the server at render time - 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 - 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
- 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:
- Create a per-request i18n instance on the server
- Pre-load translations on the server (read from disk)
- Pass the loaded translations to the client via props
- On the client, initialize i18n with those same translations (so hydration matches)
- 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:
isRtlLanguage(lng)— checks if a language code is RTL (ar, he, fa, ur)resolveLanguage(lng)— maps a raw language code to a supported one (strips region, falls back to'en')createI18nextInstance({lng, ns, resources, backend})— creates and initializes an i18next instance with:initImmediate: false— sync init (fs-backend usesreadSyncinternally 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-seedsresources(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:
createServerI18n(lng, namespaces)— callscreateI18nextInstancewithi18next-fs-backendpointed atpublic/locales/{{lng}}/{{ns}}.json. Returns an initialized instance (per request for each user) with needed translations loaded synchronously.getLoadedResources(i18nInstance, lng, namespaces)— extracts loaded translations from the instance viagetResourceBundle(). Always includesen(fallback) alongside the requested language. Returns{ en: { common: {...} }, ar: { common: {...} } }. This object is serialized into props for client hydration.detectLanguage(req)— readsreq.cookies.lang; if not set, readsAccept-Languageheader; if not set, falls back to'en'. UsesresolveLanguage()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);
});
*/
-
Added
cookie-parsermiddleware (forreq.cookies.lang) -
Added
/localesstatic route (serves translation JSON for client HTTP backend) -
Added
POST /set-languageendpoint (setslangcookie, redirects back — no-JS fallback)
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
- Wrapped with
withTranslation('common')— all navbar strings translated:
export default withTranslation('common')(Layout);
- Added language toggle dropdown (
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_oneandeditions_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