Compare commits

..

2 Commits

Author SHA1 Message Date
pigui
bca3e4bcc6 Show who signs a post and its tags, and read the blog by tag
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:20:57 +02:00
pigui
918465a8ab Keep posts with code inside a phone's width
The single-column layout grew to its widest line, so a code block pushed the
whole text past the edge of the screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:12:19 +02:00
11 changed files with 290 additions and 8 deletions

View File

@@ -265,6 +265,11 @@ any page. That category has to exist with the slug `blog` in every language
- `/blog` (`/en/blog`, `/ca/blog`) lists them newest first. - `/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 - A post shows its date and leads back to the blog; its structured data is a
`BlogPosting`. `BlogPosting`.
- A post shows who signs it (`byline`, linked to their profile) and its tags,
each linking to `/blog?tag=…`. The blog offers the tags in use as a row of
links and narrows to one with `?tag=` — plain links, so a tag's listing can
be shared and works before any script runs. Tags and bylines are set in the
panel.
- `/blog/rss.xml?lang=xx` is the feed, one per language, announced in `<head>`. - `/blog/rss.xml?lang=xx` is the feed, one per language, announced in `<head>`.
- Once there is a post, the blog is in the header menu (desktop and mobile) - Once there is a post, the blog is in the header menu (desktop and mobile)
and the landing shows the three latest under "Novedades"; the footer links and the landing shows the three latest under "Novedades"; the footer links

View File

@@ -165,6 +165,14 @@ export interface PublicPage {
unpublish_at?: string; unpublish_at?: string;
updated_at: string; updated_at: string;
category: PublicCategory; category: PublicCategory;
/**
* The ones named in this language, to link to the blog filtered by each.
*/
tags: ({ name: string; slug: string })[];
/**
* The member who signs it, when they have a public profile.
*/
byline?: { name: string; slug: string; headline?: string; avatar?: Image; path: string };
alternates: Alternates; alternates: Alternates;
path?: string; path?: string;
} }
@@ -268,6 +276,21 @@ export interface PublicRepository {
good_first_issues: PublicIssue[]; good_first_issues: PublicIssue[];
} }
export interface PublicSearchResult {
type: 'page' | 'project' | 'service' | 'call' | 'member' | 'technology';
id: number;
title: string;
/**
* Plain text around the first match.
*/
excerpt: string;
path?: string;
/**
* Higher is a better match. Only comparable within one response.
*/
score: number;
}
export interface PublicService { export interface PublicService {
id: number; id: number;
locale: string; locale: string;
@@ -382,6 +405,8 @@ export interface PublicApiPayloads {
'/public/v1/site': { locales: ({ code: string; name: string; flag: string; is_default: boolean })[]; default_locale: string; content_version: number; submissions_enabled: boolean; stats: { projects: number; active_projects: number; members: number; open_calls: number; good_first_issues: number; stars: number } }; '/public/v1/site': { locales: ({ code: string; name: string; flag: string; is_default: boolean })[]; default_locale: string; content_version: number; submissions_enabled: boolean; stats: { projects: number; active_projects: number; members: number; open_calls: number; good_first_issues: number; stars: number } };
'/public/v1/slugs': Record<string, ({ id: number; updated_at: string; alternates: Record<string, string> })[]>; '/public/v1/slugs': Record<string, ({ id: number; updated_at: string; alternates: Record<string, string> })[]>;
'/public/v1/redirects': ({ from: string; to: string; status: 301 | 302; locale?: string })[]; '/public/v1/redirects': ({ from: string; to: string; status: 301 | 302; locale?: string })[];
'/public/v1/search': PublicSearchResult[];
'/public/v1/tags': ({ name: string; slug: string; count: number })[];
'/public/v1/projects': PublicProject[]; '/public/v1/projects': PublicProject[];
'/public/v1/projects/{slug}': PublicProject; '/public/v1/projects/{slug}': PublicProject;
'/public/v1/members': PublicMember[]; '/public/v1/members': PublicMember[];

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import type { PublicPage } from './api.types';
import { tagHref, taggedWith, tagsOf } from './blog';
const post = (id: number, tags: { name: string; slug: string }[]) =>
({ id, tags }) as unknown as PublicPage;
describe('blog tags', () => {
const posts = [
post(1, [{ name: 'Angular', slug: 'angular' }]),
post(2, [
{ name: 'Angular', slug: 'angular' },
{ name: 'Accesibilidad', slug: 'accesibilidad' },
]),
post(3, []),
];
it('lists each tag once, most used first', () => {
expect(tagsOf(posts)).toEqual([
{ name: 'Angular', slug: 'angular', count: 2 },
{ name: 'Accesibilidad', slug: 'accesibilidad', count: 1 },
]);
});
it('narrows the posts to a tag, or keeps them all', () => {
expect(taggedWith(posts, 'accesibilidad').map((p) => p.id)).toEqual([2]);
expect(taggedWith(posts, null)).toHaveLength(3);
});
it('links to the blog in each language', () => {
expect(tagHref('es', 'angular')).toBe('/blog?tag=angular');
expect(tagHref('en', 'a b')).toBe('/en/blog?tag=a%20b');
});
});

View File

@@ -8,6 +8,7 @@
*/ */
import type { Locale } from './api'; import type { Locale } from './api';
import type { PublicPage } from './api.types'; import type { PublicPage } from './api.types';
import { listingPath } from './routes';
export const BLOG_CATEGORY: Record<Locale, string> = { export const BLOG_CATEGORY: Record<Locale, string> = {
es: 'blog', es: 'blog',
@@ -25,3 +26,44 @@ export function byPublication(pages: PublicPage[]): PublicPage[] {
(b.published_at ?? '').localeCompare(a.published_at ?? ''), (b.published_at ?? '').localeCompare(a.published_at ?? ''),
); );
} }
export interface BlogTag {
name: string;
slug: string;
count: number;
}
/** The blog, showing only the posts with this tag. */
export function tagHref(locale: Locale, slug: string): string {
return `${listingPath('blog', locale)}?tag=${encodeURIComponent(slug)}`;
}
/** Every tag on these posts, most used first, then by name. */
export function tagsOf(posts: PublicPage[]): BlogTag[] {
const tags = new Map<string, BlogTag>();
for (const post of posts) {
for (const tag of post.tags ?? []) {
const known = tags.get(tag.slug);
tags.set(tag.slug, {
name: tag.name,
slug: tag.slug,
count: (known?.count ?? 0) + 1,
});
}
}
return [...tags.values()].sort(
(a, b) => b.count - a.count || a.name.localeCompare(b.name),
);
}
/** The posts carrying a tag, or all of them when there is none. */
export function taggedWith(
posts: PublicPage[],
tag: string | null,
): PublicPage[] {
return tag
? posts.filter((post) => (post.tags ?? []).some((t) => t.slug === tag))
: posts;
}

View File

@@ -137,6 +137,11 @@ export interface Copy {
published: string; published: string;
feed: string; feed: string;
seeAll: string; seeAll: string;
by: string;
tags: string;
allTags: string;
tagged: string;
noneTagged: string;
}; };
service: { service: {
eyebrow: string; eyebrow: string;
@@ -328,6 +333,11 @@ const es: Copy = {
published: 'Publicado el', published: 'Publicado el',
feed: 'Suscríbete por RSS', feed: 'Suscríbete por RSS',
seeAll: 'Ver todas las novedades', seeAll: 'Ver todas las novedades',
by: 'Por',
tags: 'Temas',
allTags: 'Todo',
tagged: 'Entradas sobre «{tag}»',
noneTagged: 'No hay entradas con esta etiqueta.',
}, },
service: { service: {
eyebrow: 'Servicio', eyebrow: 'Servicio',
@@ -532,6 +542,11 @@ const en: Copy = {
published: 'Published on', published: 'Published on',
feed: 'Subscribe via RSS', feed: 'Subscribe via RSS',
seeAll: 'See all the news', seeAll: 'See all the news',
by: 'By',
tags: 'Topics',
allTags: 'Everything',
tagged: 'Posts about “{tag}”',
noneTagged: 'There are no posts with this tag.',
}, },
service: { service: {
eyebrow: 'Service', eyebrow: 'Service',
@@ -736,6 +751,11 @@ const ca: Copy = {
published: 'Publicat el', published: 'Publicat el',
feed: "Subscriu-t'hi per RSS", feed: "Subscriu-t'hi per RSS",
seeAll: 'Veure totes les novetats', seeAll: 'Veure totes les novetats',
by: 'Per',
tags: 'Temes',
allTags: 'Tot',
tagged: 'Entrades sobre «{tag}»',
noneTagged: 'No hi ha entrades amb aquesta etiqueta.',
}, },
service: { service: {
eyebrow: 'Servei', eyebrow: 'Servei',

View File

@@ -32,6 +32,10 @@
// ending in a dead end. // ending in a dead end.
.content-page__layout { .content-page__layout {
display: grid; display: grid;
// One column below the desktop layout, and one that may shrink: an
// implicit `auto` track grows to its widest line, and a code block in a
// post pushed the whole text past the edge of a phone.
grid-template-columns: minmax(0, 1fr);
gap: 3rem; gap: 3rem;
padding-bottom: 5rem; padding-bottom: 5rem;

View File

@@ -30,3 +30,42 @@
margin: 0; margin: 0;
} }
} }
.blog-tagged {
margin: 1rem 0 0;
font-weight: 600;
}
.blog-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0 0 2rem;
}
.blog-tag {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border: 1px solid color-mix(in oklab, currentColor 18%, transparent);
border-radius: 999px;
color: inherit;
font-size: 0.875rem;
text-decoration: none;
&:hover {
border-color: var(--pi-color-primary);
}
&[aria-current='page'] {
border-color: var(--pi-color-primary);
background: var(--pi-color-primary);
color: var(--pi-color-primary-contrast, #fff);
}
}
.blog-tag__count {
opacity: 0.7;
font-size: 0.75rem;
}

View File

@@ -1,4 +1,13 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { map } from 'rxjs';
import { import {
PiNoteComponent, PiNoteComponent,
PiTextColorDirective, PiTextColorDirective,
@@ -8,12 +17,17 @@ import {
import type { Locale } from '../core/api'; import type { Locale } from '../core/api';
import type { PublicPage } from '../core/api.types'; import type { PublicPage } from '../core/api.types';
import { tagHref, taggedWith, tagsOf } from '../core/blog';
import type { Copy } from '../core/copy'; import type { Copy } from '../core/copy';
import { listingPath } from '../core/routes';
import { PostCard } from './post-card'; import { PostCard } from './post-card';
/** /**
* The blog: the pages in the panel's "Blog" category, newest first, each * The blog: the pages in the panel's "Blog" category, newest first, each
* leading to its own address like any page. * leading to its own address like any page.
*
* `?tag=` narrows it to one subject. Plain links rather than a client-side
* filter, so a tag's page can be shared and works before any script runs.
*/ */
@Component({ @Component({
selector: 'app-blog-page', selector: 'app-blog-page',
@@ -34,6 +48,11 @@ import { PostCard } from './post-card';
<p l pi-color color="grey" class="content-hero__lead"> <p l pi-color color="grey" class="content-hero__lead">
{{ copy().blog.lead }} {{ copy().blog.lead }}
</p> </p>
@if (activeTag(); as tag) {
<p class="blog-tagged">
{{ copy().blog.tagged.replace('{tag}', tag.name) }}
</p>
}
<p class="blog-feed"> <p class="blog-feed">
<a [href]="'/blog/rss.xml?lang=' + locale()">{{ <a [href]="'/blog/rss.xml?lang=' + locale()">{{
copy().blog.feed copy().blog.feed
@@ -43,9 +62,29 @@ import { PostCard } from './post-card';
</header> </header>
<div class="landing-shell blog-body"> <div class="landing-shell blog-body">
@if (posts().length > 0) { @if (tags().length > 0) {
<nav class="blog-tags" [attr.aria-label]="copy().blog.tags">
<a
class="blog-tag"
[href]="blogHref()"
[attr.aria-current]="activeSlug() === null ? 'page' : null"
>{{ copy().blog.allTags }}</a
>
@for (tag of tags(); track tag.slug) {
<a
class="blog-tag"
[href]="tagHref(tag.slug)"
[attr.aria-current]="activeSlug() === tag.slug ? 'page' : null"
>{{ tag.name }}
<span class="blog-tag__count">{{ tag.count }}</span></a
>
}
</nav>
}
@if (shown().length > 0) {
<ul class="blog-list"> <ul class="blog-list">
@for (post of posts(); track post.id) { @for (post of shown(); track post.id) {
<li> <li>
<app-post-card <app-post-card
[post]="post" [post]="post"
@@ -61,7 +100,7 @@ import { PostCard } from './post-card';
status="info" status="info"
appearance="fill" appearance="fill"
gap="xs" gap="xs"
[title]="copy().blog.empty" [title]="activeSlug() ? copy().blog.noneTagged : copy().blog.empty"
[text]="''" [text]="''"
/> />
} }
@@ -72,4 +111,29 @@ export class BlogPage {
readonly posts = input.required<PublicPage[]>(); readonly posts = input.required<PublicPage[]>();
readonly copy = input.required<Copy>(); readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>(); readonly locale = input.required<Locale>();
protected readonly activeSlug = toSignal(
inject(ActivatedRoute).queryParamMap.pipe(
map((params) => params.get('tag')),
),
{ initialValue: null },
);
protected readonly tags = computed(() => tagsOf(this.posts()));
protected readonly activeTag = computed(
() => this.tags().find((tag) => tag.slug === this.activeSlug()) ?? null,
);
protected readonly shown = computed(() =>
taggedWith(this.posts(), this.activeSlug()),
);
protected readonly blogHref = computed(() =>
listingPath('blog', this.locale()),
);
protected tagHref(slug: string): string {
return tagHref(this.locale(), slug);
}
} }

View File

@@ -17,3 +17,28 @@
margin: 1rem 0 0; margin: 1rem 0 0;
font-size: 0.9375rem; font-size: 0.9375rem;
} }
.content-page__byline {
color: inherit;
font-weight: 600;
}
.content-page__tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 2.5rem;
}
.content-page__tag {
padding: 0.25rem 0.75rem;
border-radius: 999px;
background: color-mix(in oklab, var(--pi-color-primary) 12%, transparent);
color: inherit;
font-size: 0.875rem;
text-decoration: none;
&:hover {
background: color-mix(in oklab, var(--pi-color-primary) 22%, transparent);
}
}

View File

@@ -12,7 +12,7 @@ import {
import type { Locale } from '../core/api'; import type { Locale } from '../core/api';
import type { PublicPage } from '../core/api.types'; import type { PublicPage } from '../core/api.types';
import { isBlogPost } from '../core/blog'; import { isBlogPost, tagHref } from '../core/blog';
import type { Copy } from '../core/copy'; import type { Copy } from '../core/copy';
import { listingPath } from '../core/routes'; import { listingPath } from '../core/routes';
import { ApiImage } from './api-image'; import { ApiImage } from './api-image';
@@ -55,6 +55,13 @@ import { ContactCta } from './contact-cta';
@if (isPost() && page().published_at; as published) { @if (isPost() && page().published_at; as published) {
<p class="content-page__muted content-page__date"> <p class="content-page__muted content-page__date">
@if (page().byline; as byline) {
{{ copy().blog.by }}
<a class="content-page__byline" [href]="byline.path">{{
byline.name
}}</a>
·
}
{{ copy().blog.published }} {{ copy().blog.published }}
<time [attr.datetime]="published">{{ publishedOn() }}</time> <time [attr.datetime]="published">{{ publishedOn() }}</time>
</p> </p>
@@ -82,6 +89,16 @@ import { ContactCta } from './contact-cta';
} }
<div class="content-page__body" [innerHTML]="page().body ?? ''"></div> <div class="content-page__body" [innerHTML]="page().body ?? ''"></div>
@if (isPost() && (page().tags ?? []).length > 0) {
<nav class="content-page__tags" [attr.aria-label]="copy().blog.tags">
@for (tag of page().tags; track tag.slug) {
<a class="content-page__tag" [href]="tagHref(tag.slug)"
>#{{ tag.name }}</a
>
}
</nav>
}
</article> </article>
<aside class="content-page__aside"> <aside class="content-page__aside">
@@ -126,6 +143,10 @@ export class ContentPage {
listingPath('blog', this.locale()), listingPath('blog', this.locale()),
); );
protected tagHref(slug: string): string {
return tagHref(this.locale(), slug);
}
protected readonly publishedOn = computed(() => { protected readonly publishedOn = computed(() => {
const published = this.page().published_at; const published = this.page().published_at;

View File

@@ -75,10 +75,12 @@ const richBody =
'<figure><img src="/logo.png" alt="Otra vez el logo"><figcaption>Una imagen con pie de foto.</figcaption></figure>' + '<figure><img src="/logo.png" alt="Otra vez el logo"><figcaption>Una imagen con pie de foto.</figcaption></figure>' +
'<blockquote><p>Lo que no se mide en un móvil de gama baja, no está medido.</p></blockquote>'; '<blockquote><p>Lo que no se mide en un móvil de gama baja, no está medido.</p></blockquote>';
const post = (id, title, slug, published_at) => ({ const post = (id, title, slug, published_at, tags = []) => ({
...page(id, title, slug, false), ...page(id, title, slug, false),
body: richBody, body: richBody,
published_at, published_at,
tags,
byline: { name: 'Ada Lovelace', slug: 'ada', headline: 'Backend', avatar: null, path: '/equipo/ada' },
excerpt: 'Lo que hemos aprendido este mes, contado sin prisa y con ejemplos de código.', 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 }, category: { id: 9, name: 'Blog', slug: 'blog', position: 3 },
}); });
@@ -86,8 +88,8 @@ const post = (id, title, slug, published_at) => ({
const pages = [ const pages = [
page(1, 'Sobre nosotros', 'sobre-nosotros', true), page(1, 'Sobre nosotros', 'sobre-nosotros', true),
page(2, 'Contacto', 'contacto', false), page(2, 'Contacto', 'contacto', false),
post(3, 'Por qué volvimos a renderizar en el servidor', 'volvimos-al-servidor', '2026-09-20T09:00:00Z'), post(3, 'Por qué volvimos a renderizar en el servidor', 'volvimos-al-servidor', '2026-09-20T09:00:00Z', [{ name: 'Angular', slug: 'angular' }, { name: 'Rendimiento', slug: 'rendimiento' }]),
post(4, 'Nuestro primer año como colectivo', 'primer-ano', '2026-06-01T09:00:00Z'), post(4, 'Nuestro primer año como colectivo', 'primer-ano', '2026-06-01T09:00:00Z', [{ name: 'Colectivo', slug: 'colectivo' }]),
]; ];
const data = { const data = {