Add a blog, built from pages

A post is a page in the panel's "Blog" category, so it is written,
translated, scheduled and reviewed like any page, and the MCP server
can publish one the same way. Nothing new in the API.

- /blog (/en/blog, /ca/blog): posts newest first, with image, date and
  excerpt.
- A post shows its date and leads back to the blog; its structured data
  is a BlogPosting.
- /blog/rss.xml?lang=xx: one feed per language, announced in <head>.
- Posts stay out of the header and the footer, which links the blog
  once there is any post instead of growing with every one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pigui
2026-09-29 15:45:08 +02:00
parent 86b7485b32
commit fea60a168c
17 changed files with 656 additions and 27 deletions

View File

@@ -0,0 +1,27 @@
/**
* The blog is not a type of its own: a post is a page in the panel's "Blog"
* category, written, translated, scheduled and reviewed like any other page,
* and reachable by the MCP server the same way.
*
* That category has to exist with this slug in every language — the API
* filters by the slug in the language asked for.
*/
import type { Locale } from './api';
import type { PublicPage } from './api.types';
export const BLOG_CATEGORY: Record<Locale, string> = {
es: 'blog',
en: 'blog',
ca: 'blog',
};
export function isBlogPost(page: PublicPage, locale: Locale): boolean {
return page.category?.slug === BLOG_CATEGORY[locale];
}
/** Newest first, whatever order they arrived in. */
export function byPublication(pages: PublicPage[]): PublicPage[] {
return [...pages].sort((a, b) =>
(b.published_at ?? '').localeCompare(a.published_at ?? ''),
);
}

View File

@@ -128,6 +128,15 @@ export interface Copy {
otherWays: string;
otherWaysLead: string;
};
blog: {
eyebrow: string;
title: string;
lead: string;
empty: string;
readMore: string;
published: string;
feed: string;
};
service: {
eyebrow: string;
projects: string;
@@ -301,6 +310,15 @@ const es: Copy = {
otherWays: 'Otras formas de ayudar',
otherWaysLead: 'También necesitamos diseño, documentación, traducciones y gente que pruebe lo que hacemos. Escríbenos y lo hablamos.',
},
blog: {
eyebrow: 'Blog',
title: 'Novedades',
lead: 'Lo que estamos haciendo, lo que hemos aprendido y lo que viene.',
empty: 'Todavía no hemos publicado nada. Vuelve pronto.',
readMore: 'Leer',
published: 'Publicado el',
feed: 'Suscríbete por RSS',
},
service: {
eyebrow: 'Servicio',
projects: 'Trabajo relacionado',
@@ -487,6 +505,15 @@ const en: Copy = {
otherWays: 'Other ways to help',
otherWaysLead: 'We also need design, documentation, translations and people to try what we build. Write to us and we will talk it over.',
},
blog: {
eyebrow: 'Blog',
title: 'News',
lead: 'What we are working on, what we have learned and what comes next.',
empty: 'We have not published anything yet. Come back soon.',
readMore: 'Read',
published: 'Published on',
feed: 'Subscribe via RSS',
},
service: {
eyebrow: 'Service',
projects: 'Related work',
@@ -673,6 +700,15 @@ const ca: Copy = {
otherWays: "Altres maneres d'ajudar",
otherWaysLead: 'També necessitem disseny, documentació, traduccions i gent que provi el que fem. Escriu-nos i en parlem.',
},
blog: {
eyebrow: 'Blog',
title: 'Novetats',
lead: 'El que estem fent, el que hem après i el que ve.',
empty: 'Encara no hem publicat res. Torna aviat.',
readMore: 'Llegir',
published: 'Publicat el',
feed: "Subscriu-t'hi per RSS",
},
service: {
eyebrow: 'Servei',
projects: 'Feina relacionada',

View File

@@ -22,6 +22,7 @@ import type {
PublicService,
PublicTechnology,
} from './api.types';
import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog';
import { resolveRoute, type RouteMatch } from './routes';
/** The record, tagged with its type so the template can switch on it. */
@@ -32,6 +33,7 @@ export type RoutedRecord =
| { type: 'call'; record: PublicCall }
| { type: 'service'; record: PublicService }
| { type: 'radar'; record: PublicTechnology[] }
| { type: 'blog'; record: PublicPage[] }
| {
type: 'contribute';
record: { calls: PublicCall[]; issues: PublicIssue[] };
@@ -92,6 +94,14 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
const { data } = await pireonApi.service(locale, slug);
return data ? { type: 'service', record: data } : null;
}
case 'blog': {
const { data, error } = await pireonApi.pages(locale, {
category: BLOG_CATEGORY[locale],
per_page: 50,
});
// An empty blog is still the blog: the page says so rather than 404.
return error ? null : { type: 'blog', record: byPublication(data ?? []) };
}
case 'contribute': {
const [calls, issues] = await Promise.all([
pireonApi.calls(locale),
@@ -115,12 +125,22 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
}
}
/** Featured pages go in the header; the rest are one click away in the footer. */
/**
* Featured pages go in the header; the rest are one click away in the footer.
* Blog posts are neither: they have the blog.
*/
export function pageNavLinks(
pages: PublicPage[],
locale: Locale,
): { href: string; label: string }[] {
return pages
.filter((page) => page.is_featured && page.path && page.title)
.filter(
(page) =>
page.is_featured &&
page.path &&
page.title &&
!isBlogPost(page, locale),
)
.map((page) => ({
href: page.path as string,
label: page.title as string,

View File

@@ -16,7 +16,7 @@ import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api';
export type RecordType = 'page' | 'project' | 'member' | 'call' | 'service';
/** Pages this site composes itself rather than one record's: no slug. */
export type ListingType = 'radar' | 'contribute';
export type ListingType = 'radar' | 'contribute' | 'blog';
/**
* The section each type lives under, per language. The API's own
@@ -41,6 +41,7 @@ export const LISTINGS: Record<ListingType, Record<Locale, string>> = {
radar: { es: 'radar', en: 'radar', ca: 'radar' },
// The calls' own section, so /colabora lists what /colabora/{slug} are.
contribute: { es: 'colabora', en: 'contribute', ca: 'col-labora' },
blog: { es: 'blog', en: 'blog', ca: 'blog' },
};
export interface RouteMatch {

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import type { PublicPage } from './api.types';
import { byPublication, isBlogPost } from './blog';
import { buildFeed } from './rss';
const post = (id: number, title: string, published_at: string, slug = 'blog') =>
({
id,
title,
path: `/${title.toLowerCase().replace(/\s/g, '-')}`,
excerpt: 'Resumen con <etiquetas> & símbolos',
published_at,
category: { id: 9, slug, name: 'Blog' },
}) as unknown as PublicPage;
describe('blog', () => {
it("knows a post by its category, in the visitor's language", () => {
expect(isBlogPost(post(1, 'Hola', '2026-01-01'), 'es')).toBe(true);
expect(isBlogPost(post(2, 'Sobre', '2026-01-01', 'colectivo'), 'es')).toBe(
false,
);
});
it('orders posts newest first', () => {
const ordered = byPublication([
post(1, 'Viejo', '2026-01-01'),
post(2, 'Nuevo', '2026-09-01'),
]);
expect(ordered.map((p) => p.title)).toEqual(['Nuevo', 'Viejo']);
});
});
describe('buildFeed', () => {
const xml = buildFeed(
'https://pireon.dev',
'es',
{ title: 'Pireon — Blog', description: 'Novedades', link: '/blog' },
[post(1, 'Primer post', '2026-09-01T10:00:00Z')],
);
it('links every item absolutely, with its date', () => {
expect(xml).toContain('<link>https://pireon.dev/primer-post</link>');
expect(xml).toContain('<pubDate>Tue, 01 Sep 2026 10:00:00 GMT</pubDate>');
});
it('escapes what it carries and parses as XML', () => {
expect(xml).toContain('&lt;etiquetas&gt; &amp; símbolos');
expect(
new DOMParser()
.parseFromString(xml, 'application/xml')
.getElementsByTagName('parsererror'),
).toHaveLength(0);
});
});

View File

@@ -0,0 +1,56 @@
/**
* The blog as RSS 2.0, for feed readers and for anything that republishes it.
*/
import type { Locale } from './api';
import type { PublicPage } from './api.types';
export function buildFeed(
origin: string,
locale: Locale,
channel: { title: string; description: string; link: string },
posts: PublicPage[],
): string {
const items = posts.map((post) => {
const link = `${origin}${post.path ?? ''}`;
return [
' <item>',
` <title>${escape(post.title ?? '')}</title>`,
` <link>${escape(link)}</link>`,
` <guid isPermaLink="true">${escape(link)}</guid>`,
...(post.published_at
? [
` <pubDate>${new Date(post.published_at).toUTCString()}</pubDate>`,
]
: []),
...(post.excerpt
? [` <description>${escape(post.excerpt)}</description>`]
: []),
' </item>',
].join('\n');
});
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
' <channel>',
` <title>${escape(channel.title)}</title>`,
` <link>${escape(origin + channel.link)}</link>`,
` <description>${escape(channel.description)}</description>`,
` <language>${locale}</language>`,
` <atom:link href="${escape(`${origin}/blog/rss.xml?lang=${locale}`)}" rel="self" type="application/rss+xml"/>`,
...items,
' </channel>',
'</rss>',
'',
].join('\n');
}
function escape(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}