Bilingual (English / Arabic) website for **SHARP-ER** — *Shared Action on Rule of Law
Progress – Exit Route* — a European Union funded programme running nationwide in Libya
from December 2025 to December 2027.
SHARP-ER supports Libyan civil society organisations, local institutions and communities
working on justice, accountability and governance. It is implemented by a consortium of
**Acted** (lead), **United Efforts Consultancy (UEC)** and **Stichting Bureau Ensemble (SBE)**,
and continues the work of SHARP I and SHARP II (2019–2024).
The site publishes project information, events and registrations, calls and applications,
news from the project and its partner network, and a knowledge hub of resources and
publications — all of it in English and Arabic, with the Arabic side laid out right to left.
> EU visibility is a contractual requirement: the *Funded by the European Union* emblem is
> present on every page, and the three implementing partners are always shown together.
---
## Contents
- [The stack, and why](#the-stack-and-why)
- [Repository layout](#repository-layout)
- [Choosing a setup path](#choosing-a-setup-path)
- [Path A — manual setup](#path-a--manual-setup)
- [Path B — Docker](#path-b--docker)
- [Available scripts](#available-scripts)
- [Design tokens](#design-tokens)
- [Bilingual EN / AR](#bilingual-en--ar)
- [Adding a content type, end to end](#adding-a-content-type-end-to-end)
- [Fonts (licensed, not in this repository)](#fonts-licensed-not-in-this-repository)
- [Deployment and operations](#deployment-and-operations)
- [Troubleshooting](#troubleshooting)
- [Source documents](#source-documents)
---
## The stack, and why
| Layer | Choice | Why |
| --- | --- | --- |
| Front end | **React Router 8**, framework mode, `ssr: true` | The terms of reference require an SEO-friendly structure. Server-side rendering means every page — including the Arabic one — is delivered as complete HTML with the correct `<html lang>` and `<html dir>` in the first byte, plus canonical and `hreflang` tags a crawler can read without executing JavaScript. |
| UI | **React 19**, **TypeScript**, **Vite 8** | The framework's own toolchain. Typed loaders and route modules, so a content shape change is a compile error rather than a blank page. |
| Styling | **Tailwind CSS 4** over a **two-tier design-token** layer | Tier 1 holds raw values, tier 2 holds meanings; components only ever spend tier‑2 tokens. Re-branding is a one-line edit in one file. See [Design tokens](#design-tokens). |
| CMS | **Strapi 5.53** (TypeScript) | Self-hosted and handed over to UEC at the end of the assignment, as the ToR requires. Its i18n plugin models EN/AR as first-class locales of the same entry, and draft & publish gives editors a review step. |
| Database | **PostgreSQL 16** | Strapi's recommended production database; proper UTF‑8 and JSON support for bilingual content. |
| Runtime | **Node 22**, **pnpm 9** workspace | One lockfile, two applications, one `pnpm install`. |
The web application never talks to the database. It reads the Strapi REST API from its
**loaders on the server**, so the API token never reaches the browser, and every page falls
back to bundled draft copy if the CMS is unreachable — the site stays up when Strapi is down.
| Database | your local PostgreSQL, port 5432 | container, published on port **5433** |
| Time to first run | ~5 minutes | ~10 minutes (image build) |
Check your machine before you start:
```bash
./scripts/check-prereqs.sh # both paths
./scripts/check-prereqs.sh manual # or just one
./scripts/check-prereqs.sh docker
```
---
## Path A — manual setup
### 1. Prerequisites
| Tool | Version | Notes |
| --- | --- | --- |
| **Node.js** | **>= 22.22.0** | React Router 8 will not run on anything older — this is a hard floor, not a recommendation. `.nvmrc` pins **22.23.2**, the version this repository is developed on. Strapi 5 accepts Node 20 – 26, so 22 satisfies both. |
| **pnpm** | **9.x** (repo pins `pnpm@9.12.3`) | npm and yarn will not work: the workspace, the lockfile and the hoisted `node_modules` layout in `.npmrc` are all pnpm's. |
| **PostgreSQL** | **16** (server + client tools) | Client tools give you `psql`, `createdb` and `pg_isready`. |
| **Git** | any recent version | |
```bash
# Node — with nvm, the pinned version is one command
<h2className="text-[#f8f9fa] text-[28px]"/>// no — hex and raw type
<h2className="text-[var(--color-neutral-50)]"/>// no — tier-1 primitive
```
Because every component spends meanings rather than values, moving the brand green is four
lines in `primitives.css` (`--color-green-600` and its hover / active / disabled steps) and
no component changes at all.
The home page is **dark by default**, while the navigation and footer also have light
variants, so the palette is modelled as **surfaces, not a global theme**: `:root`*is* the
dark surface, and any element can flip its own subtree with `data-surface="light"`, which
re-resolves every tier-2 token and paints itself.
Brand colours are White `#ffffff`, Dark Grey `#1D1D1B`, Dark Turquoise `#008764` and
Red `#DD322F`, taken from the communication guideline in `docs/`.
Full reference — every token family, the Tailwind bridge, the surface model, the type roles
and the one deliberate deviation from Figma: **[`apps/web/app/styles/README.md`](apps/web/app/styles/README.md)**.
---
## Bilingual EN / AR
**URLs carry an explicit locale prefix on both languages.** There is no `?lang=` parameter
anywhere in the product.
```
/ 302, Vary: Accept-Language -> /en or /ar
/en, /en/about, ... English, ltr
/ar, /ar/about, ... Arabic, rtl
/EN/about 301 -> /en/about
```
The slug itself is always the Latin one (`/ar/knowledge-hub`, not a transliterated path), so
a URL survives a language switch and analytics can aggregate the two.
`<html lang>` and `<html dir>` are correct **in the first SSR byte** — they are attributes of
the server-rendered document, not something JavaScript sets afterwards. `root.tsx` also emits
a canonical link and `hreflang` alternates for `en`, `ar` and `x-default` on every page.
### Where the words live
| Content | Lives in | Edited by |
| --- | --- | --- |
| UI strings (navigation, buttons, filters, form labels, error messages) | `apps/web/app/i18n/messages/en.json` and `ar.json` — identical key sets, enforced by the `Messages` type | developers |
| Page and article content | Strapi, as an `en` and an `ar` version of the same entry | editors |
| Draft fallback copy | `apps/web/app/routes/_page-kit/copy.ts`, `_shared/page-copy.ts` — the approved prose used when the CMS is empty or unreachable | developers |
Read a string with the `useTranslation()` hook in a component, or `translate(locale, key)` in
a loader; keys are dotted paths (`t("pagination.page", { page: 2, pageCount: 7 })`) and are
typechecked against `en.json`, so a missing translation is a build error.
### Right-to-left
RTL is not a stylesheet; it is a consequence of using **CSS logical properties everywhere**.
| `SITE_URL` | web | `https://sharper.ly` — canonical URLs, `hreflang`, JSON-LD are built from it |
| `STRAPI_URL` | web | the CMS origin, e.g. `https://cms.sharper.ly`. It is also prefixed onto media URLs, so it must be resolvable **by the browser**, not only from the server |
| `STRAPI_TOKEN` | web | only needed for draft/protected content; published content is public |
| `IS_PROXIED` | cms | `true` behind nginx / a load balancer, so `X-Forwarded-*` is trusted |
| `CORS_ORIGINS` | cms | `https://sharper.ly,https://www.sharper.ly` — an empty list blocks every cross-origin browser call, which is the safe default |
| `DATABASE_*` | cms | managed PostgreSQL; set `DATABASE_SSL=true` for a hosted database |
| `APP_KEYS` and the other five secrets | cms | **different values from staging**, generated with `./scripts/generate-secrets.sh` |
Uploaded media is written to `apps/cms/public/uploads` by the default provider, so give the
CMS a persistent volume — or switch to S3, which `apps/cms/config/plugins.ts` documents step
by step (remember to add the bucket host to `img-src` / `media-src` in
`config/middlewares.ts`).
### Requirements from the terms of reference
The ToR (`docs/UEC_SHARPER_Website_TOR V3.docx`, sections 3.5 and 3.6) commits the service
provider to an operating standard, not only to a delivery. In summary:
| Requirement | How it is met, and what still has to be set up |
| --- | --- |
| **Secure hosting with SSL** | Terminate TLS at the proxy (Let's Encrypt or the host's certificate), redirect HTTP to HTTPS, and enable HSTS. Strapi must run with `IS_PROXIED=true` behind it. |
| **Regular backups** | Nightly `pg_dump` of `sharper_cms`**plus** the uploads directory — the database alone does not contain the media files. Keep them off the application server and test a restore before go-live. The commands are in [Stopping and tearing down](#stopping-and-tearing-down). |
| **Security checks and updates** | Watch Strapi and Node releases; `pnpm --filter cms upgrade:dry` previews a Strapi upgrade's codemods. Rotate `STRAPI_TOKEN` and the admin accounts at handover. |
| **Analytics (e.g. Google Analytics)** | **Not yet integrated.** The tag belongs in `apps/web/app/root.tsx`, in the document `<head>`, and must respect the site's privacy notice — collect no personal data through it and document what is collected. |
| **Monthly viewership report** | Comes from the analytics property once it exists: sessions, users, top pages, EN vs AR split (the locale is in the URL prefix, so `/en/*` and `/ar/*` aggregate cleanly), referrers, devices. |
| **Errors addressed within one day of being reported** | Keep a monitored inbox or tracker, and an uptime check on `https://sharper.ly/en` and `https://cms.sharper.ly/_health`. Both applications log to stdout; capture it with the process manager. |
| **Bi-weekly content support** | Editors work in the Strapi admin panel; no deployment is needed for content. Draft & publish gives a review step before anything goes live. |
| **Fast loading, mobile responsive, basic accessibility** | SSR HTML, no client-side data fetching on first paint, `font-display: swap`, responsive layouts and logical properties throughout. Re-check with Lighthouse on both `/en` and `/ar` before each release. |
| **SEO-friendly structure** | Server-rendered HTML, one canonical per page, `hreflang` for `en` / `ar` / `x-default`, JSON-LD (Organization, WebSite, Article, Event, BreadcrumbList), and `noindex` on filtered listing permutations. A `sitemap.xml` and `robots.txt` are **not yet built** — add them before launch. |
| **CMS handover to UEC** | The schema lives in `apps/cms/src/api/**` in version control, the admin panel is self-hosted, and `.env.example` documents every variable. Hand over: the repository, the server access, the database credentials and a super-admin account. |
---
## Troubleshooting
**`Unsupported engine` / the dev server exits immediately after `pnpm dev:web`.**
React Router 8 requires **Node >= 22.22.0** — this was the first thing to bite while
bootstrapping the project. Check with `node -v`; `nvm install "$(cat .nvmrc)" && nvm use`
fixes it. `pnpm install` warns but does not stop you, because `.npmrc` sets
`engine-strict=false` so that Strapi's narrower range does not block the workspace.
**Strapi exits with `Cannot find module 'pg'` or `knex: Required dependency pg not found`.**
`DATABASE_CLIENT=postgres` needs the `pg` driver. It is already a dependency of `apps/cms`,
so this means the install is incomplete or was run in the wrong place: run `pnpm install`
from the **repository root**, never inside `apps/cms`.
**`pnpm install` inside `apps/web` or `apps/cms` produces an app that will not boot.**
Same cause. `.npmrc` sets `node-linker=hoisted` for the whole workspace; a nested install
builds a different tree from the one Strapi's plugin resolution expects.
**`password authentication failed for user "postgres"`.**
A fresh PostgreSQL install has no password for that role. See the collapsed note in
[Path A, step 3](#3-create-the-database).
**`Missing app.keys` on CMS start-up.** The six secrets are not set. Run
`./scripts/generate-secrets.sh --write apps/cms/.env` (manual) or