Give every technology a page of its own

A technology's name appeared in nine places -- the chips on project,
call, service and member cards and detail pages, the landing's ring
cards, the radar -- and not one was clickable. Now each leads to
`/tecnologias/:slug`, with the logo, what it is, where the collective
places it on the radar and a link to its own site.

Two constraints from the API shaped this, both verified against it:

Its address is the site's own. Technologies carry no `path` and no
`alternates`, and `/slugs` cannot list them -- its type vocabulary is a
closed enum. `routes.ts` opens by stating the API decides every record's
address; this is the first type where that is not true, so `SECTIONS`
gains the one entry and the comment says why. The sitemap composes these
URLs locally for the same reason, and an unreachable technologies call
there is not fatal the way `/slugs` is: it costs a handful of URLs
rather than misrepresenting the site.

There is no detail endpoint. `GET /technologies/{slug}` answers "route
not found" -- the route does not exist. `pireonApi.technology` filters
the collection instead, passing `in_use: false` because the default
returns only technologies attached to a project, which today is none.
An outage and an unknown slug stay distinguishable so the first is not
reported as a 404.

`website` is the only link the payload has: the project's own home page,
where its documentation lives. There is no separate docs field, so the
label says what the link is.

The chip markup was identical in all nine places, so it becomes one
`app-technology-chip` rather than nine anchors. `project-card` and
`radar-page` gain a `locale` input, which is all they lacked to address
it.

Verified: lint, 54 unit tests, build and 11 e2e. Against the mock, the
three addresses answer 200 and an unknown slug 404; the switcher moves
between them; canonical and hreflang are right; the sitemap lists all
three per technology.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pigui
2026-09-29 20:13:05 +02:00
parent 240ad149e7
commit 5ea9914583
24 changed files with 616 additions and 40 deletions

View File

@@ -238,6 +238,39 @@ export const pireonApi = {
technologies: (lang: Locale, query?: GetOptions['query']) =>
list<PublicTechnology>('/technologies', { lang, query }),
/**
* One technology, by slug.
*
* The public API has no `/technologies/{slug}` — that route does not exist,
* so asking for one answers "route not found" rather than "no such record".
* This filters the collection instead: a couple of dozen records in a single
* request the route's `s-maxage` already caches.
*
* `in_use: false` because the parameter defaults to true, which returns only
* technologies attached to a project — none of them, today.
*
* A failed call and an unknown slug have to stay distinguishable: the first
* is an outage, the second is a 404. So the collection's `error` is passed
* through untouched and only a successful, complete answer can conclude that
* a technology does not exist.
*/
technology: async (
lang: Locale,
slug: string
): Promise<ApiResult<PublicTechnology>> => {
const all = await list<PublicTechnology>('/technologies', {
lang,
query: { in_use: false },
});
return {
...all,
data: all.error
? null
: (all.data?.find((technology) => technology.slug === slug) ?? null),
};
},
goodFirstIssues: (query?: GetOptions['query']) =>
list<PublicIssue>('/good-first-issues', { query }),

View File

@@ -143,6 +143,14 @@ export interface Copy {
testimonials: string;
technologies: string;
};
technology: {
eyebrow: string;
ringTitle: string;
website: string;
usedIn: string;
membersUsing: string;
back: string;
};
radar: {
eyebrow: string;
lead: string;
@@ -325,6 +333,14 @@ const es: Copy = {
testimonials: 'Qué dicen',
technologies: 'Con qué lo hacemos',
},
technology: {
eyebrow: 'Tecnología',
ringTitle: 'En el radar',
website: 'Web oficial',
usedIn: 'proyectos la usan',
membersUsing: 'personas la usan',
back: 'Ver el radar completo',
},
radar: {
eyebrow: 'Radar tecnológico',
lead: 'Las herramientas con las que trabajamos y lo que opinamos de cada una: qué usamos sin dudar, qué estamos probando y qué preferimos evitar.',
@@ -520,6 +536,14 @@ const en: Copy = {
testimonials: 'What people say',
technologies: 'What we use',
},
technology: {
eyebrow: 'Technology',
ringTitle: 'On the radar',
website: 'Official site',
usedIn: 'projects use it',
membersUsing: 'people use it',
back: 'See the whole radar',
},
radar: {
eyebrow: 'Tech radar',
lead: 'The tools we work with and what we think of each: what we use without a second thought, what we are trying out and what we would rather avoid.',
@@ -715,6 +739,14 @@ const ca: Copy = {
testimonials: 'Què en diuen',
technologies: 'Amb què ho fem',
},
technology: {
eyebrow: 'Tecnologia',
ringTitle: 'Al radar',
website: 'Web oficial',
usedIn: 'projectes la fan servir',
membersUsing: 'persones la fan servir',
back: 'Veure el radar complet',
},
radar: {
eyebrow: 'Radar tecnològic',
lead: "Les eines amb què treballem i què en pensem de cadascuna: què fem servir sense dubtar, què estem provant i què preferim evitar.",

View File

@@ -32,6 +32,7 @@ export type RoutedRecord =
| { type: 'member'; record: PublicMember }
| { type: 'call'; record: PublicCall }
| { type: 'service'; record: PublicService }
| { type: 'technology'; record: PublicTechnology }
| { type: 'radar'; record: PublicTechnology[] }
| { type: 'blog'; record: PublicPage[] }
| {
@@ -94,6 +95,10 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
const { data } = await pireonApi.service(locale, slug);
return data ? { type: 'service', record: data } : null;
}
case 'technology': {
const { data } = await pireonApi.technology(locale, slug);
return data ? { type: 'technology', record: data } : null;
}
case 'blog': {
const { data, error } = await pireonApi.pages(locale, {
category: BLOG_CATEGORY[locale],

View File

@@ -7,6 +7,8 @@ import {
listingPath,
resolveRoute,
SECTIONS,
technologyAlternates,
technologyPath,
} from './routes';
describe('resolveRoute', () => {
@@ -121,6 +123,48 @@ describe('listings', () => {
});
});
describe('technologies', () => {
it('reads one under its section, in each language', () => {
expect(resolveRoute('/tecnologias/angular')).toEqual({
type: 'technology',
locale: 'es',
slug: 'angular',
});
expect(resolveRoute('/en/technologies/angular')).toEqual({
type: 'technology',
locale: 'en',
slug: 'angular',
});
expect(resolveRoute('/ca/tecnologies/angular')).toEqual({
type: 'technology',
locale: 'ca',
slug: 'angular',
});
});
it('builds the address resolveRoute reads back', () => {
for (const locale of SUPPORTED_LOCALES) {
const path = technologyPath('angular', locale);
expect(resolveRoute(path)).toEqual({
type: 'technology',
locale,
slug: 'angular',
});
}
});
// The slug is a proper noun the API does not translate, so the three
// addresses differ only by the prefix.
it('keeps the same slug in every language', () => {
expect(technologyAlternates('angular')).toEqual({
es: '/tecnologias/angular',
en: '/en/technologies/angular',
ca: '/ca/tecnologies/angular',
});
});
});
describe('homeHref', () => {
it('answers on ?lang= for every language but the default', () => {
expect(homeHref(DEFAULT_LOCALE)).toBe('/');

View File

@@ -13,7 +13,13 @@
*/
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api';
export type RecordType = 'page' | 'project' | 'member' | 'call' | 'service';
export type RecordType =
| 'page'
| 'project'
| 'member'
| 'call'
| 'service'
| 'technology';
/** Pages this site composes itself rather than one record's: no slug. */
export type ListingType = 'radar' | 'contribute' | 'blog';
@@ -31,6 +37,11 @@ export const SECTIONS: Record<
member: { es: 'equipo', en: 'team', ca: 'equip' },
call: { es: 'colabora', en: 'contribute', ca: 'col-labora' },
service: { es: 'servicios', en: 'services', ca: 'serveis' },
// The one section the API does not know about. It gives technologies no
// `path` and no `alternates`, and `/slugs` cannot carry them — its type
// vocabulary is a closed enum. So this address is the site's own. Should the
// API ever publish one, this is where the two have to be reconciled.
technology: { es: 'tecnologias', en: 'technologies', ca: 'tecnologies' },
};
/**
@@ -100,6 +111,26 @@ export function listingAlternates(
);
}
/**
* A technology's address in one language, as `resolveRoute` reads it back.
*
* Unlike every other record, the slug is the same in all three: a technology's
* name is a proper noun, so the API does not translate it and the addresses
* differ only by the prefix.
*/
export function technologyPath(slug: string, locale: Locale): string {
const prefix = locale === DEFAULT_LOCALE ? '' : `/${locale}`;
return `${prefix}/${SECTIONS.technology[locale]}/${slug}`;
}
/** The same technology in every language, for `hreflang`. */
export function technologyAlternates(slug: string): Record<string, string> {
return Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, technologyPath(slug, locale)]),
);
}
/** 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}`;

View File

@@ -14,7 +14,16 @@ const xml = buildSitemap('https://pireon.dev', {
},
},
],
});
}, [
{
id: 1,
name: 'Angular',
slug: 'angular',
category: 'framework',
projects_count: 0,
members_count: 0,
},
]);
describe('buildSitemap', () => {
it('lists every language of a record as its own url', () => {
@@ -41,6 +50,21 @@ describe('buildSitemap', () => {
expect(xml).toContain('<loc>https://pireon.dev/ca/col-labora</loc>');
});
// The API gives technologies no address and `/slugs` cannot carry them, so
// these are composed here and have to be in the sitemap all the same.
it('includes the technology pages this site composes', () => {
expect(xml).toContain('<loc>https://pireon.dev/tecnologias/angular</loc>');
expect(xml).toContain(
'<loc>https://pireon.dev/en/technologies/angular</loc>',
);
expect(xml).toContain(
'<loc>https://pireon.dev/ca/tecnologies/angular</loc>',
);
expect(xml).toContain(
'hreflang="ca" href="https://pireon.dev/ca/tecnologies/angular"',
);
});
it('is a well-formed document', () => {
const document = new DOMParser().parseFromString(xml, 'application/xml');

View File

@@ -7,10 +7,12 @@
* is not fetched first.
*/
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type SlugEntry } from './api';
import type { PublicTechnology } from './api.types';
import {
homeHref,
listingAlternates,
LISTINGS,
technologyAlternates,
type ListingType,
} from './routes';
@@ -22,6 +24,7 @@ interface SitemapEntry {
export function buildSitemap(
origin: string,
slugs: Record<string, SlugEntry[]>,
technologies: PublicTechnology[] = [],
): string {
const entries: SitemapEntry[] = [
// The landing answers on `?lang=`, not a prefix.
@@ -34,6 +37,12 @@ export function buildSitemap(
alternates: listingAlternates(listing),
})),
...Object.values(slugs).flat(),
// Technologies are absent from `/slugs` — the API gives them no address,
// so this site composes theirs. No `<lastmod>`: the payload has no
// `updated_at` to report.
...technologies.map((technology) => ({
alternates: technologyAlternates(technology.slug),
})),
];
const urls = entries.flatMap((entry) =>