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