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>
286 lines
8.3 KiB
TypeScript
286 lines
8.3 KiB
TypeScript
import {
|
|
ChangeDetectionStrategy,
|
|
Component,
|
|
computed,
|
|
input,
|
|
} from '@angular/core';
|
|
|
|
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';
|
|
import { ContributePage } from './contribute-page';
|
|
import { MemberDetail } from './member-detail';
|
|
import { PageShell, type PageSeo } from './page-shell';
|
|
import { ProjectDetail } from './project-detail';
|
|
|
|
/**
|
|
* A page reached by its address, whatever it holds: the shell around it, and
|
|
* the body for its type inside.
|
|
*/
|
|
@Component({
|
|
selector: 'app-routed-page',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
imports: [
|
|
BlogPage,
|
|
CallDetail,
|
|
ContentPage,
|
|
ContributePage,
|
|
MemberDetail,
|
|
PageShell,
|
|
ProjectDetail,
|
|
RadarPage,
|
|
ServiceDetail,
|
|
],
|
|
template: `
|
|
<app-page-shell [data]="data()" [seo]="seo()">
|
|
@if (data().found; as found) {
|
|
@switch (found.type) {
|
|
@case ('page') {
|
|
<app-content-page
|
|
[page]="found.record"
|
|
[pages]="data().pages"
|
|
[copy]="copy()"
|
|
[locale]="data().locale"
|
|
/>
|
|
}
|
|
@case ('project') {
|
|
<app-project-detail
|
|
[project]="found.record"
|
|
[copy]="copy()"
|
|
[locale]="data().locale"
|
|
/>
|
|
}
|
|
@case ('call') {
|
|
<app-call-detail
|
|
[call]="found.record"
|
|
[copy]="copy()"
|
|
[locale]="data().locale"
|
|
/>
|
|
}
|
|
@case ('service') {
|
|
<app-service-detail
|
|
[service]="found.record"
|
|
[copy]="copy()"
|
|
[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"
|
|
[issues]="found.record.issues"
|
|
[copy]="copy()"
|
|
[locale]="data().locale"
|
|
/>
|
|
}
|
|
@case ('radar') {
|
|
<app-radar-page [technologies]="found.record" [copy]="copy()" />
|
|
}
|
|
@case ('member') {
|
|
<app-member-detail
|
|
[member]="found.record"
|
|
[copy]="copy()"
|
|
[locale]="data().locale"
|
|
/>
|
|
}
|
|
}
|
|
}
|
|
</app-page-shell>
|
|
`,
|
|
})
|
|
export class RoutedPage {
|
|
readonly data = input.required<RoutedPageData>();
|
|
|
|
protected readonly copy = computed(() => copyFor(this.data().locale));
|
|
|
|
protected readonly seo = computed(() =>
|
|
seoFor(this.data().found, this.copy(), this.data().locale),
|
|
);
|
|
}
|
|
|
|
/** What each type puts in the document head. Null when nothing was found. */
|
|
export function seoFor(
|
|
found: RoutedRecord | null,
|
|
copy: Copy,
|
|
locale: Locale,
|
|
): PageSeo | null {
|
|
if (!found) {
|
|
return null;
|
|
}
|
|
|
|
switch (found.type) {
|
|
case 'page': {
|
|
const page = found.record;
|
|
const post = isBlogPost(page, locale);
|
|
|
|
return {
|
|
title: page.meta_title || `Pireon — ${page.title}`,
|
|
description: page.meta_description || page.excerpt || '',
|
|
path: page.path,
|
|
alternates: page.alternates ?? {},
|
|
type: 'article',
|
|
image: page.featured_image?.url,
|
|
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,
|
|
inLanguage: page.locale,
|
|
},
|
|
};
|
|
}
|
|
case 'project': {
|
|
const project = found.record;
|
|
|
|
return {
|
|
title: project.meta_title || `Pireon — ${project.title}`,
|
|
description: project.meta_description || project.tagline || '',
|
|
path: project.path,
|
|
alternates: project.alternates ?? {},
|
|
type: 'article',
|
|
image: project.cover_image?.url,
|
|
jsonLd: {
|
|
'@type': 'CreativeWork',
|
|
name: project.title,
|
|
description: project.tagline,
|
|
inLanguage: project.locale,
|
|
dateCreated: project.started_at,
|
|
dateModified: project.updated_at,
|
|
keywords:
|
|
(project.technologies ?? [])
|
|
.map((technology) => technology.name)
|
|
.join(', ') || undefined,
|
|
creator: (project.members ?? []).map((member) => ({
|
|
'@type': 'Person',
|
|
name: member.name,
|
|
})),
|
|
...(project.client
|
|
? { funder: { '@type': 'Organization', name: project.client.name } }
|
|
: {}),
|
|
},
|
|
};
|
|
}
|
|
case 'member': {
|
|
const member = found.record;
|
|
|
|
return {
|
|
title: `Pireon — ${member.name}`,
|
|
description: member.headline || member.bio?.slice(0, 160) || '',
|
|
path: member.path,
|
|
alternates: member.alternates ?? {},
|
|
type: 'profile',
|
|
image: member.avatar?.url,
|
|
jsonLd: {
|
|
'@type': 'Person',
|
|
name: member.name,
|
|
jobTitle: member.headline,
|
|
description: member.bio,
|
|
image: member.avatar?.url,
|
|
sameAs: memberLinks(member).map((link) => link.url),
|
|
memberOf: { '@type': 'Organization', name: 'Pireon' },
|
|
},
|
|
};
|
|
}
|
|
case 'service': {
|
|
const service = found.record;
|
|
|
|
return {
|
|
title: service.meta_title || `Pireon — ${service.title}`,
|
|
description: service.meta_description || service.tagline || '',
|
|
path: service.path,
|
|
alternates: service.alternates ?? {},
|
|
type: 'article',
|
|
jsonLd: {
|
|
'@type': 'Service',
|
|
name: service.title,
|
|
description: service.tagline,
|
|
provider: { '@type': 'Organization', name: 'Pireon' },
|
|
},
|
|
};
|
|
}
|
|
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}`,
|
|
description: copy.contribute.lead,
|
|
path: listingPath('contribute', locale),
|
|
alternates: listingAlternates('contribute'),
|
|
type: 'website',
|
|
jsonLd: {
|
|
'@type': 'WebPage',
|
|
name: copy.contribute.title,
|
|
description: copy.contribute.lead,
|
|
},
|
|
};
|
|
case 'radar':
|
|
return {
|
|
title: `Pireon — ${copy.sections.radar.title}`,
|
|
description: copy.radar.lead,
|
|
path: listingPath('radar', locale),
|
|
alternates: listingAlternates('radar'),
|
|
type: 'website',
|
|
jsonLd: {
|
|
'@type': 'WebPage',
|
|
name: copy.sections.radar.title,
|
|
description: copy.radar.lead,
|
|
},
|
|
};
|
|
case 'call': {
|
|
const call = found.record;
|
|
const project = call.project?.title;
|
|
|
|
return {
|
|
title: `Pireon — ${call.title}${project ? ` · ${project}` : ''}`,
|
|
description: call.description?.slice(0, 160) || '',
|
|
path: call.path,
|
|
alternates: call.alternates ?? {},
|
|
type: 'article',
|
|
image: call.project?.cover_image?.url,
|
|
jsonLd: {
|
|
'@type': 'WebPage',
|
|
name: call.title,
|
|
description: call.description,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
}
|