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',

View File

@@ -17,6 +17,7 @@ import {
PiChipComponent,
} from '@pi/ui';
import { pageNavLinks } from '../core/content-page';
import { copyFor } from '../core/copy';
import { CallCard } from '../ui/call-card';
import { ClientLogo } from '../ui/client-logo';
@@ -267,7 +268,7 @@ export const routeMeta: RouteMeta = {
</app-section>
</main>
<app-site-footer [copy]="copy()" />
<app-site-footer [copy]="copy()" [pages]="data().pages" />
}
`,
})
@@ -318,7 +319,14 @@ export default class HomePage {
},
];
return candidates.filter((link) => link.visible);
// After the sections: the featured pages the panel publishes, such as
// "Sobre nosotros", which live on their own addresses.
return [
...candidates
.filter((link) => link.visible)
.map(({ anchor, label }) => ({ anchor, label })),
...pageNavLinks(data.pages),
];
});
/**

View File

@@ -21,6 +21,7 @@ import type {
PublicClient,
PublicIssue,
PublicMember,
PublicPage,
PublicProject,
PublicService,
PublicTalk,
@@ -41,6 +42,8 @@ export interface LandingData {
calls: PublicCall[];
issues: PublicIssue[];
talks: PublicTalk[];
/** The pages the panel publishes: featured ones in the nav, all of them in the footer. */
pages: PublicPage[];
}
export const load = async ({ req }: PageServerLoad): Promise<LandingData> => {
@@ -60,6 +63,7 @@ export const load = async ({ req }: PageServerLoad): Promise<LandingData> => {
calls,
issues,
talks,
pages,
] = await Promise.all([
pireonApi.site(locale),
pireonApi.services(locale),
@@ -71,6 +75,7 @@ export const load = async ({ req }: PageServerLoad): Promise<LandingData> => {
pireonApi.calls(locale),
pireonApi.goodFirstIssues({ limit: 6 }),
pireonApi.talks(locale, { limit: 6 }),
pireonApi.pages(locale, { per_page: 100 }),
]);
return {
@@ -88,5 +93,6 @@ export const load = async ({ req }: PageServerLoad): Promise<LandingData> => {
calls: calls.data ?? [],
issues: issues.data ?? [],
talks: talks.data ?? [],
pages: pages.data ?? [],
};
};

View File

@@ -0,0 +1,18 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { injectLoad } from '@analogjs/router';
import { ContentPage } from '../../ui/content-page';
import { load } from './[slug].server';
@Component({
selector: 'app-content-page-localised',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ContentPage],
template: `<app-content-page [data]="data()" />`,
})
export default class LocalisedContentPage {
protected readonly data = toSignal(injectLoad<typeof load>(), {
requireSync: true,
});
}

View File

@@ -0,0 +1,20 @@
/**
* A content page in any other language: `/en/about-us`, `/ca/qui-som`.
*
* The prefix is checked with `contentRoute`, the same rule the 404 middleware
* uses, so `/es/sobre-nosotros` or `/fr/…` never reach the API as a slug.
*/
import type { PageServerLoad } from '@analogjs/router';
import { contentRoute, DEFAULT_LOCALE } from '../../core/api';
import { loadContentPage, type ContentPageData } from '../../core/content-page';
export const load = async ({
params,
}: PageServerLoad): Promise<ContentPageData> => {
const route = contentRoute(`/${params?.['locale']}/${params?.['slug']}`);
return route
? loadContentPage(route.locale, route.slug)
: loadContentPage(DEFAULT_LOCALE, undefined);
};

View File

@@ -0,0 +1,18 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { injectLoad } from '@analogjs/router';
import { ContentPage } from '../ui/content-page';
import { load } from './[slug].server';
@Component({
selector: 'app-content-page-default',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ContentPage],
template: `<app-content-page [data]="data()" />`,
})
export default class DefaultLocaleContentPage {
protected readonly data = toSignal(injectLoad<typeof load>(), {
requireSync: true,
});
}

View File

@@ -0,0 +1,12 @@
/**
* A content page in the default language: `/sobre-nosotros`.
*/
import type { PageServerLoad } from '@analogjs/router';
import { DEFAULT_LOCALE } from '../core/api';
import { loadContentPage, type ContentPageData } from '../core/content-page';
export const load = async ({
params,
}: PageServerLoad): Promise<ContentPageData> =>
loadContentPage(DEFAULT_LOCALE, params?.['slug']);

View File

@@ -0,0 +1,117 @@
:host {
display: block;
}
.landing-skip {
position: absolute;
left: -9999px;
&:focus {
left: 1rem;
top: 1rem;
z-index: 100;
padding: 0.5rem 1rem;
background: var(--pi-color-white, #fff);
border-radius: 0.5rem;
}
}
.content-page {
padding-block: 4rem 5rem;
@media (min-width: 768px) {
padding-block: 6rem 7rem;
}
}
// A reading column, not the landing's full width: prose past ~70 characters a
// line is hard to follow.
.content-page__inner {
max-width: 48rem;
}
.content-page__title {
margin: 0;
font-size: clamp(2rem, 5vw, 3rem);
line-height: 1.1;
}
.content-page__lead {
margin: 1.25rem 0 0;
line-height: 1.6;
}
.content-page__media {
margin: 2.5rem 0 0;
overflow: hidden;
border-radius: var(--pi-theme-card-radius, 1.5rem);
}
.content-page__body {
margin-top: 2.5rem;
font-size: 1.0625rem;
line-height: 1.75;
// The body is HTML from the panel's editor, not this component's template,
// so it carries none of the scoping attributes: only `::ng-deep` reaches it.
// Confined to this container, it styles nothing else on the page.
::ng-deep {
h2 {
margin: 2.5rem 0 0.75rem;
font-size: 1.5rem;
line-height: 1.25;
}
h3 {
margin: 2rem 0 0.5rem;
font-size: 1.2rem;
}
// One rhythm for both: left alone, the library's sheet sets paragraphs
// tighter than list items and the body reads in two different spacings.
p,
li {
line-height: 1.7;
}
p {
margin: 0 0 1rem;
}
ul,
ol {
margin: 0 0 1.25rem;
padding-left: 1.25rem;
}
li {
margin-bottom: 0.5rem;
}
a {
color: var(--pi-color-primary);
}
> :first-child {
margin-top: 0;
}
}
}
.content-page__back {
display: inline-block;
margin-top: 3rem;
color: var(--pi-color-grey-600, #5b5b6b);
text-decoration: none;
&:hover {
color: inherit;
}
}
// The `<img>` belongs to `app-api-image`, so a scoped rule cannot reach it.
.content-page__media ::ng-deep img {
display: block;
width: 100%;
height: auto;
}

View File

@@ -0,0 +1,271 @@
import { DOCUMENT } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
type OnInit,
} from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
import { injectRequest, injectResponse } from '@analogjs/router/tokens';
import {
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
} from '@pi/ui';
import { homeHref } from '../core/api';
import { pageNavLinks, type ContentPageData } from '../core/content-page';
import { copyFor } from '../core/copy';
import NotFoundPage from '../pages/[...not-found].page';
import { ApiImage } from './api-image';
import { MaintenanceScreen } from './maintenance-screen';
import { SiteFooter } from './site-footer';
import { SiteHeader } from './site-header';
/**
* One of the pages the panel publishes — "Sobre nosotros", "Contacto" —
* rendered on its own address.
*
* The body arrives as HTML from the panel's rich editor, written by the people
* who run the site, and goes through Angular's sanitiser on the way in: the
* headings, lists, links and emphasis the editor produces survive it, and
* nothing that could run does.
*/
@Component({
selector: 'app-content-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ApiImage,
MaintenanceScreen,
NotFoundPage,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
SiteFooter,
SiteHeader,
],
styleUrl: './content-page.scss',
template: `
@if (data().maintenance; as notice) {
<app-maintenance-screen
[notice]="notice"
[copy]="copy()"
[locale]="data().locale"
/>
} @else if (data().page; as page) {
<a class="landing-skip" href="#contenido">{{ copy().page.skip }}</a>
<app-site-header
[copy]="copy()"
[locale]="data().locale"
[links]="navLinks()"
[locales]="locales()"
[brandHref]="home()"
[alternates]="page.alternates"
(navigate)="goHome($event)"
/>
<main id="contenido" class="content-page">
<article class="landing-shell content-page__inner">
@if (page.category?.name; as category) {
<p class="landing-eyebrow">{{ category }}</p>
}
<h1 bold class="content-page__title">{{ page.title }}</h1>
@if (page.excerpt) {
<p l pi-color color="grey" class="content-page__lead">
{{ page.excerpt }}
</p>
}
@if (page.featured_image; as image) {
<figure class="content-page__media">
<app-api-image
[image]="image"
sizes="(min-width: 768px) 48rem, 100vw"
loading="eager"
imgClass="content-page__image"
/>
</figure>
}
<div class="content-page__body" [innerHTML]="page.body ?? ''"></div>
<a class="content-page__back" [href]="home()"
>← {{ copy().page.back }}</a
>
</article>
</main>
<app-site-footer [copy]="copy()" [pages]="data().pages" />
} @else {
<app-not-found />
}
`,
})
export class ContentPage implements OnInit {
readonly data = input.required<ContentPageData>();
private readonly document = inject(DOCUMENT);
private readonly title = inject(Title);
private readonly meta = inject(Meta);
/** Absolute, because `hreflang` and `canonical` want one. Null in the browser, where it is not needed. */
private readonly origin = originOf(injectRequest());
/** Taken here, in the injection context; used once the input has arrived. */
private readonly response = injectResponse();
protected readonly copy = computed(() => copyFor(this.data().locale));
protected readonly home = computed(() => homeHref(this.data().locale));
protected readonly navLinks = computed(() => pageNavLinks(this.data().pages));
protected readonly locales = computed(
() =>
this.data().site?.locales ?? [
{ code: 'es', name: 'Español', flag: '', is_default: true },
],
);
ngOnInit(): void {
this.answerWithStatus();
}
constructor() {
effect(() => {
const { page, locale, maintenance } = this.data();
this.document.documentElement.lang = locale;
if (maintenance) {
return;
}
// What the catch-all route says through its `routeMeta`, which only
// applies when the router picked that route — not when this one had to
// fall back to it.
if (!page) {
this.title.setTitle('Pireon — Página no encontrada');
this.meta.updateTag({ name: 'robots', content: 'noindex' });
return;
}
this.title.setTitle(page.meta_title || `Pireon — ${page.title}`);
this.meta.updateTag({
name: 'description',
content: page.meta_description || page.excerpt || '',
});
this.meta.updateTag({
property: 'og:title',
content: page.meta_title || page.title || '',
});
this.meta.updateTag({ property: 'og:type', content: 'article' });
this.linkAlternates(page.path, page.alternates ?? {});
});
}
/**
* The header's contact button and section anchors point at the landing, not
* at this page — there is no `#contacto` here to scroll to.
*/
protected goHome(anchor: string): void {
this.document.defaultView?.location.assign(`${this.home()}${anchor}`);
}
/**
* 404 for a slug the API does not know in this language, 503 during
* maintenance. Set on the render request's response, for the reason the
* landing sets its 503 the same way: the one a `load` sees never reaches the
* browser. The middleware leaves page-shaped addresses alone precisely so
* this can decide.
*
* From `ngOnInit`, not the constructor: an input has no value until then,
* and still nothing has been flushed.
*/
private answerWithStatus(): void {
const { maintenance, page } = this.data();
const response = this.response;
if (!response || response.headersSent) {
return;
}
if (maintenance) {
response.statusCode = 503;
if (maintenance.retry_after) {
response.setHeader('Retry-After', String(maintenance.retry_after));
}
response.setHeader('Cache-Control', 'no-store');
} else if (!page) {
response.statusCode = 404;
}
}
/**
* Only this side renders the page, but only the API knows that
* `/sobre-nosotros` and `/en/about-us` are the same one — hence `alternates`.
* Languages without a translation are absent from it rather than pointing at
* a 404.
*/
private linkAlternates(
path: string | undefined,
alternates: Record<string, string>,
): void {
const head = this.document.head;
head
.querySelectorAll(
'link[rel="alternate"][hreflang], link[rel="canonical"]',
)
.forEach((link) => link.remove());
const absolute = (href: string) =>
this.origin ? `${this.origin}${href}` : href;
const add = (attributes: Record<string, string>) => {
const link = this.document.createElement('link');
for (const [name, value] of Object.entries(attributes)) {
link.setAttribute(name, value);
}
head.appendChild(link);
};
if (path) {
add({ rel: 'canonical', href: absolute(path) });
}
for (const [code, href] of Object.entries(alternates)) {
add({ rel: 'alternate', hreflang: code, href: absolute(href) });
}
}
}
/** The origin a visitor used, as the reverse proxy in front of this app reports it. */
function originOf(request: ReturnType<typeof injectRequest>): string | null {
if (!request) {
return null;
}
const first = (value: string | string[] | undefined) =>
(Array.isArray(value) ? value[0] : value)?.split(',')[0]?.trim();
const host =
first(request.headers['x-forwarded-host']) ?? first(request.headers.host);
// Behind the proxy the header is always there; without one this is a
// developer's own machine, which does not speak TLS.
const protocol =
first(request.headers['x-forwarded-proto']) ??
(/^(localhost|127\.)/.test(host ?? '') ? 'http' : 'https');
return host ? `${protocol}://${host}` : null;
}

View File

@@ -11,3 +11,18 @@
margin: 0;
}
}
.landing-footer__pages {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin-top: 1.5rem;
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -1,10 +1,16 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import {
PiDividerComponent,
PiTextColorDirective,
PiTextSmallDirective,
} from '@pi/ui';
import type { PublicPage } from '../core/api.types';
import type { Copy } from '../core/copy';
@Component({
@@ -16,6 +22,18 @@ import type { Copy } from '../core/copy';
<footer class="landing-footer">
<div class="landing-shell">
<pi-divider color="grey" width="large" />
@if (links().length > 0) {
<nav
class="landing-footer__pages"
[attr.aria-label]="copy().footer.pages"
>
@for (link of links(); track link.href) {
<a s pi-color color="grey" [href]="link.href">{{ link.label }}</a>
}
</nav>
}
<div class="landing-footer__row">
<p s pi-color color="grey">
© {{ year }} Pireon. {{ copy().footer.rights }}
@@ -27,6 +45,17 @@ import type { Copy } from '../core/copy';
})
export class SiteFooter {
readonly copy = input.required<Copy>();
/** Every published page — the footer is where the ones not featured in the header are found. */
readonly pages = input<PublicPage[]>([]);
protected readonly links = computed(() =>
this.pages()
.filter((page) => page.path && page.title)
.map((page) => ({
href: page.path as string,
label: page.title as string,
})),
);
protected readonly year = new Date().getFullYear();
}

View File

@@ -20,9 +20,14 @@ import { AppearanceService } from '../core/appearance';
import type { Copy } from '../core/copy';
import type { Locale, SitePayload } from '../core/api';
/**
* A section of the landing (`anchor`) or another address altogether (`href`),
* such as one of the pages the panel publishes.
*/
export interface NavLink {
anchor: string;
label: string;
anchor?: string;
href?: string;
}
@Component({
@@ -37,7 +42,7 @@ export interface NavLink {
template: `
<header class="landing-header">
<div class="landing-shell landing-header__inner">
<a class="landing-brand" href="#top">
<a class="landing-brand" [href]="brandHref()">
<img
class="landing-brand__mark"
src="/logo.png"
@@ -50,8 +55,8 @@ export interface NavLink {
</a>
<nav class="landing-header__nav" aria-label="Principal">
@for (link of links(); track link.anchor) {
<a [href]="'#' + link.anchor">{{ link.label }}</a>
@for (link of links(); track link.label) {
<a [href]="hrefOf(link)">{{ link.label }}</a>
}
</nav>
@@ -102,8 +107,8 @@ export interface NavLink {
@if (open()) {
<nav id="mobile-nav" class="landing-header__mobile">
<div class="landing-shell">
@for (link of links(); track link.anchor) {
<a [href]="'#' + link.anchor" (click)="open.set(false)">{{
@for (link of links(); track link.label) {
<a [href]="hrefOf(link)" (click)="open.set(false)">{{
link.label
}}</a>
}
@@ -126,7 +131,7 @@ export interface NavLink {
<div class="landing-header__mobile-locales">
@for (option of locales(); track option.code) {
<a
[href]="'/?lang=' + option.code"
[href]="localeHref(option.code)"
[attr.hreflang]="option.code"
[attr.aria-current]="option.code === locale() ? 'true' : null"
[class.is-active]="option.code === locale()"
@@ -146,6 +151,15 @@ export class SiteHeader {
readonly locale = input.required<Locale>();
readonly links = input.required<NavLink[]>();
readonly locales = input.required<SitePayload['locales']>();
/** Where the logo leads: the top of the landing when on it, the landing itself elsewhere. */
readonly brandHref = input('#top');
/**
* The same page in each language, as the API's `alternates` spells it. Null
* on the landing, which answers on `?lang=` instead. A language missing from
* it has no translation of this page, so the switcher leads to the landing
* in that language rather than to a 404.
*/
readonly alternates = input<Partial<Record<string, string>> | null>(null);
readonly navigate = output<string>();
@@ -171,6 +185,14 @@ export class SiteHeader {
return;
}
this.document.defaultView?.location.assign(`/?lang=${next}`);
this.document.defaultView?.location.assign(this.localeHref(next));
}
protected localeHref(code: string): string {
return this.alternates()?.[code] ?? `/?lang=${code}`;
}
protected hrefOf(link: NavLink): string {
return link.href ?? `#${link.anchor}`;
}
}

View File

@@ -1,12 +1,14 @@
import { defineEventHandler, getRequestURL, setResponseStatus } from 'h3';
import { contentRoute } from '../../app/core/api';
/**
* Answers 404 for addresses this app does not serve.
*
* The status cannot be set from the catch-all page's `load`: Analog resolves
* loaders through an internal request, so anything set on that response never
* reaches the browser. Here it can be, and the route table is small enough to
* state outright — the landing is one page.
* state outright — the landing, plus the pages the panel publishes.
*/
const KNOWN_PATHS = new Set(['/', '/index.html']);
@@ -28,5 +30,13 @@ export default defineEventHandler((event) => {
return;
}
// Shaped like a content page (`/sobre-nosotros`, `/en/about-us`). Only the
// API knows whether it exists, and asking it here would be a second request
// for what the page asks anyway — so the page component sets the 404 itself
// when the API has no such page. See `ui/content-page.ts`.
if (contentRoute(pathname)) {
return;
}
setResponseStatus(event, 404);
});