diff --git a/src/app/(pages)/speakers/[slug]/page.tsx b/src/app/(pages)/speakers/[slug]/page.tsx index 30d47dd..9e78644 100644 --- a/src/app/(pages)/speakers/[slug]/page.tsx +++ b/src/app/(pages)/speakers/[slug]/page.tsx @@ -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"; @@ -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 })); @@ -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(); @@ -87,7 +96,7 @@ const SpeakerSlugPage = async ({ return ( <> - + diff --git a/src/assets/data/schedule.ts b/src/assets/data/schedule.ts index 96d5045..03bfcf0 100644 --- a/src/assets/data/schedule.ts +++ b/src/assets/data/schedule.ts @@ -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"], }, { diff --git a/src/assets/data/workshop-speakers-content.locale.ts b/src/assets/data/workshop-speakers-content.locale.ts index d54db2a..19e1a37 100644 --- a/src/assets/data/workshop-speakers-content.locale.ts +++ b/src/assets/data/workshop-speakers-content.locale.ts @@ -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.", @@ -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: diff --git a/src/components/blocks/speakers/speaker-detail.tsx b/src/components/blocks/speakers/speaker-detail.tsx index 83ea624..4d407a1 100644 --- a/src/components/blocks/speakers/speaker-detail.tsx +++ b/src/components/blocks/speakers/speaker-detail.tsx @@ -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; @@ -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], ); @@ -145,11 +145,19 @@ const SpeakerDetail = ({ slug }: SpeakerDetailProps) => { className="space-y-4" >

- {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")}

- {talk ? : null} + {talks.length > 0 ? ( +
+ {talks.map((talk) => ( + + ))} +
+ ) : null} diff --git a/src/components/blocks/talks/talk-speakers-list.tsx b/src/components/blocks/talks/talk-speakers-list.tsx index 3f0a1e3..5b220bb 100644 --- a/src/components/blocks/talks/talk-speakers-list.tsx +++ b/src/components/blocks/talks/talk-speakers-list.tsx @@ -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 = { @@ -74,7 +75,7 @@ const TalkSpeakersList = ({ className="bg-card gap-0 overflow-hidden rounded-[14px] border border-border/60 py-0 shadow-xs" > @@ -120,7 +121,7 @@ const TalkSpeakersList = ({ return ( diff --git a/src/lib/i18n/blocks-en.ts b/src/lib/i18n/blocks-en.ts index bb8ce72..a00eb5e 100644 --- a/src/lib/i18n/blocks-en.ts +++ b/src/lib/i18n/blocks-en.ts @@ -152,6 +152,7 @@ export const blocksEn = { about: "About", talk: "Session", workshop: "Workshop", + sessions: "Sessions", country: "Country", language: "Language", level: "Level", diff --git a/src/lib/i18n/blocks-es.ts b/src/lib/i18n/blocks-es.ts index 3325e72..2424206 100644 --- a/src/lib/i18n/blocks-es.ts +++ b/src/lib/i18n/blocks-es.ts @@ -156,6 +156,7 @@ export const blocksEs: BlocksMessages = { about: "Acerca de", talk: "Charla", workshop: "Taller", + sessions: "Sesiones", country: "País", language: "Idioma", level: "Nivel", diff --git a/src/lib/schedule-speakers.ts b/src/lib/schedule-speakers.ts index ce71119..0dbe988 100644 --- a/src/lib/schedule-speakers.ts +++ b/src/lib/schedule-speakers.ts @@ -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, @@ -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), }; } @@ -109,7 +110,7 @@ export function resolveSpeakersForScheduleEvent( name: speaker.name, slug: speaker.slug, image: speaker.image, - href: `/speakers/${speaker.slug}`, + href: getSpeakerProfileHref(speaker.slug), })); } diff --git a/src/lib/speaker-seo.ts b/src/lib/speaker-seo.ts index 7c68d0c..d54d140 100644 --- a/src/lib/speaker-seo.ts +++ b/src/lib/speaker-seo.ts @@ -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"; @@ -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}`, ); @@ -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; @@ -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: { diff --git a/src/lib/speakers.ts b/src/lib/speakers.ts index 07f4955..b815a12 100644 --- a/src/lib/speakers.ts +++ b/src/lib/speakers.ts @@ -1,4 +1,9 @@ -import { type Speaker, speakers } from "@/assets/data/speakers"; +import { + type Speaker, + type SpeakerTrack, + speakers, + speakerTrackOrder, +} from "@/assets/data/speakers"; import { type SpeakerContent, speakerContentByLocale, @@ -7,8 +12,64 @@ import type { SiteLocale } from "@/lib/site-messages"; export type LocalizedSpeaker = Speaker & SpeakerContent; +export function getCanonicalSpeakerSlug(slug: string): string { + return slug.replace(/-\d+$/, ""); +} + +export function getSpeakerSlugAliases(slug: string): string[] { + const canonical = getCanonicalSpeakerSlug(slug); + + return speakers + .filter((speaker) => getCanonicalSpeakerSlug(speaker.slug) === canonical) + .map((speaker) => speaker.slug) + .sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" })); +} + +export function getSpeakerProfileHref(slug: string): string { + return `/speakers/${getCanonicalSpeakerSlug(slug)}`; +} + +function mergeSpeakerTracks(records: Speaker[]): SpeakerTrack[] { + const trackSet = new Set(); + + for (const speaker of records) { + for (const track of speaker.tracks) { + trackSet.add(track); + } + } + + return speakerTrackOrder.filter((track) => trackSet.has(track)); +} + +function getPrimarySpeakerSlug(slug: string): string | undefined { + const canonical = getCanonicalSpeakerSlug(slug); + const aliases = getSpeakerSlugAliases(slug); + + if (aliases.includes(canonical)) { + return canonical; + } + + return aliases[0]; +} + export function getAllSpeakerSlugs() { - return speakers.map((speaker) => speaker.slug); + const slugs = new Set(); + + for (const speaker of speakers) { + const canonical = getCanonicalSpeakerSlug(speaker.slug); + + if ( + slugs.has(canonical) || + (!getSpeakerContent(canonical, "en") && + !getSpeakerContent(canonical, "es")) + ) { + continue; + } + + slugs.add(canonical); + } + + return [...slugs]; } export function getSpeakerBySlug(slug: string): Speaker | undefined { @@ -19,27 +80,59 @@ export function getSpeakerContent( slug: string, locale: SiteLocale, ): SpeakerContent | undefined { - return speakerContentByLocale[locale][slug]; + const contentBySlug = speakerContentByLocale[locale]; + + return contentBySlug[slug] ?? contentBySlug[getCanonicalSpeakerSlug(slug)]; } export function getLocalizedSpeaker( slug: string, locale: SiteLocale, ): LocalizedSpeaker | undefined { - const speaker = getSpeakerBySlug(slug); - const content = getSpeakerContent(slug, locale); + const primarySlug = getPrimarySpeakerSlug(slug); + + if (!primarySlug) { + return undefined; + } + + const speaker = getSpeakerBySlug(primarySlug); + const content = getSpeakerContent(primarySlug, locale); if (!speaker || !content) { return undefined; } - return { ...speaker, ...content }; + const aliasRecords = speakers.filter( + (entry) => getCanonicalSpeakerSlug(entry.slug) === primarySlug, + ); + + return { + ...speaker, + ...content, + slug: primarySlug, + tracks: mergeSpeakerTracks(aliasRecords), + }; } export function getAllLocalizedSpeakers( locale: SiteLocale, ): LocalizedSpeaker[] { + const seen = new Set(); + return speakers - .map((speaker) => getLocalizedSpeaker(speaker.slug, locale)) - .filter((speaker): speaker is LocalizedSpeaker => speaker !== undefined); + .map((speaker) => { + const canonical = getCanonicalSpeakerSlug(speaker.slug); + + if (seen.has(canonical)) { + return undefined; + } + + seen.add(canonical); + + return getLocalizedSpeaker(canonical, locale); + }) + .filter((speaker): speaker is LocalizedSpeaker => speaker !== undefined) + .toSorted((a, b) => + a.name.localeCompare(b.name, "es", { sensitivity: "base" }), + ); } diff --git a/src/lib/talk-seo.ts b/src/lib/talk-seo.ts index 7954e71..14d4426 100644 --- a/src/lib/talk-seo.ts +++ b/src/lib/talk-seo.ts @@ -10,6 +10,7 @@ import { SITE_NAME, } from "@/lib/site-seo"; import { getSpeakerProfileImageUrl } from "@/lib/speaker-seo"; +import { getSpeakerProfileHref } from "@/lib/speakers"; import type { TalkFormat } from "@/lib/talk-formats"; import type { TalkLanguage } from "@/lib/talk-languages"; import { getTalkLanguageCode } from "@/lib/talk-languages"; @@ -269,7 +270,7 @@ export function buildTalkJsonLd( return { "@type": "Person", name: speaker.name, - url: `${getSiteUrl()}/speakers/${speaker.slug}`, + url: `${getSiteUrl()}${getSpeakerProfileHref(speaker.slug)}`, jobTitle: speaker.title, ...(imageUrl ? { image: imageUrl } : {}), }; diff --git a/src/lib/talks.ts b/src/lib/talks.ts index d5901ee..6edd142 100644 --- a/src/lib/talks.ts +++ b/src/lib/talks.ts @@ -10,8 +10,9 @@ import { type SpeakerTrackFilter, } from "@/lib/speaker-tracks"; import { - getSpeakerBySlug, + getLocalizedSpeaker, getSpeakerContent, + getSpeakerSlugAliases, type LocalizedSpeaker, } from "@/lib/speakers"; import { @@ -170,25 +171,37 @@ export function getTalkBySpeakerSlug( slug: string, locale: SiteLocale = "en", ): Talk | undefined { - return talksByLocale[locale].find((talk) => talk.speakerSlugs.includes(slug)); + return getTalksBySpeakerSlug(slug, locale)[0]; +} + +export function getTalksBySpeakerSlug( + slug: string, + locale: SiteLocale = "en", +): Talk[] { + const aliases = new Set(getSpeakerSlugAliases(slug)); + + return talksByLocale[locale].filter((talk) => + talk.speakerSlugs.some((speakerSlug) => aliases.has(speakerSlug)), + ); } export function getTalkSpeakers( talk: Talk, locale: SiteLocale = "en", ): LocalizedSpeaker[] { - return talk.speakerSlugs - .map((slug) => { - const speaker = getSpeakerBySlug(slug); - const content = getSpeakerContent(slug, locale); + const seen = new Set(); - if (!speaker || !content) { - return undefined; + return talk.speakerSlugs + .map((slug) => getLocalizedSpeaker(slug, locale)) + .filter((speaker): speaker is LocalizedSpeaker => { + if (!speaker || seen.has(speaker.slug)) { + return false; } - return { ...speaker, ...content }; - }) - .filter((speaker): speaker is LocalizedSpeaker => speaker !== undefined); + seen.add(speaker.slug); + + return true; + }); } export function isTalkId(value: string, locale: SiteLocale = "en"): boolean { @@ -203,9 +216,9 @@ export function getTalkHrefForSpeaker( slug: string, locale: SiteLocale = "en", ): string | undefined { - const talk = getTalkBySpeakerSlug(slug, locale); + const talks = getTalksBySpeakerSlug(slug, locale); - return talk ? getTalkHref(talk.id) : undefined; + return talks[0] ? getTalkHref(talks[0].id) : undefined; } export function getTalkCountsByLevel(