initial commit
This commit is contained in:
@@ -0,0 +1,906 @@
|
||||
# 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 `<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
|
||||
|
||||
Two files, with different jobs:
|
||||
|
||||
| File | Read by | Required in manual mode |
|
||||
| --- | --- | --- |
|
||||
| `apps/cms/.env` | Strapi (it loads its own `.env`) | **yes** |
|
||||
| `.env` (root) | `docker compose`, and optionally the web server | no — the code defaults already point at `localhost` |
|
||||
|
||||
```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 reads `STRAPI_URL`, `STRAPI_TOKEN` and `SITE_URL` from its **process
|
||||
environment**. Nothing loads the root `.env` for you in manual mode, and you do not need it:
|
||||
`STRAPI_URL` already defaults to `http://localhost:1337`, and with `SITE_URL` unset the
|
||||
canonical URL is derived from the incoming request. To override them anyway:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
set -a; . ./.env; set +a # export it into the current shell
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 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, `<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 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 <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` | `react-router-serve ./build/server/index.js` — the production server, port 3000 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
---
|
||||
|
||||
## 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 :3000
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### Production environment
|
||||
|
||||
| Variable | Where | Value |
|
||||
| --- | --- | --- |
|
||||
| `NODE_ENV` | both | `production` |
|
||||
| `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` |
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user