Give projects and members their own pages

The API has always served a project's and a member's detail, with
`path` and `alternates` in three languages, and the site had nowhere to
show it. A project now has its page — tagline, status, technologies,
description, technical decisions, what was learned, gallery, client,
dates, lead, team, repositories and open calls — and so does a member:
headline, bio, links, technologies and projects. Cards on the landing
link to both.

Addresses follow the API's rule read backwards (core/routes.ts):
pages at the root, records under a section named in their language,
the default language unprefixed. One catch-all route serves them all.
Not a route per shape: Analog registers each loader as an internal
Nitro route, and Nitro's router cannot hold `/:prefix/:slug` beside
`/:prefix/:section/:slug` — the first became unreachable.

The frame around a record (header, footer, lights, 404/503, title,
description, canonical, hreflang) is one component now, PageShell, and
each type only brings its body, all sharing one layout partial.

Also fixes chips that stepped down one row after the first, on the
landing's cards too: @pi/ui's reset gives every `li + li` a top margin,
right for prose and wrong for a row spaced by `gap`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pigui
2026-09-29 14:57:13 +02:00
parent 9af6b5aff9
commit 439f951f2e
33 changed files with 2001 additions and 745 deletions

View File

@@ -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');
});
});

View File

@@ -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<PublicPage>(`/pages/${encodeURIComponent(slug)}`, { lang }),
project: (lang: Locale, slug: string) =>
get<PublicProject>(`/projects/${encodeURIComponent(slug)}`, { lang }),
member: (lang: Locale, slug: string) =>
get<PublicMember>(`/members/${encodeURIComponent(slug)}`, { lang }),
} as const;
/** POST bodies the public API accepts. Only two endpoints write. */

View File

@@ -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<ContentPageData> {
const [site, page, pages] = await Promise.all([
pireonApi.site(locale),
slug ? pireonApi.page(locale, slug) : Promise.resolve(null),
pireonApi.pages(locale, { per_page: 100 }),
]);
return {
locale,
site: site.data,
maintenance: site.maintenance,
page: page?.data ?? null,
pages: pages.data ?? [],
};
}
/** Featured pages go in the header; the rest are one click away in the footer. */
export function pageNavLinks(
pages: PublicPage[],
): { href: string; label: string }[] {
return pages
.filter((page) => page.is_featured && page.path && page.title)
.map((page) => ({
href: page.path as string,
label: page.title as string,
}));
}

View File

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

View File

@@ -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<RoutedPageData> {
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<RoutedRecord | null> {
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,
}));
}

View File

@@ -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');
});
});

View File

@@ -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<RecordType, 'page'>,
Record<Locale, string>
> = {
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}`;
}