From fea60a168c24f9757d56b1d0428b37744c5e2c00 Mon Sep 17 00:00:00 2001 From: pigui Date: Tue, 29 Sep 2026 15:45:08 +0200 Subject: [PATCH] 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 . - 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 --- README.md | 19 ++- landing/src/app/core/blog.ts | 27 +++++ landing/src/app/core/copy.ts | 36 ++++++ landing/src/app/core/routed-page.ts | 24 +++- landing/src/app/core/routes.ts | 3 +- landing/src/app/core/rss.spec.ts | 56 +++++++++ landing/src/app/core/rss.ts | 56 +++++++++ landing/src/app/pages/(home).page.ts | 4 +- landing/src/app/ui/blog-page.scss | 95 +++++++++++++++ landing/src/app/ui/blog-page.ts | 114 ++++++++++++++++++ landing/src/app/ui/content-page.scss | 14 +++ landing/src/app/ui/content-page.ts | 38 +++++- landing/src/app/ui/page-shell.ts | 32 ++++- landing/src/app/ui/routed-page.ts | 49 +++++++- landing/src/app/ui/site-footer.ts | 38 ++++-- landing/src/server/routes/blog/rss.xml.get.ts | 54 +++++++++ tools/mock-api.mjs | 24 +++- 17 files changed, 656 insertions(+), 27 deletions(-) create mode 100644 landing/src/app/core/blog.ts create mode 100644 landing/src/app/core/rss.spec.ts create mode 100644 landing/src/app/core/rss.ts create mode 100644 landing/src/app/ui/blog-page.scss create mode 100644 landing/src/app/ui/blog-page.ts create mode 100644 landing/src/server/routes/blog/rss.xml.get.ts 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: ` +
+
+

{{ copy().blog.eyebrow }}

+

{{ copy().blog.title }}

+

+ {{ copy().blog.lead }} +

+

+ {{ + copy().blog.feed + }} +

+
+
+ +
+ @if (posts().length > 0) { +
    + @for (post of posts(); track post.id) { +
  • + @if (post.featured_image; as image) { + + } +
    + @if (post.published_at) { + + } +

    + {{ post.title }} +

    + @if (post.excerpt; as excerpt) { +

    + {{ excerpt }} +

    + } + {{ copy().blog.readMore }} → +
    +
  • + } +
+ } @else { + + } +
+ `, +}) +export class BlogPage { + readonly posts = input.required(); + readonly copy = input.required(); + readonly locale = input.required(); + + protected date(value: string): string { + return new Intl.DateTimeFormat(this.locale(), { + day: 'numeric', + month: 'long', + year: 'numeric', + }).format(new Date(value)); + } +} diff --git a/landing/src/app/ui/content-page.scss b/landing/src/app/ui/content-page.scss index 0db1d26..7265915 100644 --- a/landing/src/app/ui/content-page.scss +++ b/landing/src/app/ui/content-page.scss @@ -3,3 +3,17 @@ :host { display: block; } + +.content-page__eyebrow-link { + color: inherit; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.content-page__date { + margin: 1rem 0 0; + font-size: 0.9375rem; +} diff --git a/landing/src/app/ui/content-page.ts b/landing/src/app/ui/content-page.ts index 45ced04..0d299ed 100644 --- a/landing/src/app/ui/content-page.ts +++ b/landing/src/app/ui/content-page.ts @@ -12,7 +12,9 @@ import { import type { Locale } from '../core/api'; import type { PublicPage } from '../core/api.types'; +import { isBlogPost } from '../core/blog'; import type { Copy } from '../core/copy'; +import { listingPath } from '../core/routes'; import { ApiImage } from './api-image'; import { ContactCta } from './contact-cta'; @@ -38,12 +40,26 @@ import { ContactCta } from './contact-cta'; template: `
- @if (page().category?.name; as category) { + @if (isPost()) { + +

+ ← {{ copy().blog.eyebrow }} +

+ } @else if (page().category?.name; as category) {

{{ category }}

}

{{ page().title }}

+ @if (isPost() && page().published_at; as published) { +

+ {{ copy().blog.published }} + +

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

{{ excerpt }} @@ -102,6 +118,26 @@ export class ContentPage { readonly copy = input.required(); readonly locale = input.required(); + protected readonly isPost = computed(() => + isBlogPost(this.page(), this.locale()), + ); + + protected readonly blogHref = computed(() => + listingPath('blog', this.locale()), + ); + + protected readonly publishedOn = computed(() => { + const published = this.page().published_at; + + return published + ? new Intl.DateTimeFormat(this.locale(), { + day: 'numeric', + month: 'long', + year: 'numeric', + }).format(new Date(published)) + : ''; + }); + /** * Somewhere to go next, so a page is not a dead end: the rest of its * category first, then anything else, three at most. diff --git a/landing/src/app/ui/page-shell.ts b/landing/src/app/ui/page-shell.ts index f7e9dd4..ff6a20b 100644 --- a/landing/src/app/ui/page-shell.ts +++ b/landing/src/app/ui/page-shell.ts @@ -31,6 +31,8 @@ export interface PageSeo { type: 'article' | 'profile' | 'website'; /** The record's own picture, for link previews. The site's logo otherwise. */ image?: string | null; + /** The RSS feed this page belongs to, announced in ``. */ + feed?: string; /** schema.org data for the record; `url` is added here, absolute. */ jsonLd?: Record | null; } @@ -72,7 +74,11 @@ export interface PageSeo { - + } @else { } @@ -94,7 +100,9 @@ export class PageShell implements OnInit { 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 navLinks = computed(() => + pageNavLinks(this.data().pages, this.data().locale), + ); protected readonly locales = computed( () => @@ -157,6 +165,7 @@ export class PageShell implements OnInit { ); this.linkAlternates(seo.path, seo.alternates ?? {}); + this.linkFeed(seo.feed); }); } @@ -199,6 +208,25 @@ export class PageShell implements OnInit { } } + /** ``, one at most. */ + private linkFeed(feed: string | undefined): void { + this.document.head + .querySelector('link[type="application/rss+xml"]') + ?.remove(); + + if (!feed) { + return; + } + + const link = this.document.createElement('link'); + + link.setAttribute('rel', 'alternate'); + link.setAttribute('type', 'application/rss+xml'); + link.setAttribute('title', 'Pireon'); + link.setAttribute('href', absoluteUrl(this.origin, feed)); + this.document.head.appendChild(link); + } + /** * Only this side renders the record, but only the API knows that * `/proyectos/x` and `/en/projects/x` are the same one — hence `alternates`. diff --git a/landing/src/app/ui/routed-page.ts b/landing/src/app/ui/routed-page.ts index 6852622..00a8bf6 100644 --- a/landing/src/app/ui/routed-page.ts +++ b/landing/src/app/ui/routed-page.ts @@ -8,9 +8,11 @@ import { import { copyFor, type Copy } from '../core/copy'; import { listingAlternates, listingPath } from '../core/routes'; import { memberLinks } from './member-card'; +import { isBlogPost } from '../core/blog'; import type { Locale } from '../core/api'; import { RadarPage } from './radar-page'; import { ServiceDetail } from './service-detail'; +import { BlogPage } from './blog-page'; import { CallDetail } from './call-detail'; import type { RoutedPageData, RoutedRecord } from '../core/routed-page'; import { ContentPage } from './content-page'; @@ -27,6 +29,7 @@ import { ProjectDetail } from './project-detail'; selector: 'app-routed-page', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ + BlogPage, CallDetail, ContentPage, ContributePage, @@ -69,6 +72,13 @@ import { ProjectDetail } from './project-detail'; [locale]="data().locale" /> } + @case ('blog') { + + } @case ('contribute') { (); /** Every published page — the footer is where the ones not featured in the header are found. */ readonly pages = input([]); + readonly locale = input(DEFAULT_LOCALE); - protected readonly links = computed(() => - this.pages() - .filter((page) => page.path && page.title) - .map((page) => ({ - href: page.path as string, - label: page.title as string, - })), - ); + /** + * Every page but the blog's posts, which would grow the footer without end: + * those are one link, to the blog, once there is any. + */ + protected readonly links = computed(() => { + const locale = this.locale(); + const pages = this.pages().filter((page) => page.path && page.title); + const hasPosts = pages.some((page) => isBlogPost(page, locale)); + + return [ + ...pages + .filter((page) => !isBlogPost(page, locale)) + .map((page) => ({ + href: page.path as string, + label: page.title as string, + })), + ...(hasPosts + ? [ + { + href: listingPath('blog', locale), + label: this.copy().blog.eyebrow, + }, + ] + : []), + ]; + }); protected readonly year = new Date().getFullYear(); } diff --git a/landing/src/server/routes/blog/rss.xml.get.ts b/landing/src/server/routes/blog/rss.xml.get.ts new file mode 100644 index 0000000..8113014 --- /dev/null +++ b/landing/src/server/routes/blog/rss.xml.get.ts @@ -0,0 +1,54 @@ +/** + * `/blog/rss.xml?lang=xx`: the blog as RSS, in one language — a feed reader + * subscribes to one, and a post only has the addresses of the languages it was + * translated into. + */ +import { + defineEventHandler, + getQuery, + getRequestHeaders, + setResponseHeader, + setResponseStatus, +} from 'h3'; + +import { pireonApi, resolveLocale } from '../../../app/core/api'; +import { BLOG_CATEGORY, byPublication } from '../../../app/core/blog'; +import { copyFor } from '../../../app/core/copy'; +import { originFromHeaders } from '../../../app/core/origin'; +import { listingPath } from '../../../app/core/routes'; +import { buildFeed } from '../../../app/core/rss'; + +export default defineEventHandler(async (event) => { + const locale = resolveLocale(getQuery(event)['lang']); + const origin = originFromHeaders(getRequestHeaders(event)) ?? ''; + const copy = copyFor(locale); + + const { data, error } = await pireonApi.pages(locale, { + category: BLOG_CATEGORY[locale], + per_page: 50, + }); + + if (error) { + setResponseStatus(event, 503); + setResponseHeader(event, 'Cache-Control', 'no-store'); + + return 'Feed temporarily unavailable'; + } + + setResponseHeader( + event, + 'Content-Type', + 'application/rss+xml; charset=utf-8', + ); + + return buildFeed( + origin, + locale, + { + title: `Pireon — ${copy.blog.title}`, + description: copy.blog.lead, + link: listingPath('blog', locale), + }, + byPublication(data ?? []), + ); +}); diff --git a/tools/mock-api.mjs b/tools/mock-api.mjs index 6b583a2..b81df00 100644 --- a/tools/mock-api.mjs +++ b/tools/mock-api.mjs @@ -55,7 +55,19 @@ const page = (id, title, slug, featured) => ({ alternates: { es: `/${slug}`, en: `/en/${slug}-en`, ca: `/ca/${slug}-ca` }, path: `/${slug}`, }); -const pages = [page(1, 'Sobre nosotros', 'sobre-nosotros', true), page(2, 'Contacto', 'contacto', false)]; +const post = (id, title, slug, published_at) => ({ + ...page(id, title, slug, false), + published_at, + excerpt: 'Lo que hemos aprendido este mes, contado sin prisa y con ejemplos de código.', + category: { id: 9, name: 'Blog', slug: 'blog', position: 3 }, +}); + +const pages = [ + page(1, 'Sobre nosotros', 'sobre-nosotros', true), + page(2, 'Contacto', 'contacto', false), + post(3, 'Por qué volvimos a renderizar en el servidor', 'volvimos-al-servidor', '2026-09-20T09:00:00Z'), + post(4, 'Nuestro primer año como colectivo', 'primer-ano', '2026-06-01T09:00:00Z'), +]; const data = { '/public/v1/pages': pages, @@ -184,8 +196,14 @@ data['/public/v1/slugs'] = { data['/public/v1/redirects'] = [{ from: '/proyectos/catalogo-viejo', to: '/proyectos/catalogo', status: 301, locale: null }]; createServer((req, res) => { - const path = new URL(req.url, 'http://x').pathname.replace(/^\/api/, ''); - const payload = data[path]; + const url = new URL(req.url, 'http://x'); + const path = url.pathname.replace(/^\/api/, ''); + let payload = data[path]; + + // The one filter the frontend relies on: pages by category slug. + if (path === '/public/v1/pages' && url.searchParams.get('category')) { + payload = payload.filter((p) => p.category?.slug === url.searchParams.get('category')); + } res.setHeader('content-type', 'application/json');