diff --git a/README.md b/README.md index e401dad..99b8308 100644 --- a/README.md +++ b/README.md @@ -150,11 +150,13 @@ 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 - core/content-page.ts the load function both content-page routes share + core/routes.ts which record an address belongs to (resolveRoute) + core/routed-page.ts the load function for every address but the landing 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 + pages/[...path].page.ts everything else: pages, projects, members, 404 + ui/page-shell.ts header, footer, lights, 404/503 and SEO around a record + ui/routed-page.ts picks the body for the record's type ui/ presentational components, one per payload type (thin wrappers over `@pi/ui`), each with its `.scss` server/ @@ -186,9 +188,13 @@ 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 — 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. +addresses shaped like a record, where only the API knows whether it exists: the +middleware lets those through and `ui/page-shell.ts` sets the 404 itself from +`ngOnInit`, once its input has arrived. + +**`@pi/ui`'s reset spaces every `li + li`** with a top margin — right for a +list of prose, wrong for a row of chips laid out with `gap`, where it dropped +every chip but the first. Lists spaced by `gap` reset it on their own `> li`. **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. @@ -202,14 +208,29 @@ 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`. +**Records are the exception**, because the API decides their address: a +record's `path` and `alternates` put the default language unprefixed and every +other one under its code, pages at the root and everything else under a section +named in that language: + +``` +/sobre-nosotros /en/about-us /ca/qui-som +/proyectos/catalogo /en/projects/catalogo /ca/projectes/catalogo +/equipo/ada /en/team/ada /ca/equip/ada +``` + +`core/routes.ts#resolveRoute` is that rule read backwards, and its `SECTIONS` +mirror the API's `config/site.php` (`paths`) — a change there has to be made +here too. `/es/…` is not a shape the API produces, so a record has one address +rather than two. The switcher on a record follows `alternates`, and a language +it has no translation for leads to the landing in that language instead of a +404. The same `alternates` become `hreflang` links, with a `canonical`. + +**One catch-all route** (`pages/[...path]`) serves every address but the +landing, and `resolveRoute` sorts the shapes out. Not one route per shape: +Analog registers each loader as an internal Nitro route, and Nitro's router +cannot hold two parameters of different names at the same depth — +`/:prefix/:slug` beside `/:prefix/:section/:slug` made the first unreachable. Featured pages go in the header after the section anchors; every page is in the footer. diff --git a/landing-e2e/src/landing.spec.ts b/landing-e2e/src/landing.spec.ts index ba6ab55..f921803 100644 --- a/landing-e2e/src/landing.spec.ts +++ b/landing-e2e/src/landing.spec.ts @@ -53,6 +53,23 @@ test.describe('landing', () => { await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex'); }); + test('answers 404 for a record the API does not have, in any section', async ({ page }) => { + for (const path of ['/proyectos/no-existe', '/en/team/nobody', '/ca/projectes/res']) { + const response = await page.goto(path); + + expect(response?.status(), path).toBe(404); + await expect(page.locator('h1')).toContainText('no existe'); + } + }); + + test('answers 404 for a section spelled in another language', async ({ page }) => { + // `/projects/x` is the English section without the English prefix: not an + // address the API ever hands out. + const response = await page.goto('/projects/anything'); + + expect(response?.status()).toBe(404); + }); + 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 20ce504..84d9689 100644 --- a/landing/src/app/core/api.spec.ts +++ b/landing/src/app/core/api.spec.ts @@ -1,12 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - contentRoute, - DEFAULT_LOCALE, - homeHref, - localeFromUrl, - resolveLocale, -} from './api'; +import { DEFAULT_LOCALE, localeFromUrl, resolveLocale } from './api'; describe('resolveLocale', () => { it('accepts the locales the API declares', () => { @@ -35,35 +29,3 @@ describe('localeFromUrl', () => { }); }); -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 f8f7c24..169751f 100644 --- a/landing/src/app/core/api.ts +++ b/landing/src/app/core/api.ts @@ -74,42 +74,6 @@ 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' @@ -262,6 +226,12 @@ export const pireonApi = { /** By slug, which only resolves in the language it belongs to. */ page: (lang: Locale, slug: string) => get(`/pages/${encodeURIComponent(slug)}`, { lang }), + + project: (lang: Locale, slug: string) => + get(`/projects/${encodeURIComponent(slug)}`, { lang }), + + member: (lang: Locale, slug: string) => + get(`/members/${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 deleted file mode 100644 index 169e897..0000000 --- a/landing/src/app/core/content-page.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * 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 44df53f..80c728e 100644 --- a/landing/src/app/core/copy.ts +++ b/landing/src/app/core/copy.ts @@ -91,6 +91,29 @@ export interface Copy { }; footer: { rights: string; pages: string }; page: { skip: string; related: string; ctaTitle: string; ctaLead: string }; + project: { + decisions: string; + postmortem: string; + gallery: string; + details: string; + status: string; + client: string; + period: string; + lead: string; + team: string; + repositories: string; + forks: string; + dormant: string; + archived: string; + openCalls: string; + technologies: string; + }; + member: { + projects: string; + technologies: string; + links: string; + since: string; + }; maintenance: { title: string; back: string; until: string }; } @@ -190,6 +213,29 @@ const es: Copy = { ctaTitle: '¿Hablamos?', ctaLead: 'Si algo de esto encaja con lo que buscas, escríbenos y te respondemos.', }, + project: { + decisions: 'Decisiones técnicas', + postmortem: 'Qué aprendimos', + gallery: 'Galería', + details: 'El proyecto', + status: 'Estado', + client: 'Para', + period: 'Fechas', + lead: 'Responsable', + team: 'Equipo', + repositories: 'Código', + forks: 'forks', + dormant: 'Sin actividad reciente', + archived: 'Archivado', + openCalls: 'Buscamos ayuda', + technologies: 'Tecnologías', + }, + member: { + projects: 'Proyectos', + technologies: 'Tecnologías', + links: 'Enlaces', + since: 'En el colectivo desde', + }, maintenance: { title: 'Estamos de mantenimiento', back: 'Volvemos en un rato.', @@ -293,6 +339,29 @@ const en: Copy = { ctaTitle: 'Shall we talk?', ctaLead: 'If any of this fits what you are looking for, write to us and we will get back to you.', }, + project: { + decisions: 'Technical decisions', + postmortem: 'What we learned', + gallery: 'Gallery', + details: 'The project', + status: 'Status', + client: 'For', + period: 'Dates', + lead: 'Lead', + team: 'Team', + repositories: 'Code', + forks: 'forks', + dormant: 'No recent activity', + archived: 'Archived', + openCalls: 'Looking for help', + technologies: 'Technologies', + }, + member: { + projects: 'Projects', + technologies: 'Technologies', + links: 'Links', + since: 'In the collective since', + }, maintenance: { title: 'We are under maintenance', back: 'Back in a while.', @@ -396,6 +465,29 @@ const ca: Copy = { ctaTitle: 'En parlem?', ctaLead: "Si alguna cosa d'aquestes encaixa amb el que busques, escriu-nos i et respondrem.", }, + project: { + decisions: 'Decisions tècniques', + postmortem: 'Què vam aprendre', + gallery: 'Galeria', + details: 'El projecte', + status: 'Estat', + client: 'Per a', + period: 'Dates', + lead: 'Responsable', + team: 'Equip', + repositories: 'Codi', + forks: 'forks', + dormant: 'Sense activitat recent', + archived: 'Arxivat', + openCalls: 'Busquem ajuda', + technologies: 'Tecnologies', + }, + member: { + projects: 'Projectes', + technologies: 'Tecnologies', + links: 'Enllaços', + since: 'Al col·lectiu des de', + }, maintenance: { title: 'Estem de manteniment', back: 'Tornem d’aquí una estona.', diff --git a/landing/src/app/core/routed-page.ts b/landing/src/app/core/routed-page.ts new file mode 100644 index 0000000..952bf19 --- /dev/null +++ b/landing/src/app/core/routed-page.ts @@ -0,0 +1,89 @@ +/** + * What a page reached by its address renders — a content page, a project, a + * member — fetched on the server in one pass. + * + * Shared by the routes that differ only in how many segments the address has + * (`/sobre-nosotros`, `/proyectos/x`, `/en/projects/x`), whose `.server.ts` + * files exist because Analog needs one next to each `.page.ts`. + */ +import { + pireonApi, + DEFAULT_LOCALE, + type Locale, + type MaintenanceNotice, + type SitePayload, +} from './api'; +import type { PublicMember, PublicPage, PublicProject } from './api.types'; +import { resolveRoute, type RouteMatch } from './routes'; + +/** The record, tagged with its type so the template can switch on it. */ +export type RoutedRecord = + | { type: 'page'; record: PublicPage } + | { type: 'project'; record: PublicProject } + | { type: 'member'; record: PublicMember }; + +export interface RoutedPageData { + locale: Locale; + site: SitePayload | null; + maintenance: MaintenanceNotice | null; + /** Null when there is nothing at this address: the route answers 404. */ + found: RoutedRecord | null; + /** Every published page, for the header and the footer. */ + pages: PublicPage[]; +} + +export async function loadRoutedPage( + pathname: string, +): Promise { + const route = resolveRoute(pathname); + const locale = route?.locale ?? DEFAULT_LOCALE; + + const [site, found, pages] = await Promise.all([ + pireonApi.site(locale), + route ? fetchRecord(route) : Promise.resolve(null), + pireonApi.pages(locale, { per_page: 100 }), + ]); + + return { + locale, + site: site.data, + maintenance: site.maintenance, + found, + pages: pages.data ?? [], + }; +} + +async function fetchRecord(route: RouteMatch): Promise { + const { locale, slug } = route; + + switch (route.type) { + case 'page': { + const { data } = await pireonApi.page(locale, slug); + return data ? { type: 'page', record: data } : null; + } + case 'project': { + const { data } = await pireonApi.project(locale, slug); + return data ? { type: 'project', record: data } : null; + } + case 'member': { + const { data } = await pireonApi.member(locale, slug); + return data ? { type: 'member', record: data } : null; + } + default: + // A type the site has no page for yet: an address the API hands out + // but nothing here renders, which is a 404 until it does. + return null; + } +} + +/** 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/routes.spec.ts b/landing/src/app/core/routes.spec.ts new file mode 100644 index 0000000..80c124e --- /dev/null +++ b/landing/src/app/core/routes.spec.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from './api'; +import { homeHref, resolveRoute, SECTIONS } from './routes'; + +describe('resolveRoute', () => { + it('reads a page at the root as the default language', () => { + expect(resolveRoute('/sobre-nosotros')).toEqual({ + type: 'page', + locale: DEFAULT_LOCALE, + slug: 'sobre-nosotros', + }); + }); + + it('reads a prefixed page in the language it names', () => { + expect(resolveRoute('/en/about-us')).toEqual({ + type: 'page', + locale: 'en', + slug: 'about-us', + }); + expect(resolveRoute('/ca/qui-som/')).toEqual({ + type: 'page', + locale: 'ca', + slug: 'qui-som', + }); + }); + + it('reads a record under its section, in each language', () => { + expect(resolveRoute('/proyectos/catalogo')).toEqual({ + type: 'project', + locale: 'es', + slug: 'catalogo', + }); + expect(resolveRoute('/en/projects/catalogo')).toEqual({ + type: 'project', + locale: 'en', + slug: 'catalogo', + }); + expect(resolveRoute('/ca/equip/ada')).toEqual({ + type: 'member', + locale: 'ca', + slug: 'ada', + }); + expect(resolveRoute('/ca/col-labora/frontend')).toEqual({ + type: 'call', + locale: 'ca', + slug: 'frontend', + }); + }); + + // A section only means something in its own language: `/projects/x` is not + // an address the API hands out for Spanish. + it('refuses a section spelled in another language', () => { + expect(resolveRoute('/projects/catalogo')).toBeNull(); + expect(resolveRoute('/en/proyectos/catalogo')).toBeNull(); + }); + + // The API never spells the default language with a prefix, so one record + // has one address rather than two. + it('refuses the default language as a prefix', () => { + expect(resolveRoute('/es/sobre-nosotros')).toBeNull(); + expect(resolveRoute('/es/proyectos/catalogo')).toBeNull(); + }); + + it('refuses what is not shaped like a record', () => { + expect(resolveRoute('/')).toBeNull(); + expect(resolveRoute('/fr/a-propos')).toBeNull(); + expect(resolveRoute('/proyectos/catalogo/extra')).toBeNull(); + }); + + it('knows every section in every language', () => { + for (const sections of Object.values(SECTIONS)) { + expect(Object.keys(sections).sort()).toEqual( + [...SUPPORTED_LOCALES].sort(), + ); + } + }); +}); + +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/routes.ts b/landing/src/app/core/routes.ts new file mode 100644 index 0000000..123e17d --- /dev/null +++ b/landing/src/app/core/routes.ts @@ -0,0 +1,71 @@ +/** + * Which record an address on this site belongs to. + * + * The API decides every record's address — it sends `path` and `alternates` + * with each one — and this is the same rule read backwards. The default + * language has no prefix, the others their code; pages sit at the root, every + * other type under a section named in the visitor's language: + * + * /sobre-nosotros /en/about-us /ca/qui-som + * /proyectos/catalogo /en/projects/catalogo /ca/projectes/catalogo + * + * Only the shape is known here. Whether the record exists is the API's answer. + */ +import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api'; + +export type RecordType = 'page' | 'project' | 'member' | 'call' | 'service'; + +/** + * The section each type lives under, per language. The API's own + * `config/site.php` (`paths`) is the source; a change there has to be made here + * too, or the API will hand out addresses this site answers 404 on. + */ +export const SECTIONS: Record< + Exclude, + Record +> = { + project: { es: 'proyectos', en: 'projects', ca: 'projectes' }, + member: { es: 'equipo', en: 'team', ca: 'equip' }, + call: { es: 'colabora', en: 'contribute', ca: 'col-labora' }, + service: { es: 'servicios', en: 'services', ca: 'serveis' }, +}; + +export interface RouteMatch { + type: RecordType; + locale: Locale; + slug: string; +} + +/** + * A prefix naming the default language (`/es/…`) is not a shape the API ever + * produces, so it is not one this answers on either — one record, one address. + */ +export function resolveRoute(pathname: string): RouteMatch | null { + const segments = pathname.replace(/\/+$/, '').split('/').filter(Boolean); + const [first] = segments; + + const prefixed = + first !== DEFAULT_LOCALE && SUPPORTED_LOCALES.includes(first as Locale); + + const locale = prefixed ? (first as Locale) : DEFAULT_LOCALE; + const rest = prefixed ? segments.slice(1) : segments; + + if (rest.length === 1) { + return { type: 'page', locale, slug: rest[0] }; + } + + if (rest.length === 2) { + const type = (Object.keys(SECTIONS) as (keyof typeof SECTIONS)[]).find( + (candidate) => SECTIONS[candidate][locale] === rest[0], + ); + + return type ? { type, locale, slug: rest[1] } : null; + } + + 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}`; +} diff --git a/landing/src/app/pages/(home).page.ts b/landing/src/app/pages/(home).page.ts index c07a462..605c104 100644 --- a/landing/src/app/pages/(home).page.ts +++ b/landing/src/app/pages/(home).page.ts @@ -17,7 +17,7 @@ import { PiChipComponent, } from '@pi/ui'; -import { pageNavLinks } from '../core/content-page'; +import { pageNavLinks } from '../core/routed-page'; import { copyFor } from '../core/copy'; import { CallCard } from '../ui/call-card'; import { ClientLogo } from '../ui/client-logo'; diff --git a/landing/src/app/pages/[slug].page.ts b/landing/src/app/pages/[...path].page.ts similarity index 56% rename from landing/src/app/pages/[slug].page.ts rename to landing/src/app/pages/[...path].page.ts index 336aec6..760e637 100644 --- a/landing/src/app/pages/[slug].page.ts +++ b/landing/src/app/pages/[...path].page.ts @@ -2,16 +2,16 @@ 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'; +import { RoutedPage } from '../ui/routed-page'; +import { load } from './[...path].server'; @Component({ - selector: 'app-content-page-default', + selector: 'app-route-path', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ContentPage], - template: ``, + imports: [RoutedPage], + template: ``, }) -export default class DefaultLocaleContentPage { +export default class PathRoute { protected readonly data = toSignal(injectLoad(), { requireSync: true, }); diff --git a/landing/src/app/pages/[...path].server.ts b/landing/src/app/pages/[...path].server.ts new file mode 100644 index 0000000..21598ce --- /dev/null +++ b/landing/src/app/pages/[...path].server.ts @@ -0,0 +1,18 @@ +/** + * Every address but the landing's: a page (`/sobre-nosotros`), a record + * (`/proyectos/x`, `/en/team/x`), or nothing at all. + * + * One catch-all rather than a route per shape. Analog registers each loader as + * an internal route, and Nitro's router cannot hold two parameters of + * different names at the same depth — `/:prefix/:slug` beside + * `/:prefix/:section/:slug` left the first unreachable. `resolveRoute` sorts + * the shapes out instead, and an address it does not recognise loads as a 404. + */ +import type { PageServerLoad } from '@analogjs/router'; + +import { loadRoutedPage, type RoutedPageData } from '../core/routed-page'; + +export const load = async ({ + params, +}: PageServerLoad): Promise => + loadRoutedPage(`/${params?.['path'] ?? ''}`); diff --git a/landing/src/app/pages/[locale]/[slug].page.ts b/landing/src/app/pages/[locale]/[slug].page.ts deleted file mode 100644 index d10aadc..0000000 --- a/landing/src/app/pages/[locale]/[slug].page.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 8b17c31..0000000 --- a/landing/src/app/pages/[locale]/[slug].server.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * 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].server.ts b/landing/src/app/pages/[slug].server.ts deleted file mode 100644 index 2a340b9..0000000 --- a/landing/src/app/pages/[slug].server.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * 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/_detail.scss b/landing/src/app/ui/_detail.scss new file mode 100644 index 0000000..28d50eb --- /dev/null +++ b/landing/src/app/ui/_detail.scss @@ -0,0 +1,290 @@ +// The layout every page reached by its address shares — a content page, a +// project, a member — so moving between them reads as one site. +// +// A partial each body `@use`s rather than a global sheet: component styles are +// scoped, and each body gets its own scoped copy of these rules. + +// The top of the page is the landing's hero, minus the stats and buttons: the +// same left edge as the logo, the same heading weight. +.content-hero { + padding-block: 5rem 2.5rem; + + @media (min-width: 768px) { + padding-block: 7rem 3.5rem; + } +} + +.content-hero__title { + margin: 0; + max-width: 22ch; + font-size: clamp(2.25rem, 5.5vw, 3.5rem); + line-height: 1.08; + letter-spacing: -0.02em; +} + +.content-hero__lead { + max-width: 46ch; + margin: 1.5rem 0 0; +} + +// Reading column and a side column, on the landing's own grid width. The +// column keeps prose to a readable measure; the side keeps the page from +// ending in a dead end. +.content-page__layout { + display: grid; + gap: 3rem; + padding-bottom: 5rem; + + @media (min-width: 1024px) { + grid-template-columns: minmax(0, 46rem) minmax(16rem, 20rem); + justify-content: space-between; + gap: 4rem; + padding-bottom: 7rem; + } +} + +.content-page__media { + margin: 0 0 2.5rem; + overflow: hidden; + border-radius: var(--pi-theme-card-radius, 1.5rem); +} + +// 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; +} + +.content-page__body { + font-size: 1.0625rem; + + // 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 { + // Bold, like every heading on the landing — a light heading here was the + // one thing that made the page read as another site. + h2 { + margin: 3rem 0 1rem; + font-size: clamp(1.5rem, 3vw, 1.875rem); + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.015em; + } + + h3 { + margin: 2rem 0 0.75rem; + font-size: 1.25rem; + font-weight: 700; + } + + // 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.5rem; + padding-left: 1.25rem; + } + + li { + margin: 0 0 0.5rem; + + &::marker { + color: var(--pi-color-primary); + } + } + + strong { + font-weight: 700; + } + + a { + color: var(--pi-color-primary); + } + + > :first-child { + margin-top: 0; + } + } +} + +.content-page__aside { + display: flex; + flex-direction: column; + gap: 2rem; + + // Stays in view beside a long page, under the sticky header. + @media (min-width: 1024px) { + position: sticky; + top: 6rem; + align-self: start; + } +} + +.content-page__related { + display: grid; + gap: 1rem; + margin: 0; + padding: 0; + list-style: none; +} + +// `landing-card` is the landing's card surface and hover, drawn on a link +// rather than on `pi-card`, which brings no padding or radius of its own here. +.content-page__card, +.content-page__cta { + display: block; + padding: 1.25rem 1.5rem; + border-radius: var(--pi-theme-card-radius, 1.5rem); + box-shadow: var(--pi-theme-card-shadow, none); +} + +.content-page__card { + color: inherit; + text-decoration: none; +} + +.content-page__card-title { + display: block; + font-size: 1.0625rem; + font-weight: 700; +} + +.content-page__card-text { + display: -webkit-box; + margin-top: 0.375rem; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + font-size: 0.875rem; + line-height: 1.5; + // Relative to the card's own text colour, so it holds up in dark mode too, + // where the grey token is nearly the background. + color: color-mix(in srgb, currentColor 68%, transparent); +} + +.content-page__cta { + padding: 1.5rem; + + // The call to action is not a link as a whole, so it does not lift. + &:hover { + transform: none; + } + + p { + margin: 0.5rem 0 1.25rem; + line-height: 1.6; + } +} + +.content-page__cta-title { + margin: 0; + font-size: 1.375rem; + font-weight: 700; +} + +// Hero actions — a demo, a repository — and the chips under them. +.content-hero__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 2rem; + + a { + text-decoration: none; + } +} + +.content-hero__chips { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin: 1.5rem 0 0; + padding: 0; + list-style: none; +} + +// A section inside the reading column, under its own bold heading. +.content-page__section { + margin-top: 3.5rem; + + > h2 { + margin: 0 0 1.25rem; + font-size: clamp(1.5rem, 3vw, 1.875rem); + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.015em; + } +} + +.content-page__main > :first-child { + margin-top: 0; +} + +// A card in the side column that holds facts rather than a link. +.content-page__panel { + display: block; + padding: 1.25rem 1.5rem; + border-radius: var(--pi-theme-card-radius, 1.5rem); + box-shadow: var(--pi-theme-card-shadow, none); + + &:hover { + transform: none; + } + + h2 { + margin: 0 0 1rem; + font-size: 1.0625rem; + font-weight: 700; + } +} + +// Label and value, one pair a row. +.content-page__facts { + display: grid; + gap: 0.875rem; + margin: 0; + + dt { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: color-mix(in srgb, currentColor 60%, transparent); + } + + dd { + margin: 0.125rem 0 0; + } + + a { + color: var(--pi-color-primary); + } +} + +.content-page__muted { + color: color-mix(in srgb, currentColor 68%, transparent); +} + +// Lists this layout spaces with `gap`. `@pi/ui`'s reset adds a top margin to +// every `li + li`, which suits a list of prose and staggers everything else. +.content-hero__chips, +.content-page__related { + > li { + margin: 0; + } +} + +.content-hero__chips { + align-items: center; +} diff --git a/landing/src/app/ui/contact-cta.ts b/landing/src/app/ui/contact-cta.ts new file mode 100644 index 0000000..b223af0 --- /dev/null +++ b/landing/src/app/ui/contact-cta.ts @@ -0,0 +1,72 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { PiActionButtonComponent, PiTextColorDirective } from '@pi/ui'; + +import type { Locale } from '../core/api'; +import type { Copy } from '../core/copy'; +import { homeHref } from '../core/routes'; + +/** + * "Shall we talk?" at the end of a page's side column, so no page is a dead + * end. The form lives on the landing; this leads to it. + */ +@Component({ + selector: 'app-contact-cta', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PiActionButtonComponent, PiTextColorDirective], + template: ` +
+

{{ copy().page.ctaTitle }}

+

{{ copy().page.ctaLead }}

+ + + + +
+ `, + styles: ` + .contact-cta { + display: block; + padding: 1.5rem; + border-radius: var(--pi-theme-card-radius, 1.5rem); + box-shadow: var(--pi-theme-card-shadow, none); + + &:hover { + transform: none; + } + + p { + margin: 0.5rem 0 1.25rem; + line-height: 1.6; + } + } + + .contact-cta__title { + margin: 0; + font-size: 1.375rem; + font-weight: 700; + } + + .contact-cta__link { + display: inline-block; + text-decoration: none; + } + `, +}) +export class ContactCta { + readonly copy = input.required(); + readonly locale = input.required(); + + protected readonly href = computed( + () => `${homeHref(this.locale())}#contacto`, + ); +} diff --git a/landing/src/app/ui/content-page.scss b/landing/src/app/ui/content-page.scss index e0a980d..0db1d26 100644 --- a/landing/src/app/ui/content-page.scss +++ b/landing/src/app/ui/content-page.scss @@ -1,215 +1,5 @@ -@use './lights'; +@use './detail'; :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; - } -} - -// The lights sit on the whole page rather than on its top: on the hero alone -// they end in a hard edge where it does, and here the text starts right there. -// A page is tall enough for them to fade out on their own. -.content-page { - @include lights.glow($overflow: clip); -} - -// The top of the page is the landing's hero, minus the stats and buttons: the -// same left edge as the logo, the same heading weight. -.content-hero { - padding-block: 5rem 2.5rem; - - @media (min-width: 768px) { - padding-block: 7rem 3.5rem; - } -} - -.content-hero__title { - margin: 0; - max-width: 22ch; - font-size: clamp(2.25rem, 5.5vw, 3.5rem); - line-height: 1.08; - letter-spacing: -0.02em; -} - -.content-hero__lead { - max-width: 46ch; - margin: 1.5rem 0 0; -} - -// Reading column and a side column, on the landing's own grid width. The -// column keeps prose to a readable measure; the side keeps the page from -// ending in a dead end. -.content-page__layout { - display: grid; - gap: 3rem; - padding-bottom: 5rem; - - @media (min-width: 1024px) { - grid-template-columns: minmax(0, 46rem) minmax(16rem, 20rem); - justify-content: space-between; - gap: 4rem; - padding-bottom: 7rem; - } -} - -.content-page__media { - margin: 0 0 2.5rem; - overflow: hidden; - border-radius: var(--pi-theme-card-radius, 1.5rem); -} - -// 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; -} - -.content-page__body { - font-size: 1.0625rem; - - // 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 { - // Bold, like every heading on the landing — a light heading here was the - // one thing that made the page read as another site. - h2 { - margin: 3rem 0 1rem; - font-size: clamp(1.5rem, 3vw, 1.875rem); - font-weight: 700; - line-height: 1.2; - letter-spacing: -0.015em; - } - - h3 { - margin: 2rem 0 0.75rem; - font-size: 1.25rem; - font-weight: 700; - } - - // 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.5rem; - padding-left: 1.25rem; - } - - li { - margin-bottom: 0.5rem; - - &::marker { - color: var(--pi-color-primary); - } - } - - strong { - font-weight: 700; - } - - a { - color: var(--pi-color-primary); - } - - > :first-child { - margin-top: 0; - } - } -} - -.content-page__aside { - display: flex; - flex-direction: column; - gap: 2rem; - - // Stays in view beside a long page, under the sticky header. - @media (min-width: 1024px) { - position: sticky; - top: 6rem; - align-self: start; - } -} - -.content-page__related { - display: grid; - gap: 1rem; - margin: 0; - padding: 0; - list-style: none; -} - -// `landing-card` is the landing's card surface and hover, drawn on a link -// rather than on `pi-card`, which brings no padding or radius of its own here. -.content-page__card, -.content-page__cta { - display: block; - padding: 1.25rem 1.5rem; - border-radius: var(--pi-theme-card-radius, 1.5rem); - box-shadow: var(--pi-theme-card-shadow, none); -} - -.content-page__card { - color: inherit; - text-decoration: none; -} - -.content-page__card-title { - display: block; - font-size: 1.0625rem; - font-weight: 700; -} - -.content-page__card-text { - display: -webkit-box; - margin-top: 0.375rem; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - font-size: 0.875rem; - line-height: 1.5; - // Relative to the card's own text colour, so it holds up in dark mode too, - // where the grey token is nearly the background. - color: color-mix(in srgb, currentColor 68%, transparent); -} - -.content-page__cta { - padding: 1.5rem; - - // The call to action is not a link as a whole, so it does not lift. - &:hover { - transform: none; - } - - p { - margin: 0.5rem 0 1.25rem; - line-height: 1.6; - } -} - -.content-page__cta-title { - margin: 0; - font-size: 1.375rem; - font-weight: 700; -} diff --git a/landing/src/app/ui/content-page.ts b/landing/src/app/ui/content-page.ts index 88f3436..45ced04 100644 --- a/landing/src/app/ui/content-page.ts +++ b/landing/src/app/ui/content-page.ts @@ -1,34 +1,23 @@ -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 { - PiActionButtonComponent, 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 type { Locale } from '../core/api'; +import type { PublicPage } from '../core/api.types'; +import type { Copy } from '../core/copy'; import { ApiImage } from './api-image'; -import { MaintenanceScreen } from './maintenance-screen'; -import { SiteFooter } from './site-footer'; -import { SiteHeader } from './site-header'; +import { ContactCta } from './contact-cta'; /** - * One of the pages the panel publishes — "Sobre nosotros", "Contacto" — - * rendered on its own address. + * One of the pages the panel publishes — "Sobre nosotros", "Contacto". * * 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 @@ -40,294 +29,94 @@ import { SiteHeader } from './site-header'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ApiImage, - MaintenanceScreen, - NotFoundPage, - PiActionButtonComponent, + ContactCta, 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.category?.name; as category) { -

{{ category }}

- } + @if (page().excerpt; as excerpt) { +

+ {{ excerpt }} +

+ } +
+
-

{{ page.title }}

+
+
+ @if (page().featured_image; as image) { +
+ +
+ } - @if (page.excerpt) { -

- {{ page.excerpt }} -

- } -
-
+
+ -
- - - -
- - - - } @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)); +export class ContentPage { + readonly page = input.required(); + readonly pages = input.required(); + readonly copy = input.required(); + readonly locale = input.required(); /** * Somewhere to go next, so a page is not a dead end: the rest of its * category first, then anything else, three at most. */ protected readonly related = computed(() => { - const { page, pages } = this.data(); - const others = pages.filter( - (other) => other.id !== page?.id && other.path && other.title, + const page = this.page(); + const others = this.pages().filter( + (other) => other.id !== page.id && other.path && other.title, ); - const sameCategory = (other: (typeof pages)[number]) => - other.category?.id === page?.category?.id; + const sameCategory = (other: PublicPage) => + other.category?.id === page.category?.id; return [ ...others.filter(sameCategory), ...others.filter((other) => !sameCategory(other)), ].slice(0, 3); }); - - 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/member-card.ts b/landing/src/app/ui/member-card.ts index efad36f..f252e70 100644 --- a/landing/src/app/ui/member-card.ts +++ b/landing/src/app/ui/member-card.ts @@ -54,7 +54,13 @@ import type { PublicMember } from '../core/api.types'; -

{{ member().name }}

+

+ @if (member().path; as path) { + {{ member().name }} + } @else { + {{ member().name }} + } +

@if (member().role || member().headline; as subtitle) {

{{ subtitle }}

} @@ -93,22 +99,24 @@ export class MemberCard { readonly member = input.required(); readonly copy = input.required(); - protected readonly links = computed(() => { - const member = this.member(); - const entries = Object.entries(member.links ?? {}).map(([label, url]) => ({ - label, - url, - })); - - // The username is handed over separately, so build the URL rather than - // hope somebody also filled the links map in. - if (member.github_username && !entries.some((link) => link.label === 'github')) { - entries.unshift({ - label: 'github', - url: `https://github.com/${member.github_username}`, - }); - } - - return entries.slice(0, 3); - }); + protected readonly links = computed(() => memberLinks(this.member()).slice(0, 3)); +} + +/** A member's links, GitHub first when there is a username for it. */ +export function memberLinks(member: PublicMember): { label: string; url: string }[] { + const entries = Object.entries(member.links ?? {}).map(([label, url]) => ({ + label, + url, + })); + + // The username is handed over separately, so build the URL rather than + // hope somebody also filled the links map in. + if (member.github_username && !entries.some((link) => link.label === 'github')) { + entries.unshift({ + label: 'github', + url: `https://github.com/${member.github_username}`, + }); + } + + return entries; } diff --git a/landing/src/app/ui/member-detail.scss b/landing/src/app/ui/member-detail.scss new file mode 100644 index 0000000..8f98702 --- /dev/null +++ b/landing/src/app/ui/member-detail.scss @@ -0,0 +1,54 @@ +@use './detail'; + +:host { + display: block; +} + +.member-hero { + display: flex; + flex-direction: column; + gap: 1.5rem; + + @media (min-width: 640px) { + flex-direction: row; + align-items: center; + gap: 2rem; + } +} + +// A textarea's line breaks are the writer's paragraphs. +.member-bio { + margin: 0; + font-size: 1.0625rem; + line-height: 1.7; + white-space: pre-line; +} + +.member-projects { + display: grid; + gap: 1.25rem; + + @media (min-width: 640px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.member-links { + display: grid; + gap: 0.25rem; + margin: 0; + padding: 0; + list-style: none; + text-transform: capitalize; +} + +.member-technologies { + margin-top: 0; +} + +// `@pi/ui`'s reset spaces every `li + li`; these lists are spaced by `gap`. +.member-links { + > li { + margin: 0; + } +} diff --git a/landing/src/app/ui/member-detail.ts b/landing/src/app/ui/member-detail.ts new file mode 100644 index 0000000..757dae0 --- /dev/null +++ b/landing/src/app/ui/member-detail.ts @@ -0,0 +1,182 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { + PiAvatarComponent, + PiChipComponent, + PiTextColorDirective, + PiTextLargeDirective, + PiTextWeightBoldDirective, +} from '@pi/ui'; + +import type { Locale } from '../core/api'; +import type { PublicMember } from '../core/api.types'; +import type { Copy } from '../core/copy'; +import { ContactCta } from './contact-cta'; +import { memberLinks } from './member-card'; +import { ProjectCard } from './project-card'; + +/** + * Someone in the collective, on their own address: who they are, what they + * work with, and what they have worked on. + * + * The bio is plain text from a textarea, not HTML, so it is printed as text + * with its line breaks kept rather than handed to `innerHTML`. + */ +@Component({ + selector: 'app-member-detail', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ContactCta, + PiAvatarComponent, + PiChipComponent, + PiTextColorDirective, + PiTextLargeDirective, + PiTextWeightBoldDirective, + ProjectCard, + ], + styleUrl: './member-detail.scss', + template: ` +
+
+ + +
+

{{ copy().nav.team }}

+

{{ member().name }}

+ + @if (member().headline; as headline) { +

+ {{ headline }} +

+ } + + @if (member().is_founder || !member().is_current) { +
    + @if (member().is_founder) { +
  • + +
  • + } + @if (!member().is_current) { +
  • + +
  • + } +
+ } +
+
+
+ +
+
+ @if (member().bio; as bio) { +

{{ bio }}

+ } + + @if ((member().projects ?? []).length > 0) { +
+

{{ copy().member.projects }}

+
+ @for (project of member().projects; track project.id) { + + } +
+
+ } +
+ + +
+ `, +}) +export class MemberDetail { + readonly member = input.required(); + readonly copy = input.required(); + readonly locale = input.required(); + + protected readonly links = computed(() => memberLinks(this.member())); + + protected readonly since = computed(() => { + const joined = this.member().joined_at; + + return joined + ? new Intl.DateTimeFormat(this.locale(), { + month: 'long', + year: 'numeric', + }).format(new Date(joined)) + : null; + }); +} diff --git a/landing/src/app/pages/not-found.page.scss b/landing/src/app/ui/not-found.scss similarity index 100% rename from landing/src/app/pages/not-found.page.scss rename to landing/src/app/ui/not-found.scss diff --git a/landing/src/app/pages/[...not-found].page.ts b/landing/src/app/ui/not-found.ts similarity index 79% rename from landing/src/app/pages/[...not-found].page.ts rename to landing/src/app/ui/not-found.ts index d4f5851..b301b4a 100644 --- a/landing/src/app/pages/[...not-found].page.ts +++ b/landing/src/app/ui/not-found.ts @@ -1,5 +1,4 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; -import type { RouteMeta } from '@analogjs/router'; import { PiActionButtonComponent, PiIconComponent, @@ -10,15 +9,10 @@ import { PiTextXLargeDirective, } from '@pi/ui'; -export const routeMeta: RouteMeta = { - title: 'Pireon — Página no encontrada', - meta: [{ name: 'robots', content: 'noindex' }], -}; - /** - * The status itself comes from `src/server/middleware/not-found-status.ts`: - * a page `load` cannot set it, because Analog resolves loaders through an - * internal request whose response never reaches the browser. + * What an address with nothing at it shows. Rendered by `PageShell`, which + * also sets the 404 status, the title and `noindex` — the middleware answers + * 404 up front for addresses no record could ever live at. * * Composed from the library's parts rather than from `pi-empty-state`, which * would have been the obvious fit: it renders its title as an `

`, and a @@ -39,7 +33,7 @@ export const routeMeta: RouteMeta = { PiTextColorDirective, PiTextWeightBoldDirective, ], - styleUrl: './not-found.page.scss', + styleUrl: './not-found.scss', template: `
@@ -69,4 +63,4 @@ export const routeMeta: RouteMeta = {
`, }) -export default class NotFoundPage {} +export class NotFound {} diff --git a/landing/src/app/ui/page-shell.scss b/landing/src/app/ui/page-shell.scss new file mode 100644 index 0000000..935267b --- /dev/null +++ b/landing/src/app/ui/page-shell.scss @@ -0,0 +1,26 @@ +@use './lights'; + +: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; + } +} + +// The lights sit on the whole page rather than on its top: on the hero alone +// they end in a hard edge where it does, and here the text starts right there. +// A page is tall enough for them to fade out on their own. +.content-page { + @include lights.glow($overflow: clip); +} diff --git a/landing/src/app/ui/page-shell.ts b/landing/src/app/ui/page-shell.ts new file mode 100644 index 0000000..5bd9ff6 --- /dev/null +++ b/landing/src/app/ui/page-shell.ts @@ -0,0 +1,238 @@ +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 type { Alternates } from '../core/api.types'; +import { copyFor } from '../core/copy'; +import { pageNavLinks, type RoutedPageData } from '../core/routed-page'; +import { homeHref } from '../core/routes'; +import { MaintenanceScreen } from './maintenance-screen'; +import { NotFound } from './not-found'; +import { SiteFooter } from './site-footer'; +import { SiteHeader } from './site-header'; + +/** What the document head says about the record on the page. */ +export interface PageSeo { + title: string; + description: string; + path?: string; + alternates: Alternates; + type: 'article' | 'profile' | 'website'; +} + +/** + * Everything around a page reached by its address: header, footer, the + * landing's lights, and the answers that are not a page at all — maintenance + * and 404. + * + * The record itself is projected in by `RoutedPage`, which knows its type. + * `seo` doubles as "was anything found": null is a 404. + */ +@Component({ + selector: 'app-page-shell', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MaintenanceScreen, NotFound, SiteFooter, SiteHeader], + styleUrl: './page-shell.scss', + template: ` + @if (data().maintenance; as notice) { + + } @else if (seo(); as seo) { + {{ copy().page.skip }} + + + +
+ +
+ + + } @else { + + } + `, +}) +export class PageShell implements OnInit { + readonly data = input.required(); + readonly seo = 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 inputs have 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 { locale, maintenance } = this.data(); + const seo = this.seo(); + + 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 (!seo) { + this.title.setTitle('Pireon — Página no encontrada'); + this.meta.updateTag({ name: 'robots', content: 'noindex' }); + + return; + } + + this.title.setTitle(seo.title); + this.meta.updateTag({ name: 'description', content: seo.description }); + this.meta.updateTag({ property: 'og:title', content: seo.title }); + this.meta.updateTag({ + property: 'og:description', + content: seo.description, + }); + this.meta.updateTag({ property: 'og:type', content: seo.type }); + + this.linkAlternates(seo.path, seo.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 when the API has nothing at this address 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 record-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 } = 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 (!this.seo()) { + response.statusCode = 404; + } + } + + /** + * Only this side renders the record, but only the API knows that + * `/proyectos/x` and `/en/projects/x` 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: Alternates, + ): 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)) { + if (href) { + 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/project-card.ts b/landing/src/app/ui/project-card.ts index cdbb40a..c7b1a9d 100644 --- a/landing/src/app/ui/project-card.ts +++ b/landing/src/app/ui/project-card.ts @@ -16,6 +16,18 @@ import type { Copy } from '../core/copy'; import type { PublicProject } from '../core/api.types'; import { ApiImage } from './api-image'; +/** The API's five project states onto the status chip's five tones. */ +export const PROJECT_STATUS_TONES: Record< + PublicProject['status'], + 'success' | 'error' | 'pending' | 'info' | 'disabled' +> = { + idea: 'info', + active: 'success', + paused: 'pending', + completed: 'info', + archived: 'disabled', +}; + @Component({ selector: 'app-project-card', changeDetection: ChangeDetectionStrategy.OnPush, @@ -70,7 +82,13 @@ import { ApiImage } from './api-image'; -

{{ project().title }}

+

+ @if (project().path; as path) { + {{ project().title }} + } @else { + {{ project().title }} + } +

@if (project().tagline; as tagline) {

{{ tagline }}

} @@ -130,19 +148,7 @@ export class ProjectCard { () => (this.project().repositories ?? []).find((repo) => repo.url)?.url ?? null ); - /** The API's five project states onto the chip's five tones. */ - protected readonly statusTone = computed(() => { - const tones: Record< - PublicProject['status'], - 'success' | 'error' | 'pending' | 'info' | 'disabled' - > = { - idea: 'info', - active: 'success', - paused: 'pending', - completed: 'info', - archived: 'disabled', - }; - - return tones[this.project().status]; - }); + protected readonly statusTone = computed( + () => PROJECT_STATUS_TONES[this.project().status] + ); } diff --git a/landing/src/app/ui/project-detail.scss b/landing/src/app/ui/project-detail.scss new file mode 100644 index 0000000..fa78f1e --- /dev/null +++ b/landing/src/app/ui/project-detail.scss @@ -0,0 +1,89 @@ +@use './detail'; + +:host { + display: block; +} + +.project-gallery { + display: grid; + gap: 1rem; + + @media (min-width: 640px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.project-gallery__item { + margin: 0; + + figcaption { + margin-top: 0.5rem; + font-size: 0.875rem; + color: color-mix(in srgb, currentColor 68%, transparent); + } +} + +// The `` belongs to `app-api-image`, so a scoped rule cannot reach it. +.project-gallery__item ::ng-deep img { + display: block; + width: 100%; + height: auto; + border-radius: var(--pi-border-radius-medium, 0.75rem); +} + +.project-team, +.project-repos { + display: grid; + gap: 0.875rem; + margin: 0; + padding: 0; + list-style: none; +} + +.project-team__member { + display: flex; + align-items: center; + gap: 0.75rem; + color: inherit; + text-decoration: none; + + > span { + display: flex; + flex-direction: column; + font-size: 0.875rem; + } + + &:hover .project-team__name { + text-decoration: underline; + } +} + +.project-team__name { + font-weight: 600; + font-size: 0.9375rem; +} + +.project-repos li { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + + a { + color: var(--pi-color-primary); + font-weight: 600; + word-break: break-word; + } +} + +.project-repos__stats { + font-size: 0.8125rem; +} + +// `@pi/ui`'s reset spaces every `li + li`; these lists are spaced by `gap`. +.project-team, +.project-repos { + > li { + margin: 0; + } +} diff --git a/landing/src/app/ui/project-detail.ts b/landing/src/app/ui/project-detail.ts new file mode 100644 index 0000000..ca0fb4b --- /dev/null +++ b/landing/src/app/ui/project-detail.ts @@ -0,0 +1,335 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { + PiActionButtonComponent, + PiAvatarComponent, + PiChipComponent, + PiChipStatusComponent, + PiTextColorDirective, + PiTextLargeDirective, + PiTextWeightBoldDirective, +} from '@pi/ui'; + +import type { Locale } from '../core/api'; +import type { PublicProject } from '../core/api.types'; +import type { Copy } from '../core/copy'; +import { ApiImage } from './api-image'; +import { ContactCta } from './contact-cta'; +import { PROJECT_STATUS_TONES } from './project-card'; + +/** + * A project on its own address: what it is, why it was built the way it was, + * what went wrong, who made it, and where the code is. + * + * `description`, `decisions` and `postmortem` are HTML from the panel's rich + * editor and go through Angular's sanitiser, as a page's body does. + */ +@Component({ + selector: 'app-project-detail', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ApiImage, + ContactCta, + PiActionButtonComponent, + PiAvatarComponent, + PiChipComponent, + PiChipStatusComponent, + PiTextColorDirective, + PiTextLargeDirective, + PiTextWeightBoldDirective, + ], + styleUrl: './project-detail.scss', + template: ` +
+
+

{{ copy().project.details }}

+ +

{{ project().title }}

+ + @if (project().tagline; as tagline) { +

+ {{ tagline }} +

+ } + +
    +
  • + +
  • + @if (project().needs_help) { +
  • + +
  • + } + @for ( + technology of project().technologies ?? []; + track technology.id + ) { +
  • + +
  • + } +
+ + @if (project().demo_url || repositoryUrl()) { +
+ @if (project().demo_url; as demo) { + + + + } + @if (repositoryUrl(); as repository) { + + + + } +
+ } +
+
+ +
+
+ @if (project().cover_image; as cover) { +
+ +
+ } + + @if (project().description; as description) { +
+ } + + @if (project().decisions; as decisions) { +
+

{{ copy().project.decisions }}

+
+
+ } + + @if (project().postmortem; as postmortem) { +
+

{{ copy().project.postmortem }}

+
+
+ } + + @if ((project().gallery ?? []).length > 0) { +
+

{{ copy().project.gallery }}

+ +
+ } +
+ + +
+ `, +}) +export class ProjectDetail { + readonly project = input.required(); + readonly copy = input.required(); + readonly locale = input.required(); + + protected readonly statusTone = computed( + () => PROJECT_STATUS_TONES[this.project().status], + ); + + protected readonly repositoryUrl = computed( + () => + (this.project().repositories ?? []).find((repository) => repository.url) + ?.url ?? null, + ); + + /** "marzo de 2025 – junio de 2026", or just the start while it runs. */ + protected readonly period = computed(() => { + const { started_at, ended_at } = this.project(); + const format = (date?: string) => + date + ? new Intl.DateTimeFormat(this.locale(), { + month: 'long', + year: 'numeric', + }).format(new Date(date)) + : null; + + const [start, end] = [format(started_at), format(ended_at)]; + + return start && end ? `${start} – ${end}` : (start ?? end); + }); +} diff --git a/landing/src/app/ui/routed-page.ts b/landing/src/app/ui/routed-page.ts new file mode 100644 index 0000000..b180c0a --- /dev/null +++ b/landing/src/app/ui/routed-page.ts @@ -0,0 +1,103 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; + +import { copyFor } from '../core/copy'; +import type { RoutedPageData, RoutedRecord } from '../core/routed-page'; +import { ContentPage } from './content-page'; +import { MemberDetail } from './member-detail'; +import { PageShell, type PageSeo } from './page-shell'; +import { ProjectDetail } from './project-detail'; + +/** + * A page reached by its address, whatever it holds: the shell around it, and + * the body for its type inside. + */ +@Component({ + selector: 'app-routed-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ContentPage, MemberDetail, PageShell, ProjectDetail], + template: ` + + @if (data().found; as found) { + @switch (found.type) { + @case ('page') { + + } + @case ('project') { + + } + @case ('member') { + + } + } + } + + `, +}) +export class RoutedPage { + readonly data = input.required(); + + protected readonly copy = computed(() => copyFor(this.data().locale)); + + protected readonly seo = computed(() => seoFor(this.data().found)); +} + +/** What each type puts in the document head. Null when nothing was found. */ +export function seoFor(found: RoutedRecord | null): PageSeo | null { + if (!found) { + return null; + } + + switch (found.type) { + case 'page': { + const page = found.record; + + return { + title: page.meta_title || `Pireon — ${page.title}`, + description: page.meta_description || page.excerpt || '', + path: page.path, + alternates: page.alternates ?? {}, + type: 'article', + }; + } + case 'project': { + const project = found.record; + + return { + title: project.meta_title || `Pireon — ${project.title}`, + description: project.meta_description || project.tagline || '', + path: project.path, + alternates: project.alternates ?? {}, + type: 'article', + }; + } + case 'member': { + const member = found.record; + + return { + title: `Pireon — ${member.name}`, + description: member.headline || member.bio?.slice(0, 160) || '', + path: member.path, + alternates: member.alternates ?? {}, + type: 'profile', + }; + } + } +} diff --git a/landing/src/server/middleware/not-found-status.ts b/landing/src/server/middleware/not-found-status.ts index 8d08baa..bd6271b 100644 --- a/landing/src/server/middleware/not-found-status.ts +++ b/landing/src/server/middleware/not-found-status.ts @@ -1,6 +1,6 @@ import { defineEventHandler, getRequestURL, setResponseStatus } from 'h3'; -import { contentRoute } from '../../app/core/api'; +import { resolveRoute } from '../../app/core/routes'; /** * Answers 404 for addresses this app does not serve. @@ -8,7 +8,7 @@ import { contentRoute } from '../../app/core/api'; * 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, plus the pages the panel publishes. + * state outright — the landing, plus the addresses records live at. */ const KNOWN_PATHS = new Set(['/', '/index.html']); @@ -30,11 +30,11 @@ 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)) { + // Shaped like a record's address (`/sobre-nosotros`, `/proyectos/x`, + // `/en/team/x`). 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 + // sets the 404 itself when the API has nothing there. See `ui/page-shell.ts`. + if (resolveRoute(pathname)) { return; } diff --git a/landing/src/styles.scss b/landing/src/styles.scss index cf44a00..d903924 100644 --- a/landing/src/styles.scss +++ b/landing/src/styles.scss @@ -221,6 +221,19 @@ html { font-size: 1.125rem; } +// A card's title leads to the record's own page. The card itself stays +// unlinked: it also holds links of its own (demo, repository) and a link +// inside a link is invalid. +.landing-card__title-link { + color: inherit; + text-decoration: none; + + &:hover, + &:focus-visible { + text-decoration: underline; + } +} + .landing-card__media { aspect-ratio: 16 / 9; overflow: hidden; @@ -237,10 +250,17 @@ html { .landing-card__chips { display: flex; flex-wrap: wrap; + align-items: center; gap: 0.375rem; margin: 0.75rem 0 0; padding: 0; list-style: none; + + // `@pi/ui`'s reset gives every `li + li` a top margin — a rhythm for a list + // of prose. In a row spaced by `gap` it dropped every chip but the first. + > li { + margin: 0; + } } .landing-card__chips--center { diff --git a/tools/mock-api.mjs b/tools/mock-api.mjs index e6c58c4..b400da7 100644 --- a/tools/mock-api.mjs +++ b/tools/mock-api.mjs @@ -103,6 +103,47 @@ const data = { ], }; +// Detail endpoints: what a project's or a member's own page asks for. Richer +// than the listings — gallery, prose, team — because that is what the detail +// endpoints add. +const prose = (topic) => + `

${topic} empezó como una necesidad concreta de una asociación y acabó siendo una herramienta que usan varias.

` + + '
  • Pensado para que lo mantenga gente que no programa.
  • Con pruebas y documentación desde el primer día.
'; + +const team = data['/public/v1/members'].slice(0, 3).map((m, i) => ({ + ...m, + role: ['Responsable', 'Frontend', 'Backend'][i], +})); + +for (const listed of data['/public/v1/projects']) { + data[`/public/v1/projects/${listed.slug}`] = { + ...listed, + description: prose(listed.title), + decisions: '

Elegimos renderizar en el servidor porque la mayoría de visitas llegan desde móviles modestos.

', + postmortem: '

Subestimamos la importación de datos antiguos: nos llevó el doble de lo previsto.

', + meta_title: `${listed.title} · Pireon`, + meta_description: listed.tagline, + started_at: '2025-03-01T00:00:00Z', + ended_at: listed.status === 'completed' ? '2026-06-01T00:00:00Z' : null, + client: data['/public/v1/clients'][0], + members: team, + lead: team[0], + calls: data['/public/v1/calls'].slice(0, listed.id % 2 ? 1 : 0).map((c) => ({ ...c, path: `/colabora/${c.slug}` })), + alternates: { es: `/proyectos/${listed.slug}`, en: `/en/projects/${listed.slug}`, ca: `/ca/projectes/${listed.slug}` }, + }; +} + +for (const listed of data['/public/v1/members']) { + data[`/public/v1/members/${listed.slug}`] = { + ...listed, + bio: 'Programa desde hace años en proyectos de todo tipo.\nAhora dedica unas horas a la semana al colectivo, sobre todo a revisar código y acompañar a quien empieza.', + joined_at: '2024-09-01T00:00:00Z', + technologies: technologies.slice(0, 4), + projects: data['/public/v1/projects'].slice(0, 2).map((p) => ({ ...p, path: `/proyectos/${p.slug}` })), + alternates: { es: `/equipo/${listed.slug}`, en: `/en/team/${listed.slug}`, ca: `/ca/equip/${listed.slug}` }, + }; +} + createServer((req, res) => { const path = new URL(req.url, 'http://x').pathname.replace(/^\/api/, ''); const payload = data[path];