Compare commits

...

2 Commits

Author SHA1 Message Date
pigui
e2e90d5512 Fix lists in posts and pages, and style images inside the text
Every list in a post rendered its bullet alone on one line and the
item's text on the next. The panel's editor wraps each item's text in
a <p>, and @pi/ui's reset puts the bullet inside the line: a block
after an inline marker falls below it, paragraph margins and all.
Bullets now sit outside, with the paragraph inside an item unspaced.

Images in the text — attached in the panel or written in by URL through
the API — are never wider than the column and sit apart from the prose,
with a caption under a figure. Inline code and code blocks get a tinted
surface; quotes keep the library's own quotation marks.

The mock's posts carry such a body, so the typography can be reviewed
without writing one in the panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:11:46 +02:00
pigui
e5d91d673a Put the blog where people look for it
The blog was reachable only from a link in the footer, which is to say
hardly at all. Once there is a post:

- "Blog" joins the header menu, on desktop and in the mobile menu, on
  every page.
- The landing shows the three latest posts under "Novedades", with a
  link to the rest.

The post card moves into its own component, post-card, shared by the
blog (image beside the text) and the landing (image above).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:02:55 +02:00
10 changed files with 308 additions and 117 deletions

View File

@@ -266,8 +266,10 @@ any page. That category has to exist with the slug `blog` in every language
- 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.
- 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
it too, but does not list posts one by one — it would grow without end.
A blog reachable only from the footer turned out to be one nobody found.
## Search engines and link previews

View File

@@ -136,6 +136,7 @@ export interface Copy {
readMore: string;
published: string;
feed: string;
seeAll: string;
};
service: {
eyebrow: string;
@@ -326,6 +327,7 @@ const es: Copy = {
readMore: 'Leer',
published: 'Publicado el',
feed: 'Suscríbete por RSS',
seeAll: 'Ver todas las novedades',
},
service: {
eyebrow: 'Servicio',
@@ -529,6 +531,7 @@ const en: Copy = {
readMore: 'Read',
published: 'Published on',
feed: 'Subscribe via RSS',
seeAll: 'See all the news',
},
service: {
eyebrow: 'Service',
@@ -732,6 +735,7 @@ const ca: Copy = {
readMore: 'Llegir',
published: 'Publicat el',
feed: "Subscriu-t'hi per RSS",
seeAll: 'Veure totes les novetats',
},
service: {
eyebrow: 'Servei',

View File

@@ -23,7 +23,8 @@ import type {
PublicTechnology,
} from './api.types';
import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog';
import { resolveRoute, type RouteMatch } from './routes';
import { copyFor } from './copy';
import { listingPath, resolveRoute, type RouteMatch } from './routes';
/** The record, tagged with its type so the template can switch on it. */
export type RoutedRecord =
@@ -132,13 +133,15 @@ async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
/**
* Featured pages go in the header; the rest are one click away in the footer.
* Blog posts are neither: they have the blog.
* Blog posts are neither: they have the blog, which joins the header as soon
* as there is a post in it — a blog reachable only from the footer is one
* nobody finds.
*/
export function pageNavLinks(
pages: PublicPage[],
locale: Locale,
): { href: string; label: string }[] {
return pages
const featured = pages
.filter(
(page) =>
page.is_featured &&
@@ -150,4 +153,16 @@ export function pageNavLinks(
href: page.path as string,
label: page.title as string,
}));
const hasPosts = pages.some((page) => isBlogPost(page, locale));
return hasPosts
? [
...featured,
{
href: listingPath('blog', locale),
label: copyFor(locale).blog.eyebrow,
},
]
: featured;
}

View File

@@ -17,6 +17,7 @@ import {
PiChipComponent,
} from '@pi/ui';
import { byPublication, isBlogPost } from '../core/blog';
import { pageNavLinks } from '../core/routed-page';
import { listingPath } from '../core/routes';
import { copyFor } from '../core/copy';
@@ -30,6 +31,7 @@ import { Hero } from '../ui/hero';
import { IssueRow } from '../ui/issue-row';
import { MaintenanceScreen } from '../ui/maintenance-screen';
import { MemberCard } from '../ui/member-card';
import { PostCard } from '../ui/post-card';
import { ProjectCard } from '../ui/project-card';
import { Section } from '../ui/section';
import { ServiceCard } from '../ui/service-card';
@@ -66,6 +68,7 @@ export const routeMeta: RouteMeta = {
IssueRow,
MaintenanceScreen,
MemberCard,
PostCard,
ProjectCard,
Section,
ServiceCard,
@@ -267,6 +270,25 @@ export const routeMeta: RouteMeta = {
</app-section>
}
<!-- The latest posts, so the blog is found from the landing rather than
only from a link in the footer. -->
@if (latestPosts().length > 0) {
<app-section
anchor="novedades"
[title]="copy().blog.title"
[lead]="copy().blog.lead"
>
<div class="landing-grid">
@for (post of latestPosts(); track post.id) {
<app-post-card [post]="post" [copy]="copy()" [locale]="data().locale" />
}
</div>
<p class="landing-section__more">
<a [href]="blogHref()">{{ copy().blog.seeAll }} →</a>
</p>
</app-section>
}
<app-section
anchor="contacto"
[title]="copy().sections.contact.title"
@@ -302,6 +324,13 @@ export default class HomePage {
protected readonly radarHref = computed(() => listingPath('radar', this.data().locale));
protected readonly blogHref = computed(() => listingPath('blog', this.data().locale));
/** The three most recent posts; the rest are on the blog. */
protected readonly latestPosts = computed(() =>
byPublication(this.data().pages.filter((page) => isBlogPost(page, this.data().locale))).slice(0, 3),
);
protected readonly contributeHref = computed(() =>
listingPath('contribute', this.data().locale),
);

View File

@@ -90,18 +90,28 @@
margin: 0 0 1rem;
}
// The bullet outside the text, where a list's bullet goes. `@pi/ui`'s
// reset puts it inside, and the editor wraps each item's text in a `<p>`:
// together the bullet sat alone on one line and the text, a block, fell to
// the next — with a paragraph's margins on top.
ul,
ol {
margin: 0 0 1.5rem;
padding-left: 1.25rem;
padding-left: 1.5rem;
}
li {
margin: 0 0 0.5rem;
padding-left: 0.25rem;
list-style-position: outside;
&::marker {
color: var(--pi-color-primary);
}
> p {
margin: 0;
}
}
strong {
@@ -112,6 +122,67 @@
color: var(--pi-color-primary);
}
code {
padding: 0.1em 0.35em;
border-radius: 0.375rem;
background: color-mix(in srgb, var(--pi-color-primary) 10%, transparent);
font-size: 0.9em;
}
pre {
margin: 0 0 1.5rem;
padding: 1rem 1.25rem;
overflow-x: auto;
border-radius: var(--pi-border-radius-medium, 0.75rem);
background: color-mix(in srgb, var(--pi-color-primary) 7%, transparent);
line-height: 1.55;
code {
padding: 0;
background: none;
}
}
// `@pi/ui` already frames a quote with its own quotation marks; only the
// spacing is this page's business.
blockquote {
margin: 0 0 1.5rem;
p:last-child {
margin-bottom: 0;
}
}
hr {
margin: 2.5rem 0;
border: 0;
border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent);
}
// Images in the text — attached in the panel's editor, or by URL through
// the API. Never wider than the column, and set apart from the prose.
img {
display: block;
max-width: 100%;
height: auto;
margin: 2rem 0;
border-radius: var(--pi-border-radius-medium, 0.75rem);
}
figure {
margin: 2rem 0;
img {
margin: 0;
}
}
figcaption {
margin-top: 0.5rem;
font-size: 0.875rem;
color: color-mix(in srgb, currentColor 68%, transparent);
}
> :first-child {
margin-top: 0;
}

View File

@@ -30,66 +30,3 @@
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;
}

View File

@@ -9,7 +9,7 @@ import {
import type { Locale } from '../core/api';
import type { PublicPage } from '../core/api.types';
import type { Copy } from '../core/copy';
import { ApiImage } from './api-image';
import { PostCard } from './post-card';
/**
* The blog: the pages in the panel's "Blog" category, newest first, each
@@ -19,7 +19,7 @@ import { ApiImage } from './api-image';
selector: 'app-blog-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ApiImage,
PostCard,
PiNoteComponent,
PiTextColorDirective,
PiTextLargeDirective,
@@ -46,44 +46,13 @@ import { ApiImage } from './api-image';
@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>
<app-post-card
[post]="post"
[copy]="copy()"
[locale]="locale()"
layout="row"
/>
</li>
}
</ul>
@@ -103,12 +72,4 @@ 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));
}
}

View File

@@ -0,0 +1,77 @@
:host {
display: block;
height: 100%;
}
.post-card {
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
overflow: hidden;
border-radius: var(--pi-theme-card-radius, 1.5rem);
box-shadow: var(--pi-theme-card-shadow, none);
}
// Beside the text rather than above it, and only when there is an image:
// without one the text would sit in the image's column with the rest empty.
:host(.post-card--row) .post-card--media {
@media (min-width: 768px) {
grid-template-rows: none;
grid-template-columns: minmax(0, 20rem) minmax(0, 1fr);
}
}
// The `<img>` belongs to `app-api-image`, so a scoped rule cannot reach it.
.post-card__media ::ng-deep img {
display: block;
width: 100%;
height: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
}
.post-card__body {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1.5rem 1.75rem;
}
.post-card__date {
font-size: 0.8125rem;
color: color-mix(in srgb, currentColor 68%, transparent);
}
.post-card__title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.25;
:host(.post-card--row) & {
font-size: 1.375rem;
}
a {
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.post-card__excerpt {
margin: 0;
line-height: 1.6;
color: color-mix(in srgb, currentColor 68%, transparent);
}
.post-card__more {
margin-top: auto;
padding-top: 0.5rem;
color: var(--pi-color-primary);
font-weight: 600;
text-decoration: none;
}

View File

@@ -0,0 +1,83 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import type { Locale } from '../core/api';
import type { PublicPage } from '../core/api.types';
import type { Copy } from '../core/copy';
import { ApiImage } from './api-image';
/**
* One blog post as a card: image, date, title, excerpt, and the way in.
*
* `row` on the blog itself, image beside the text, one post a line; `stack`
* in a grid — the landing's latest posts — image above.
*/
@Component({
selector: 'app-post-card',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ApiImage],
styleUrl: './post-card.scss',
host: { '[class.post-card--row]': "layout() === 'row'" },
template: `
<article
class="landing-card post-card"
[class.post-card--media]="!!post().featured_image"
>
@if (post().featured_image; as image) {
<a
class="post-card__media"
[href]="post().path"
tabindex="-1"
aria-hidden="true"
>
<app-api-image
[image]="image"
[sizes]="
layout() === 'row'
? '(min-width: 1024px) 20rem, 100vw'
: '(min-width: 1024px) 24rem, 100vw'
"
/>
</a>
}
<div class="post-card__body">
@if (post().published_at; as published) {
<time class="post-card__date" [attr.datetime]="published">{{
date()
}}</time>
}
<h3 class="post-card__title">
<a [href]="post().path">{{ post().title }}</a>
</h3>
@if (post().excerpt; as excerpt) {
<p class="post-card__excerpt">{{ excerpt }}</p>
}
<a class="post-card__more" [href]="post().path"
>{{ copy().blog.readMore }} →</a
>
</div>
</article>
`,
})
export class PostCard {
readonly post = input.required<PublicPage>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
readonly layout = input<'row' | 'stack'>('stack');
protected readonly date = computed(() => {
const published = this.post().published_at;
return published
? new Intl.DateTimeFormat(this.locale(), {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(new Date(published))
: '';
});
}

View File

@@ -64,8 +64,20 @@ const page = (id, title, slug, featured) => ({
alternates: { es: `/${slug}`, en: `/en/${slug}-en`, ca: `/ca/${slug}-ca` }, path: `/${slug}`,
});
// A body with everything the editor can produce, images included, so the
// post's typography can be reviewed without writing one in the panel.
const richBody =
'<p>Volvimos a renderizar en el servidor por tres motivos.</p>' +
'<ul><li><p><strong>Rendimiento</strong> en móviles modestos.</p></li><li><p><strong>SEO</strong> sin trucos.</p></li></ul>' +
'<img src="/logo.png" alt="El logo de Pireon">' +
'<h2>Cómo lo hicimos</h2><p>Con <code>injectLoad</code> y un loader por página:</p>' +
'<pre><code>export const load = async () => fetchPage();</code></pre>' +
'<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>';
const post = (id, title, slug, published_at) => ({
...page(id, title, slug, false),
body: richBody,
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 },