Add initial project files
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user