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:
19
README.md
19
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 `<head>`.
|
||||
- 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
|
||||
|
||||
27
landing/src/app/core/blog.ts
Normal file
27
landing/src/app/core/blog.ts
Normal 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 ?? ''),
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
56
landing/src/app/core/rss.spec.ts
Normal file
56
landing/src/app/core/rss.spec.ts
Normal 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('<etiquetas> & símbolos');
|
||||
expect(
|
||||
new DOMParser()
|
||||
.parseFromString(xml, 'application/xml')
|
||||
.getElementsByTagName('parsererror'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
56
landing/src/app/core/rss.ts
Normal file
56
landing/src/app/core/rss.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -277,7 +277,7 @@ export const routeMeta: RouteMeta = {
|
||||
</app-section>
|
||||
</main>
|
||||
|
||||
<app-site-footer [copy]="copy()" [pages]="data().pages" />
|
||||
<app-site-footer [copy]="copy()" [pages]="data().pages" [locale]="data().locale" />
|
||||
}
|
||||
`,
|
||||
})
|
||||
@@ -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),
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
95
landing/src/app/ui/blog-page.scss
Normal file
95
landing/src/app/ui/blog-page.scss
Normal file
@@ -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;
|
||||
}
|
||||
114
landing/src/app/ui/blog-page.ts
Normal file
114
landing/src/app/ui/blog-page.ts
Normal file
@@ -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: `
|
||||
<header class="content-hero">
|
||||
<div class="landing-shell">
|
||||
<p class="landing-eyebrow">{{ copy().blog.eyebrow }}</p>
|
||||
<h1 bold class="content-hero__title">{{ copy().blog.title }}</h1>
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
{{ copy().blog.lead }}
|
||||
</p>
|
||||
<p class="blog-feed">
|
||||
<a [href]="'/blog/rss.xml?lang=' + locale()">{{
|
||||
copy().blog.feed
|
||||
}}</a>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="landing-shell blog-body">
|
||||
@if (posts().length > 0) {
|
||||
<ul class="blog-list">
|
||||
@for (post of posts(); track post.id) {
|
||||
<li
|
||||
class="landing-card blog-card"
|
||||
[class.blog-card--media]="!!post.featured_image"
|
||||
>
|
||||
@if (post.featured_image; as image) {
|
||||
<a
|
||||
class="blog-card__media"
|
||||
[href]="post.path"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<app-api-image
|
||||
[image]="image"
|
||||
sizes="(min-width: 1024px) 24rem, 100vw"
|
||||
/>
|
||||
</a>
|
||||
}
|
||||
<div class="blog-card__body">
|
||||
@if (post.published_at) {
|
||||
<time
|
||||
class="content-page__muted blog-card__date"
|
||||
[attr.datetime]="post.published_at"
|
||||
>
|
||||
{{ date(post.published_at) }}
|
||||
</time>
|
||||
}
|
||||
<h2 class="blog-card__title">
|
||||
<a [href]="post.path">{{ post.title }}</a>
|
||||
</h2>
|
||||
@if (post.excerpt; as excerpt) {
|
||||
<p class="content-page__muted blog-card__excerpt">
|
||||
{{ excerpt }}
|
||||
</p>
|
||||
}
|
||||
<a class="blog-card__more" [href]="post.path"
|
||||
>{{ copy().blog.readMore }} →</a
|
||||
>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<pi-note
|
||||
status="info"
|
||||
appearance="fill"
|
||||
gap="xs"
|
||||
[title]="copy().blog.empty"
|
||||
[text]="''"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class BlogPage {
|
||||
readonly posts = input.required<PublicPage[]>();
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
|
||||
protected date(value: string): string {
|
||||
return new Intl.DateTimeFormat(this.locale(), {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: `
|
||||
<header class="content-hero">
|
||||
<div class="landing-shell">
|
||||
@if (page().category?.name; as category) {
|
||||
@if (isPost()) {
|
||||
<!-- A post leads back to the blog it belongs to. -->
|
||||
<p class="landing-eyebrow">
|
||||
<a class="content-page__eyebrow-link" [href]="blogHref()"
|
||||
>← {{ copy().blog.eyebrow }}</a
|
||||
>
|
||||
</p>
|
||||
} @else if (page().category?.name; as category) {
|
||||
<p class="landing-eyebrow">{{ category }}</p>
|
||||
}
|
||||
|
||||
<h1 bold class="content-hero__title">{{ page().title }}</h1>
|
||||
|
||||
@if (isPost() && page().published_at; as published) {
|
||||
<p class="content-page__muted content-page__date">
|
||||
{{ copy().blog.published }}
|
||||
<time [attr.datetime]="published">{{ publishedOn() }}</time>
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (page().excerpt; as excerpt) {
|
||||
<p l pi-color color="grey" class="content-hero__lead">
|
||||
{{ excerpt }}
|
||||
@@ -102,6 +118,26 @@ export class ContentPage {
|
||||
readonly copy = input.required<Copy>();
|
||||
readonly locale = input.required<Locale>();
|
||||
|
||||
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.
|
||||
|
||||
@@ -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 `<head>`. */
|
||||
feed?: string;
|
||||
/** schema.org data for the record; `url` is added here, absolute. */
|
||||
jsonLd?: Record<string, unknown> | null;
|
||||
}
|
||||
@@ -72,7 +74,11 @@ export interface PageSeo {
|
||||
<ng-content />
|
||||
</main>
|
||||
|
||||
<app-site-footer [copy]="copy()" [pages]="data().pages" />
|
||||
<app-site-footer
|
||||
[copy]="copy()"
|
||||
[pages]="data().pages"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
} @else {
|
||||
<app-not-found />
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
/** `<link rel="alternate" type="application/rss+xml">`, 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`.
|
||||
|
||||
@@ -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') {
|
||||
<app-blog-page
|
||||
[posts]="found.record"
|
||||
[copy]="copy()"
|
||||
[locale]="data().locale"
|
||||
/>
|
||||
}
|
||||
@case ('contribute') {
|
||||
<app-contribute-page
|
||||
[calls]="found.record.calls"
|
||||
@@ -115,6 +125,7 @@ export function seoFor(
|
||||
switch (found.type) {
|
||||
case 'page': {
|
||||
const page = found.record;
|
||||
const post = isBlogPost(page, locale);
|
||||
|
||||
return {
|
||||
title: page.meta_title || `Pireon — ${page.title}`,
|
||||
@@ -123,7 +134,19 @@ export function seoFor(
|
||||
alternates: page.alternates ?? {},
|
||||
type: 'article',
|
||||
image: page.featured_image?.url,
|
||||
jsonLd: {
|
||||
feed: post ? `/blog/rss.xml?lang=${locale}` : undefined,
|
||||
jsonLd: post
|
||||
? {
|
||||
'@type': 'BlogPosting',
|
||||
headline: page.title,
|
||||
description: page.excerpt,
|
||||
inLanguage: page.locale,
|
||||
datePublished: page.published_at,
|
||||
dateModified: page.updated_at,
|
||||
image: page.featured_image?.url,
|
||||
publisher: { '@type': 'Organization', name: 'Pireon' },
|
||||
}
|
||||
: {
|
||||
'@type': 'WebPage',
|
||||
name: page.title,
|
||||
description: page.excerpt,
|
||||
@@ -200,6 +223,20 @@ export function seoFor(
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'blog':
|
||||
return {
|
||||
title: `Pireon — ${copy.blog.title}`,
|
||||
description: copy.blog.lead,
|
||||
path: listingPath('blog', locale),
|
||||
alternates: listingAlternates('blog'),
|
||||
type: 'website',
|
||||
feed: `/blog/rss.xml?lang=${locale}`,
|
||||
jsonLd: {
|
||||
'@type': 'Blog',
|
||||
name: copy.blog.title,
|
||||
description: copy.blog.lead,
|
||||
},
|
||||
};
|
||||
case 'contribute':
|
||||
return {
|
||||
title: `Pireon — ${copy.contribute.title}`,
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
} from '@pi/ui';
|
||||
|
||||
import type { PublicPage } from '../core/api.types';
|
||||
import { DEFAULT_LOCALE, type Locale } from '../core/api';
|
||||
import { isBlogPost } from '../core/blog';
|
||||
import type { Copy } from '../core/copy';
|
||||
import { listingPath } from '../core/routes';
|
||||
|
||||
@Component({
|
||||
selector: 'app-site-footer',
|
||||
@@ -47,15 +50,34 @@ export class SiteFooter {
|
||||
readonly copy = input.required<Copy>();
|
||||
/** Every published page — the footer is where the ones not featured in the header are found. */
|
||||
readonly pages = input<PublicPage[]>([]);
|
||||
readonly locale = input<Locale>(DEFAULT_LOCALE);
|
||||
|
||||
protected readonly links = computed(() =>
|
||||
this.pages()
|
||||
.filter((page) => page.path && page.title)
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
54
landing/src/server/routes/blog/rss.xml.get.ts
Normal file
54
landing/src/server/routes/blog/rss.xml.get.ts
Normal file
@@ -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 ?? []),
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user