1020 lines
48 KiB
Markdown
1020 lines
48 KiB
Markdown
# 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 `<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.
|
||
|
||
---
|
||
|
||
## Repository layout
|
||
|
||
```
|
||
sharp-er/
|
||
├── apps/
|
||
│ ├── web/ React Router 8 front end (SSR)
|
||
│ │ ├── app/
|
||
│ │ │ ├── root.tsx HTML shell: <html lang/dir>, 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 <repository-url> 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();"
|
||
```
|
||
|
||
<details>
|
||
<summary>If <code>psql</code> asks for a password you do not have (fresh Ubuntu install)</summary>
|
||
|
||
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'
|
||
```
|
||
</details>
|
||
|
||
### 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.
|
||
|
||
### 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, `<html lang="en" dir="ltr">` |
|
||
| `http://localhost:5173/ar` | Arabic home page, `<html lang="ar" dir="rtl">`, 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 typecheck` | `tsc` in both applications, preceded in the web app by `react-router typegen` |
|
||
|
||
**`apps/web`** — run with `pnpm --filter web <script>`:
|
||
|
||
| Command | Does |
|
||
| --- | --- |
|
||
| `dev` | `react-router dev` — SSR dev server with HMR |
|
||
| `build` | `react-router build` → `build/client` (static assets) + `build/server` (SSR bundle) |
|
||
| `start` | the production server — `react-router-serve ./build/server/index.js`, started with `node --env-file-if-exists=.env` so `apps/web/.env` can set `PORT` |
|
||
| `typecheck` | regenerates `+types/*` route types, then `tsc` |
|
||
|
||
**`apps/cms`** — run with `pnpm --filter cms <script>`:
|
||
|
||
| Command | Does |
|
||
| --- | --- |
|
||
| `develop` / `dev` | `strapi develop` — the only mode in which the Content-Type Builder can write schemas |
|
||
| `build` | builds the admin panel and compiles the TypeScript server into `dist/` |
|
||
| `start` | `strapi start` — production mode; the Content-Type Builder is disabled |
|
||
| `seed` | writes the CMS content — `pnpm --filter cms seed -- --demo` to include the fixtures |
|
||
| `console` | a REPL with `strapi` in scope, for one-off data fixes |
|
||
| `strapi` | the CLI itself, e.g. `pnpm --filter cms exec strapi admin:create-user` |
|
||
| `upgrade` / `upgrade:dry` | `@strapi/upgrade` codemods for a minor/major version bump |
|
||
|
||
**`scripts/`** — repository helpers, all read-only unless you pass `--write`:
|
||
|
||
| Script | Does |
|
||
| --- | --- |
|
||
| `./scripts/check-prereqs.sh [all\|manual\|docker]` | verifies Node, pnpm, PostgreSQL, Docker and the env files; exits non-zero if something required is missing |
|
||
| `./scripts/setup-db.sh` | idempotent `createdb` + connection/permission verification (Path A, step 3) |
|
||
| `./scripts/generate-secrets.sh [--write FILE] [--force]` | fresh Strapi secrets; fills only empty/placeholder keys unless forced |
|
||
|
||
---
|
||
|
||
## Seeding content
|
||
|
||
A fresh database has an empty CMS, so every page renders its fallback copy and
|
||
every listing renders its empty state. `pnpm seed` fills it in. Both
|
||
applications can stay running while it does — it talks to PostgreSQL directly
|
||
and never opens a port of its own.
|
||
|
||
```bash
|
||
pnpm seed # the approved content
|
||
pnpm seed -- --demo # the same, plus fixtures to look at
|
||
pnpm seed -- --demo --clean # remove those fixtures again
|
||
```
|
||
|
||
> The `--` is pnpm's, not the seed's. Running it from `apps/cms` (`pnpm seed
|
||
> --demo`) needs no separator.
|
||
|
||
**It is safe to run twice.** Every write is matched on a slug and written over
|
||
the same document, so a second run changes nothing but the fields the seed
|
||
owns — your own edits to other fields survive. It deletes nothing except on
|
||
`--demo --clean`. It uploads nothing: where a page already has a picture, the
|
||
existing file is read back and written out again with the new copy.
|
||
|
||
### The approved set — `apps/cms/src/seed/approved.ts`
|
||
|
||
Transcribed from `docs/`, in both languages, and safe to publish: the site
|
||
settings, the three consortium members, the topic taxonomy, the five activity
|
||
streams, the launch article, and the About, What We Do, Contact and Home
|
||
pages.
|
||
|
||
What `docs/` does not supply is not written. There are no statistics anywhere
|
||
(the source marks every number "TBD — must not be published until confirmed"),
|
||
no project email, no Facebook URL and no consortium addresses, because the
|
||
source writes all of those as bracketed placeholders. Two fields are judgement
|
||
calls and say so at the line: every activity's `activityStatus`, which the
|
||
schema requires and the source never states, and the launch article's year,
|
||
which is derived — the source says "Tuesday, June 30", and within the project's
|
||
own December 2025 – December 2027 window that is 2026 and no other year.
|
||
|
||
### The demo set — `apps/cms/src/seed/demo.ts`
|
||
|
||
Invented content: twelve network organisations, six articles, six events, four
|
||
opportunities, six resources, six knowledge items, eight questions and five
|
||
regions, across the calendar and across every enum value, so the listings,
|
||
filters, detail pages and the home page's logo marquee have something in them.
|
||
|
||
It is kept apart from real content three ways:
|
||
|
||
1. **Opt-in.** `pnpm seed` never writes it.
|
||
2. **Refused in production.** With `NODE_ENV=production` the seed exits with an
|
||
error rather than writing a fixture, whatever flags it is given.
|
||
3. **Tagged in the data.** Every demo slug starts `demo-`, so it is visible in
|
||
every URL — `/en/news-and-updates/demo-…` — and `--demo --clean` finds them
|
||
again by that prefix alone, with nothing remembered between runs.
|
||
|
||
### After seeding
|
||
|
||
```bash
|
||
curl -s "http://localhost:1337/api/news-articles?locale=ar" | head -c 200
|
||
```
|
||
|
||
Then the home page at `http://localhost:5173/en` and `/ar`: the hero carries the
|
||
CMS headline, the newsroom band has cards, and — with `--demo` — the network
|
||
logo wall becomes the marquee, which needs at least eight partners before it
|
||
stops being a grid.
|
||
|
||
---
|
||
|
||
## Design tokens
|
||
|
||
Two tiers, so re-branding is a one-line edit in one file.
|
||
|
||
```
|
||
app/app.css entry: tailwindcss, then the three files below
|
||
app/styles/primitives.css TIER 1 raw values, no meaning (@theme)
|
||
app/styles/semantic.css TIER 2 roles that alias tier 1 (per surface)
|
||
app/styles/typography.css TIER 2 type roles, @font-face, EN/AR font switch
|
||
```
|
||
|
||
**The one rule: components may reference only tier-2 tokens.** Never a raw hex, never a
|
||
tier-1 primitive.
|
||
|
||
```tsx
|
||
<h2 className="heading-h2 text-[var(--text-primary)]" /> // yes
|
||
<h2 className="text-[#f8f9fa] text-[28px]" /> // no — hex and raw type
|
||
<h2 className="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**.
|
||
Write `margin-inline-start`, `padding-inline`, `inset-inline-start`, `text-align: start`,
|
||
`border-inline-start` — and in Tailwind the logical utilities `ps-` `pe-` `ms-` `me-`
|
||
`start-` `end-`, never `pl-` `pr-` `ml-` `mr-` `left-` `right-`. The Arabic layout then
|
||
mirrors for free. `rtl:` and `ltr:` variants exist for the rare case that genuinely cannot be
|
||
expressed logically — an asymmetric icon, a background position.
|
||
|
||
Dates and numbers go through `~/i18n` (`formatDate`, `formatDateRange`, `formatNumber`,
|
||
`formatFileSize`), which uses `en-GB` and `ar-LY` with Western digits, matching the approved
|
||
Arabic copy.
|
||
|
||
### Adding a third locale
|
||
|
||
The types are exhaustive `Record<Locale, …>` maps, so after step 1 the compiler lists every
|
||
place that still has to be filled in.
|
||
|
||
1. `apps/web/app/components/ui/LanguageSwitcher/locale-path.ts` — add the code to `LOCALES`,
|
||
and an entry to `DIRECTION_BY_LOCALE`, `LOCALE_ENDONYM` and `LOCALE_SHORT_LABEL`.
|
||
2. `apps/web/app/i18n/config.ts` — add it to `INTL_LOCALE` and `OG_LOCALE`.
|
||
3. `apps/web/app/i18n/messages/<code>.json` — copy `en.json`, translate it, and register it in
|
||
`getMessages()`.
|
||
4. `apps/cms/config/plugins.ts` — add `{ code, name }` to the `locales` array and restart the
|
||
CMS; `src/bootstrap/locales.ts` creates it in the database on the next boot.
|
||
5. Nothing else: routing (`/:lang`), Accept-Language negotiation, the navigation, the footer
|
||
link columns and the `hreflang` alternates all derive from `SUPPORTED_LOCALES` and
|
||
`ROUTE_SLUGS`.
|
||
|
||
Note that the language switcher's UI is built for exactly two languages (it renders *the
|
||
other* one); a third locale needs it turned into a menu.
|
||
|
||
---
|
||
|
||
## Adding a content type, end to end
|
||
|
||
Worked example: a **Stories** collection, listed at `/en/stories` and `/ar/stories`.
|
||
|
||
### 1. Model it in Strapi
|
||
|
||
Run the CMS in development mode (`pnpm dev:cms`) — the Content-Type Builder only writes
|
||
schemas in `strapi develop`. Create the collection type, then in **Advanced settings** enable
|
||
**Internationalization** and **Draft & publish**.
|
||
|
||
Strapi writes the result to `apps/cms/src/api/story/` (`content-types/story/schema.json`,
|
||
plus a controller, routes and a service). **Commit that folder** — it is the schema, not a
|
||
local artefact. You can equally write the JSON by hand and restart.
|
||
|
||
Field conventions used across this project: `title` (string, required), `slug`
|
||
(uid from title, required), `summary` (text), `body` (blocks or rich text — both render),
|
||
`cover` (single media), `publicationDate` (date — the field everything sorts and filters on),
|
||
`topics` (relation to the shared `topics` taxonomy, so a tag means the same thing everywhere).
|
||
|
||
### 2. Restart the CMS, then check the API
|
||
|
||
`apps/cms/src/bootstrap/public-permissions.ts` grants the Public role `find` and `findOne`
|
||
on every `api::*` content type at boot, so there is nothing to click:
|
||
|
||
```bash
|
||
curl "http://localhost:1337/api/stories?locale=en&populate=cover"
|
||
```
|
||
|
||
A collection holding personal data is the exception — add its API name to
|
||
`PUBLIC_SUBMISSION_CONTENT_TYPES` in `apps/cms/src/bootstrap/constants.ts` and it gets
|
||
`create` only, never `find`.
|
||
|
||
### 3. Give it a URL
|
||
|
||
For a new top-level section, add the slug to `apps/web/app/lib/route-slugs.ts`
|
||
(`ROUTE_SLUGS`, and the right group in `ROUTE_SECTION_GROUPS`) and a label under
|
||
`nav.sections` in **both** `app/i18n/messages/en.json` and `ar.json`. The navigation, the
|
||
footer sitemap and `sectionPath()` all derive from those two, so nothing else needs editing.
|
||
|
||
Then register the module in `apps/web/app/routes.ts`. Section modules are picked up by their
|
||
conventional path (`SECTION_MODULES`); a detail route is one explicit line:
|
||
|
||
```ts
|
||
route("stories/:slug", "routes/stories.$slug.tsx", { id: "storyDetail" }),
|
||
```
|
||
|
||
Vite does not re-evaluate `routes.ts` when a new file appears — **restart `pnpm dev:web`**
|
||
after adding a route module.
|
||
|
||
### 4. Write the route module
|
||
|
||
`apps/web/app/routes/stories.tsx`. Fetch on the server, in the loader; the Strapi client is
|
||
server-only, and the API token never reaches the browser.
|
||
|
||
```tsx
|
||
import { buildMeta } from "~/lib/seo";
|
||
import { getMany } from "~/lib/strapi";
|
||
import { readRootSeo, requireLocale } from "~/routes/_shared/listing";
|
||
import type { LoaderFunctionArgs, MetaArgs } from "react-router";
|
||
|
||
interface Story {
|
||
documentId: string;
|
||
title: string;
|
||
slug: string;
|
||
summary?: string;
|
||
publicationDate?: string;
|
||
}
|
||
|
||
export async function loader({ params }: LoaderFunctionArgs) {
|
||
const locale = requireLocale(params.lang); // 404s on anything but en | ar
|
||
|
||
const { data } = await getMany<Story>("stories", {
|
||
locale,
|
||
sort: "publicationDate:desc",
|
||
populate: { cover: { fields: ["url", "alternativeText"] } },
|
||
pagination: { page: 1, pageSize: 12 },
|
||
});
|
||
|
||
return { locale, stories: data };
|
||
}
|
||
|
||
export function meta({ matches }: MetaArgs) {
|
||
const root = readRootSeo(matches); // { locale, pathname, siteUrl }
|
||
return buildMeta({ ...root, title: "Stories", description: "…" });
|
||
}
|
||
|
||
export default function Stories({ loaderData }: { loaderData: Awaited<ReturnType<typeof loader>> }) {
|
||
return <main>{/* Card.Content, PageShell, … from the shared kit */}</main>;
|
||
}
|
||
```
|
||
|
||
For a detail page use `requireEntity(await getBySlug<Story>("stories", params.slug, { locale }))`
|
||
— `requireEntity` throws the 404 the root ErrorBoundary renders, localised.
|
||
|
||
Do **not** pass `alternates: true` to `buildMeta`: `root.tsx` already emits the canonical link
|
||
and the `hreflang` alternates for every route, and a leaf `meta` that repeats them duplicates
|
||
the tags. Use `handle.canonicalPath` to override the canonical instead.
|
||
|
||
Render with the existing kit rather than new markup: `~/components/ui` (Button, Input, Card,
|
||
…) and `app/routes/_shared/listing-ui` / `_page-kit/sections` (PageShell, PageHeader,
|
||
FilterBar, Pagination, EmptyState, RichText).
|
||
|
||
### 5. Verify
|
||
|
||
```bash
|
||
pnpm typecheck
|
||
```
|
||
|
||
Then open `/en/stories` and `/ar/stories`, confirm the Arabic page mirrors, and confirm an
|
||
unknown slug renders the localised 404 rather than a 500. If the CMS is stopped, the page
|
||
must still render its fallback copy.
|
||
|
||
---
|
||
|
||
## Fonts (licensed, not in this repository)
|
||
|
||
The brand faces are **Panton** for English and **Montserrat Arabic** for Arabic.
|
||
|
||
> **Note on the guideline.** The communication guideline PDF names the Arabic face
|
||
> "Montaser Arabic". That is a typo in the guideline — the correct face is **Montserrat
|
||
> Arabic**, confirmed with the client on 2026-09-11. Both files are supplied.
|
||
|
||
Both are **commercial typefaces licensed to the project, not to this repository** —
|
||
`.gitignore` excludes every `woff2` under `apps/web/public/fonts/`, and `.dockerignore` keeps
|
||
them out of built images. Do not commit them, and do not publish them to a CDN that serves
|
||
them outside the project's licence.
|
||
|
||
Drop the files into **`apps/web/public/fonts/`**, with exactly these names — matching is
|
||
case-sensitive in production:
|
||
|
||
| File | Family | Weight | Status |
|
||
| --- | --- | --- | --- |
|
||
| `Panton-Regular.woff2` | Panton | 400 | supplied |
|
||
| `Panton-Bold.woff2` | Panton | 700 | supplied |
|
||
| `Panton-SemiBold.woff2` | Panton | 500 | **missing** |
|
||
| `Montserrat-Arabic-Regular.woff2` | Montserrat Arabic | 400 | supplied |
|
||
| `Montserrat-Arabic-Medium.woff2` | Montserrat Arabic | 500–600 | supplied |
|
||
| `Montserrat-Arabic-Bold.woff2` | Montserrat Arabic | 700 | supplied |
|
||
|
||
Because a fresh clone has none of them, the stacks in `app/styles/primitives.css` degrade
|
||
politely and the site is fully usable without any of them:
|
||
|
||
```css
|
||
--font-en: "Panton", "Figtree", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
--font-ar: "Montserrat Arabic", "IBM Plex Sans Arabic", "Noto Sans Arabic", ui-sans-serif, system-ui, sans-serif;
|
||
```
|
||
|
||
The brand family is always **first** in the stack, so the moment a licensed file is dropped
|
||
in, it takes over — no code change, no rebuild of anything but the page. One current gap:
|
||
|
||
- **Panton SemiBold.** `label-md` asks for weight 500; with no SemiBold face declared, CSS
|
||
font matching resolves it to Panton Regular. When the cut arrives, add one `@font-face` for
|
||
`"Panton"` at `font-weight: 500 600` in `app/styles/typography.css`. Do not declare that
|
||
face before the file exists — a face that fails to load drops weight-500 text out of the
|
||
family altogether.
|
||
Under Docker, `docker-compose.yml` mounts `./apps/web/public/fonts` into the running web
|
||
container read-only, so a local container uses your licensed files without ever baking them
|
||
into an image. On a server, copy them into the deployed `build/client/fonts/` directory (or
|
||
mount them the same way) as part of the release, outside version control.
|
||
|
||
---
|
||
|
||
## Deployment and operations
|
||
|
||
### Building and running a release
|
||
|
||
```bash
|
||
pnpm install --frozen-lockfile
|
||
pnpm build # cms (admin panel + dist/) then web (build/client + build/server)
|
||
|
||
pnpm --filter cms start # strapi start :1337
|
||
pnpm --filter web start # react-router-serve :$PORT (see below)
|
||
```
|
||
|
||
Both are ordinary long-running Node processes. Run them under systemd, PM2 or the
|
||
containers in `docker-compose.yml`, behind a reverse proxy (nginx, Caddy) that terminates
|
||
TLS and forwards to 3000 and 1337.
|
||
|
||
#### Choosing the web server's port
|
||
|
||
`react-router-serve` binds `PORT` when it is set, and otherwise takes the first **free** port
|
||
from 3000 upwards — so a busy 3000 moves the site to 3001, 3002, … with no error, and the
|
||
proxy's upstream stops matching. Nothing in the repository defaults it for you.
|
||
|
||
`pnpm --filter web start` runs under `node --env-file-if-exists=.env`, so `apps/web/.env` is
|
||
enough:
|
||
|
||
```bash
|
||
echo 'PORT=8080' >> apps/web/.env
|
||
pnpm --filter web start # :8080
|
||
```
|
||
|
||
It has to be that file and not the root `.env`: Strapi reads a bare `PORT` too, so a shared
|
||
one would move the CMS onto the website's port. `apps/web/.env` is read by the web server
|
||
alone, exactly as `apps/cms/.env` is read by Strapi alone.
|
||
|
||
A real environment variable wins over the file, so `PORT=9090 pnpm --filter web start` and a
|
||
systemd unit's `Environment=PORT=8080` both override it. `HOST` works the same way — unset it
|
||
listens on every interface, `HOST=127.0.0.1` only accepts the reverse proxy on the same host.
|
||
|
||
Under Docker none of this applies: `PORT=3000` is set in the image and in
|
||
`docker-compose.yml`, `WEB_PORT` in the root `.env` is the **host** port the container is
|
||
published on (`WEB_PORT=8080` gives `8080:3000`), and `.dockerignore` keeps every `.env` out
|
||
of the build context.
|
||
|
||
Whichever port you pick, `SITE_URL` stays the public origin (`https://sharper.ly`), not
|
||
`http://127.0.0.1:8080` — canonical URLs, `hreflang` and JSON-LD are built from it.
|
||
|
||
### Production environment
|
||
|
||
| Variable | Where | Value |
|
||
| --- | --- | --- |
|
||
| `NODE_ENV` | both | `production` |
|
||
| `PORT` | web | the port `react-router-serve` binds, e.g. `8080`. Unset, it takes the first free port from 3000 upwards |
|
||
| `HOST` | web | optional; `127.0.0.1` to accept only the local reverse proxy, unset to listen on every interface |
|
||
| `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 |
|
||
| `PUBLIC_URL` | cms | `https://cms.sharper.ly` — admin redirects and generated links |
|
||
| `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` |
|
||
|
||
For per-visitor submission limits, the edge proxy must overwrite `X-Forwarded-For` with
|
||
the verified client address (not append to a value supplied by the client). The web server
|
||
relays that header to Strapi, which trusts it only when `IS_PROXIED=true`.
|
||
|
||
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
|
||
`./scripts/generate-secrets.sh --write .env` (Docker).
|
||
|
||
**Port already in use.** 5432 usually means a PostgreSQL is already running — in Docker mode
|
||
the container is published on 5433 for exactly that reason (`POSTGRES_PUBLISHED_PORT`). For
|
||
1337, 3000 and 5173: `ss -ltnp | grep -E '1337|3000|5173'`.
|
||
|
||
**`WARN The field "pnpm.onlyBuiltDependencies" was found in apps/cms/package.json`.**
|
||
Harmless. pnpm only honours that field at the workspace root, where it is already declared.
|
||
|
||
**A new page 404s even though the route module exists.** `app/routes.ts` existence-checks the
|
||
section modules while Vite builds the route config, and Vite does not re-evaluate it when a
|
||
file appears. Restart `pnpm dev:web`.
|
||
|
||
**`Property 'data' does not exist` in a route's `meta`, or an Arabic page renders English
|
||
metadata.** In React Router 8 a meta match exposes `loaderData`, not `data`;
|
||
`matches[0].data` is `undefined` at runtime and a type error at compile time. Use
|
||
`readRootSeo(matches)` from `~/routes/_shared/listing`.
|
||
|
||
**Type errors about `./+types/...`.** Those files are generated. Run `pnpm typecheck`
|
||
(which runs `react-router typegen` first) or `pnpm --filter web exec react-router typegen`.
|
||
|
||
**The browser reports a CORS error calling the API.** Add the origin to `CORS_ORIGINS` in the
|
||
CMS environment and restart it. `http://localhost:5173` and `http://127.0.0.1:5173` are
|
||
always allowed outside production.
|
||
|
||
**Images uploaded in the CMS do not load under Docker.** Expected until `cms` resolves in
|
||
your browser — see [Images from the CMS](#images-from-the-cms).
|
||
|
||
**`docker compose` complains about `depends_on` / `condition`.** You are on the old
|
||
`docker-compose` v1 binary. Install the Compose v2 plugin and use `docker compose` (a space,
|
||
not a hyphen).
|
||
|
||
**The site renders in a different typeface than the design.** Expected on a fresh clone: the
|
||
licensed fonts are not in the repository. See [Fonts](#fonts-licensed-not-in-this-repository).
|
||
|
||
**Everything looks broken after a schema change in Strapi.** `strapi develop` writes schema
|
||
changes to `apps/cms/src/api/**` and migrates the database; if the two disagree, stop the
|
||
CMS, check `git status` in that folder, and restart. As a last resort in **development
|
||
only**, drop and recreate the database (`dropdb sharper_cms && ./scripts/setup-db.sh`, or
|
||
`docker compose down -v`) — this destroys all content.
|
||
|
||
---
|
||
|
||
## Source documents
|
||
|
||
`docs/` holds the material this site is built from. It is **read-only source material** —
|
||
never edit it, and never invent a figure that is not in it.
|
||
|
||
| File | What it is |
|
||
| --- | --- |
|
||
| `UEC_SHARPER_Website_TOR V3.docx` | Terms of reference: scope, functional and technical requirements, deliverables, timeline |
|
||
| `SHARPER_Website_Draft_Section_EN_AR.docx` | Approved draft copy for every section, English and Arabic |
|
||
| `SHARPER Home Page .docx` | Home page copy |
|
||
| `SHARP-ER_Website Recommendations.docx` | Structure and content recommendations |
|
||
| `Sharp Updated communication guidline_ (2).pdf` | Brand: colours, typefaces, EU visibility and partner-logo rules, tone of voice |
|
||
|
||
Tone, everywhere: professional, neutral, factual, inclusive and non-confrontational. The site
|
||
is not political, not advocacy and not campaign-style.
|
||
|
||
---
|
||
|
||
## Licence and credits
|
||
|
||
Funded by the European Union. Implemented by Acted, United Efforts Consultancy and
|
||
Stichting Bureau Ensemble. The views expressed on the site are those of the implementing
|
||
consortium and do not necessarily reflect those of the European Union.
|
||
|
||
Content, branding and the licensed typefaces belong to the project and its partners; they are
|
||
not covered by any open-source licence granted over this source code.
|