The landing was one address. It now also answers on the pages the panel publishes -- "Sobre nosotros" and the like -- at the addresses the API itself chooses: `path` and `alternates` put the default language unprefixed and every other one under its code, so `/sobre-nosotros`, `/en/about-us`, `/ca/qui-som` are the routes. `core/api.ts#contentRoute` is the single rule for that shape, and `/es/…` is deliberately not one of them, so a page has one address rather than two. Two routes share one load function, because Analog wants a `.server.ts` beside each `.page.ts`: `[slug]` for the default language, and `[locale]/[slug]` for the rest. Whether a slug exists is the API's answer, not something the route table knows, so the 404 middleware lets page-shaped addresses through and `ui/content-page.ts` sets the status itself from `ngOnInit` -- the same `injectResponse()` the landing uses for its 503, and `ngOnInit` because an input has no value in the constructor. Body HTML comes from the panel's editor and goes through Angular's sanitiser. The header shows the featured pages beside the section anchors; the footer lists them all. On a page the language switcher follows the API's `alternates`, and a language with no translation leads to the landing in that language instead of a 404. Verified: lint, 20 unit tests, build, and 6 e2e -- including a new one that a page-shaped address the API does not know answers 404 with `robots: noindex`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
452 lines
22 KiB
Markdown
452 lines
22 KiB
Markdown
# 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/content-page.ts the load function both content-page routes share
|
||
pages/(home).server.ts one load function, eleven parallel calls
|
||
pages/(home).page.ts the landing itself
|
||
pages/[slug].page.ts a page the panel publishes, default language
|
||
pages/[locale]/[slug].page.ts the same, in any other language
|
||
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
|
||
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 one write goes through this app's own `/api/contact` handler.
|
||
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 content page, where only the API knows whether the page
|
||
exists: the middleware lets those through and `ui/content-page.ts` sets the 404
|
||
itself from `ngOnInit`, once its input has arrived.
|
||
|
||
**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 `<html lang>` follows.
|
||
|
||
**Content pages are the exception**, because the API decides their address: a
|
||
page's `path` and `alternates` put the default language unprefixed and every
|
||
other one under its code — `/sobre-nosotros`, `/en/about-us`, `/ca/qui-som` —
|
||
so those are the routes that answer. `core/api.ts#contentRoute` is the one rule
|
||
for that shape; `/es/…` is not one of them, so a page has one address rather
|
||
than two. The switcher on a page follows `alternates`, and a language the page
|
||
has no translation for leads to the landing in that language instead of a 404.
|
||
The same `alternates` become `hreflang` links, with a `canonical`.
|
||
|
||
Featured pages go in the header after the section anchors; every page is in the
|
||
footer.
|
||
|
||
## 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/<name>/package.json
|
||
{
|
||
"name": "@pi/<name>",
|
||
"publishConfig": {
|
||
"registry": "https://nexus.pireon.dev/repository/npm-releases/"
|
||
}
|
||
}
|
||
```
|
||
|
||
## Styles
|
||
|
||
SCSS throughout, and no Tailwind — the library brings its own tokens, so a
|
||
second system on top would have been two vocabularies for one page.
|
||
|
||
Each component owns its stylesheet next to it (`hero.ts` + `hero.scss`), scoped
|
||
by Angular's emulated encapsulation. `styles.scss` holds only what cannot be
|
||
scoped: the Material theme, the `@pi/ui` sheets, the document base, and the few
|
||
primitives shared across components — `.landing-shell`, `.landing-eyebrow`, and
|
||
the `.landing-card*` family every card on the page uses.
|
||
|
||
Two consequences of that encapsulation are worth remembering, because both bit
|
||
during the split:
|
||
|
||
- A parent's sheet cannot reach inside a child component. `.landing-card__media
|
||
img` targets an `<img>` that belongs to `app-api-image`, so it stays global;
|
||
`.landing-logo__mark` is handed to that component as `imgClass` for the same
|
||
reason.
|
||
- It cannot restyle a library component's internals either. The 404 page leaves
|
||
`pi-empty-state` at the size the library gives it rather than reaching in with
|
||
`::ng-deep` to win a `font-size` argument.
|
||
|
||
Component styles are injected after the global sheet, so a library component's
|
||
own `:host { display: … }` ties on specificity and wins on order. Where the page
|
||
needs to override one — hiding the locale switcher on mobile — the selector is
|
||
scoped through an ancestor to break the tie.
|
||
|
||
`nx.json` generates `scss` for new applications, components and pages.
|
||
|
||
## The component library
|
||
|
||
Everything visible is `@pi/ui` — `pi-card`, `pi-chip`, `pi-stat-card`,
|
||
`pi-avatar`, `pi-list-item`, the form fields — under the library's
|
||
`alternative` shell (its marketing look: gradients, hover glow, larger
|
||
controls). The components in `ui/` are thin adapters: they turn one API
|
||
payload into one library component and hold nothing else. Page rhythm — the
|
||
band spacing, the header bar, the hero — is the only CSS this app writes, in
|
||
`styles.scss`.
|
||
|
||
### The mark, and the colour that comes from it
|
||
|
||
`public/logo.png` is the brand mark, used in the header and on the maintenance
|
||
screen, and as the favicon at two sizes. It came in as a 1024×1024 PNG of
|
||
1.4 MB with most of the frame empty; it is cropped to the artwork and resized,
|
||
which is what takes it to 42 KB — the header draws it at 32 px, so shipping the
|
||
original would have been thirty times the bytes for the same pixels. The glow is
|
||
baked into the file, so it needs no background of its own and reads on both the
|
||
light and the dark surface.
|
||
|
||
**The brand palette is sampled from it**, not chosen. The mark's gradient runs
|
||
from `#b952fe` through `#317ef4` to `#1388dc`, so `styles.scss` takes its two
|
||
ends — hue 276° and 205° — as `--pi-color-primary` and `--pi-color-accent`,
|
||
replacing the violet and teal that `pi-shell__alternative` ships. The values are
|
||
not the samples themselves: at the logo's own lightness, white text on them
|
||
lands at 3.7:1, under the 4.5:1 floor. Each is the darkest step of its hue that
|
||
clears it, with saturation pulled back so a page of them is not electric.
|
||
Everything downstream — buttons, chips, the hero aura, the card edges, the focus
|
||
ring — reads those tokens, so the page and the mark are one brand.
|
||
|
||
`favicon.ico` is still the scaffold's. Modern browsers take the PNG links ahead
|
||
of it; it only answers for clients that ask for `/favicon.ico` by convention.
|
||
|
||
**The rest of the logo folder.** `/Users/pigui/Desktop/logos/white/` is a
|
||
byte-for-byte duplicate of the folder above it, not a light-on-dark set. Of the
|
||
eight files, the three wordmarks (`10_38_19`, `10_41_03`, `10_47_10`) have **no
|
||
alpha channel** — the transparency checkerboard is painted into the pixels — so
|
||
they cannot be used as-is. The four that are genuinely transparent are
|
||
`10_39_58`, `10_42_40` (the one in use), `10_49_03` and `10_49_22`. That last
|
||
one is the newest file and the best asset in the set: the full "pireon" wordmark
|
||
in white, transparent, with the gradient `e`. It is unused here only because it
|
||
is white-on-dark and the landing's default is light; putting it in the header
|
||
needs either a dark-text export of the same lockup, or a decision to make that
|
||
bar dark in both modes.
|
||
|
||
### Motion
|
||
|
||
Two pieces, both of which have to survive a page that renders on the server:
|
||
|
||
- **`ui/testimonial-carousel.ts`** wraps the quotes in `pi-carousel`, which
|
||
projects `<ng-template pi-carousel-item>` children and instantiates them
|
||
itself. It needs a `device` to decide how many fit — the library ships
|
||
`DeviceConfigsFacade` for that but nothing in it watches the window, so
|
||
`core/device.ts` reads the viewport instead and answers `DESKTOP` on the
|
||
server. Two of its defaults are worth knowing: `hideSwipeButtons` is `true`,
|
||
so the arrows have to be asked for, and the controls only appear at all once
|
||
there are more items than fit — with two testimonials it degrades to a plain
|
||
row, which is the right answer.
|
||
- **`ui/reveal.ts`** fades each section in as it arrives. The hidden state is
|
||
*not* in the server-rendered HTML: it only applies under `html.reveal-ready`,
|
||
a class the directive adds at runtime, so a visitor without JavaScript reads a
|
||
full page instead of a blank column. `prefers-reduced-motion` skips the
|
||
observer entirely.
|
||
|
||
`PiThemeService` owns the shell class, pinned to `alternative` in
|
||
`app.config.ts` — the library's marketing look: pill buttons with a gradient
|
||
fill, a tinted field surface, rounded cards with elevation. `styles.scss` layers
|
||
on what the theme contract cannot express: the contract stops at
|
||
`--pi-theme-card-radius` and `--pi-theme-card-shadow`, with no token for a
|
||
card's fill, so the gradient surface, the gradient edge (`mask-composite`, the
|
||
same technique the library uses for its focus ring) and the hover lift are
|
||
written by hand. The `gradient` theme's focus ring *is* a token, so it is lifted
|
||
onto this shell without swapping themes.
|
||
|
||
The dark palette — the service writes `body.className` from its own effect and
|
||
starts on `default`, so without that the page would render server-side in one
|
||
shell and visibly swap to the other on hydration.
|
||
|
||
### Dark mode
|
||
|
||
The library has none: `PiThemeService` toggles `html.dark-mode` and persists it,
|
||
but `grep -r dark-mode node_modules/@pi/ui/styles` comes back empty, and its
|
||
theme layer exposes exactly one surface token (`--pi-theme-field-surface`), so a
|
||
dark palette cannot be expressed as a theme. It is written in `styles.scss`
|
||
instead, by redefining the library's own colour contract. Three things about it
|
||
are worth knowing before touching it:
|
||
|
||
- **The tokens go on `html.dark-mode .pi-shell`, not on `html.dark-mode`.** The
|
||
shell class writes its own primary / accent / highlighted ramps to `<body>`,
|
||
and a custom property set on `<html>` is only inherited — so the shell won
|
||
every tie and half the palette stayed light. Matching the element and adding a
|
||
class is enough.
|
||
- **`color-scheme: dark` stays on the root.** `mat.theme` emits its values as
|
||
`light-dark(…)`, so that one line flips every Material token, plus the native
|
||
form controls and scrollbars.
|
||
- **`--pi-color-white` is deliberately not inverted.** The library uses it as
|
||
"the colour that contrasts with a brand fill" — the text of a filled button —
|
||
not only as a surface. The surfaces that do resolve to it are redirected one
|
||
by one, with `!important`, because the library paints a card from a five-class
|
||
selector this file cannot outrank.
|
||
|
||
`AppearanceService` owns the switch rather than `PiThemeService`, whose
|
||
`readStoredDarkMode()` is `!!localStorage.getItem(key)` while `persist()` writes
|
||
the boolean through: storing `false` reads back as the string `"false"`, which is
|
||
truthy, so dark mode could be turned on but never off. It also honours
|
||
`prefers-color-scheme` on a first visit, and an inline script in `index.html`
|
||
applies the class before first paint so a dark visitor never sees a white flash.
|
||
|
||
Five things had to be worked around, all documented at their call site:
|
||
|
||
- **The library's own stylesheet does not compile.**
|
||
`@pi/ui/styles/common/index.scss` ends in `mat.define-palette()` /
|
||
`mat.define-light-theme()`, removed from Angular Material in v19, while the
|
||
library asks for `^22.0.0`. The six parts it aggregates do compile, so
|
||
`styles.scss` pulls them in one by one and declares the Material theme with
|
||
the current API.
|
||
- **No SVGs ship with it.** `pi-icon` renders a `mat-icon` whose name is looked
|
||
up in Material's registry, and the library's `IconRegistryService` registers
|
||
every name as a URL under `/assets/icons/`. That is one request per icon, and
|
||
during SSR those have no origin to resolve against — every one failed and the
|
||
server HTML came out iconless. `core/icons.ts` registers the same names as
|
||
literals instead: no network, and the icon is in the first byte the browser
|
||
receives. A name from the API is never passed through unchecked; services
|
||
carry a free-form `icon` that may well be an emoji.
|
||
- **Form fields show errors before they are touched.**
|
||
`PiBaseFormFieldComponent.hasError` is `errors().length > 0`, with no
|
||
`touched` in it, so an untouched empty form renders red borders and "falta el
|
||
nombre" on first paint. The contact form gates every rule behind
|
||
`applyWhen(field, touched, …)`; `submit()` marks the tree touched before it
|
||
checks validity, so an empty form still cannot be sent.
|
||
- **`pi-chip-value` glues the number to its unit** ("4h/semana" as one word), so
|
||
those chips build their own label.
|
||
- **`pi-headline` is a figure, not a heading** — it loads its image from the
|
||
collective's CDN — and `pi-not-found` and `pi-hamburger` translate their own
|
||
strings through an HTTP loader. Section titles and the mobile menu are plain
|
||
HTML with the library's text directives.
|
||
|
||
Two `input.required` surprises worth knowing: `pi-card` requires `alignment`,
|
||
`innerPadding` and `context`, and `pi-list-item` requires `color` and
|
||
`borderType`. The library's README flags this — the Angular 14 → 22 migration
|
||
turned `@Input() x!: T` into `input.required()`, which those never were.
|
||
|
||
## Analog gotchas worth knowing before editing
|
||
|
||
- **A `.server.ts` must be referenced from its `.page.ts`.** Analog treats an
|
||
unreferenced one as a server-function module, parses it as JavaScript, and the
|
||
build fails on the first `import type`. `injectLoad<typeof load>()` in the page
|
||
is what keeps it compiled.
|
||
- **`@analogjs/router`'s server entry reads `import.meta.env` at module scope.**
|
||
Nitro leaves node_modules external in dev, so Node imports it raw and it
|
||
throws before any loader runs. `vite.config.ts` inlines the package
|
||
(`nitro.externals.inline`) to put it back through the bundler. Remove that and
|
||
`npm start` renders an empty page.
|
||
- **Partially-compiled libraries need Vite's SSR transform.** `@angular/cdk`,
|
||
`@angular/material`, `@ngx-translate/*` and `@pi/ui` all ship partially
|
||
compiled. Left external for SSR, the Angular linker never runs over them and
|
||
the first Material component to render asks for the JIT compiler. They are
|
||
listed in `ssr.noExternal` for that reason.
|