From 80a1924519d2b2bdbb0dcaf18db3adb8c38fa11b Mon Sep 17 00:00:00 2001 From: pigui Date: Mon, 28 Sep 2026 09:55:10 +0200 Subject: [PATCH] Serve the pages the panel publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 22 +- landing-e2e/src/landing.spec.ts | 10 + landing/src/app/core/api.spec.ts | 41 ++- landing/src/app/core/api.ts | 63 +++- landing/src/app/core/content-page.ts | 56 ++++ landing/src/app/core/copy.ts | 18 +- landing/src/app/pages/(home).page.ts | 12 +- landing/src/app/pages/(home).server.ts | 6 + landing/src/app/pages/[locale]/[slug].page.ts | 18 ++ .../src/app/pages/[locale]/[slug].server.ts | 20 ++ landing/src/app/pages/[slug].page.ts | 18 ++ landing/src/app/pages/[slug].server.ts | 12 + landing/src/app/ui/content-page.scss | 117 ++++++++ landing/src/app/ui/content-page.ts | 271 ++++++++++++++++++ landing/src/app/ui/site-footer.scss | 15 + landing/src/app/ui/site-footer.ts | 31 +- landing/src/app/ui/site-header.ts | 38 ++- .../src/server/middleware/not-found-status.ts | 12 +- tools/mock-api.mjs | 14 + 19 files changed, 775 insertions(+), 19 deletions(-) create mode 100644 landing/src/app/core/content-page.ts create mode 100644 landing/src/app/pages/[locale]/[slug].page.ts create mode 100644 landing/src/app/pages/[locale]/[slug].server.ts create mode 100644 landing/src/app/pages/[slug].page.ts create mode 100644 landing/src/app/pages/[slug].server.ts create mode 100644 landing/src/app/ui/content-page.scss create mode 100644 landing/src/app/ui/content-page.ts diff --git a/README.md b/README.md index 4bba13e..e401dad 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,11 @@ landing/src/ core/copy.ts the chrome's own words, in es / en / ca core/icons.ts the icon set, inlined and registered as literals core/pi-strings.ts the strings `@pi/ui` translates on its own - pages/(home).server.ts one load function, ten parallel calls + core/content-page.ts the load function both content-page routes share + pages/(home).server.ts one load function, eleven parallel calls pages/(home).page.ts the landing itself + pages/[slug].page.ts a page the panel publishes, default language + pages/[locale]/[slug].page.ts the same, in any other language ui/ presentational components, one per payload type (thin wrappers over `@pi/ui`), each with its `.scss` server/ @@ -182,7 +185,10 @@ cached maintenance page outlives the maintenance. That status is set from the page component through `injectResponse()` (`@analogjs/router/tokens`), which is the *render* request's response; the one a page `load` sees belongs to the internal request Analog resolves loaders through and never reaches the browser. -That is also why the 404 status comes from middleware instead. +That is also why the 404 status comes from middleware instead — except for +addresses shaped like a content page, where only the API knows whether the page +exists: the middleware lets those through and `ui/content-page.ts` sets the 404 +itself from `ngOnInit`, once its input has arrived. **Empty sections disappear**, along with their entry in the nav — a young site shows a hero and a contact form rather than eight headings over nothing. @@ -196,6 +202,18 @@ page cached with `s-maxage` that varied on `Accept-Language` would hand the second visitor the wrong language anyway. The switcher in the header is three plain links, and `` follows. +**Content pages are the exception**, because the API decides their address: a +page's `path` and `alternates` put the default language unprefixed and every +other one under its code — `/sobre-nosotros`, `/en/about-us`, `/ca/qui-som` — +so those are the routes that answer. `core/api.ts#contentRoute` is the one rule +for that shape; `/es/…` is not one of them, so a page has one address rather +than two. The switcher on a page follows `alternates`, and a language the page +has no translation for leads to the landing in that language instead of a 404. +The same `alternates` become `hreflang` links, with a `canonical`. + +Featured pages go in the header after the section anchors; every page is in the +footer. + ## Rendering and caching Nothing is prerendered: the page is assembled from content the panel publishes diff --git a/landing-e2e/src/landing.spec.ts b/landing-e2e/src/landing.spec.ts index 38f7ed8..ba6ab55 100644 --- a/landing-e2e/src/landing.spec.ts +++ b/landing-e2e/src/landing.spec.ts @@ -43,6 +43,16 @@ test.describe('landing', () => { await expect(page.locator('h1')).toContainText('no existe'); }); + test('answers 404 for a page the API does not have', async ({ page }) => { + // Shaped like a content page, so the middleware lets it through and the + // status has to come from the page component, once the API said no. + const response = await page.goto('/en/no-such-page'); + + expect(response?.status()).toBe(404); + await expect(page.locator('h1')).toContainText('no existe'); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex'); + }); + test('refuses an incomplete contact submission without calling the API', async ({ page, }) => { diff --git a/landing/src/app/core/api.spec.ts b/landing/src/app/core/api.spec.ts index ac6e89d..20ce504 100644 --- a/landing/src/app/core/api.spec.ts +++ b/landing/src/app/core/api.spec.ts @@ -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'); + }); +}); diff --git a/landing/src/app/core/api.ts b/landing/src/app/core/api.ts index d57f599..f8f7c24 100644 --- a/landing/src/app/core/api.ts +++ b/landing/src/app/core/api.ts @@ -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 { 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(path: string, options: GetOptions = {}): Promise(path: string, options: GetOptions = {}): Promise list('/good-first-issues', { query }), + + pages: (lang: Locale, query?: GetOptions['query']) => + list('/pages', { lang, query }), + + /** By slug, which only resolves in the language it belongs to. */ + page: (lang: Locale, slug: string) => + get(`/pages/${encodeURIComponent(slug)}`, { lang }), } as const; /** POST bodies the public API accepts. Only two endpoints write. */ diff --git a/landing/src/app/core/content-page.ts b/landing/src/app/core/content-page.ts new file mode 100644 index 0000000..169e897 --- /dev/null +++ b/landing/src/app/core/content-page.ts @@ -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 { + 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, + })); +} diff --git a/landing/src/app/core/copy.ts b/landing/src/app/core/copy.ts index 2740a27..cb28fd9 100644 --- a/landing/src/app/core/copy.ts +++ b/landing/src/app/core/copy.ts @@ -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', diff --git a/landing/src/app/pages/(home).page.ts b/landing/src/app/pages/(home).page.ts index 9be28bd..c07a462 100644 --- a/landing/src/app/pages/(home).page.ts +++ b/landing/src/app/pages/(home).page.ts @@ -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 = { - + } `, }) @@ -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), + ]; }); /** diff --git a/landing/src/app/pages/(home).server.ts b/landing/src/app/pages/(home).server.ts index afdb3e8..bda3657 100644 --- a/landing/src/app/pages/(home).server.ts +++ b/landing/src/app/pages/(home).server.ts @@ -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 => { @@ -60,6 +63,7 @@ export const load = async ({ req }: PageServerLoad): Promise => { calls, issues, talks, + pages, ] = await Promise.all([ pireonApi.site(locale), pireonApi.services(locale), @@ -71,6 +75,7 @@ export const load = async ({ req }: PageServerLoad): Promise => { 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 => { calls: calls.data ?? [], issues: issues.data ?? [], talks: talks.data ?? [], + pages: pages.data ?? [], }; }; diff --git a/landing/src/app/pages/[locale]/[slug].page.ts b/landing/src/app/pages/[locale]/[slug].page.ts new file mode 100644 index 0000000..d10aadc --- /dev/null +++ b/landing/src/app/pages/[locale]/[slug].page.ts @@ -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: ``, +}) +export default class LocalisedContentPage { + protected readonly data = toSignal(injectLoad(), { + requireSync: true, + }); +} diff --git a/landing/src/app/pages/[locale]/[slug].server.ts b/landing/src/app/pages/[locale]/[slug].server.ts new file mode 100644 index 0000000..8b17c31 --- /dev/null +++ b/landing/src/app/pages/[locale]/[slug].server.ts @@ -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 => { + const route = contentRoute(`/${params?.['locale']}/${params?.['slug']}`); + + return route + ? loadContentPage(route.locale, route.slug) + : loadContentPage(DEFAULT_LOCALE, undefined); +}; diff --git a/landing/src/app/pages/[slug].page.ts b/landing/src/app/pages/[slug].page.ts new file mode 100644 index 0000000..336aec6 --- /dev/null +++ b/landing/src/app/pages/[slug].page.ts @@ -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: ``, +}) +export default class DefaultLocaleContentPage { + protected readonly data = toSignal(injectLoad(), { + requireSync: true, + }); +} diff --git a/landing/src/app/pages/[slug].server.ts b/landing/src/app/pages/[slug].server.ts new file mode 100644 index 0000000..2a340b9 --- /dev/null +++ b/landing/src/app/pages/[slug].server.ts @@ -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 => + loadContentPage(DEFAULT_LOCALE, params?.['slug']); diff --git a/landing/src/app/ui/content-page.scss b/landing/src/app/ui/content-page.scss new file mode 100644 index 0000000..e88f961 --- /dev/null +++ b/landing/src/app/ui/content-page.scss @@ -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 `` belongs to `app-api-image`, so a scoped rule cannot reach it. +.content-page__media ::ng-deep img { + display: block; + width: 100%; + height: auto; +} diff --git a/landing/src/app/ui/content-page.ts b/landing/src/app/ui/content-page.ts new file mode 100644 index 0000000..4f24eb8 --- /dev/null +++ b/landing/src/app/ui/content-page.ts @@ -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) { + + } @else if (data().page; as page) { + {{ copy().page.skip }} + + + +
+
+ @if (page.category?.name; as category) { +

{{ category }}

+ } + +

{{ page.title }}

+ + @if (page.excerpt) { +

+ {{ page.excerpt }} +

+ } + + @if (page.featured_image; as image) { +
+ +
+ } + +
+ + ← {{ copy().page.back }} +
+
+ + + } @else { + + } + `, +}) +export class ContentPage implements OnInit { + readonly data = input.required(); + + 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, + ): 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) => { + 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): 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; +} diff --git a/landing/src/app/ui/site-footer.scss b/landing/src/app/ui/site-footer.scss index a81f87a..8ff3642 100644 --- a/landing/src/app/ui/site-footer.scss +++ b/landing/src/app/ui/site-footer.scss @@ -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; + } + } +} diff --git a/landing/src/app/ui/site-footer.ts b/landing/src/app/ui/site-footer.ts index 688eae7..5a10eaf 100644 --- a/landing/src/app/ui/site-footer.ts +++ b/landing/src/app/ui/site-footer.ts @@ -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';