diff --git a/README.md b/README.md
index 9a659ac..5c4da25 100644
--- a/README.md
+++ b/README.md
@@ -164,6 +164,7 @@ landing/src/
routes/api/calls/[slug]/apply.post.ts forwards an application to one call
routes/sitemap.xml.get.ts every published address, from the API's /slugs
routes/robots.txt.get.ts points crawlers at the sitemap
+ routes/blog/rss.xml.get.ts the blog as RSS, one feed per language
middleware/redirects.ts 301s for slugs renamed in the panel
middleware/not-found-status.ts
styles.scss Material theme, `@pi/ui` sheets, shared primitives
@@ -254,6 +255,20 @@ cannot hold two parameters of different names at the same depth —
Featured pages go in the header after the section anchors; every page is in the
footer.
+## The blog
+
+Not a type of its own: **a post is a page in the panel's "Blog" category** —
+written, translated, scheduled, reviewed and reachable by the MCP server like
+any page. That category has to exist with the slug `blog` in every language
+(`core/blog.ts`), because the API filters by the slug in the language asked for.
+
+- `/blog` (`/en/blog`, `/ca/blog`) lists them newest first.
+- A post shows its date and leads back to the blog; its structured data is a
+ `BlogPosting`.
+- `/blog/rss.xml?lang=xx` is the feed, one per language, announced in `
`.
+- The footer does not list posts one by one — it would grow without end — but
+ links the blog once there is any.
+
## Search engines and link previews
- **`/sitemap.xml`** comes from the API's `/slugs` — one request for every
@@ -270,8 +285,8 @@ footer.
own picture (the logo otherwise), and schema.org JSON-LD — `Organization` on
the landing, `WebPage`, `CreativeWork`, `Person` or `Service` elsewhere.
-In development only `/api/*` reaches Nitro, so `/sitemap.xml` and
-`/robots.txt` answer 404 under `npm start`; build and run `npm run preview` to
+In development only `/api/*` reaches Nitro, so `/sitemap.xml`, `/robots.txt`
+and `/blog/rss.xml` answer 404 under `npm start`; build and run `npm run preview` to
see them. Middleware does run in development.
## Rendering and caching
diff --git a/landing/src/app/core/blog.ts b/landing/src/app/core/blog.ts
new file mode 100644
index 0000000..fb8324b
--- /dev/null
+++ b/landing/src/app/core/blog.ts
@@ -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 = {
+ 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 ?? ''),
+ );
+}
diff --git a/landing/src/app/core/copy.ts b/landing/src/app/core/copy.ts
index d2bc08d..9cad5f3 100644
--- a/landing/src/app/core/copy.ts
+++ b/landing/src/app/core/copy.ts
@@ -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',
diff --git a/landing/src/app/core/routed-page.ts b/landing/src/app/core/routed-page.ts
index 39cca4d..eaabf04 100644
--- a/landing/src/app/core/routed-page.ts
+++ b/landing/src/app/core/routed-page.ts
@@ -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 {
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 {
}
}
-/** 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,
diff --git a/landing/src/app/core/routes.ts b/landing/src/app/core/routes.ts
index be5942e..3cb7359 100644
--- a/landing/src/app/core/routes.ts
+++ b/landing/src/app/core/routes.ts
@@ -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> = {
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 {
diff --git a/landing/src/app/core/rss.spec.ts b/landing/src/app/core/rss.spec.ts
new file mode 100644
index 0000000..c07543f
--- /dev/null
+++ b/landing/src/app/core/rss.spec.ts
@@ -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 & 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('https://pireon.dev/primer-post');
+ expect(xml).toContain('Tue, 01 Sep 2026 10:00:00 GMT');
+ });
+
+ it('escapes what it carries and parses as XML', () => {
+ expect(xml).toContain('<etiquetas> & símbolos');
+ expect(
+ new DOMParser()
+ .parseFromString(xml, 'application/xml')
+ .getElementsByTagName('parsererror'),
+ ).toHaveLength(0);
+ });
+});
diff --git a/landing/src/app/core/rss.ts b/landing/src/app/core/rss.ts
new file mode 100644
index 0000000..916e33a
--- /dev/null
+++ b/landing/src/app/core/rss.ts
@@ -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 [
+ ' ',
+ ` ${escape(post.title ?? '')}`,
+ ` ${escape(link)}`,
+ ` ${escape(link)}`,
+ ...(post.published_at
+ ? [
+ ` ${new Date(post.published_at).toUTCString()}`,
+ ]
+ : []),
+ ...(post.excerpt
+ ? [` ${escape(post.excerpt)}`]
+ : []),
+ ' ',
+ ].join('\n');
+ });
+
+ return [
+ '',
+ '',
+ ' ',
+ ` ${escape(channel.title)}`,
+ ` ${escape(origin + channel.link)}`,
+ ` ${escape(channel.description)}`,
+ ` ${locale}`,
+ ` `,
+ ...items,
+ ' ',
+ '',
+ '',
+ ].join('\n');
+}
+
+function escape(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
diff --git a/landing/src/app/pages/(home).page.ts b/landing/src/app/pages/(home).page.ts
index d20557b..50a19b3 100644
--- a/landing/src/app/pages/(home).page.ts
+++ b/landing/src/app/pages/(home).page.ts
@@ -277,7 +277,7 @@ export const routeMeta: RouteMeta = {
-
+
}
`,
})
@@ -343,7 +343,7 @@ export default class HomePage {
...candidates
.filter((link) => link.visible)
.map(({ anchor, label }) => ({ anchor, label })),
- ...pageNavLinks(data.pages),
+ ...pageNavLinks(data.pages, data.locale),
];
});
diff --git a/landing/src/app/ui/blog-page.scss b/landing/src/app/ui/blog-page.scss
new file mode 100644
index 0000000..547cdd5
--- /dev/null
+++ b/landing/src/app/ui/blog-page.scss
@@ -0,0 +1,95 @@
+@use './detail';
+
+:host {
+ display: block;
+}
+
+.blog-feed {
+ margin: 1.25rem 0 0;
+
+ a {
+ color: var(--pi-color-primary);
+ font-weight: 600;
+ text-decoration: none;
+ }
+}
+
+.blog-body {
+ padding-bottom: 6rem;
+}
+
+.blog-list {
+ display: grid;
+ gap: 1.5rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+
+ // `@pi/ui`'s reset spaces every `li + li`; this list is spaced by `gap`.
+ > li {
+ margin: 0;
+ }
+}
+
+.blog-card {
+ display: grid;
+ overflow: hidden;
+ border-radius: var(--pi-theme-card-radius, 1.5rem);
+ box-shadow: var(--pi-theme-card-shadow, none);
+}
+
+// The image column only when there is an image: without one the text would
+// sit in the image's column with the rest of the card empty.
+.blog-card--media {
+ @media (min-width: 768px) {
+ grid-template-columns: minmax(0, 20rem) minmax(0, 1fr);
+ }
+}
+
+.blog-card__media ::ng-deep img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ aspect-ratio: 16 / 10;
+ object-fit: cover;
+}
+
+.blog-card__body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 1.5rem 1.75rem;
+}
+
+.blog-card__date {
+ font-size: 0.8125rem;
+}
+
+.blog-card__title {
+ margin: 0;
+ font-size: 1.375rem;
+ font-weight: 700;
+ line-height: 1.25;
+
+ a {
+ color: inherit;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
+
+.blog-card__excerpt {
+ margin: 0;
+ line-height: 1.6;
+}
+
+.blog-card__more {
+ margin-top: auto;
+ padding-top: 0.5rem;
+ color: var(--pi-color-primary);
+ font-weight: 600;
+ text-decoration: none;
+}
diff --git a/landing/src/app/ui/blog-page.ts b/landing/src/app/ui/blog-page.ts
new file mode 100644
index 0000000..1af13c5
--- /dev/null
+++ b/landing/src/app/ui/blog-page.ts
@@ -0,0 +1,114 @@
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+import {
+ PiNoteComponent,
+ PiTextColorDirective,
+ PiTextLargeDirective,
+ PiTextWeightBoldDirective,
+} from '@pi/ui';
+
+import type { Locale } from '../core/api';
+import type { PublicPage } from '../core/api.types';
+import type { Copy } from '../core/copy';
+import { ApiImage } from './api-image';
+
+/**
+ * The blog: the pages in the panel's "Blog" category, newest first, each
+ * leading to its own address like any page.
+ */
+@Component({
+ selector: 'app-blog-page',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [
+ ApiImage,
+ PiNoteComponent,
+ PiTextColorDirective,
+ PiTextLargeDirective,
+ PiTextWeightBoldDirective,
+ ],
+ styleUrl: './blog-page.scss',
+ template: `
+
+