Serve the pages the panel publishes

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>
This commit is contained in:
pigui
2026-09-28 09:55:10 +02:00
parent 47c9af682c
commit 80a1924519
19 changed files with 775 additions and 19 deletions

View File

@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_LOCALE, localeFromUrl, resolveLocale } from './api';
import {
contentRoute,
DEFAULT_LOCALE,
homeHref,
localeFromUrl,
resolveLocale,
} from './api';
describe('resolveLocale', () => {
it('accepts the locales the API declares', () => {
@@ -28,3 +34,36 @@ describe('localeFromUrl', () => {
expect(localeFromUrl(undefined)).toBe(DEFAULT_LOCALE);
});
});
describe('contentRoute', () => {
it('reads an unprefixed page as the default language', () => {
expect(contentRoute('/sobre-nosotros')).toEqual({
locale: DEFAULT_LOCALE,
slug: 'sobre-nosotros',
});
});
it('reads a prefixed page in the language it names', () => {
expect(contentRoute('/en/about-us')).toEqual({ locale: 'en', slug: 'about-us' });
expect(contentRoute('/ca/qui-som/')).toEqual({ locale: 'ca', slug: 'qui-som' });
});
// The API never spells the default language with a prefix, so one page has
// one address rather than two.
it('refuses the default language as a prefix', () => {
expect(contentRoute('/es/sobre-nosotros')).toBeNull();
});
it('refuses what is not shaped like a page', () => {
expect(contentRoute('/')).toBeNull();
expect(contentRoute('/fr/a-propos')).toBeNull();
expect(contentRoute('/en/projects/catalogo')).toBeNull();
});
});
describe('homeHref', () => {
it('answers on ?lang= for every language but the default', () => {
expect(homeHref(DEFAULT_LOCALE)).toBe('/');
expect(homeHref('ca')).toBe('/?lang=ca');
});
});

View File

@@ -12,6 +12,7 @@ import type {
PublicClient,
PublicIssue,
PublicMember,
PublicPage,
PublicProject,
PublicService,
PublicTalk,
@@ -73,6 +74,42 @@ export function localeFromUrl(url: string | undefined): Locale {
return resolveLocale(requested);
}
/**
* Where a content page lives, as the API spells it in `path` and `alternates`:
* the default language unprefixed (`/sobre-nosotros`), every other one under
* its code (`/en/about-us`). Only the API knows which slug belongs to which
* language, so this only recognises the shape; whether the page exists is the
* API's answer.
*
* A prefix naming the default language (`/es/sobre-nosotros`) is not a shape
* the API ever produces, so it is not one this answers on either — one page,
* one address.
*/
export function contentRoute(
pathname: string
): { locale: Locale; slug: string } | null {
const segments = pathname.replace(/\/+$/, '').split('/').filter(Boolean);
if (segments.length === 1) {
return { locale: DEFAULT_LOCALE, slug: segments[0] };
}
if (
segments.length === 2 &&
segments[0] !== DEFAULT_LOCALE &&
SUPPORTED_LOCALES.includes(segments[0] as Locale)
) {
return { locale: segments[0] as Locale, slug: segments[1] };
}
return null;
}
/** The landing itself in a given language. It answers on `?lang=`, not a prefix. */
export function homeHref(locale: Locale): string {
return locale === DEFAULT_LOCALE ? '/' : `/?lang=${locale}`;
}
function apiBase(): string {
const configured =
typeof process !== 'undefined'
@@ -91,6 +128,8 @@ export interface ApiResult<T> {
data: T | null;
maintenance: MaintenanceNotice | null;
error: string | null;
/** The HTTP status, when there was a response — a 404 means something else than an outage. */
status: number | null;
}
interface GetOptions {
@@ -137,6 +176,7 @@ async function get<T>(path: string, options: GetOptions = {}): Promise<ApiResult
return {
data: null,
error: null,
status: 503,
maintenance: {
message: body?.message ?? 'El sitio está en mantenimiento.',
maintenance: true,
@@ -149,16 +189,26 @@ async function get<T>(path: string, options: GetOptions = {}): Promise<ApiResult
}
if (!response.ok) {
return { data: null, maintenance: null, error: `HTTP ${response.status}` };
return {
data: null,
maintenance: null,
error: `HTTP ${response.status}`,
status: response.status,
};
}
const body = (await response.json()) as { data: T };
return { data: body.data ?? null, maintenance: null, error: null };
return {
data: body.data ?? null,
maintenance: null,
error: null,
status: response.status,
};
} catch (cause) {
const error = cause instanceof Error ? cause.message : 'Unknown error';
return { data: null, maintenance: null, error };
return { data: null, maintenance: null, error, status: null };
} finally {
clearTimeout(timer);
}
@@ -205,6 +255,13 @@ export const pireonApi = {
goodFirstIssues: (query?: GetOptions['query']) =>
list<PublicIssue>('/good-first-issues', { query }),
pages: (lang: Locale, query?: GetOptions['query']) =>
list<PublicPage>('/pages', { lang, query }),
/** By slug, which only resolves in the language it belongs to. */
page: (lang: Locale, slug: string) =>
get<PublicPage>(`/pages/${encodeURIComponent(slug)}`, { lang }),
} as const;
/** POST bodies the public API accepts. Only two endpoints write. */

View File

@@ -0,0 +1,56 @@
/**
* What a content page renders — one of the pages the panel publishes, such as
* "Sobre nosotros" — fetched on the server in one pass.
*
* Shared by the two routes a page answers on, `/:slug` and `/:locale/:slug`,
* whose `.server.ts` files only have to exist because Analog needs one next to
* each `.page.ts`.
*/
import {
pireonApi,
type Locale,
type MaintenanceNotice,
type SitePayload,
} from './api';
import type { PublicPage } from './api.types';
export interface ContentPageData {
locale: Locale;
site: SitePayload | null;
maintenance: MaintenanceNotice | null;
/** Null when the API has no such page in this language: the route answers 404. */
page: PublicPage | null;
/** Every published page, for the header and the footer. */
pages: PublicPage[];
}
export async function loadContentPage(
locale: Locale,
slug: string | undefined,
): Promise<ContentPageData> {
const [site, page, pages] = await Promise.all([
pireonApi.site(locale),
slug ? pireonApi.page(locale, slug) : Promise.resolve(null),
pireonApi.pages(locale, { per_page: 100 }),
]);
return {
locale,
site: site.data,
maintenance: site.maintenance,
page: page?.data ?? null,
pages: pages.data ?? [],
};
}
/** Featured pages go in the header; the rest are one click away in the footer. */
export function pageNavLinks(
pages: PublicPage[],
): { href: string; label: string }[] {
return pages
.filter((page) => page.is_featured && page.path && page.title)
.map((page) => ({
href: page.path as string,
label: page.title as string,
}));
}

View File

@@ -89,7 +89,8 @@ export interface Copy {
error: string;
closed: string;
};
footer: { rights: string };
footer: { rights: string; pages: string };
page: { back: string; skip: string };
maintenance: { title: string; back: string; until: string };
}
@@ -181,6 +182,11 @@ const es: Copy = {
},
footer: {
rights: 'Todos los derechos reservados.',
pages: 'Más sobre Pireon',
},
page: {
back: 'Volver al inicio',
skip: 'Saltar al contenido',
},
maintenance: {
title: 'Estamos de mantenimiento',
@@ -277,6 +283,11 @@ const en: Copy = {
},
footer: {
rights: 'All rights reserved.',
pages: 'More about Pireon',
},
page: {
back: 'Back to home',
skip: 'Skip to content',
},
maintenance: {
title: 'We are under maintenance',
@@ -373,6 +384,11 @@ const ca: Copy = {
},
footer: {
rights: 'Tots els drets reservats.',
pages: 'Més sobre Pireon',
},
page: {
back: "Tornar a l'inici",
skip: 'Salta al contingut',
},
maintenance: {
title: 'Estem de manteniment',