feat(library): add multilingual author archives and linked bylines

This commit is contained in:
DeCentN2Madness committed 2026-10-10 00:17:25 -07:00
1 parent 0957c06a18
commit 8646792d46
23 files changed
+2098 -115

No files matched your search

+7 -2
View File
@@ -1,5 +1,6 @@
---
import type { CollectionEntry } from 'astro:content';
import { getCollection, type CollectionEntry } from 'astro:content';
import { createAuthorResolver } from '../utils/authors';
import LikeViewCounter from './LikeViewCounter.astro';
interface Props {
@@ -8,13 +9,14 @@ interface Props {
}
const { post, prefix = '' } = Astro.props;
const author = createAuthorResolver(await getCollection('authors'))(post.data.lang, post.data.author);
const href = `${prefix}/library/${post.data.slug}`.replace('//', '/');
const readMore = { en: 'Read More', es: 'Leer Más', ar: 'اقرأ المزيد' }[post.data.lang] || 'Read More';
---
<article class="card blog-card">
{post.data.featuredImage && <a href={href}><img src={post.data.featuredImage} alt={post.data.alt || ''} loading="lazy" /></a>}
<div class="card-body">
<div class="blog-card-meta"><img src="/assets/images/rula-diab-avatar.jpg" alt="" /><p>Rula Diab, Clinical Director, M.Ed, BCBA, LBA<br /><time datetime={post.data.date.toISOString()}>{post.data.date.toLocaleDateString(post.data.lang, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })}</time></p></div>
<div class="blog-card-meta">{author.avatar && <img src={author.avatar} alt="" loading="lazy" />}<p><a class="author-byline" rel="author" href={author.archiveRoute}><bdi>{author.displayName}</bdi></a><br /><time datetime={post.data.date.toISOString()}>{post.data.date.toLocaleDateString(post.data.lang, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })}</time></p></div>
<h3><a href={href}>{post.data.title}</a></h3>
<p>{post.data.description}</p>
<div class="blog-card-footer" style="align-items:center;display:flex;justify-content:space-between;gap:var(--space-lg);margin-top:auto;">
@@ -25,6 +27,9 @@ const readMore = { en: 'Read More', es: 'Leer Más', ar: 'اقرأ المزيد'
</article>
<style>
.author-byline { color: inherit; text-decoration: none; }
.author-byline:hover { text-decoration: underline; }
.author-byline:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 3px; }
.blog-card-meta { align-items: center; display: flex; font-size: .78rem; gap: .75rem; }
.blog-card-meta img { aspect-ratio: 1; border-radius: 50%; height: 42px; width: 42px; }
.blog-card { background: white; border: 0; border-radius: 4px; box-shadow: rgba(54, 48, 48, 0.28) 0 0 12px 0; display: flex; margin-bottom: 40px; overflow: hidden; }
+7 -4
View File
@@ -4,13 +4,16 @@ import {
type LibraryPageSlice
} from '../utils/library-pagination';
import type { LibraryLocale } from '../utils/library-catalog';
import { authorPageRoute } from '../utils/author-archives';
interface Props {
locale: LibraryLocale;
page: LibraryPageSlice<unknown>;
authorRoot?: string;
}
const { locale, page } = Astro.props;
const { locale, page, authorRoot } = Astro.props;
const pageRoute = (number: number) => authorRoot ? authorPageRoute(authorRoot, number) : libraryPageRoute(locale, number);
const messages = {
en: { navigation: 'Library pagination', previous: 'Previous', next: 'Next', page: 'Page', current: 'current page' },
es: { navigation: 'Paginación de la biblioteca', previous: 'Anterior', next: 'Siguiente', page: 'Página', current: 'página actual' },
@@ -23,13 +26,13 @@ const pages = Array.from({ length: page.totalPages }, (_, index) => index + 1);
<ul>
{page.previousPage && (
<li class="library-pagination__previous">
<a rel="prev" href={libraryPageRoute(locale, page.previousPage)}>{messages.previous}</a>
<a rel="prev" href={pageRoute(page.previousPage)}>{messages.previous}</a>
</li>
)}
{pages.map((number) => (
<li>
<a
href={libraryPageRoute(locale, number)}
href={pageRoute(number)}
aria-current={number === page.currentPage ? 'page' : undefined}
aria-label={`${messages.page} ${number}${number === page.currentPage ? `, ${messages.current}` : ''}`}
>{number}</a>
@@ -37,7 +40,7 @@ const pages = Array.from({ length: page.totalPages }, (_, index) => index + 1);
))}
{page.nextPage && (
<li class="library-pagination__next">
<a rel="next" href={libraryPageRoute(locale, page.nextPage)}>{messages.next}</a>
<a rel="next" href={pageRoute(page.nextPage)}>{messages.next}</a>
</li>
)}
</ul>
@@ -0,0 +1,72 @@
---
import { render, type CollectionEntry } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import PageHero from '../PageHero.astro';
import BlogCard from '../BlogCard.astro';
import LibraryPagination from '../LibraryPagination.astro';
import { getLibraryCatalog } from '../../utils/library';
import { libraryRootRoute, paginateLibraryItems } from '../../utils/library-pagination';
import type { AuthorArchiveDescriptor } from '../../utils/author-archives';
interface Props { page: AuthorArchiveDescriptor<CollectionEntry<'authors'>>; }
const { page } = Astro.props;
const { author, locale, currentPage } = page;
const catalog = (await getLibraryCatalog()).byLocale[locale];
const slice = paginateLibraryItems(catalog.filter(({ entry }) => entry.data.author === author.slug), currentPage);
if (!slice?.items.length) throw new Error(`Empty author archive: ${author.archiveRoute}, page ${currentPage}`);
const { Content } = await render(author.entry);
const prefix = locale === 'en' ? '' : `/${locale}`;
const text = {
en: { heading: 'The Library at AZ Institute for Autism', title: 'Library & News Room | Arizona Institute for Autism', page: 'Page' },
es: { heading: 'La Biblioteca en AZ Institute for Autism', title: 'Biblioteca y Noticias | Instituto de Autismo de Arizona', page: 'Página' },
ar: { heading: 'مكتبة معهد أريزونا للتوحد', title: 'المكتبة وغرفة الأخبار | معهد أريزونا للتوحد', page: 'الصفحة' }
}[locale];
const title = `${text.title} | ${author.displayName}${currentPage > 1 ? ` | ${text.page} ${currentPage}` : ''}`;
// Use the existing catalog's latest-post treatment; no second article list.
const popular = catalog.slice(0, 5);
---
<BaseLayout title={title} description={author.description} image={author.avatar} lang={locale}>
<PageHero title={text.heading} image="/assets/images/hero-library-index.webp" eyebrow="Welcome to" constrain />
<section class="author-profile" aria-labelledby="author-heading">
{author.avatar && <img class="author-profile__avatar" src={author.avatar} alt={author.displayName} width="176" height="176" />}
<div class="author-profile__content">
<h2 id="author-heading"><bdi>{author.displayName}</bdi></h2>
<div class="author-biography"><Content /></div>
</div>
</section>
<div class="author-listing container">
<section aria-labelledby="author-posts-heading">
<h2 id="author-posts-heading" class="author-posts-heading"><a href={libraryRootRoute(locale)}>All Post</a></h2>
<div class="blog-list">{slice.items.map(({ entry }) => <BlogCard post={entry} prefix={prefix} />)}</div>
<LibraryPagination locale={locale} page={slice} authorRoot={author.archiveRoute} />
</section>
<aside class="author-sidebar" aria-labelledby="author-popular-heading">
<h2 id="author-popular-heading">Popular Posts</h2>
<ul>{popular.map(({ entry, route }) => <li><a href={route}>{entry.data.title}</a></li>)}</ul>
</aside>
</div>
</BaseLayout>
<style>
.author-profile { align-items: start; display: flex; gap: 4rem; margin: 100px auto 60px; max-width: 768px; width: calc(100% - 96px); }
.author-profile__avatar { flex: 0 0 176px; height: 176px; object-fit: cover; width: 176px; }
.author-profile__content { flex: 1; min-width: 0; }
.author-profile h2 { font-size: 2.4rem; line-height: 1.25; margin: 0 0 10px; }
.author-biography { font-size: 1rem; line-height: 26px; }
.author-biography :global(p) { margin: 0 0 20px; }
.author-listing { align-items: start; display: grid; gap: 30px; grid-template-columns: minmax(0, 1fr) 294px; padding-bottom: 60px; }
.author-posts-heading { font-family: var(--font-body); font-size: 1rem; margin: 0 0 24px; }
.author-posts-heading a { color: inherit; }
.author-sidebar h2 { font-size: 1.25rem; margin: 0 0 20px; }
.author-sidebar ul { list-style: none; margin: 0; padding: 0; }
.author-sidebar li { margin-bottom: 20px; }
.author-sidebar a { color: inherit; }
.author-listing a:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 3px; }
@media (max-width: 760px) {
.author-profile { flex-direction: column; gap: 5px; }
.author-profile__avatar { flex-basis: auto; width: 100px; height: 100px; }
.author-profile h2 { font-size: 2rem; }
.author-biography { line-height: 20px; }
.author-listing { grid-template-columns: minmax(0, 1fr); }
}
</style>
+3 -2
View File
@@ -59,8 +59,9 @@ const authors = defineCollection({
generateId: ({ entry }) => entry.replace(/\.(md|mdx)$/, '')
}),
schema: z.object({
name: z.string(),
slug: z.string(),
name: z.string().refine((value) => value.trim().length > 0, 'Author name must not be blank'),
displayName: z.string().refine((value) => value.trim().length > 0, 'Author displayName must not be blank').optional(),
slug: z.string().regex(/^[\p{L}\p{N}]+(?:[-_][\p{L}\p{N}]+)*$/u, 'Author slug must be a safe single path segment'),
description: z.string().optional(),
avatar: z.string().optional(),
lang: language,
+4 -1
View File
@@ -1,8 +1,11 @@
---
name: "Rula Diab"
displayName: "رولا دياب، المديرة الإكلينيكية، ماجستير في التربية، BCBA، LBA"
slug: "rula-diab"
description: "Founder and clinical leader at Arizona Institute for Autism."
description: "رولا دياب طبيبة سريرية ورائدة أعمال شغوفة، تُكرّس جهودها لدعم الأطفال المصابين بالتوحد. أسست رولا معهد أريزونا للتوحد (AIA) لتقديم علاج تحليل سلوك تطبيقي عالي الجودة ومُصمّم خصيصًا لكل طفل في بيئة داعمة ومُؤهلة ثقافيًا. تلتزم رولا بالنزاهة والتمكين والتميز، وتحرص على أن يُولي المعهد الأولوية لكل من نجاح المتعلمين وتطوير الموظفين. تنبع رؤيتها من رغبة عميقة في مساعدة الأطفال على تحقيق أقصى إمكاناتهم، مما يُحدث تأثيرًا إيجابيًا على عائلاتهم ومجتمعهم. بفضل مثابرتها وتفانيها، نجحت في بناء معهد أريزونا للتوحد ليصبح مركزًا موثوقًا لرعاية مرضى التوحد. إلى جانب عملها، تستمتع رولا بالجمال الثقافي والطبيعي في سكوتسديل ومحيطها، من سحر المدينة القديمة إلى حديقة النباتات الصحراوية ومناظر الصخور الحمراء في سيدونا."
avatar: "/assets/images/rula-diab-avatar.jpg"
lang: "ar"
translationKey: "rula-diab"
---
رولا دياب طبيبة سريرية ورائدة أعمال شغوفة، تُكرّس جهودها لدعم الأطفال المصابين بالتوحد. أسست رولا معهد أريزونا للتوحد (AIA) لتقديم علاج تحليل سلوك تطبيقي عالي الجودة ومُصمّم خصيصًا لكل طفل في بيئة داعمة ومُؤهلة ثقافيًا. تلتزم رولا بالنزاهة والتمكين والتميز، وتحرص على أن يُولي المعهد الأولوية لكل من نجاح المتعلمين وتطوير الموظفين. تنبع رؤيتها من رغبة عميقة في مساعدة الأطفال على تحقيق أقصى إمكاناتهم، مما يُحدث تأثيرًا إيجابيًا على عائلاتهم ومجتمعهم. بفضل مثابرتها وتفانيها، نجحت في بناء معهد أريزونا للتوحد ليصبح مركزًا موثوقًا لرعاية مرضى التوحد. إلى جانب عملها، تستمتع رولا بالجمال الثقافي والطبيعي في سكوتسديل ومحيطها، من سحر المدينة القديمة إلى حديقة النباتات الصحراوية ومناظر الصخور الحمراء في سيدونا.
+4 -1
View File
@@ -1,8 +1,11 @@
---
name: "Rula Diab"
displayName: "Rula Diab, Clinical Director, M.Ed, BCBA, LBA"
slug: "rula-diab"
description: "Founder and clinical leader at Arizona Institute for Autism."
description: "Rula Diab is a passionate clinician and entrepreneur dedicated to supporting children with autism. She founded the Arizona Institute for Autism (AIA) to provide high-quality, individualized ABA therapy in a nurturing and culturally competent environment. Committed to integrity, empowerment, and excellence, Rula ensures that AIA prioritizes both learner success and staff development. Her vision stems from a deep desire to help children reach their highest potential, creating a positive impact on families and the community. Through perseverance and dedication, she has built AIA into a trusted center for autism care. Beyond her work, Rula enjoys the cultural and natural beauty in and around Scottsdale, from Old Town’s charm to the Desert Botanical Garden and Sedona’s red rock landscapes."
avatar: "/assets/images/rula-diab-avatar.jpg"
lang: "en"
translationKey: "rula-diab"
---
Rula Diab is a passionate clinician and entrepreneur dedicated to supporting children with autism. She founded the Arizona Institute for Autism (AIA) to provide high-quality, individualized ABA therapy in a nurturing and culturally competent environment. Committed to integrity, empowerment, and excellence, Rula ensures that AIA prioritizes both learner success and staff development. Her vision stems from a deep desire to help children reach their highest potential, creating a positive impact on families and the community. Through perseverance and dedication, she has built AIA into a trusted center for autism care. Beyond her work, Rula enjoys the cultural and natural beauty in and around Scottsdale, from Old Town’s charm to the Desert Botanical Garden and Sedona’s red rock landscapes.
+4 -1
View File
@@ -1,8 +1,11 @@
---
name: "Rula Diab"
displayName: "Rula Diab, Directora Clínica, M.Ed, BCBA, LBA"
slug: "rula-diab"
description: "Founder and clinical leader at Arizona Institute for Autism."
description: "Rula Diab es una clínica apasionada y emprendedora dedicada a apoyar a los niños con autismo. Fundó el Arizona Institute for Autism (AIA) para ofrecer terapia ABA de alta calidad e individualizada en un entorno acogedor y culturalmente competente. Comprometida con la integridad, el empoderamiento y la excelencia, Rula se asegura de que AIA priorice tanto el éxito de los alumnos como el desarrollo del personal. Su visión surge de un profundo deseo de ayudar a los niños a alcanzar su máximo potencial, creando un impacto positivo en las familias y en la comunidad. A través de la perseverancia y la dedicación, ha convertido a AIA en un centro de confianza para la atención del autismo. Más allá de su trabajo, Rula disfruta de la belleza cultural y natural en Scottsdale y sus alrededores, desde el encanto de Old Town hasta el Jardín Botánico del Desierto y los paisajes de roca roja de Sedona."
avatar: "/assets/images/rula-diab-avatar.jpg"
lang: "es"
translationKey: "rula-diab"
---
Rula Diab es una clínica apasionada y emprendedora dedicada a apoyar a los niños con autismo. Fundó el Arizona Institute for Autism (AIA) para ofrecer terapia ABA de alta calidad e individualizada en un entorno acogedor y culturalmente competente. Comprometida con la integridad, el empoderamiento y la excelencia, Rula se asegura de que AIA priorice tanto el éxito de los alumnos como el desarrollo del personal. Su visión surge de un profundo deseo de ayudar a los niños a alcanzar su máximo potencial, creando un impacto positivo en las familias y en la comunidad. A través de la perseverancia y la dedicación, ha convertido a AIA en un centro de confianza para la atención del autismo. Más allá de su trabajo, Rula disfruta de la belleza cultural y natural en Scottsdale y sus alrededores, desde el encanto de Old Town hasta el Jardín Botánico del Desierto y los paisajes de roca roja de Sedona.
+9 -3
View File
@@ -6,10 +6,13 @@ import { getCollection, type CollectionEntry } from 'astro:content';
import { getPublication } from '../utils/publication';
import { blogPostingSchema, serializeJsonLd } from '../utils/structured-data';
import site from '../data/site.json';
import { createAuthorResolver } from '../utils/authors';
interface Props { entry: CollectionEntry<'blog'> }
const { entry } = Astro.props;
const publication = await getPublication(Astro.url.pathname);
const schema = blogPostingSchema(entry, publication, await getCollection('authors'), site.url);
const authors = await getCollection('authors');
const author = createAuthorResolver(authors)(entry.data.lang, entry.data.author);
const schema = blogPostingSchema(entry, publication, authors, site.url);
---
<BaseLayout title={entry.data.title} description={entry.data.description} image={entry.data.featuredImage} lang={entry.data.lang} type="article">
{schema && <script slot="head" type="application/ld+json" is:inline set:html={serializeJsonLd(schema)} />}
@@ -17,8 +20,8 @@ const schema = blogPostingSchema(entry, publication, await getCollection('author
<header class="article-header container">
<h1>{entry.data.displayH1 ?? entry.data.title}</h1>
<div class="article-byline">
<img src="/assets/images/rula-diab-avatar.jpg" alt="" />
<p><strong>Rula Diab, Clinical Director, M.Ed, BCBA, LBA</strong><br /><time datetime={entry.data.date.toISOString()}>{entry.data.date.toLocaleDateString(entry.data.lang, { day: '2-digit', month: 'long', year: 'numeric', timeZone: 'UTC' })}</time></p>
{author.avatar && <img src={author.avatar} alt="" />}
<p><strong><a class="author-byline" rel="author" href={author.archiveRoute}><bdi>{author.displayName}</bdi></a></strong><br /><time datetime={entry.data.date.toISOString()}>{entry.data.date.toLocaleDateString(entry.data.lang, { day: '2-digit', month: 'long', year: 'numeric', timeZone: 'UTC' })}</time></p>
</div>
{entry.data.featuredImage && <div class="article-featured"><img src={entry.data.featuredImage} alt={entry.data.alt || ''} /></div>}
</header>
@@ -29,6 +32,9 @@ const schema = blogPostingSchema(entry, publication, await getCollection('author
</BaseLayout>
<style>
.author-byline { color: inherit; text-decoration: none; }
.author-byline:hover { text-decoration: underline; }
.author-byline:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 3px; }
.article-header { padding-block: 4.5rem 0; text-align: left; }
.article-header h1 { font-size: clamp(2.3rem, 5vw, 3rem); max-width: 900px; }
.article-byline { align-items: center; display: flex; font-size: .9rem; gap: 1rem; }
+10 -6
View File
@@ -2,6 +2,8 @@
import { render, type CollectionEntry } from 'astro:content';
import { getRoutePublicationManifest } from '../utils/publication';
import type { LibraryPageDescriptor } from '../utils/library-pagination';
import type { AuthorArchiveDescriptor } from '../utils/author-archives';
import AuthorArchive from '../components/pages/AuthorArchive.astro';
import PageLayout from '../layouts/PageLayout.astro';
import BlogPostLayout from '../layouts/BlogPostLayout.astro';
import HomePage from '../components/pages/HomePage.astro';
@@ -14,20 +16,22 @@ export async function getStaticPaths() {
params: { slug: publication.route === '/' ? undefined : decodeURI(publication.route.slice(1)) },
props: {
entry: publication.entry,
libraryPage: 'libraryPage' in publication ? publication.libraryPage : undefined
libraryPage: 'libraryPage' in publication ? publication.libraryPage : undefined,
authorPage: 'authorPage' in publication ? publication.authorPage : undefined
}
}));
}
const { entry, libraryPage } = Astro.props as {
const { entry, libraryPage, authorPage } = Astro.props as {
entry: CollectionEntry<'pages'> | CollectionEntry<'blog'>;
libraryPage?: LibraryPageDescriptor;
authorPage?: AuthorArchiveDescriptor<CollectionEntry<'authors'>>;
};
const { Content } = await render(entry);
const { Content } = authorPage ? { Content: null } : await render(entry);
---
{entry.collection === 'blog'
? <BlogPostLayout entry={entry}><Content /></BlogPostLayout>
{authorPage ? <AuthorArchive page={authorPage} /> : entry.collection === 'blog'
? <BlogPostLayout entry={entry}>{Content && <Content />}</BlogPostLayout>
: entry.data.slug === 'index'
? entry.data.lang === 'ar' ? <ArabicHome /> : <HomePage entry={entry} />
: entry.data.slug === 'library'
? entry.data.lang === 'es' ? <LibrarySpanish page={libraryPage?.currentPage} /> : entry.data.lang === 'ar' ? <LibraryArabic page={libraryPage?.currentPage} /> : <LibraryEnglish page={libraryPage?.currentPage} />
: <PageLayout entry={entry}><Content /></PageLayout>}
: <PageLayout entry={entry}>{Content && <Content />}</PageLayout>}
+54
View File
@@ -0,0 +1,54 @@
import { createAuthorResolver, type AuthorEntry, type ResolvedAuthor } from './authors.ts';
import { libraryLocales, type LibraryBlogEntry, type LibraryCatalog } from './library-catalog.ts';
import { paginateLibraryItems, type ContentPublication, type LibraryPageDescriptor } from './library-pagination.ts';
import { normalizeRoute, publicationFor, type PublicationEntry } from './publication-policy.ts';
export interface AuthorArchiveDescriptor<T extends AuthorEntry = AuthorEntry> extends LibraryPageDescriptor {
author: ResolvedAuthor<T>;
}
/** A validated root and the existing positive-integer convention; page 1 has no suffix. */
export function authorPageRoute(root: string, page: number): string {
if (!Number.isSafeInteger(page) || page < 1) throw new Error(`Invalid author page number: ${page}`);
if (!/^\/(?:es\/|ar\/)?library\/author\/[^/]+$/.test(root) || normalizeRoute(root) !== root) {
throw new Error(`Invalid author archive root: ${root}`);
}
return page === 1 ? root : `${root}/page/${page}`;
}
/** Called after the content manifest/catalog: never recursively loads either. */
export function createAuthorArchivePublications<TBlog extends LibraryBlogEntry, TAuthor extends AuthorEntry>(
catalog: LibraryCatalog<TBlog>, authors: readonly TAuthor[], manifest: ContentPublication[], site: string
) {
const resolve = createAuthorResolver(authors);
const collision = manifest.find(({ route }) => /^\/(?:es\/|ar\/)?library\/author(?:\/|$)/.test(route));
if (collision) throw new Error(`Reserved author archive route collision: ${collision.route} (${collision.entry.id})`);
const canonicals = new Set(manifest.filter(({ eligible }) => eligible).map(({ canonical }) => canonical));
const publications: Array<ContentPublication & { authorPage: AuthorArchiveDescriptor<TAuthor> }> = [];
for (const locale of libraryLocales) {
const slugs = [...new Set(catalog.byLocale[locale].map(({ entry }) => entry.data.author))].sort();
for (const slug of slugs) {
const author = resolve(locale, slug);
const items = catalog.byLocale[locale].filter(({ entry }) => entry.data.author === slug);
const first = paginateLibraryItems(items, 1)!;
for (let page = 1; page <= first.totalPages; page++) {
const route = authorPageRoute(author.archiveRoute, page);
const entry: PublicationEntry = {
id: `generated/${locale}/author/${slug}/${page}`, collection: 'pages',
data: {
lang: locale, slug: route.replace(/^\/(?:es\/|ar\/)?/, ''), noindex: true,
featuredImage: author.avatar, alt: author.displayName
}
};
const publication = publicationFor(entry, site);
if (canonicals.has(publication.canonical)) throw new Error(`Duplicate canonical: ${publication.canonical}`);
canonicals.add(publication.canonical);
publications.push({
...publication, entry, translations: [],
authorPage: { author, locale, currentPage: page, totalItems: first.totalItems, totalPages: first.totalPages }
});
}
}
}
return publications.sort((a, b) => a.route.localeCompare(b.route, 'en'));
}
+58
View File
@@ -0,0 +1,58 @@
import { normalizeRoute } from './publication-policy.ts';
export type AuthorLocale = 'en' | 'es' | 'ar';
export interface AuthorEntry {
id: string;
body?: string;
data: {
lang: string;
slug: string;
name: string;
displayName?: string;
description?: string;
avatar?: string;
};
}
export interface ResolvedAuthor<T extends AuthorEntry = AuthorEntry> {
entry: T;
locale: AuthorLocale;
slug: string;
name: string;
displayName: string;
description: string;
avatar?: string;
archiveRoute: string;
}
const nonblank = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
const validSlug = (value: unknown): value is string => typeof value === 'string' && /^[\p{L}\p{N}]+(?:[-_][\p{L}\p{N}]+)*$/u.test(value);
/** One exact locale/slug index for listings, bylines, schema and archives. */
export function createAuthorResolver<T extends AuthorEntry>(records: readonly T[]) {
const authors = new Map<string, ResolvedAuthor<T>>();
for (const entry of records) {
const data = entry.data;
if (!data || !['en', 'es', 'ar'].includes(data.lang) || !validSlug(data.slug) || !nonblank(data.name)
|| (data.displayName !== undefined && !nonblank(data.displayName))
|| (data.description !== undefined && typeof data.description !== 'string')) {
throw new Error(`Invalid author record: ${entry.id} (expected locale, safe slug and nonblank name/displayName)`);
}
if (data.avatar !== undefined && (typeof data.avatar !== 'string' || !data.avatar.startsWith('/assets/')
|| normalizeRoute(data.avatar) !== data.avatar)) {
throw new Error(`Invalid author avatar: ${entry.id} (expected a normalized local /assets/ path)`);
}
const key = `${data.lang}:${data.slug}`;
if (authors.has(key)) throw new Error(`Missing or ambiguous article author: ${key} (${authors.get(key)!.entry.id}, ${entry.id})`);
authors.set(key, {
entry, locale: data.lang as AuthorLocale, slug: data.slug, name: data.name,
displayName: data.displayName ?? data.name, description: data.description ?? '', avatar: data.avatar,
archiveRoute: normalizeRoute(`${data.lang === 'en' ? '' : `/${data.lang}`}/library/author/${data.slug}`)
});
}
return (locale: string, slug: string): ResolvedAuthor<T> => {
const author = authors.get(`${locale}:${slug}`);
if (!author) throw new Error(`Missing or ambiguous article author: ${locale}:${slug} (add the corresponding locale author record)`);
return author;
};
}
+6 -20
View File
@@ -1,3 +1,5 @@
import { createAuthorResolver, type AuthorEntry } from './authors.ts';
export const libraryLocales = ['en', 'es', 'ar'] as const;
export type LibraryLocale = typeof libraryLocales[number];
@@ -17,14 +19,7 @@ export interface LibraryBlogEntry {
};
}
export interface LibraryAuthorEntry {
id: string;
data: {
lang: string;
slug: string;
name: string;
};
}
export type LibraryAuthorEntry = AuthorEntry;
export interface LibraryPublication<TEntry extends LibraryBlogEntry = LibraryBlogEntry> {
route: string;
@@ -112,15 +107,7 @@ export function createLibraryCatalog<TEntry extends LibraryBlogEntry>(
manifest: LibraryPublication<TEntry>[],
authors: LibraryAuthorEntry[]
): LibraryCatalog<TEntry> {
const authorMap = new Map<string, LibraryAuthorEntry>();
for (const author of authors) {
if (!localeOrder.has(author.data.lang) || !author.data.slug || !author.data.name.trim()) {
throw new Error(`Invalid Library author record: ${author.id}`);
}
const key = `${author.data.lang}:${author.data.slug}`;
if (authorMap.has(key)) throw new Error(`Missing or ambiguous article author: ${key} (duplicate Library author records)`);
authorMap.set(key, author);
}
const resolveAuthor = createAuthorResolver(authors);
const seenRoutes = new Set<string>();
const dated: Array<LibraryCatalogItem<TEntry> & { publicationTime: number }> = [];
@@ -132,8 +119,7 @@ export function createLibraryCatalog<TEntry extends LibraryBlogEntry>(
if (seenRoutes.has(publication.route)) throw new Error(`Duplicate Library route: ${publication.route}`);
seenRoutes.add(publication.route);
const author = authorMap.get(`${locale}:${entry.data.author}`);
if (!author) throw new Error(`Missing or ambiguous article author: ${locale}:${entry.data.author} for ${entry.id}`);
const author = resolveAuthor(locale, entry.data.author);
const date = publishedAt(entry.data.date, entry.id);
dated.push({
route: publication.route,
@@ -147,7 +133,7 @@ export function createLibraryCatalog<TEntry extends LibraryBlogEntry>(
headings: extractMarkdownHeadings(entry.body),
category: entry.data.category?.trim() ?? '',
tags: normalizedTags(entry.data.tags),
author: { name: author.data.name, slug: author.data.slug },
author: { name: author.name, slug: author.slug },
publishedAt: date.iso
}
});
+8 -2
View File
@@ -3,6 +3,7 @@ import site from '../data/site.json';
import { createPublicationManifest, normalizeRoute } from './publication-policy';
import { createLibraryCatalog } from './library-catalog';
import { createLibraryPaginationPublications } from './library-pagination';
import { createAuthorArchivePublications } from './author-archives';
import { createLibrarySearchPublication } from './search-publication';
export const allowIndexing = import.meta.env.PUBLIC_ALLOW_INDEXING === 'true';
@@ -27,8 +28,13 @@ export async function getLibraryPaginationManifest(
/** Content publications plus deterministic generated archive routes. */
export async function getRoutePublicationManifest() {
const content = await getPublicationManifest();
const pagination = await getLibraryPaginationManifest(content);
return [...content, ...pagination].sort((left, right) => left.route.localeCompare(right.route, 'en'));
const authors = await getCollection('authors');
const catalog = createLibraryCatalog(
content as Parameters<typeof createLibraryCatalog<CollectionEntry<'blog'>>>[0], authors
);
const pagination = createLibraryPaginationPublications(catalog, content, site.url);
const archives = createAuthorArchivePublications(catalog, authors, [...content, ...pagination], site.url);
return [...content, ...pagination, ...archives].sort((left, right) => left.route.localeCompare(right.route, 'en'));
}
export async function getPublication(route: string) {
+3 -5
View File
@@ -2,6 +2,7 @@ import type { CollectionEntry } from 'astro:content';
import { load } from 'cheerio';
import type { Publication } from './publication-policy';
import { socialImageUrl } from './seo.ts';
import { createAuthorResolver } from './authors.ts';
/** JSON embedded in HTML must not be able to close its script element. */
export function serializeJsonLd(value: unknown): string {
@@ -18,10 +19,7 @@ export function blogPostingSchema(
// Syndicated records must not acquire an invented original author/publisher.
if (!publication.sitemapEligible) return undefined;
const data = entry.data;
const matches = authors.filter(({ data: author }) => author.slug === data.author && author.lang === data.lang);
if (matches.length !== 1 || !matches[0].data.name.trim()) {
throw new Error(`Missing or ambiguous article author: ${entry.id} (${data.lang}/${data.author})`);
}
const author = createAuthorResolver(authors)(data.lang, data.author);
const image = socialImageUrl(data.featuredImage, site);
// Current content records establish calendar days, not publication times.
const datePublished = data.date.toISOString().slice(0, 10);
@@ -41,7 +39,7 @@ export function blogPostingSchema(
datePublished,
...(dateModified ? { dateModified } : {}),
...(image ? { image } : {}),
author: { '@type': 'Person', name: matches[0].data.name }
author: { '@type': 'Person', name: author.name }
};
}