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