TASKS-1823 WebManager module

This commit is contained in:
Igor Martyniuk
2026-08-28 08:35:58 +00:00
parent fdb2cf3837
commit c93d8c025b
654 changed files with 26232 additions and 6075 deletions
+65
View File
@@ -0,0 +1,65 @@
# Web Manager
The CMS behind the public website. Editors assemble localized pages from ready-made blocks in the
admin panel; the public Next.js site renders what they publish.
| Part | Source | Owns |
| ---------------------- | ------------------------------------------- | -------------------------------------------------------- |
| Backend (`WebManager`) | `apps/backend/app/base_modules/WebManager/` | Data, validation, draft/publish transactions, APIs |
| Admin (`Web Studio`) | `apps/admin/src/core/WebStudio/` | Editor UI, drafts, publishing actions, preview, settings |
| Public site | `apps/web/web-runtime/`, `apps/web/app/` | Server rendering, layouts, blocks, caching, SEO |
## Documentation
| Page | Purpose |
| ------------------------------- | ------------------------------------------------------------------ |
| [Backend](./backend.md) | Data model, HTTP API, query budget, tuning constants. |
| [Admin](./admin.md) | Web Studio: routes, editor, config files, UI conventions. |
| [Web](./web.md) | Public site rendering, layouts, hero, SEO, degradation. |
| [Integration](./integration.md) | Contracts, cross-app flows, caching, env wiring, extending, tests. |
## For Editors
A page is a title, an optional hero, an ordered list of blocks, and SEO fields. Blocks are the
prebuilt sections — text columns, FAQ, media, banner, article list — so building a page is picking
blocks and filling them in, not writing markup.
| Term | What it means |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Page** | One address in one language, e.g. `/en/about-us`. |
| **Group key** | Ties the same page across languages together (`about-us` links `/en/about-us` and `/uk/pro-nas`) so the site can offer a language switch. |
| **Page type** | Landing, Blog, or Blog item. Decides the layout and which blocks are offered. |
| **Hero** | The opening section. Four intensities: none, low, middle, high. |
| **Draft** | Your working copy. Never visible to visitors. |
| **Published** | Live on the site. |
| **Unpublished** | Taken down, but kept — content, blocks and address stay reserved, so you can put it back. |
| **Preview** | A private, time-limited link that renders the draft exactly as the site would. |
| **Site settings** | Header menus, footer, and legal links — shared by every page in one language. |
Everything you edit is a draft. A live page keeps serving the old version until you press Publish,
so an unfinished edit can never reach visitors. Nothing is deleted unless you press Delete.
## Page Lifecycle
```txt
create publish
(none) ────────► draft ───────────► published ◄──────┐
│ │ │
discard│ │unpublish │publish
▼ ▼ │
deleted unpublished ───────┘
published + open draft clone: visitors see the published row, the editor edits the clone
```
Every write happens in one transaction with rows locked `FOR UPDATE`; routes and permissions are in [backend.md](./backend.md), purge behavior in [integration.md](./integration.md#caching-and-revalidation).
- **Create** — `createDraft` validates the payload, asserts the `locale` + `path` is free across every root row (any status), and inserts the page with `status = draft` plus its components. Nothing public yet.
- **Save a draft** — `ensureDraft` edits a `draft` or `unpublished` page in place; for a `published` page it reuses or creates the draft clone. Components are replaced wholesale in the submitted order; a clone inherits its original's address.
- **Edit a published page** — the clone is a second row with `draft_of_id` pointing at the original (one clone per page, enforced by a partial unique index). Edit always opens the clone; the original keeps serving until the clone is published.
- **Preview** — a signed token (HMAC over `APP_KEY`, TTL 900s clamped to 60–3600s) lets the web app render the draft in an iframe sheet: `force-dynamic`, `noindex`, disallowed in robots. Absent, tampered, or expired tokens render "preview unavailable".
- **Publish** — publishes the **last saved** draft (the confirmation dialog says so). A pending clone is always preferred, so publishing can never drop the editor's work: a root draft goes live directly; a clone is folded into its original and deleted. A _structural_ change (first publish, republish, or changed locale/path/title/group key/page type) purges the whole cache tag; anything else purges only the affected page tags.
- **Unpublish** — same permission as publish; only valid on a published page (else 409). Sets `status = unpublished`, keeping content, components, and any open clone; the address stays reserved. Full purge — the URL now 404s.
- **Republish** — publish again; an unpublished page is edited in place, and republishing counts as structural.
- **Discard a draft** — deletes the draft row and its components; a clone's original is untouched. No purge.
- **Delete** — the only destructive action, behind a confirmation dialog. Components and any open clone cascade with the row; full purge if it was published.
+79
View File
@@ -0,0 +1,79 @@
# Web Manager — Admin (Web Studio)
Admin side of [Web Manager](./Readme.md). Source: `apps/admin/src/core/WebStudio/`.
## Routes
```txt
/web-studio/pages/listing list, search, filters, row actions
/web-studio/pages/-/create new draft
/web-studio/pages/-/:id editor (page or draft clone)
/web-studio/settings/:section header | footer | legal
```
The list shows title with full path, locale, slug, group key, page type, status, draft marker and
updated time; search is debounced and filters, pagination and locale live in the query string
(`useWebStudioQueryParams`), so links and the back arrow keep the editor's context. Row actions are
Edit, Preview, Open in web app, Publish, Unpublish, Discard and Delete — Edit opens the draft clone
when `draftId` is present, so an editor always lands on the working copy.
## Editor
The editor is a flat two-column layout: a sticky ~320px Page details column, and Hero / Blocks / SEO
as line tabs beside it. One React Hook Form instance (`PAGE_EDITOR_FORM_ID`) owns everything;
the header's Save button submits it by form id, and submitting disables the whole fieldset.
Actions sit in a split button: Save draft, then Preview / Publish / Unpublish / Discard / Delete in
the dropdown, each behind its own control component in `view/controls/` and its own permission.
Publish is offered whenever the page is not already live, so it doubles as republish.
Blocks are added from a sheet filtered to the page type's `allowedBlockTypes`, reordered by drag
(`view/components/SortableList.tsx`), and duplicated or removed from their own header. Nested
collections — content columns, CTAs, FAQ items, navigation links — behave the same way one level
down.
## Key Files
| Concern | File |
| ------------------- | ------------------------------------------------------------------------------------------------------ |
| Module registration | `config/module.ts`, `config/routing.tsx`, `config/menu.ts` |
| Languages | `config/languages.ts` — code, labels, direction, public prefix |
| Page types | `config/pages.ts` — allowed blocks and default path per type |
| Block catalogue | `config/blocks.ts` — label, description, preview image, factory |
| Settings shape | `config/siteSettings.ts` |
| HTTP + multipart | `data/api/WebStudioAPI.ts`, `data/api/PagePayloadSerializer.ts` |
| Domain types | `entities/` — `PageType`, `PageStatus`, `BlockType`, `HeroType`, block and field shapes |
| Form ↔ DTO | `pages/PageEditPage/helpers/toEditorValues.ts`, `view/forms/PageEditorForm/helpers/toCreatePayload.ts` |
Page types decide what an editor can build:
| Type | Layout | Blocks offered | Default path |
| ----------- | ---------- | --------------------------------------------------- | ------------ |
| `landing` | Standard | all six | `/` |
| `blog` | Blog index | none — the listing is rendered from published posts | `/blog` |
| `blog-item` | Editorial | all except `articles` | `/` |
## UI Conventions
The module is a content tool, not a dashboard of cards. Flat surfaces, few separators, one primary
action per screen.
| Element | Treatment |
| ------------------- | -------------------------------------------------------- |
| Screen heading | `text-mono text-xl font-semibold` |
| Supporting copy | `text-muted-foreground text-sm` |
| Editor settings | `bg-muted/40 space-y-5 p-5` |
| Top-level block | `bg-muted/35`, compact header, one `border-b` while open |
| Nested field group | `border-l-2 pl-4` (`view/components/FieldCard.tsx`) |
| Inline actions | small ghost icon buttons |
| Destructive trigger | ghost button, destructive text, confirmation dialog |
| Empty collection | `bg-muted/35`, generous padding, short copy |
`view/components/BlockCard.tsx` is named for compatibility but is a flat section, not a card — no
shadow, no hover lift, no bordered container nested inside every block. The block picker stays a
sheet with a divided list, because picking is a temporary task. Shared visual principles live in the
[Admin UI Style Guide](../../../apps/admin/ui-style-guide.md); style Web Studio from Web Studio and do
not edit `apps/admin/src/shared` for a module-only visual change.
A visual change must not alter routes, `locale` / `group_key` query behavior, list filtering or DTO
mapping, draft-clone semantics, form values or validation, block factory output, settings payload
shape, or the toast / error / loading states. Anything that does is a feature change.
+127
View File
@@ -0,0 +1,127 @@
# Web Manager — Backend
Backend of [Web Manager](./Readme.md). Source: `apps/backend/app/base_modules/WebManager/`.
Lifecycle mechanics: [Page Lifecycle](./Readme.md#page-lifecycle). Cross-app flows: [Integration](./integration.md).
## Data Model
### `web_pages`
```txt
id
status -- draft | published | unpublished
draft_of_id -- nullable FK to web_pages, ON DELETE CASCADE
locale -- en, uk, de
path -- '' for home, else 'about-us' or 'blog/post/about-me' (no leading slash)
group_key -- links localized versions of the same page
page_type -- landing | blog | blog-item
title
hero -- jsonb, nullable
seo_title -- defaults to ''
seo_description
published_at -- nullable
created_at / updated_at
```
Indexes: `(group_key, status)`, `(locale, path, status)`, a partial unique index on
`(locale, path) WHERE status = 'published'`, and a partial unique index on
`(draft_of_id) WHERE draft_of_id IS NOT NULL` — one draft clone per page, enforced by the database.
Rules:
- `path` is the full route after the locale, without leading or trailing slashes: `/en/blog/post/x`
is `locale = en`, `path = blog/post/x`. The editor form shows it with a leading slash.
- Public reads only ever match `status = published`.
- A root row is `draft_of_id = null`; a draft clone points at its published original.
- Route uniqueness is enforced twice: the partial index for live collisions, and
`assertNewRouteAvailable` for root rows of any status, so an unpublished page keeps its address.
### `web_components`
```txt
id
page_id -- FK to web_pages, ON DELETE CASCADE
type -- content | faq | media-content | articles | banner | media
data -- jsonb, block-specific shape validated by validator/web_content_validator.ts
sort_order -- position in the page, index of the submitted array
created_at / updated_at
```
Indexed on `(page_id, sort_order)`. Saving a draft deletes and reinserts the whole set rather than
diffing it.
### `web_settings`
```txt
id
locale -- unique
header -- jsonb { menus: [{ title, items }], ctaItems }
footer -- jsonb { copyright, menus }
legal -- jsonb { privacyPath, termsPath }
created_at / updated_at
```
One row per locale, upserted by locale. Shape lives in `WebStudio/config/siteSettings.ts`.
## HTTP API
Admin routes are under `/web-manager` behind `middleware.auth()`; every action authorizes through
`WebManagerPolicy`.
| Method | Route | Permission | Purpose |
| ------ | -------------------------------------- | ---------------------- | ------------------------------------------- |
| GET | `/web-manager/pages` | `web_manager.view` | Paginated root rows with a `draft` relation |
| GET | `/web-manager/pages/group-keys` | `web_manager.view` | Distinct group keys, alphabetized |
| POST | `/web-manager/pages` | `web_manager.create` | Create a draft (multipart) |
| GET | `/web-manager/pages/:id` | `web_manager.view` | Page + components |
| PUT | `/web-manager/pages/:id/draft` | `web_manager.update` | Save a draft (multipart) |
| POST | `/web-manager/pages/:id/publish` | `web_manager.publish` | Publish, returns `meta.revalidation` |
| POST | `/web-manager/pages/:id/unpublish` | `web_manager.publish` | Take down, returns `meta.revalidation` |
| POST | `/web-manager/pages/:id/preview-token` | `web_manager.view` | Signed preview token |
| DELETE | `/web-manager/pages/:id` | `web_manager.delete` | Delete page or discard draft |
| GET | `/web-manager/settings` | `web_manager.view` | Settings, all locales or one |
| PUT | `/web-manager/settings` | `web_manager.settings` | Upsert settings for a locale |
Public routes are under `/web-manager/public`, unauthenticated, and return published content only —
except `preview`, which is gated by the signed token.
| Route | Returns |
| ------------------------------------ | ----------------------------------------------------------------------------------- |
| `/pages/resolve?locale=&path=` | Page, components, settings, breadcrumbs, `languageAlternatives` |
| `/pages/preview?token=` | The same shape for any status, for a valid token |
| `/pages/sitemap?page=&perPage=` | `locale`, `path`, `groupKey`, `pageType`, `publishedAt`, `updatedAt` + `pagination` |
| `/blog/pages?locale=&page=&perPage=` | Published `blog-item` pages, newest first, with hero media |
| `/settings?locale=` | Header, footer, legal for one locale |
The sitemap is the one public read proportional to the site, so it is paginated
(`SITEMAP_PAGE_SIZE` 1000, capped at `SITEMAP_MAX_PAGE_SIZE` 5000) and the caller walks it;
`groupKey` is included so hreflang alternates need no second lookup. The resolve payload carries
`publishedAt` / `updatedAt`, which the web app emits as `datePublished` / `dateModified`.
Permissions are seeded from `WebManager/config/permissions.ts`: `web_manager.view`, `.create`,
`.update`, `.publish`, `.delete`, `.settings`.
## Public Resolve Query Budget
| Query | Bounded by |
| --------------------- | ----------------------------------------------------------- |
| the page row | route lookup |
| its components | blocks on the page |
| site settings | one row per locale |
| language alternatives | locales sharing the `group_key` |
| breadcrumbs | path depth (~4–6 rows), plus one query per group-key jump |
| internal page links | internal links in the page's own content, skipped when none |
Two details keep that true. **Link map by id**: `collectInternalPageIds` walks hero, components and
settings first, then `createPageLinkMap` fetches only those ids — unknown or unpublished ids are
absent and render as an empty href. **Breadcrumbs by candidate**: the walk can only climb to a
shorter prefix of the current path, the home page, or the group key in
`BREADCRUMB_PAGE_TYPE_PARENTS`, so `buildBreadcrumbs` asks for precisely those rows; a second query
happens only when a group-key jump lands on a branch whose ancestors were not already loaded
(`ancestorsAlreadyLoaded`). Both are served by the existing indexes.
## Tuning Constants
Backend tuning constants live in `WebManager/config/general.ts`: media upload path and 10 MB size
limit, preview token TTLs, sitemap page sizes, and `BREADCRUMB_PAGE_TYPE_PARENTS` (which makes
`blog` the breadcrumb parent of every `blog-item`).
+185
View File
@@ -0,0 +1,185 @@
# Web Manager — Integration
How the three apps of [Web Manager](./Readme.md) connect: contracts, flows, caching, env wiring, and cross-app extension checklists.
## How the Three Apps Connect
```txt
┌───────────────────────┐ ┌──────────────────────────┐
│ Admin (Web Studio) │───────►│ Backend (AdonisJS) │
│ editor, drafts, │◄───────│ pages, components, │
│ publish, preview │ DTOs │ settings, transactions │
└───────────┬───────────┘ └────────────┬─────────────┘
│ │
│ iframe /preview?token=… │ POST /api/revalidate (tag purge)
▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ Public site (Next.js) │
│ server-renders GET /web-manager/public/*, tagged 60s cache │
└─────────────────────────────────────────────────────────────┘
```
The three never share code, only contracts:
| Contract | Defined by | Consumed by |
| --------------------- | ------------------------------------------------------------ | ------------------------------------------------------- |
| Page / block payloads | `types/web_content.ts`, `validator/web_content_validator.ts` | Admin DTOs, `web-runtime/types.ts` |
| Public read API | `WebManager/routes.ts` (`/web-manager/public/*`) | `web-runtime/api/web-runtime-api.ts` |
| Cache tag names | `services/web_revalidation_service.ts` | `web-runtime/api/cache-tags.ts` — **must stay in sync** |
| Preview token | `services/preview_token_service.ts` | Admin preview sheet, `app/[locale]/preview/` |
| Block / page types | `types/web_content.ts` | `WebStudio/config/blocks.ts`, `web-runtime/*/registry` |
## Save Flow (admin → backend)
```txt
editor submits React Hook Form values
PagePayloadSerializer → FormData: `payload` JSON string + one binary part per new file
POST /web-manager/pages | PUT /web-manager/pages/:id/draft (multipart/form-data)
PagePayloadService parses `payload`, validates, stores files through FileManager,
rewrites { name, fileKey } media values into { name, ref }
WebManagerService writes page + components in one transaction
```
Media inside `hero` and block `data` is either `{ name, ref }` (already stored) or
`{ name, fileKey }` (new upload); only `ref` values are ever persisted. Part names are the
`fileKey`s (`file-0`, `file-1`, …). A request without a `payload` part is read as a plain JSON body,
so the endpoints stay usable from tests and scripts. The payload travels as one JSON string so
booleans, numbers, nulls and empty arrays survive the multipart transport.
## Publish Flow (backend → public site)
```txt
publish/unpublish/settings commit
WebRevalidationService POSTs { tags } to WEB_APP_URL/api/revalidate with X-Revalidate-Secret
Next.js revalidateTag(tag, 'max') per tag
response meta.revalidation = purged | retrying | disabled → admin toast
```
## Public Request Flow (visitor → web → backend)
```txt
GET /en/about-us
next-intl middleware resolves the locale segment
app/[locale]/[[...path]]/page.tsx → resolvePageRenderData(locale, path)
GET /web-manager/public/pages/resolve?locale=en&path=about-us (next: revalidate 60 + tags)
serializePublicPage returns page, components, settings, breadcrumbs, languageAlternatives
renderer.tsx → header + RegisteredPageLayout + footer; blocks via the block registry
```
Before it leaves the backend, `PublicContentService.prepare` resolves internal links to hrefs,
turns stored file refs into URLs, and sanitizes rich text (`content_sanitizer.ts`: allowlisted tags,
`http/https/mailto/tel` only, `rel` limited to `nofollow`/`noopener`/`noreferrer`). The web app
renders rich text as HTML and trusts that sanitization.
## Caching and Revalidation
Every public fetch is tagged and time-boxed, so a purge that never lands costs about a minute of
stale content rather than correctness.
| Fetch | `revalidate` | Tags |
| --------- | ------------ | --------------------------------------------------------------- |
| page | 60s | `web-manager`, `web-manager:page:{locale}:{path}`, settings tag |
| settings | 60s | `web-manager`, `web-manager:settings:{locale}` |
| blog list | 60s | `web-manager`, `web-manager:blog:{locale}` |
| sitemap | 3600s | `web-manager` only — it spans every locale and page |
Tag builders are duplicated in `web_revalidation_service.ts` (backend) and `cache-tags.ts` (web) and
must stay in sync; both sides have tests over the names, because a drift stops purges from landing
without anything failing.
Delivery is one attempt inline with the request (so the outcome can be reported to the editor) and
attempts 2–3 in the background with exponential backoff and jitter. Publish, unpublish and settings
save therefore return `meta.revalidation`:
| `status` | Meaning |
| ---------- | ----------------------------------------------------------------------- |
| `purged` | The web app confirmed the purge. |
| `retrying` | The first attempt failed; background retries are running. |
| `disabled` | `WEB_APP_URL` or `WEB_REVALIDATE_SECRET` is unset, so nothing was sent. |
The admin shows a warning toast on anything but `purged`, so an editor is never told the site is up
to date when it is not. Retries live in memory only — a restart drops them onto the same 60s floor a
failed purge already has. Exhausting all attempts logs at `error`; publishing again sends a fresh
purge. A deployment missing either variable logs each skipped purge at `warn` in production.
## Configuration
| App | Variable | Purpose |
| ------- | ----------------------- | ----------------------------------------------------------------------------- |
| backend | `APP_KEY` | Signs preview tokens. |
| backend | `WEB_APP_URL` | Where to POST cache purges. Server-side URL (compose service name in Docker). |
| backend | `WEB_REVALIDATE_SECRET` | Must equal the web app's `REVALIDATE_SECRET`. |
| web | `BACKEND_API_URL` | Backend base URL, without the `/web-manager/public/...` part. Required. |
| web | `REVALIDATE_SECRET` | Guards `/api/revalidate`. |
| web | `SITE_URL` | Canonical origin for metadata. Required in production. |
| admin | `VITE_WEB_URL` | Public site origin used to build preview and "view page" links. |
Backend tuning constants live in `WebManager/config/general.ts` — see
[Backend → Tuning Constants](./backend.md#tuning-constants).
## Extending
### Add a block type
1. Backend: add the value to `BLOCK_TYPES` and its data type in `WebManager/types/web_content.ts`,
and its shape to the component union in `validator/web_content_validator.ts`, so the payload is
validated server-side.
2. Admin: add the enum value to `entities/BlockType.ts`, the block shape under `entities/block/`,
an editor under `view/forms/blocks/<Name>Block/` with its `schema.ts`, and an entry in
`config/blocks.ts` (label, description, preview image, `createBlock` factory). Add it to the
`allowedBlockTypes` of every page type that should offer it in `config/pages.ts`.
3. Web: add the renderer under `web-runtime/blocks/components/` and register it in
`web-runtime/blocks/registry.tsx`, plus the type in `web-runtime/types.ts`.
4. Translations: add the label and description keys to both `config/translation/en.json` and
`uk.json` — the two files are kept at exact key parity.
`apps/web/tests/registry.test.ts` keeps a hand-mirrored copy of `BLOCK_TYPES` and `PAGE_TYPES` and
asserts the registries match it exactly — add the type there too, and it becomes the tripwire for a
block the backend can publish but the site cannot draw.
### Block preview images
Wireframe SVGs in `apps/admin/public/media/web-studio/blocks/<type>.svg`, referenced by
`previewImage` in `config/blocks.ts` and rendered by `BlockPickerSheet` as a 4:3 thumbnail. They are
schematic, not screenshots: `viewBox="0 0 400 300"`, no `width`/`height`, content inside
`x: 40–360` / `y: 32–268`, and no text — everything reads as bars and shapes at 112px wide.
| Token | Hex | Used for |
| -------------- | --------- | --------------------------------------------- |
| background | `#f7f4ec` | full-canvas rect, always the first element |
| surface | `#fffdf8` | cards, accordion rows, banner strip |
| surface border | `#d9dacf` | 1px stroke on surfaces and image placeholders |
| image fill | `#e7e9dc` | image placeholder rects |
| ink | `#1f291f` | titles, button inner bars, icons |
| text line | `#ccd1c0` | body copy bars |
| muted line | `#b9bfab` | subtitles, captions, image glyphs |
| accent | `#c7ff3f` | primary CTA pills, sun circle, active state |
| accent border | `#a8c930` | stroke marking an active or open element |
Reuse the shared vocabulary so previews read as one family: title bar (dark rect, `height 14–18`,
`rx = height/2`), body line (`height 9–10`, last line ~70% width), card (surface + border,
`rx 10–14`), CTA pill (accent, `height 24–28`, dark label bar inside), image placeholder (mountain
polygon plus accent sun). Draw the distinguishing structure only — three cards means "a list",
2–4 lines mean "a paragraph" — and keep at most one accent element per preview.
### Add a page type
Add the value to `PAGE_TYPES` in `WebManager/types/web_content.ts` and `entities/PageType.ts`, a
`WebStudioPageConfig` in `config/pages.ts` (allowed blocks, default path, label keys), a layout
component in `web-runtime/layouts/components/`, its entry in `PAGE_LAYOUT_REGISTRY`, and the
mirrored list in `registry.test.ts`. An unregistered type still renders through the standard
layout, so the site cannot break mid-rollout.
## Tests
| Suite | Command | Location | Covers |
| ------- | ---------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Backend | `npm run test:backend` | `apps/backend/tests/functional/web_manager/` | Preview token, revalidation, public content, sanitizer, breadcrumbs; page lifecycle (`web_manager_service.spec.ts`: draft/clone/publish/unpublish, route reservation, purge tags); HTTP surface (`pages_api.spec.ts`: 401/403 per permission, multipart, public endpoints). |
| Admin | `npm run test:admin` | `apps/admin/tests/unit/core/WebStudio/` | Menu, SEO helpers, list mapper, payload serializer, editor-value mapping and validation, `toCreatePayload`, block factory↔schema round-trips, field schemas, page paths, permission helper. |
| Web | `npm run test:web` | `apps/web/tests/` | Normalization, cache tags, block/layout registries, page metadata, sitemap, site URL, structured data, `safeHref`, published-only gate, `/api/revalidate`, API client, blog pager. |
Notes:
- DB-backed backend specs run against a dedicated `adonis_test` database (`.env.test`): the runner migrates up/down and each test runs in a global transaction (fixtures in `tests/utils/web_manager.ts`). `.env.test` leaves `WEB_REVALIDATE_SECRET` empty, so revalidation reports `disabled` while still exposing computed tags for assertions.
- `apps/web/vitest.config.ts` maps `#*` to the app root (Vitest ignores `tsconfig` `paths`) and stubs `server-only`, which only resolves inside a Next build.
- Not covered: rendering the block and layout components — needs jsdom and `@testing-library/react`, neither a dependency yet.
+71
View File
@@ -0,0 +1,71 @@
# Web Manager — Public Site (Next.js)
How the public site renders what [Web Manager](./Readme.md) publishes. Source: `apps/web/web-runtime/`, `apps/web/app/`.
## Structure
```txt
app/[locale]/[[...path]]/page.tsx localized catch-all
app/[locale]/preview/page.tsx token-gated draft rendering, noindex, force-dynamic
app/api/revalidate/route.ts secret-guarded tag purge
app/sitemap.ts, app/robots.ts generated from the public API
web-runtime/api/ HTTP client, render-data resolution, cache tags
web-runtime/data/ transport normalization
web-runtime/blocks/ block renderers + registry
web-runtime/layouts/ page layouts and hero
web-runtime/chrome/ header and footer
web-runtime/components/ link, rich text and media primitives
web-runtime/lib/ metadata, site URL, sitemap, structured data
web-runtime/types.ts normalized public contract
web-runtime/renderer.tsx header + layout + footer
```
Locales come from `site.config.ts` (`en`, `uk`, `de`, default `en`); with more than one locale
next-intl always prefixes the URL.
Everything degrades rather than fails. An unknown `page_type` falls back to the standard layout; an
unknown block type renders a dev-only "renderer missing" banner and nothing at all in production;
the normalizer degrades malformed payloads instead of throwing. Publishing a block type the web app
does not know yet can therefore never take the site down.
## Hero
Hero impact is an intensity choice, not a block type:
| Impact | Use for | Rendering |
| -------- | ------------------------------- | ------------------------------------------------------- |
| `none` | pages that start with content | hero omitted |
| `low` | legal, utility, index pages | compact padding, smallest title scale |
| `middle` | default marketing and content | medium padding, balanced scale |
| `high` | campaigns, launches, conversion | near-viewport height, largest scale, radial accent wash |
Media beside the hero switches it to a two-column grid at every impact. A page with no hero object
falls back to a page-type eyebrow plus the title.
## SEO
`generateMetadata` delegates to `pageMetadata` (`web-runtime/lib/page-metadata.ts`), a pure function
of the resolved render data:
- **title / description** — the SEO fields, falling back to the page title; an empty description is
omitted rather than sent blank.
- **canonical** — the page's own locale-prefixed path.
- **hreflang** — one alternate per locale in `languageAlternatives`, plus `x-default` pointing at
the default locale, or at this page when the default locale has no version. Omitted for a page
with no translations.
- **Open Graph / Twitter** — the hero image becomes the share card with width, height and alt when
supplied; `summary_large_image` with an image, `summary` without; `og:type` is `article` for
`blog-item`, `website` otherwise.
`app/sitemap.ts` walks the paginated sitemap endpoint (capped at 50 requests — the protocol's
50,000-URL limit) and hands entries to `buildSitemap`; pages sharing a `groupKey` link to each other
through `alternates.languages`, and `lastModified` uses `updatedAt` falling back to `publishedAt`.
`app/robots.ts` allows everything except `/*/preview` and advertises the sitemap.
`web-runtime/lib/structured-data.tsx` emits `BreadcrumbList` on every page with a trail and
`Article` on editorial pages — skipped entirely without a publication date rather than shipping
markup a crawler discards.
**`SITE_URL` must be set.** `metadataBase` comes from `siteUrl()`, and Next resolves every relative
metadata URL against it. Without it Next falls back to `http://localhost:3000`: the build succeeds
and pages look fine, but canonicals and share cards point at localhost. A production build without
it logs an error; a malformed value throws.