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