From bca3e4bcc6c54fd117ef5e736984bdde7ee59583 Mon Sep 17 00:00:00 2001 From: pigui Date: Thu, 1 Oct 2026 11:20:57 +0200 Subject: [PATCH] Show who signs a post and its tags, and read the blog by tag Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++ landing/src/app/core/api.types.ts | 25 ++++++++++ landing/src/app/core/blog.spec.ts | 35 ++++++++++++++ landing/src/app/core/blog.ts | 42 ++++++++++++++++ landing/src/app/core/copy.ts | 20 ++++++++ landing/src/app/ui/blog-page.scss | 39 +++++++++++++++ landing/src/app/ui/blog-page.ts | 72 ++++++++++++++++++++++++++-- landing/src/app/ui/content-page.scss | 25 ++++++++++ landing/src/app/ui/content-page.ts | 23 ++++++++- tools/mock-api.mjs | 8 ++-- 10 files changed, 286 insertions(+), 8 deletions(-) create mode 100644 landing/src/app/core/blog.spec.ts diff --git a/README.md b/README.md index ec475ff..4098626 100644 --- a/README.md +++ b/README.md @@ -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. - A post shows its date and leads back to the blog; its structured data is a `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 ``. - 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 diff --git a/landing/src/app/core/api.types.ts b/landing/src/app/core/api.types.ts index 557a531..749e830 100644 --- a/landing/src/app/core/api.types.ts +++ b/landing/src/app/core/api.types.ts @@ -165,6 +165,14 @@ export interface PublicPage { unpublish_at?: string; updated_at: string; 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; path?: string; } @@ -268,6 +276,21 @@ export interface PublicRepository { 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 { id: number; 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/slugs': Record })[]>; '/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/{slug}': PublicProject; '/public/v1/members': PublicMember[]; diff --git a/landing/src/app/core/blog.spec.ts b/landing/src/app/core/blog.spec.ts new file mode 100644 index 0000000..1810a4a --- /dev/null +++ b/landing/src/app/core/blog.spec.ts @@ -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'); + }); +}); diff --git a/landing/src/app/core/blog.ts b/landing/src/app/core/blog.ts index fb8324b..5b67c88 100644 --- a/landing/src/app/core/blog.ts +++ b/landing/src/app/core/blog.ts @@ -8,6 +8,7 @@ */ import type { Locale } from './api'; import type { PublicPage } from './api.types'; +import { listingPath } from './routes'; export const BLOG_CATEGORY: Record = { es: 'blog', @@ -25,3 +26,44 @@ export function byPublication(pages: PublicPage[]): PublicPage[] { (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(); + + 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; +} diff --git a/landing/src/app/core/copy.ts b/landing/src/app/core/copy.ts index 8696acb..e505f3b 100644 --- a/landing/src/app/core/copy.ts +++ b/landing/src/app/core/copy.ts @@ -137,6 +137,11 @@ export interface Copy { published: string; feed: string; seeAll: string; + by: string; + tags: string; + allTags: string; + tagged: string; + noneTagged: string; }; service: { eyebrow: string; @@ -328,6 +333,11 @@ const es: Copy = { published: 'Publicado el', feed: 'Suscríbete por RSS', seeAll: 'Ver todas las novedades', + by: 'Por', + tags: 'Temas', + allTags: 'Todo', + tagged: 'Entradas sobre «{tag}»', + noneTagged: 'No hay entradas con esta etiqueta.', }, service: { eyebrow: 'Servicio', @@ -532,6 +542,11 @@ const en: Copy = { published: 'Published on', feed: 'Subscribe via RSS', seeAll: 'See all the news', + by: 'By', + tags: 'Topics', + allTags: 'Everything', + tagged: 'Posts about “{tag}”', + noneTagged: 'There are no posts with this tag.', }, service: { eyebrow: 'Service', @@ -736,6 +751,11 @@ const ca: Copy = { published: 'Publicat el', feed: "Subscriu-t'hi per RSS", seeAll: 'Veure totes les novetats', + by: 'Per', + tags: 'Temes', + allTags: 'Tot', + tagged: 'Entrades sobre «{tag}»', + noneTagged: 'No hi ha entrades amb aquesta etiqueta.', }, service: { eyebrow: 'Servei', diff --git a/landing/src/app/ui/blog-page.scss b/landing/src/app/ui/blog-page.scss index 4d49aa8..0a98f3b 100644 --- a/landing/src/app/ui/blog-page.scss +++ b/landing/src/app/ui/blog-page.scss @@ -30,3 +30,42 @@ 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; +} diff --git a/landing/src/app/ui/blog-page.ts b/landing/src/app/ui/blog-page.ts index a5ca971..b9dd7e4 100644 --- a/landing/src/app/ui/blog-page.ts +++ b/landing/src/app/ui/blog-page.ts @@ -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 { PiNoteComponent, PiTextColorDirective, @@ -8,12 +17,17 @@ import { import type { Locale } from '../core/api'; import type { PublicPage } from '../core/api.types'; +import { tagHref, taggedWith, tagsOf } from '../core/blog'; import type { Copy } from '../core/copy'; +import { listingPath } from '../core/routes'; import { PostCard } from './post-card'; /** * The blog: the pages in the panel's "Blog" category, newest first, each * 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({ selector: 'app-blog-page', @@ -34,6 +48,11 @@ import { PostCard } from './post-card';

{{ copy().blog.lead }}

+ @if (activeTag(); as tag) { +

+ {{ copy().blog.tagged.replace('{tag}', tag.name) }} +

+ }

{{ copy().blog.feed @@ -43,9 +62,29 @@ import { PostCard } from './post-card';

- @if (posts().length > 0) { + @if (tags().length > 0) { + + } + + @if (shown().length > 0) {
    - @for (post of posts(); track post.id) { + @for (post of shown(); track post.id) {
  • } @@ -72,4 +111,29 @@ export class BlogPage { readonly posts = input.required(); readonly copy = input.required(); readonly locale = input.required(); + + 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); + } } diff --git a/landing/src/app/ui/content-page.scss b/landing/src/app/ui/content-page.scss index 7265915..8be94f4 100644 --- a/landing/src/app/ui/content-page.scss +++ b/landing/src/app/ui/content-page.scss @@ -17,3 +17,28 @@ margin: 1rem 0 0; 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); + } +} diff --git a/landing/src/app/ui/content-page.ts b/landing/src/app/ui/content-page.ts index 0d299ed..99c7c6c 100644 --- a/landing/src/app/ui/content-page.ts +++ b/landing/src/app/ui/content-page.ts @@ -12,7 +12,7 @@ import { import type { Locale } from '../core/api'; 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 { listingPath } from '../core/routes'; import { ApiImage } from './api-image'; @@ -55,6 +55,13 @@ import { ContactCta } from './contact-cta'; @if (isPost() && page().published_at; as published) {

    + @if (page().byline; as byline) { + {{ copy().blog.by }} + + · + } {{ copy().blog.published }}

    @@ -82,6 +89,16 @@ import { ContactCta } from './contact-cta'; }
    + + @if (isPost() && (page().tags ?? []).length > 0) { + + }