# 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) - [Seeding content](#seeding-content) - [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 eleven 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 `ar` (default) and `en` 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. ### Partner content review Partner contributors use Strapi's **Author** admin role. To onboard one, create the admin account, then as Super Admin create a **Partner Access** entry in Content Manager with the admin user ID (from the user detail URL) and the linked Partner organisation. Each admin account has one assignment; multiple accounts can be assigned to the same partner. Partner Access has no public Content API and is reserved for Super Admins. Authors can create and edit their own resource, news article, activity, event, or opportunity drafts. The CMS enforces their partner assignment on every save, regardless of what the browser submits, and removes the organisation selector from their edit fields. Authors cannot publish or delete. An **Editor** reviews the draft in the side panel below Preview. Set its status to **Approved for publishing** before publishing, or choose **Changes requested** or **Rejected** and add feedback. The Publish action stays disabled until approval is saved, and the server rejects publish requests for these content types unless their review status is approved. The partner author can read the status and feedback in the CMS; both fields are excluded from the public API. Resubmitting resets the status to **Pending** and clears old feedback. The CMS also emails reviewers when a partner draft is first submitted or resubmitted, and emails the submitting admin when the reviewer requests changes, rejects the draft, or publishes it. Reviewer mail goes to all active Super Admins by default; set the optional comma-separated `PARTNER_REVIEW_NOTIFICATION_EMAILS` to use a dedicated inbox. These notices use the configured SMTP provider. Email failures are logged and do not undo a saved draft or review decision. Strapi's built-in Author role limits authors to entries they created. Different users assigned to the same partner therefore share the organisation assignment, but not each other's drafts. Use Editor for central review; partner-wide collaboration would need a separate custom access policy. ### 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 seed` | writes the approved content into the CMS — see [Seeding content](#seeding-content) | | `pnpm seed -- --legacy-only` | writes SHARP I, SHARP II, and the home section below the hero through the bilingual Legacy page; preserves its uploaded media | | `pnpm typecheck` | `tsc` in both applications, preceded in the web app by `react-router typegen` | **`apps/web`** — run with `pnpm --filter web