Add initial project files
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Cache tags attached to public Web Manager fetches. The backend references
|
||||
* the same tag names when it asks `/api/revalidate` to purge cached content,
|
||||
* so any change here must stay in sync with the backend revalidation service
|
||||
* (`apps/backend/app/base_modules/WebManager/services/web_revalidation_service.ts`).
|
||||
*
|
||||
* `WEB_MANAGER_TAG` is on every fetch and exists as the deliberate sledgehammer:
|
||||
* the backend purges it for structural changes (a page created, deleted or moved)
|
||||
*/
|
||||
|
||||
export const WEB_MANAGER_TAG = 'web-manager';
|
||||
|
||||
export function pageCacheTag(locale: string, path: string): string {
|
||||
return `${WEB_MANAGER_TAG}:page:${locale}:${path || '/'}`;
|
||||
}
|
||||
|
||||
export function settingsCacheTag(locale: string): string {
|
||||
return `${WEB_MANAGER_TAG}:settings:${locale}`;
|
||||
}
|
||||
|
||||
export function blogCacheTag(locale: string): string {
|
||||
return `${WEB_MANAGER_TAG}:blog:${locale}`;
|
||||
}
|
||||
|
||||
export function pageCacheTags(locale: string, path: string): string[] {
|
||||
return [WEB_MANAGER_TAG, pageCacheTag(locale, path), settingsCacheTag(locale)];
|
||||
}
|
||||
|
||||
export function blogListCacheTags(locale: string): string[] {
|
||||
return [WEB_MANAGER_TAG, blogCacheTag(locale)];
|
||||
}
|
||||
|
||||
export function settingsCacheTags(locale: string): string[] {
|
||||
return [WEB_MANAGER_TAG, settingsCacheTag(locale)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The sitemap spans every locale and every page, so the only tag that can invalidate it is the
|
||||
* site-wide one — which the backend purges for exactly the changes that alter the URL set
|
||||
* (a page created, deleted, moved, published or unpublished).
|
||||
*/
|
||||
export function sitemapCacheTags(): string[] {
|
||||
return [WEB_MANAGER_TAG];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'server-only';
|
||||
import { cache } from 'react';
|
||||
import { createEmptySiteSettings, normalizeRoutePath } from '../data/normalize';
|
||||
import type { PageRenderData } from '../types';
|
||||
import { getWebRuntimeAPI } from './web-runtime-api';
|
||||
|
||||
export const resolvePageRenderData = cache(
|
||||
async (locale: string, requestedPath: string): Promise<PageRenderData | null> => {
|
||||
const api = getWebRuntimeAPI();
|
||||
const request = {
|
||||
locale: locale.trim(),
|
||||
path: normalizeRoutePath(requestedPath)
|
||||
};
|
||||
const resolution = await api.resolvePage(request);
|
||||
|
||||
if (!resolution || resolution.page.status !== 'published') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const settings = resolution.settings ?? (await api.getSettings(request.locale));
|
||||
|
||||
return {
|
||||
page: resolution.page,
|
||||
settings: settings ?? createEmptySiteSettings(request.locale),
|
||||
languageAlternatives: resolution.languageAlternatives
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export async function resolvePreviewRenderData(token: string): Promise<PageRenderData | null> {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolution = await getWebRuntimeAPI().getPreview(token);
|
||||
|
||||
if (!resolution) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
page: resolution.page,
|
||||
settings: resolution.settings ?? createEmptySiteSettings(resolution.page.locale),
|
||||
languageAlternatives: resolution.languageAlternatives
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import 'server-only';
|
||||
import { isRecord, normalizePageResolution, normalizePageTeaserList, normalizeSitemapEntries, normalizeSiteSettings, sitemapLastPage } from '../data/normalize';
|
||||
import type { PageRequest, PageResolution, PageTeaserList, SitemapEntry, SiteSettings } from '../types';
|
||||
import { blogListCacheTags, pageCacheTags, settingsCacheTags, sitemapCacheTags } from './cache-tags';
|
||||
|
||||
/**
|
||||
* Ceiling on sitemap round trips, so a runaway `lastPage` cannot spin forever. At the backend's
|
||||
* 1000 rows per request this covers 50,000 URLs — the sitemap protocol's own limit per file.
|
||||
*/
|
||||
const SITEMAP_MAX_REQUESTS = 50;
|
||||
|
||||
function extractPreviewRequest(payload: unknown): PageRequest {
|
||||
if (isRecord(payload)) {
|
||||
const root = isRecord(payload['data']) ? payload['data'] : payload;
|
||||
const page = isRecord(root['page']) ? root['page'] : root;
|
||||
|
||||
return {
|
||||
locale: typeof page['locale'] === 'string' ? page['locale'] : 'en',
|
||||
path: typeof page['path'] === 'string' ? page['path'] : ''
|
||||
};
|
||||
}
|
||||
|
||||
return { locale: 'en', path: '' };
|
||||
}
|
||||
|
||||
export class WebRuntimeAPI {
|
||||
constructor(private readonly baseUrl: string) {}
|
||||
|
||||
async resolvePage(request: PageRequest): Promise<PageResolution | null> {
|
||||
const payload = await this.request('/web-manager/public/pages/resolve', {
|
||||
params: { locale: request.locale, path: request.path },
|
||||
next: {
|
||||
revalidate: 60,
|
||||
tags: pageCacheTags(request.locale, request.path)
|
||||
}
|
||||
});
|
||||
|
||||
if (payload === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizePageResolution(payload, request);
|
||||
}
|
||||
|
||||
async listBlogPosts(request: {
|
||||
locale: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}): Promise<PageTeaserList> {
|
||||
const payload = await this.request('/web-manager/public/blog/pages', {
|
||||
params: {
|
||||
locale: request.locale,
|
||||
page: String(request.page ?? 1),
|
||||
perPage: String(request.perPage ?? 12)
|
||||
},
|
||||
next: { revalidate: 60, tags: blogListCacheTags(request.locale) }
|
||||
});
|
||||
|
||||
return normalizePageTeaserList(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every published page, walked page by page. Tagged like any other public read, so
|
||||
* publishing refreshes the sitemap rather than leaving it a day out of date.
|
||||
*/
|
||||
async listSitemapPages(): Promise<SitemapEntry[]> {
|
||||
const entries: SitemapEntry[] = [];
|
||||
let page = 1;
|
||||
let lastPage = 1;
|
||||
|
||||
do {
|
||||
const payload = await this.request('/web-manager/public/pages/sitemap', {
|
||||
params: { page: String(page) },
|
||||
next: { revalidate: 3600, tags: sitemapCacheTags() }
|
||||
});
|
||||
|
||||
entries.push(...normalizeSitemapEntries(payload));
|
||||
lastPage = Math.min(sitemapLastPage(payload), SITEMAP_MAX_REQUESTS);
|
||||
page += 1;
|
||||
} while (page <= lastPage);
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async getSettings(locale: string): Promise<SiteSettings | null> {
|
||||
const payload = await this.request('/web-manager/public/settings', {
|
||||
params: { locale },
|
||||
next: { revalidate: 60, tags: settingsCacheTags(locale) }
|
||||
});
|
||||
|
||||
if (payload === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawSettings =
|
||||
isRecord(payload) && isRecord(payload['settings']) ? payload['settings'] : payload;
|
||||
|
||||
return normalizeSiteSettings(rawSettings, locale);
|
||||
}
|
||||
|
||||
async getPreview(token: string): Promise<PageResolution | null> {
|
||||
const payload = await this.request('/web-manager/public/pages/preview', {
|
||||
params: { token },
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (payload === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizePageResolution(payload, extractPreviewRequest(payload));
|
||||
}
|
||||
|
||||
private async request(
|
||||
path: string,
|
||||
{ params, ...init }: RequestInit & { params: Record<string, string> }
|
||||
): Promise<unknown> {
|
||||
const url = new URL(`${this.baseUrl}${path}`);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Web Manager request to ${path} failed with status ${response.status}.`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
|
||||
let api: WebRuntimeAPI | null = null;
|
||||
|
||||
export function getWebRuntimeAPI(): WebRuntimeAPI {
|
||||
if (!api) {
|
||||
const baseUrl = process.env['BACKEND_API_URL']?.trim().replace(/\/$/, '');
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error('BACKEND_API_URL is required');
|
||||
}
|
||||
|
||||
api = new WebRuntimeAPI(baseUrl);
|
||||
}
|
||||
|
||||
return api;
|
||||
}
|
||||
Reference in New Issue
Block a user