# landing-web Landing page for the Pireon collective, built with [Nx](https://nx.dev) and [AnalogJS](https://analogjs.org) (Angular 22 on Vite), rendered on the server, fed by the public half of the [Pireon API](https://landing-api.pireon.dev/docs/api) and built out of `@pi/ui`, the collective's own component library. ```sh npm start # dev server on http://localhost:4200 npm run build # production build into dist/landing npm run preview # run the built server (PORT=3000 by default) npm test # unit tests (Vitest) npm run lint # ESLint npm run e2e # Playwright end-to-end tests ``` ## Deploying ```sh cp .env.example .env # then fill in NEXUS_TOKEN docker compose build docker compose up -d ``` Compose reads `.env` on its own, for the build token as well as the runtime settings. `.env` is gitignored; `.env.example` is the one that is committed. If you would rather not keep the token on disk: ```sh NEXUS_TOKEN=… docker compose build ``` An empty or missing token fails the build in under a second with a message saying so. That check is there because the mount's own `required=true` only asserts the secret was *declared* — an empty one passes it and then surfaces a minute later as a Nexus 401 that reads like a credentials problem when it is really a forgotten variable. Two stages. The build one runs Nx and the Angular compiler over the whole workspace and is thrown away; only `dist/landing/analog` crosses into the runtime image, because Nitro vendors its own dependencies into that output — 17 MB that runs with no install step, on `node:22-alpine`, as the unprivileged `node` user. The image comes out at 182 MB, nearly all of it the base. `@pi/ui` comes from the private Nexus, so the install needs a token. It is passed as a **BuildKit secret**, not a build ARG: an ARG stays readable in the image history forever. `docker history` on the result shows no trace of it. `/api/health` answers the container health check. It deliberately does not call the Pireon API — a check that failed when the upstream was down would have the orchestrator restart a container that is working correctly, since the landing answers maintenance in that case by design. **The API is external** — a separate deployment on a sibling subdomain, not part of this stack. **The landing must keep `/api` on its own origin.** Analog serves the app's server routes there, and — more importantly — resolves *every page's data* through `/api/_analog/pages/*`. Handing that path to the Pireon API on a shared origin would not cost the contact form, it would blank the site. Moving the app's own prefix is not the way out either: `apiPrefix` exists in the Analog config but `get-page-handlers.js` hardcodes `/api` for the loader routes regardless of it, so setting it splits the two apart and the loaders stop being registered at all. Verified by trying it. Different subdomains, as deployed today, have no conflict. The consequence for deployment is egress: the landing is server-rendered, so every request it serves makes ten calls to `landing-api.pireon.dev` *from inside the container*. Behind a restrictive egress policy that host has to be allowed, or every page renders as though the API were in maintenance. `core/api.ts` gives each call an 8-second budget, which is what stands between a slow upstream and a hung render. The container runs with a read-only filesystem and `no-new-privileges`, with a tmpfs at `/tmp` for Nitro's scratch space. The container publishes no host port: the reverse proxy reaches it over the compose network as `landing:3000`, and binding a host port only invites a collision with whatever else is on that number. `docker-compose.yml` carries a commented `ports` block for when you do want one. Put that proxy in front for TLS and let it pass response headers through untouched: the app answers 503 with `Retry-After` during maintenance and 404 for addresses it does not serve, and both matter to crawlers and CDNs. | Variable | Where | Default | | --- | --- | --- | | `PIREON_API_URL` | runtime | `https://landing-api.pireon.dev/api` | | `LANDING_PORT` | only if you publish a host port | `3000` | | `NEXUS_TOKEN` | **build only** | — | Copy `.env.example` to `.env`; compose reads it. ## Seeing it with content The live API answers `{"data": []}` for every collection until the panel has something published, so `npm start` shows a hero and a contact form and nothing else. That is correct, and impossible to review. Two terminals: ```sh npm run mock # stand-in API on :4599, with content in every section npm run start:mock # the app pointed at it, on :4200 ``` `npm run mock:down` is the other half — every endpoint answering 503 — which is the only way to see the maintenance banner: ```sh npm run mock:down PIREON_API_URL=http://localhost:4600/api npm run preview ``` `docs/screenshots/` holds a capture of every screen in both modes, taken against those two servers. ## What it talks to Only `/public/v1/*` — unauthenticated, read-only, and already published on the public website. The landing assembles itself from ten endpoints in one parallel pass on the server: | Section | Endpoint | | --- | --- | | Hero numbers, locales, form switch | `/site` | | Qué hacemos | `/services` | | Proyectos | `/projects?per_page=6&translated=1` | | Con quién trabajamos | `/clients` | | Lo que dicen | `/testimonials` | | Radar técnico | `/radar` | | Quiénes somos | `/members` | | Buscamos gente | `/calls` | | Primeras contribuciones | `/good-first-issues?limit=6` | | Charlas | `/talks?limit=6` | | Contact form | `POST /contact` | `landing/openapi.yaml` is the specification the app was written against, and `landing/src/app/core/api.types.ts` is the API's own generated type file. Both are copies — refresh them from the API when the contract moves: ```sh curl -o landing/openapi.yaml https://landing-api.pireon.dev/openapi.yaml curl -o landing/src/app/core/api.types.ts https://landing-api.pireon.dev/api-types.d.ts ``` ## How it is put together ``` landing/src/ app/ core/api.ts server-side API client (never imported by the browser) core/api.types.ts generated payload types from the API core/copy.ts the chrome's own words, in es / en / ca core/icons.ts the icon set, inlined and registered as literals core/pi-strings.ts the strings `@pi/ui` translates on its own core/routes.ts which record an address belongs to (resolveRoute) core/routed-page.ts the load function for every address but the landing pages/(home).server.ts one load function, eleven parallel calls pages/(home).page.ts the landing itself pages/[...path].page.ts everything else: pages, projects, members, 404 ui/page-shell.ts header, footer, lights, 404/503 and SEO around a record ui/routed-page.ts picks the body for the record's type ui/ presentational components, one per payload type (thin wrappers over `@pi/ui`), each with its `.scss` server/ routes/api/contact.post.ts forwards the contact form to the API routes/api/calls/[slug]/apply.post.ts forwards an application to one call routes/sitemap.xml.get.ts every published address, from the API's /slugs routes/robots.txt.get.ts points crawlers at the sitemap routes/blog/rss.xml.get.ts the blog as RSS, one feed per language middleware/redirects.ts 301s for slugs renamed in the panel middleware/not-found-status.ts styles.scss Material theme, `@pi/ui` sheets, shared primitives ``` **Nothing renders from the browser.** All reads happen in the page's `load` function, and the two writes go through this app's own handlers — `/api/contact` for the general form, `/api/calls/{slug}/apply` for a call's — which share one check (`core/submission.ts`) before spending a round trip. An application to a call sends no `kind`: the API refuses one there, because answering a call is always an offer of time. That is what the API expects: it throttles submissions per e-mail address rather than per IP *because* an SSR frontend posts on the visitor's behalf, and keeping the API host out of the browser means CORS never comes up. **No call can reject.** `core/api.ts` turns every failure into `{ data, maintenance, error }`, so a section whose endpoint blinked renders empty instead of taking the page down. **Maintenance takes over the whole page.** When `/public/v1/*` answers 503 the landing is replaced by `ui/maintenance-screen.ts`, not covered with a banner: every collection is unavailable at that point, so what was left underneath was a hero, eight empty sections and a contact form the API would refuse. The notice arrives already resolved to the visitor's language and is rendered as sent. The response says so too — 503, the API's own `Retry-After`, and `Cache-Control: no-store` to override the `s-maxage` route rule, because a cached maintenance page outlives the maintenance. That status is set from the page component through `injectResponse()` (`@analogjs/router/tokens`), which is the *render* request's response; the one a page `load` sees belongs to the internal request Analog resolves loaders through and never reaches the browser. That is also why the 404 status comes from middleware instead — except for addresses shaped like a record, where only the API knows whether it exists: the middleware lets those through and `ui/page-shell.ts` sets the 404 itself from `ngOnInit`, once its input has arrived. **`@pi/ui`'s reset spaces every `li + li`** with a top margin — right for a list of prose, wrong for a row of chips laid out with `gap`, where it dropped every chip but the first. Lists spaced by `gap` reset it on their own `> li`. **Empty sections disappear**, along with their entry in the nav — a young site shows a hero and a contact form rather than eight headings over nothing. ## Languages Content is translatable and the API resolves it per request. The locale comes from `?lang=es|en|ca` and nothing else: Analog resolves `load` functions through an internal request that forwards the query string but not the headers, and a page cached with `s-maxage` that varied on `Accept-Language` would hand the second visitor the wrong language anyway. The switcher in the header is three plain links, and `` follows. **Records are the exception**, because the API decides their address: a record's `path` and `alternates` put the default language unprefixed and every other one under its code, pages at the root and everything else under a section named in that language: ``` /sobre-nosotros /en/about-us /ca/qui-som /proyectos/catalogo /en/projects/catalogo /ca/projectes/catalogo /equipo/ada /en/team/ada /ca/equip/ada /colabora/frontend /en/contribute/frontend /ca/col-labora/frontend /servicios/formacion /en/services/formacion /ca/serveis/formacion ``` Besides records there are **listings**, pages this site composes itself — the tech radar at `/radar`, `/en/radar`, `/ca/radar`, and the ways to help at `/colabora`, `/en/contribute`, `/ca/col-labora` — the calls' own section, so it lists what `/colabora/{slug}` are, next to good first issues and a guide to a first contribution (`LISTINGS`). A panel page with the same slug would be shadowed by one, so they are few and deliberately named. Radar logos sit on a light tile in both themes: a brand's mark is not ours to recolour, and some (Rust's, black) would vanish in dark mode. `core/routes.ts#resolveRoute` is that rule read backwards, and its `SECTIONS` mirror the API's `config/site.php` (`paths`) — a change there has to be made here too. `/es/…` is not a shape the API produces, so a record has one address rather than two. The switcher on a record follows `alternates`, and a language it has no translation for leads to the landing in that language instead of a 404. The same `alternates` become `hreflang` links, with a `canonical`. **One catch-all route** (`pages/[...path]`) serves every address but the landing, and `resolveRoute` sorts the shapes out. Not one route per shape: Analog registers each loader as an internal Nitro route, and Nitro's router cannot hold two parameters of different names at the same depth — `/:prefix/:slug` beside `/:prefix/:section/:slug` made the first unreachable. Featured pages go in the header after the section anchors; every page is in the footer. ## The blog Not a type of its own: **a post is a page in the panel's "Blog" category** — written, translated, scheduled, reviewed and reachable by the MCP server like any page. That category has to exist with the slug `blog` in every language (`core/blog.ts`), because the API filters by the slug in the language asked for. - `/blog` (`/en/blog`, `/ca/blog`) lists them newest first. - A post shows its date and leads back to the blog; its structured data is a `BlogPosting`. - A post shows who signs it (`byline`, linked to their profile) and its tags, each linking to `/blog?tag=…`. The blog offers the tags in use as a row of links and narrows to one with `?tag=` — plain links, so a tag's listing can be shared and works before any script runs. Tags and bylines are set in the panel. - `/blog/rss.xml?lang=xx` is the feed, one per language, announced in `
`. - Once there is a post, the blog is in the header menu (desktop and mobile) and the landing shows the three latest under "Novedades"; the footer links it too, but does not list posts one by one — it would grow without end. A blog reachable only from the footer turned out to be one nobody found. ## Search engines and link previews - **`/sitemap.xml`** comes from the API's `/slugs` — one request for every published address — plus the landing and the listings. Each address is listed in every language, with `hreflang` alternates and `x-default`. If the API is down it answers 503, not a sitemap missing half the site. - **`/robots.txt`** allows everything and names the sitemap, absolute, on whatever domain the proxy says this is. - **Renamed slugs redirect.** The panel writes a redirect the moment somebody renames one; `middleware/redirects.ts` answers the old address with a 301, from the API's `/redirects` list kept for a minute (and kept whole through an outage, so renamed links do not turn back into 404s). - **Every page says what it is**: title, description, `og:*` with the record's own picture (the logo otherwise), and schema.org JSON-LD — `Organization` on the landing, `WebPage`, `CreativeWork`, `Person` or `Service` elsewhere. In development only `/api/*` reaches Nitro, so `/sitemap.xml`, `/robots.txt` and `/blog/rss.xml` answer 404 under `npm start`; build and run `npm run preview` to see them. Middleware does run in development. ## Rendering and caching Nothing is prerendered: the page is assembled from content the panel publishes and answers on `?lang=`, so a build artefact would be stale and wrong for two of three locales. Freshness is bought back with `Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=600`, so a CDN absorbs the traffic and the origin re-renders once a minute. ## Package registry (Nexus) `.npmrc` keeps npmjs as the default registry and routes only the `@pi` scope to Nexus, which is where `@pi/ui` comes from: ``` registry=https://registry.npmjs.org/ @pi:registry=https://nexus.pireon.dev/repository/npm-releases/ ``` This is not a preference — `npm-releases` is a **hosted** repo, not a proxy. It answers 404 for anything published on npmjs, so making it the default registry breaks every install in the workspace. There is no `group`/`proxy` repo on that Nexus today; if you want a single registry for everything, someone has to create an npmjs *proxy* repo and a *group* that bundles it with `npm-releases`, and then `registry=` can point at the group. **Credentials** are not in this file. Locally they live in `~/.npmrc`. In CI, write the token before installing: ```sh npm config set //nexus.pireon.dev/repository/npm-releases/:_authToken "$NEXUS_TOKEN" ``` An `${NEXUS_TOKEN}` placeholder inside `.npmrc` would make npm fail outright on any machine where the variable is unset, so it is deliberately absent. **Publishing a library from this workspace** to Nexus — the app itself is `private` and is not publishable: ```jsonc // libs/