# SHARP-ER website 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 `` and `` 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. --- ## Repository layout ``` sharp-er/ ├── apps/ │ ├── web/ React Router 8 front end (SSR) │ │ ├── app/ │ │ │ ├── root.tsx HTML shell: , canonical + hreflang, nav, footer │ │ │ ├── routes.ts Route config: "/" redirect, ":lang" layout, the ten sections │ │ │ ├── routes/ One module per page (+ shared _page-kit / _shared helpers) │ │ │ ├── components/ui/ Button, Input, Card, Navigation, Footer, LanguageSwitcher │ │ │ ├── styles/ Design tokens — primitives.css, semantic.css, typography.css │ │ │ ├── i18n/ Locale config, message catalogues (en.json / ar.json), formatters │ │ │ └── lib/ strapi.ts (API client), seo.ts, env.server.ts, route-slugs.ts │ │ ├── public/ │ │ │ ├── assets/ Logos, EU emblem, partner logos, icons │ │ │ └── fonts/ Licensed woff2 files — NOT committed, see FONTS below │ │ └── Dockerfile │ │ │ └── cms/ Strapi 5 CMS │ ├── config/ server, admin, database, middlewares (CORS), plugins (i18n, upload) │ ├── src/ │ │ ├── api/ One folder per content type (schema, controller, routes, service) │ │ ├── components/ Reusable field groups (hero, cta, seo, blocks/*) │ │ └── bootstrap/ Boot-time reconciliation: locales + public permissions │ ├── .env.example Every Strapi variable, documented │ └── Dockerfile │ ├── docs/ SOURCE MATERIAL — read only, never edited ├── scripts/ setup-db.sh, generate-secrets.sh, check-prereqs.sh ├── docker-compose.yml postgres + cms + web ├── .env.example Root template: docker compose and the web server read this ├── CLAUDE.md Working brief for AI agent sessions └── pnpm-workspace.yaml ``` --- ## Choosing a setup path Both paths give you the same three processes. Pick one; you do not need both. | | **Path A — manual** | **Path B — Docker** | | --- | --- | --- | | You install | Node 22, pnpm 9, PostgreSQL 16 | Docker Engine + Compose v2 | | Best for | day-to-day development (hot reload, fast rebuilds) | trying the project out, review builds, servers | | Web | `http://localhost:5173` (dev server) | `http://localhost:3000` (production build) | | CMS | `http://localhost:1337/admin` | `http://localhost:1337/admin` | | 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 nvm install "$(cat .nvmrc)" && nvm use # pnpm — corepack ships with Node corepack enable && corepack prepare pnpm@9.12.3 --activate # PostgreSQL 16 on Debian / Ubuntu sudo apt install postgresql-16 postgresql-client-16 sudo systemctl enable --now postgresql # macOS with Homebrew brew install node@22 pnpm postgresql@16 && brew services start postgresql@16 ``` Verify: `node -v` → `v22.22.0` or newer, `pnpm -v` → `9.x`, `psql --version` → `16.x`. ### 2. Install dependencies ```bash git clone sharp-er cd sharp-er pnpm install # installs BOTH apps from the single root lockfile ``` Always run `pnpm install` from the repository root. `.npmrc` sets `node-linker=hoisted` because Strapi's plugin resolution expects a flat `node_modules`; installing inside `apps/cms` or `apps/web` on its own produces a tree neither application can boot from. ### 3. Create the database The database is called `sharper_cms` and is owned by the role Strapi connects as (`postgres` by default). ```bash ./scripts/setup-db.sh ``` The script is idempotent — it creates the database only if it is missing, never drops anything, and finishes by proving it can connect and that the role may create tables. It reads its defaults from `.env` and `apps/cms/.env`, and takes `--name`, `--user`, `--password`, `--host`, `--port` overrides (`--help` for the details). Doing it by hand is two commands: ```bash createdb -h 127.0.0.1 -U postgres sharper_cms psql -h 127.0.0.1 -U postgres -d sharper_cms -c "SELECT current_database(), version();" ```
If psql asks for a password you do not have (fresh Ubuntu install) A newly installed PostgreSQL has a `postgres` role with no password set, reachable only over the local socket. Set one, then use it as `DATABASE_PASSWORD` in `apps/cms/.env`: ```bash sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'postgres';" ``` For a shared or production machine, create a dedicated role instead: ```bash sudo -u postgres psql -c "CREATE ROLE sharper WITH LOGIN PASSWORD 'a-strong-password' CREATEDB;" ./scripts/setup-db.sh --user sharper --password 'a-strong-password' ```
### 4. Environment files Three files, with different jobs: | File | Read by | Required in manual mode | | --- | --- | --- | | `apps/cms/.env` | Strapi (it loads its own `.env`) | **yes** | | `apps/web/.env` | `pnpm --filter web start` (it loads its own `.env`) | no — but it is where `PORT` goes | | `.env` (root) | `docker compose` only | no | Each application reads its **own** `.env`, and the root one belongs to `docker compose`. That split is not cosmetic: `PORT` and `HOST` mean something different to each server, and a single shared value would put Strapi on the website's port. ```bash # CMS: copy the fully documented template, then fill in the six secrets cp apps/cms/.env.example apps/cms/.env ./scripts/generate-secrets.sh --write apps/cms/.env # Set the database credentials you used in step 3 # DATABASE_CLIENT=postgres # DATABASE_HOST=127.0.0.1 # DATABASE_PORT=5432 # DATABASE_NAME=sharper_cms # DATABASE_USERNAME=postgres # DATABASE_PASSWORD=... ``` `scripts/generate-secrets.sh` fills `APP_KEYS`, `API_TOKEN_SALT`, `ADMIN_JWT_SECRET`, `JWT_SECRET`, `TRANSFER_TOKEN_SALT` and `ENCRYPTION_KEY` with 32 random bytes each. It never overwrites a value that is already set unless you pass `--force`, so it is safe to re-run. Without `--write` it just prints a block you can paste. By hand: `openssl rand -base64 32`, once per key (`APP_KEYS` takes at least two, comma separated). The web application needs nothing to start: `STRAPI_URL` already defaults to `http://localhost:1337`, and with `SITE_URL` unset the canonical URL is derived from the incoming request. Copy its template when you want to override those, or to pin the production port: ```bash cp apps/web/.env.example apps/web/.env # PORT, HOST, SITE_URL, STRAPI_URL, STRAPI_TOKEN ``` `pnpm --filter web start` loads that file. `pnpm dev:web` does not — the dev server is Vite on 5173 — so export anything you need for development, or put it in the file for the production server and pass it inline while developing. ### 5. Run both applications ```bash pnpm dev # web + cms together, output interleaved and prefixed ``` or, in two terminals: ```bash pnpm dev:cms # Strapi -> http://localhost:1337 pnpm dev:web # React Router -> http://localhost:5173 ``` The first `pnpm dev:cms` takes a minute: Strapi creates its tables, then the boot-time reconciliation in `apps/cms/src/bootstrap/` runs. That step is idempotent and executes on every boot; it - creates the `en` (default) and `ar` locales if they are missing, and - grants the **Public** role `find` / `findOne` on the content types the site reads, and `create` — and only `create` — on the three submission types (event registrations, applications, contact messages), so nobody can read other people's personal data back out. You never have to click through the Users & Permissions screens after adding a content type; restart the CMS and the grant is there. ### 6. Create the first admin user Strapi has no seeded administrator. On first run, open **http://localhost:1337/admin** and complete the registration form — the first account created becomes the super admin. Or do it from the command line: ```bash pnpm --filter cms exec strapi admin:create-user \ --firstname="Site" --lastname="Admin" \ --email="admin@example.org" --password="SomeStrongPassw0rd" ``` Passwords must be at least 8 characters and contain a number and an uppercase letter. ### 7. Confirm it works | URL | Expected | | --- | --- | | `http://localhost:5173/` | 302 to `/en` or `/ar` depending on your `Accept-Language` | | `http://localhost:5173/en` | English home page, `` | | `http://localhost:5173/ar` | Arabic home page, ``, mirrored layout | | `http://localhost:1337/admin` | Strapi admin panel | | `http://localhost:1337/api/home-page?locale=en` | JSON (or `404` until an entry is published) | | `http://localhost:1337/_health` | `204 No Content` | ```bash # The one check worth automating: is the SSR HTML right in the first byte? curl -s http://localhost:5173/ar | head -c 200 ``` Then run the type checker once, so you know the baseline is clean: ```bash pnpm typecheck ``` --- ## Path B — Docker Three containers: **postgres** (database), **cms** (Strapi), **web** (React Router). Nothing else is installed on your machine — no Node, no pnpm, no PostgreSQL. ### 1. Prerequisites - **Docker Engine 24+** (or Docker Desktop) with the **Compose v2** plugin. Check with `docker compose version`; the standalone `docker-compose` binary is *not* supported — this file uses `depends_on.condition`, which is Compose v2 syntax. - Roughly 3 GB of disk for the images and 2 GB of RAM while they build. ### 2. Configure ```bash cp .env.example .env ./scripts/generate-secrets.sh --write .env ``` `docker compose` reads the `.env` file sitting next to `docker-compose.yml` and substitutes every `${VARIABLE}` in it. The six Strapi secrets are declared as `${VAR:?...}`, so a missing value stops the stack straight away with a readable message instead of a Strapi crash loop. In Docker mode `apps/cms/.env` is **not used** — the container receives its environment from `docker-compose.yml`. The root `.env` is the only file you edit. ### 3. Start ```bash docker compose up -d --build # first run: builds both images, ~5-10 minutes docker compose ps # STATUS column shows (healthy) when each is up ``` Start-up is ordered by health, not by luck: ``` postgres healthy -> cms healthy -> web pg_isready GET /_health GET /en ``` `cms` waits for a database that is actually accepting connections (`pg_isready`), and `web` waits for a CMS that answers `/_health`, so the very first page render already has content. The health checks themselves are declared in `apps/cms/Dockerfile` and `apps/web/Dockerfile`, which means `docker run` and any orchestrator inherit them too. Give the CMS up to 90 seconds on its first boot: it creates its tables, then runs the locale and permission reconciliation described in Path A, step 5. ```bash docker compose logs -f # everything, following docker compose logs -f cms # one service docker compose logs --tail=50 web ``` ### 4. Create the first admin user Open **http://localhost:1337/admin** and register — the first account is the super admin. From the command line: ```bash docker compose exec cms pnpm exec strapi admin:create-user \ --firstname="Site" --lastname="Admin" \ --email="admin@example.org" --password="SomeStrongPassw0rd" ``` ### 5. Confirm it works | URL | Expected | | --- | --- | | `http://localhost:3000/` | 302 to `/en` or `/ar` | | `http://localhost:3000/en` and `/ar` | English and Arabic home pages | | `http://localhost:1337/admin` | Strapi admin panel | | `psql -h 127.0.0.1 -p 5433 -U postgres sharper_cms` | a database prompt | ### What the compose file sets up | Service | Image | Host port | Persists in | Health check | | --- | --- | --- | --- | --- | | `postgres` | `postgres:16-alpine` | **5433** → 5432 | volume `sharper_postgres-data` | `pg_isready` | | `cms` | built from `apps/cms/Dockerfile` | 1337 | volume `sharper_cms-uploads` (Media Library) | `GET /_health` | | `web` | built from `apps/web/Dockerfile` | 3000 | nothing — stateless | `GET /en` | The database is published on **5433** on purpose, so it cannot collide with a PostgreSQL already listening on your host's 5432. Inside the compose network the CMS still reaches it as `postgres:5432`. Change the published port with `POSTGRES_PUBLISHED_PORT` in `.env`. Both images build from the **repository root** as their context, because the pnpm lockfile lives there. They are not size-optimised: the CMS image keeps its dev dependencies, because Strapi's CLI resolves its TypeScript toolchain when it starts. ### Images from the CMS Uploaded media is served by Strapi, and the web server turns a relative `/uploads/x.jpg` into an absolute URL by prefixing `STRAPI_URL`. Inside Docker that value is `http://cms:1337`, a hostname Docker resolves but your browser does not — so pictures uploaded through the Media Library will not load until it can. One line in your hosts file fixes it, permanently: ```bash echo "127.0.0.1 cms" | sudo tee -a /etc/hosts # Windows: C:\Windows\System32\drivers\etc\hosts ``` `cms` then resolves to `127.0.0.1`, where compose publishes the CMS on port 1337, so the one URL works from the browser and from inside the compose network alike. Everything else — pages, text, layout, and the site's own images in `apps/web/public/assets/` — works without this step. In production this problem disappears: the CMS has a real hostname (`https://cms.sharper.ly`), and `STRAPI_URL` is set to it. ### Everyday commands ```bash docker compose ps # status and health docker compose restart cms # restart one service docker compose exec cms sh # shell inside the CMS container docker compose exec postgres psql -U postgres sharper_cms docker compose up -d --build web # rebuild after changing app code docker compose build --no-cache cms # rebuild ignoring the layer cache ``` Source code is **not** bind-mounted: the images contain a production build, so a code change needs a rebuild. For day-to-day development use Path A, or run only the database in Docker (`docker compose up -d postgres`) and the two applications with `pnpm dev`. ### Stopping and tearing down | Command | Containers | Database + uploads | | --- | --- | --- | | `docker compose stop` | stopped, kept | kept | | `docker compose down` | removed | **kept** (named volumes survive) | | `docker compose down -v` | removed | **deleted — irreversible** | | `docker compose down --rmi local` | removed, images too | kept | `down -v` is how you start from an empty database — every entry, every uploaded file and the admin account are gone. Take a dump first if you are unsure: ```bash # Back up docker compose exec -T postgres pg_dump -U postgres sharper_cms > backup-$(date +%F).sql # Restore into a fresh stack docker compose exec -T postgres psql -U postgres sharper_cms < backup-2026-01-31.sql # Uploaded media docker run --rm -v sharper_cms-uploads:/data -v "$PWD":/backup alpine \ tar czf /backup/uploads-$(date +%F).tar.gz -C /data . ``` --- ## Available scripts **Repository root** (`package.json`) — these fan out across the workspace: | Command | Does | | --- | --- | | `pnpm dev` | runs `web` and `cms` in parallel, streaming both logs | | `pnpm dev:web` | React Router dev server with HMR → `http://localhost:5173` | | `pnpm dev:cms` | `strapi develop` (auto-reloads on schema changes) → `http://localhost:1337` | | `pnpm build` | builds the CMS, then the web app (that order: the web build has no CMS dependency, but a broken CMS build should fail first) | | `pnpm start` | runs both production builds | | `pnpm typecheck` | the web app's `react-router typegen && tsc` (the CMS has no `typecheck` script — its TypeScript is checked by `strapi build`) | **`apps/web`** — run with `pnpm --filter web