Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions src/app/(pages)/speakers/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";

import CTASection from "@/components/blocks/cta/cta";
import SpeakerDetail from "@/components/blocks/speakers/speaker-detail";
Expand All @@ -12,7 +12,11 @@ import {
buildSpeakerPageMetadata,
getSpeakerShareImageUrl,
} from "@/lib/speaker-seo";
import { getAllSpeakerSlugs, getLocalizedSpeaker } from "@/lib/speakers";
import {
getAllSpeakerSlugs,
getCanonicalSpeakerSlug,
getLocalizedSpeaker,
} from "@/lib/speakers";

export async function generateStaticParams() {
return getAllSpeakerSlugs().map((slug) => ({ slug }));
Expand All @@ -36,8 +40,13 @@ const SpeakerSlugPage = async ({
params: Promise<{ slug: string }>;
}) => {
const { slug } = await params;
const canonicalSlug = getCanonicalSpeakerSlug(slug);

if (slug !== canonicalSlug) {
redirect(`/speakers/${canonicalSlug}`);
}

const speaker = getLocalizedSpeaker(slug, STATIC_PRERENDER_LOCALE);
const speaker = getLocalizedSpeaker(canonicalSlug, STATIC_PRERENDER_LOCALE);

if (!speaker) {
notFound();
Expand Down Expand Up @@ -87,7 +96,7 @@ const SpeakerSlugPage = async ({

return (
<>
<SpeakerDetail slug={slug} />
<SpeakerDetail slug={canonicalSlug} />

<SectionSeparator />

Expand Down
2 changes: 1 addition & 1 deletion src/assets/data/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,7 @@ export const scheduleEvents: ScheduleEvent[] = [
"Multi-Agent Teams in AI-Assisted Development: A Glimpse Into the Future of Programming",
speaker: "José Hernán Ortiz Ocampo / Daniel Sabogal",
talkKey: "multi-agent-teams-ai-dev",
label: "Artificial Intelligence",
label: "LOKA Sponsor",
language: ["EN"],
},
{
Expand Down
8 changes: 4 additions & 4 deletions src/assets/data/workshop-speakers-content.locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,9 @@ export const workshopSpeakerContentByLocale: Record<
"Go beyond vibe coding and learn how to use specifications and code graphs to guide AI-assisted development. In this workshop, you'll discover how structured specs and dependency graphs give AI coding tools the context they need to produce coherent, maintainable code. We'll work with real Python projects to define specs, generate code graphs, and wire them into your AI-assisted workflow—resulting in code that actually makes sense architecturally.",
},
"esneider-bravo-benitez": {
title: "Software Engineer @ Lendingfront",
title: "Software Engineer @ Muno Labs",
description:
"Esneider is a Software Engineer at Lendingfront with a focus on code generation tools and developer experience. He explores how structured specifications can guide AI coding assistants to produce more reliable outputs, and contributes to open-source tooling for spec-driven AI development.",
"Esneider is a Software Engineer at Muno Labs with a focus on code generation tools and developer experience. He explores how structured specifications can guide AI coding assistants to produce more reliable outputs, and contributes to open-source tooling for spec-driven AI development.",
talkTitle: "Beyond Vibe Coding: Spec Driven Development with Code Graphs",
talkDescription:
"Go beyond vibe coding and learn how to use specifications and code graphs to guide AI-assisted development. In this workshop, you'll discover how structured specs and dependency graphs give AI coding tools the context they need to produce coherent, maintainable code. We'll work with real Python projects to define specs, generate code graphs, and wire them into your AI-assisted workflow—resulting in code that actually makes sense architecturally.",
Expand Down Expand Up @@ -372,9 +372,9 @@ export const workshopSpeakerContentByLocale: Record<
"Ve más allá del vibe coding y aprende a usar especificaciones y grafos de código para guiar el desarrollo asistido por IA. En este taller descubrirás cómo las especificaciones estructuradas y los grafos de dependencias dan a las herramientas de codificación de IA el contexto que necesitan para producir código coherente y mantenible. Trabajaremos con proyectos reales de Python para definir specs, generar grafos de código y conectarlos a tu flujo de trabajo asistido por IA, resultando en código que tiene sentido arquitectónicamente.",
},
"esneider-bravo-benitez": {
title: "Ingeniero de Software @ Lendingfront",
title: "Ingeniero de Software @ Muno Labs",
description:
"Esneider es Ingeniero de Software en Lendingfront con enfoque en herramientas de generación de código y experiencia del desarrollador. Explora cómo las especificaciones estructuradas pueden guiar a los asistentes de codificación de IA para producir resultados más confiables, y contribuye a herramientas de código abierto para el desarrollo dirigido por especificaciones.",
"Esneider es Ingeniero de Software en Muno Labs con enfoque en herramientas de generación de código y experiencia del desarrollador. Explora cómo las especificaciones estructuradas pueden guiar a los asistentes de codificación de IA para producir resultados más confiables, y contribuye a herramientas de código abierto para el desarrollo dirigido por especificaciones.",
talkTitle:
"Más allá del Vibe Coding: Spec Driven Development con Code Graphs",
talkDescription:
Expand Down
22 changes: 15 additions & 7 deletions src/components/blocks/speakers/speaker-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { MotionPreset } from "@/components/ui/motion-preset";
import { Separator } from "@/components/ui/separator";
import { useLanguage, useTranslations } from "@/contexts/language-context";
import { getLocalizedSpeaker } from "@/lib/speakers";
import { getTalkBySpeakerSlug } from "@/lib/talks";
import { getTalksBySpeakerSlug } from "@/lib/talks";

type SpeakerDetailProps = {
slug: string;
Expand All @@ -27,8 +27,8 @@ const SpeakerDetail = ({ slug }: SpeakerDetailProps) => {
() => getLocalizedSpeaker(slug, locale),
[slug, locale],
);
const talk = useMemo(
() => getTalkBySpeakerSlug(slug, locale),
const talks = useMemo(
() => getTalksBySpeakerSlug(slug, locale),
[slug, locale],
);

Expand Down Expand Up @@ -145,11 +145,19 @@ const SpeakerDetail = ({ slug }: SpeakerDetailProps) => {
className="space-y-4"
>
<h2 className="text-2xl font-semibold">
{talk?.format === "workshop"
? t("blocks.speakers.detail.workshop")
: t("blocks.speakers.detail.talk")}
{talks.length > 1
? t("blocks.speakers.detail.sessions")
: talks[0]?.format === "workshop"
? t("blocks.speakers.detail.workshop")
: t("blocks.speakers.detail.talk")}
</h2>
{talk ? <TalkLinkCard talk={talk} /> : null}
{talks.length > 0 ? (
<div className="space-y-4">
{talks.map((talk) => (
<TalkLinkCard key={talk.id} talk={talk} />
))}
</div>
) : null}
</MotionPreset>
</div>
</div>
Expand Down
5 changes: 3 additions & 2 deletions src/components/blocks/talks/talk-speakers-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Link from "next/link";
import SpeakerImage from "@/components/blocks/speakers/speaker-image";
import { Card, CardContent } from "@/components/ui/card";
import { useLanguage, useTranslations } from "@/contexts/language-context";
import { getSpeakerProfileHref } from "@/lib/speakers";
import { getTalkSpeakers, type Talk } from "@/lib/talks";

type TalkSpeakersListProps = {
Expand Down Expand Up @@ -74,7 +75,7 @@ const TalkSpeakersList = ({
className="bg-card gap-0 overflow-hidden rounded-[14px] border border-border/60 py-0 shadow-xs"
>
<Link
href={`/speakers/${speaker.slug}`}
href={getSpeakerProfileHref(speaker.slug)}
className="group block"
aria-label={speaker.name}
>
Expand Down Expand Up @@ -120,7 +121,7 @@ const TalkSpeakersList = ({
return (
<Link
key={speaker.slug}
href={`/speakers/${speaker.slug}`}
href={getSpeakerProfileHref(speaker.slug)}
className="pointer-events-auto flex min-w-0 items-center gap-3 transition-opacity hover:opacity-80"
aria-label={speaker.name}
>
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/blocks-en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export const blocksEn = {
about: "About",
talk: "Session",
workshop: "Workshop",
sessions: "Sessions",
country: "Country",
language: "Language",
level: "Level",
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/blocks-es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export const blocksEs: BlocksMessages = {
about: "Acerca de",
talk: "Charla",
workshop: "Taller",
sessions: "Sesiones",
country: "País",
language: "Idioma",
level: "Nivel",
Expand Down
5 changes: 3 additions & 2 deletions src/lib/schedule-speakers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ScheduleEvent } from "@/assets/data/schedule";
import { speakers } from "@/assets/data/speakers";
import { getLocalizedKeynote } from "@/lib/keynotes";
import type { SiteLocale } from "@/lib/site-messages";
import { getSpeakerProfileHref } from "@/lib/speakers";
import {
getTalkBySpeakerSlug,
getTalkByTalkKey,
Expand Down Expand Up @@ -41,7 +42,7 @@ function resolveSpeakerByName(name: string): ScheduleEventSpeaker {
name: match.name,
slug: match.slug,
image: match.image,
href: `/speakers/${match.slug}`,
href: getSpeakerProfileHref(match.slug),
};
}

Expand Down Expand Up @@ -109,7 +110,7 @@ export function resolveSpeakersForScheduleEvent(
name: speaker.name,
slug: speaker.slug,
image: speaker.image,
href: `/speakers/${speaker.slug}`,
href: getSpeakerProfileHref(speaker.slug),
}));
}

Expand Down
40 changes: 31 additions & 9 deletions src/lib/speaker-seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import {
} from "@/lib/site-seo";
import { resolveSpeakerImageSource } from "@/lib/speaker-image.server";
import {
getCanonicalSpeakerSlug,
getLocalizedSpeaker,
getSpeakerBySlug,
type LocalizedSpeaker,
} from "@/lib/speakers";
import { getTalksBySpeakerSlug } from "@/lib/talks";

const META_DESCRIPTION_MAX = 200;
const DEFAULT_OG_IMAGE = "/images/cfp.jpg";
Expand All @@ -28,7 +30,20 @@ function truncateMetaDescription(text: string): string {
return `${normalized.slice(0, META_DESCRIPTION_MAX - 1).trimEnd()}…`;
}

function buildSpeakerDescription(speaker: LocalizedSpeaker): string {
function buildSpeakerDescription(
speaker: LocalizedSpeaker,
locale: SiteLocale,
): string {
const talks = getTalksBySpeakerSlug(speaker.slug, locale);

if (talks.length > 1) {
const sessionTitles = talks.map((talk) => `"${talk.talkTitle}"`).join(", ");

return truncateMetaDescription(
`${speaker.name} presents ${sessionTitles} at PyCon Colombia 2026. ${speaker.description}`,
);
}

return truncateMetaDescription(
`${speaker.name} presents "${speaker.talkTitle}" at PyCon Colombia 2026. ${speaker.talkDescription}`,
);
Expand Down Expand Up @@ -56,13 +71,14 @@ function buildSpeakerKeywords(

/** Absolute URL for the speaker profile photo when one exists. */
export function getSpeakerProfileImageUrl(slug: string): string | undefined {
const speaker = getSpeakerBySlug(slug);
const canonicalSlug = getCanonicalSpeakerSlug(slug);
const speaker = getSpeakerBySlug(canonicalSlug) ?? getSpeakerBySlug(slug);

if (!speaker) {
return undefined;
}

const resolved = resolveSpeakerImageSource(speaker.image, slug);
const resolved = resolveSpeakerImageSource(speaker.image, speaker.slug);

if (!resolved) {
return undefined;
Expand All @@ -84,20 +100,26 @@ export function buildSpeakerPageMetadata(
slug: string,
locale: SiteLocale,
): Metadata {
const speaker = getLocalizedSpeaker(slug, locale);
const canonicalSlug = getCanonicalSpeakerSlug(slug);
const speaker = getLocalizedSpeaker(canonicalSlug, locale);

if (!speaker) {
return {};
}

const description = buildSpeakerDescription(speaker);
const canonical = `${getSiteUrl()}/speakers/${slug}`;
const ogTitle = `${speaker.name} — ${speaker.talkTitle} | PyCon Colombia 2026`;
const imageUrl = getSpeakerShareImageUrl(slug);
const talks = getTalksBySpeakerSlug(canonicalSlug, locale);
const description = buildSpeakerDescription(speaker, locale);
const canonical = `${getSiteUrl()}/speakers/${canonicalSlug}`;
const primaryTalkTitle =
talks.length > 1
? `${talks.length} sessions`
: (talks[0]?.talkTitle ?? speaker.talkTitle);
const ogTitle = `${speaker.name} — ${primaryTalkTitle} | PyCon Colombia 2026`;
const imageUrl = getSpeakerShareImageUrl(canonicalSlug);
const imageAlt = `${speaker.name} — PyCon Colombia 2026 speaker`;

return {
title: `${speaker.name} — ${speaker.talkTitle}`,
title: `${speaker.name} — ${primaryTalkTitle}`,
description,
keywords: buildSpeakerKeywords(speaker, locale),
alternates: {
Expand Down
Loading
Loading