feat(publication): share policy for manifest routes, canonicals, indexing and sitemap

This commit is contained in:
DeCentN2Madness committed 2026-10-07 12:09:06 -07:00
1 parent ea2f1eaa05
commit cd097d0e76
40 files changed
+846 -282

No files matched your search

+4 -1
View File
@@ -3,7 +3,10 @@ import Button from './Button.astro';
import LanguageSwitcher from './LanguageSwitcher.astro';
import MainNav from './MainNav.astro';
import MobileNav from './MobileNav.astro';
import { getPublicationManifest } from '../utils/publication';
const { lang = 'en' } = Astro.props;
const localizedHome = lang === 'en' ? '/' : `/${lang}`;
const homeHref = (await getPublicationManifest()).some((page) => page.eligible && page.route === localizedHome) ? localizedHome : '/';
const utility = lang === 'es'
? { credit: 'Solicitar CareCredit', bill: 'Pagar mi factura' }
: { credit: 'Apply Care Credit', bill: 'Pay My Bill' };
@@ -16,7 +19,7 @@ const utility = lang === 'es'
</div>
</div>
<div class="container header-inner">
<a class="brand" href={lang === 'en' ? '/' : `/${lang}`}>
<a class="brand" href={homeHref}>
<img src="/assets/images/aia-logo.svg" alt="Arizona Institute for Autism" width="150" height="70" />
</a>
<MainNav lang={lang} />
+2 -17
View File
@@ -1,23 +1,8 @@
---
import { getCollection } from 'astro:content';
import { getPublicationManifest } from '../utils/publication';
const pathname = Astro.url.pathname.replace(/\/$/, '') || '/';
const bare = pathname.replace(/^\/(ar|es)(?=\/|$)/, '') || '/';
const pages = await getCollection('pages', ({ data }) => !data.draft);
const posts = await getCollection('blog', ({ data }) => !data.draft);
const available = new Set<string>(['/', '/library', '/es', '/es/library', '/ar', '/ar/library']);
for (const entry of pages) {
const prefix = entry.data.lang === 'en' ? '' : `/${entry.data.lang}`;
const route = entry.data.slug === 'index'
? prefix || '/'
: entry.data.slug === 'library'
? `${prefix}/library`
: `${prefix}/${entry.data.slug}`;
available.add(route);
}
for (const entry of posts) {
const prefix = entry.data.lang === 'en' ? '' : `/${entry.data.lang}`;
available.add(`${prefix}/library/${entry.data.slug}`);
}
const available = new Set((await getPublicationManifest()).filter((page) => page.eligible).map((page) => page.route));
const langMeta: Record<string, { short: string; full: string }> = {
en: { short: 'EN', full: 'English' },
+9 -9
View File
@@ -1,33 +1,33 @@
---
import { allowIndexing } from '../utils/publication';
import { robotsFor, type Publication } from '../utils/publication-policy';
interface Props {
title: string;
description?: string;
canonical?: string;
publication: Publication;
image?: string;
lang?: string;
type?: 'website' | 'article';
robots?: string;
}
const {
title,
description = '',
canonical,
publication,
image,
lang = 'en',
type = 'website',
robots
type = 'website'
} = Astro.props;
const defaultRobots = import.meta.env.PUBLIC_ALLOW_INDEXING === 'true' ? 'index,follow' : 'noindex,nofollow';
const { canonical } = publication;
const absoluteImage = image ? new URL(image, Astro.site ?? Astro.url).href : undefined;
---
<title>{title}</title>
<meta name="description" content={description} />
<meta name="robots" content={robots ?? defaultRobots} />
{canonical && <link rel="canonical" href={canonical} />}
<meta name="robots" content={robotsFor(publication, allowIndexing)} />
<link rel="canonical" href={canonical} />
<meta property="og:type" content={type} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
{canonical && <meta property="og:url" content={canonical} />}
<meta property="og:url" content={canonical} />
{absoluteImage && <meta property="og:image" content={absoluteImage} />}
<meta name="twitter:card" content={absoluteImage ? 'summary_large_image' : 'summary'} />
<meta name="twitter:title" content={title} />
@@ -1,11 +1,11 @@
---
import { getCollection } from 'astro:content';
import { getPublicationManifest } from '../../utils/publication';
import BaseLayout from '../../layouts/BaseLayout.astro';
import HomeHero from '../../components/home/HomeHero.astro';
import BlogCard from '../../components/BlogCard.astro';
const posts = (await getCollection('blog', ({ data }) => data.lang === 'ar' && !data.draft)).slice(0, 3);
import HomeHero from '../home/HomeHero.astro';
import BlogCard from '../BlogCard.astro';
const posts = (await getPublicationManifest()).flatMap(({ eligible, entry }) => eligible && entry.collection === 'blog' && entry.data.lang === 'ar' ? [entry] : []).slice(0, 3);
---
<BaseLayout title="معهد أريزونا للتوحد" description="موارد ودعم للأسر والأطفال المصابين بالتوحد." canonical="https://www.azinstitute4autism.com/ar" lang="ar">
<BaseLayout title="معهد أريزونا للتوحد" description="موارد ودعم للأسر والأطفال المصابين بالتوحد." lang="ar">
<HomeHero section={{ type: 'hero', eyebrow: 'Arizona Institute for Autism', heading: 'الصحة السلوكية والتعليم الخاص', body: 'محتوى عربي متاح من مكتبة معهد أريزونا للتوحد.', cta: { label: 'ابدأ الآن', href: '/ar' }, image: 'playing-boy.webp', imageAlt: '' }} />
<section class="section"><div class="container"><div class="section-heading"><h2>المكتبة</h2></div><div class="card-grid">{posts.map((post) => <BlogCard post={post} prefix="/ar" />)}</div></div></section>
</BaseLayout>
+5 -8
View File
@@ -1,5 +1,5 @@
---
import { getEntry } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import HomeBenefits from '../home/HomeBenefits.astro';
import HomeDirector from '../home/HomeDirector.astro';
@@ -13,12 +13,9 @@ import HomeSkills from '../home/HomeSkills.astro';
import HomeTestimonials from '../home/HomeTestimonials.astro';
import type { HomePageContent, HomeSection } from '../../types/home-sections';
interface Props { lang?: string }
const { lang = 'en' } = Astro.props;
const es = lang === 'es';
const entry = await getEntry('pages', es ? 'es/index' : 'en/index');
if (!entry) throw new Error(`[HomePage] Content entry not found for lang="${lang}". If running in dev, restart the dev server to rebuild the content layer cache.`);
const { title, description, canonical } = entry.data;
interface Props { entry: CollectionEntry<'pages'> }
const { entry } = Astro.props;
const { title, description, lang } = entry.data;
function fromHomeContent(home: HomePageContent): HomeSection[] {
return [
@@ -39,7 +36,7 @@ const sections: HomeSection[] = entry.data.home
? fromHomeContent(entry.data.home)
: ((entry.data.sections ?? []) as HomeSection[]);
---
<BaseLayout {title} {description} {canonical} {lang}>
<BaseLayout {title} {description} {lang}>
{sections.map((section) => {
switch (section.type) {
case 'hero':
@@ -1,8 +1,8 @@
---
import { getCollection } from 'astro:content';
import BlogIndexLayout from '../../../layouts/BlogIndexLayout.astro';
import BlogCard from '../../../components/BlogCard.astro';
const posts = (await getCollection('blog', ({ data }) => data.lang === 'ar' && !data.draft)).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
import { getPublicationManifest } from '../../utils/publication';
import BlogIndexLayout from '../../layouts/BlogIndexLayout.astro';
import BlogCard from '../BlogCard.astro';
const posts = (await getPublicationManifest()).flatMap(({ eligible, entry }) => eligible && entry.collection === 'blog' && entry.data.lang === 'ar' ? [entry] : []).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const popular = posts.slice(0, 5);
---
<BlogIndexLayout title="مكتبة معهد أريزونا للتوحد" description="مقالات إخبارية حول علاج تحليل السلوك التطبيقي والتعليم الخاص، ودراسات حالة، ووصفات، ومقالات افتتاحية، ودروس تعليمية." lang="ar">
@@ -1,8 +1,8 @@
---
import { getCollection } from 'astro:content';
import { getPublicationManifest } from '../../utils/publication';
import BlogIndexLayout from '../../layouts/BlogIndexLayout.astro';
import BlogCard from '../../components/BlogCard.astro';
const posts = (await getCollection('blog', ({ data }) => data.lang === 'en' && !data.draft)).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
import BlogCard from '../BlogCard.astro';
const posts = (await getPublicationManifest()).flatMap(({ eligible, entry }) => eligible && entry.collection === 'blog' && entry.data.lang === 'en' ? [entry] : []).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const popular = posts.slice(0, 5);
---
<BlogIndexLayout
@@ -1,8 +1,8 @@
---
import { getCollection } from 'astro:content';
import BlogIndexLayout from '../../../layouts/BlogIndexLayout.astro';
import BlogCard from '../../../components/BlogCard.astro';
const posts = (await getCollection('blog', ({ data }) => data.lang === 'es' && !data.draft)).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
import { getPublicationManifest } from '../../utils/publication';
import BlogIndexLayout from '../../layouts/BlogIndexLayout.astro';
import BlogCard from '../BlogCard.astro';
const posts = (await getPublicationManifest()).flatMap(({ eligible, entry }) => eligible && entry.collection === 'blog' && entry.data.lang === 'es' ? [entry] : []).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const popular = posts.slice(0, 5);
---
<BlogIndexLayout title="La Biblioteca en AZ Institute for Autism" description="Noticias sobre terapia ABA y educación especial, casos de estudio, recetas, editoriales y tutoriales para y sobre nuestros estudiantes con autismo." lang="es">
+1
View File
@@ -14,6 +14,7 @@ const shared = {
lang: language,
translationKey: z.string().optional(),
draft: z.boolean().default(false),
noindex: z.boolean().default(false),
home: homePageSchema.optional(),
sections: homeSectionsSchema.optional()
};
@@ -2,7 +2,7 @@
title: "Community Highlights: Meet Rula Diab of Arizona Institute for Autism"
description: "Read about Rula Diab"
slug: "community-highlight-meet-rula-diab"
canonical: "https://www.azinstitute4autism.com/library/community-highlight-meet-rula-diab"
canonical: "https://voyagephoenix.com/interview/community-highlights-meet-rula-diab-of-arizona-institute-for-autism/"
lang: "en"
translationKey: "community-highlight-meet-rula-diab"
featuredImage: "/assets/images/hero-community-highlight-meet-rula-diab_800x.jpg"
@@ -11,6 +11,7 @@ author: "rula-diab"
category: "Library"
tags: []
draft: false
noindex: true
---
Check out the following article from **Voyage Phoenix** about our Clinical Director Rula Diab. Read how Rula's passion to provide accessible ABA therapy for children with Autism Spectrum Disorder drove her to create the Arizona Institute for Autism. You may also read the [original article](https://voyagephoenix.com/interview/community-highlights-meet-rula-diab-of-arizona-institute-for-autism/) on the Voyage Phoenix website.
@@ -2,7 +2,7 @@
title: "Integrity, Empowerment and Excellence: Arizona Institute for Autism expands to new office"
description: "Discover how Arizona Institute for Autism"
slug: "new-aia-scottsdale-office"
canonical: "https://www.azinstitute4autism.com/library/new-aia-scottsdale-office"
canonical: "https://www.scottsdale.org/airpark/features/integrity-empowerment-and-excellence-arizona-institute-for-autism-expands-to-new-office/article_79c31a18-2f35-11ee-9459-6f29f5a08420.html"
lang: "en"
translationKey: "new-aia-scottsdale-office"
featuredImage: "/assets/images/hero-integrity-empowerment-excellence_800x.jpg"
@@ -11,6 +11,7 @@ author: "rula-diab"
category: "Library"
tags: []
draft: false
noindex: true
---
Check out this article about our new clinic location. Our Clinical Director Rula Diab met with Scottsdale Airpark to share the details of the expansion. You can find [the original article](https://www.scottsdale.org/airpark/features/integrity-empowerment-and-excellence-arizona-institute-for-autism-expands-to-new-office/article_79c31a18-2f35-11ee-9459-6f29f5a08420) on the Scottsdale Airpark News website.
+1 -1
View File
@@ -5,7 +5,7 @@ slug: "index"
canonical: "https://www.azinstitute4autism.com/ar"
lang: "ar"
translationKey: "home"
draft: false
draft: true
---
<!-- TODO: Replace this placeholder with reviewed Arabic content. -->
@@ -6,4 +6,5 @@ canonical: "https://www.azinstitute4autism.com/employee-portal"
lang: "en"
translationKey: "employee-portal"
draft: false
noindex: true
---
@@ -6,6 +6,7 @@ canonical: "https://www.azinstitute4autism.com/schedule-consultation"
lang: "en"
translationKey: "schedule-consultation"
draft: false
noindex: true
---
import ContactObfuscation from '../../../components/ContactObfuscation.astro';
+4 -2
View File
@@ -3,9 +3,11 @@ import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import Seo from '../components/Seo.astro';
import site from '../data/site.json';
import { getPublication } from '../utils/publication';
import '../styles/global.css';
import '../styles/rtl.css';
const { title, description, canonical, image, lang = 'en', type = 'website' } = Astro.props;
const { title, description, image, lang = 'en', type = 'website' } = Astro.props;
const publication = await getPublication(Astro.url.pathname);
const dir = lang === 'ar' ? 'rtl' : 'ltr';
const organizationSchema = {
'@context': 'https://schema.org',
@@ -30,7 +32,7 @@ const organizationSchema = {
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" href="/assets/images/favicon.svg" />
<Seo title={title} description={description} canonical={canonical} image={image} lang={lang} type={type} />
<Seo title={title} description={description} publication={publication} image={image} lang={lang} type={type} />
<slot name="head" />
<script type="application/ld+json" is:inline set:html={JSON.stringify(organizationSchema)} />
</head>
+1 -3
View File
@@ -10,10 +10,8 @@ interface Props {
}
const { title, description, intro = [], lang = 'en' } = Astro.props;
const languagePrefix = lang === 'en' ? '' : `/${lang}`;
const canonical = `https://www.azinstitute4autism.com${languagePrefix}/library`;
---
<BaseLayout title={title} description={description} canonical={canonical} lang={lang}>
<BaseLayout title={title} description={description} lang={lang}>
<PageHero title={title} image="/assets/images/hero-library-index.webp" eyebrow="Welcome to" constrain />
<section class="library-intro">
<div class="container">
+1 -1
View File
@@ -4,7 +4,7 @@ import BlogPostFooter from '../components/BlogPostFooter.astro';
import LikeViewCounter from '../components/LikeViewCounter.astro';
const { entry } = Astro.props;
---
<BaseLayout title={entry.data.title} description={entry.data.description} canonical={entry.data.canonical} image={entry.data.featuredImage} lang={entry.data.lang} type="article">
<BaseLayout title={entry.data.title} description={entry.data.description} image={entry.data.featuredImage} lang={entry.data.lang} type="article">
<article class="blog-post">
<header class="article-header container">
<h1>{entry.data.title}</h1>
+1 -1
View File
@@ -55,7 +55,7 @@ const bannerTitle = serviceTitles[entry.data.lang]?.[entry.data.slug] || pageTit
const isService = Boolean(serviceTitles[entry.data.lang]?.[entry.data.slug]);
const heroImage = pageHeroImages[entry.data.slug];
---
<BaseLayout title={entry.data.title} description={entry.data.description} canonical={entry.data.canonical} image={entry.data.featuredImage || heroImage} lang={entry.data.lang}>
<BaseLayout title={entry.data.title} description={entry.data.description} image={entry.data.featuredImage || heroImage} lang={entry.data.lang}>
{entry.data.slug === 'contact' && <Fragment slot="head"><link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" /></Fragment>}
<PageHero title={bannerTitle} subtitle={entry.data.slug === 'faqs' ? 'FAQs' : undefined} image={heroImage} constrain />
{entry.data.slug === 'team' && entry.data.lang === 'en'
+19 -4
View File
@@ -1,11 +1,26 @@
---
import { getCollection, render } from 'astro:content';
import { render } from 'astro:content';
import { getPublicationManifest } from '../utils/publication';
import PageLayout from '../layouts/PageLayout.astro';
import BlogPostLayout from '../layouts/BlogPostLayout.astro';
import HomePage from '../components/pages/HomePage.astro';
import ArabicHome from '../components/pages/ArabicHome.astro';
import LibraryEnglish from '../components/pages/LibraryEnglish.astro';
import LibrarySpanish from '../components/pages/LibrarySpanish.astro';
import LibraryArabic from '../components/pages/LibraryArabic.astro';
export async function getStaticPaths() {
const pages = await getCollection('pages', ({ data }) => data.lang === 'en' && !['index', 'library'].includes(data.slug) && !data.draft);
return pages.map((entry) => ({ params: { slug: entry.data.slug }, props: { entry } }));
return (await getPublicationManifest()).filter((page) => page.eligible).map(({ route, entry }) => ({
params: { slug: route === '/' ? undefined : decodeURI(route.slice(1)) },
props: { entry }
}));
}
const { entry } = Astro.props;
const { Content } = await render(entry);
---
<PageLayout entry={entry}><Content /></PageLayout>
{entry.collection === 'blog'
? <BlogPostLayout entry={entry}><Content /></BlogPostLayout>
: entry.data.slug === 'index'
? entry.data.lang === 'ar' ? <ArabicHome /> : <HomePage entry={entry} />
: entry.data.slug === 'library'
? entry.data.lang === 'es' ? <LibrarySpanish /> : entry.data.lang === 'ar' ? <LibraryArabic /> : <LibraryEnglish />
: <PageLayout entry={entry}><Content /></PageLayout>}
-11
View File
@@ -1,11 +0,0 @@
---
import { getCollection, render } from 'astro:content';
import PageLayout from '../../layouts/PageLayout.astro';
export async function getStaticPaths() {
const pages = await getCollection('pages', ({ data }) => data.lang === 'ar' && !['index', 'library'].includes(data.slug) && !data.draft);
return pages.map((entry) => ({ params: { slug: entry.data.slug }, props: { entry } }));
}
const { entry } = Astro.props;
const { Content } = await render(entry);
---
<PageLayout entry={entry}><Content /></PageLayout>
-11
View File
@@ -1,11 +0,0 @@
---
import { getCollection, render } from 'astro:content';
import BlogPostLayout from '../../../layouts/BlogPostLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => data.lang === 'ar' && !data.draft);
return posts.map((entry) => ({ params: { slug: entry.data.slug }, props: { entry } }));
}
const { entry } = Astro.props;
const { Content } = await render(entry);
---
<BlogPostLayout entry={entry}><Content /></BlogPostLayout>
-11
View File
@@ -1,11 +0,0 @@
---
import { getCollection, render } from 'astro:content';
import PageLayout from '../../layouts/PageLayout.astro';
export async function getStaticPaths() {
const pages = await getCollection('pages', ({ data }) => data.lang === 'es' && !['index', 'library'].includes(data.slug) && !data.draft);
return pages.map((entry) => ({ params: { slug: entry.data.slug }, props: { entry } }));
}
const { entry } = Astro.props;
const { Content } = await render(entry);
---
<PageLayout entry={entry}><Content /></PageLayout>
-4
View File
@@ -1,4 +0,0 @@
---
import HomePage from '../../components/pages/HomePage.astro';
---
<HomePage lang="es" />
-11
View File
@@ -1,11 +0,0 @@
---
import { getCollection, render } from 'astro:content';
import BlogPostLayout from '../../../layouts/BlogPostLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => data.lang === 'es' && !data.draft);
return posts.map((entry) => ({ params: { slug: entry.data.slug }, props: { entry } }));
}
const { entry } = Astro.props;
const { Content } = await render(entry);
---
<BlogPostLayout entry={entry}><Content /></BlogPostLayout>
-4
View File
@@ -1,4 +0,0 @@
---
import HomePage from '../components/pages/HomePage.astro';
---
<HomePage lang="en" />
-11
View File
@@ -1,11 +0,0 @@
---
import { getCollection, render } from 'astro:content';
import BlogPostLayout from '../../layouts/BlogPostLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => data.lang === 'en' && !data.draft);
return posts.map((entry) => ({ params: { slug: entry.data.slug }, props: { entry } }));
}
const { entry } = Astro.props;
const { Content } = await render(entry);
---
<BlogPostLayout entry={entry}><Content /></BlogPostLayout>
+3 -2
View File
@@ -1,10 +1,11 @@
const allowIndexing = import.meta.env.PUBLIC_ALLOW_INDEXING === 'true';
import { allowIndexing } from '../utils/publication';
import site from '../data/site.json';
const body = allowIndexing
? [
'User-agent: *',
'Allow: /',
'Sitemap: https://www.azinstitute4autism.com/sitemap.xml',
`Sitemap: ${new URL('/sitemap.xml', site.url).href}`,
''
].join('\n')
: [
+8
View File
@@ -0,0 +1,8 @@
import { allowIndexing, getPublicationManifest } from '../utils/publication';
import { renderSitemap } from '../utils/publication-policy';
export async function GET() {
return new Response(renderSitemap(await getPublicationManifest(), allowIndexing), {
headers: { 'Content-Type': 'application/xml; charset=utf-8' }
});
}
+114
View File
@@ -0,0 +1,114 @@
export interface PublicationEntry {
id: string;
collection: 'pages' | 'blog';
data: {
lang: string;
slug: string;
draft?: boolean;
noindex?: boolean;
canonical?: string;
};
}
export interface Publication {
route: string;
url: string;
canonical: string;
eligible: boolean;
noindex: boolean;
externalCanonical: boolean;
sitemapEligible: boolean;
}
/** Public document paths have no query/fragment and no trailing slash except /. */
export function normalizeRoute(value: string): string {
if (!value.startsWith('/') || value.startsWith('//') || /[?#\\\s]/u.test(value)) {
throw new Error(`Invalid local route: ${value}`);
}
const segments = value.replace(/\/+$/, '').slice(1).split('/');
if (value.replace(/\/+$/, '') && segments.some((segment) => {
const decoded = decodeURIComponent(segment);
return !decoded || ['.', '..'].includes(decoded) || /[/\\?#\s]/u.test(decoded);
})) throw new Error(`Invalid local route: ${value}`);
return '/' + segments.map((segment) => encodeURIComponent(decodeURIComponent(segment))).join('/');
}
export function contentRoute(entry: PublicationEntry): string {
const { lang, slug } = entry.data;
if (!['en', 'es', 'ar'].includes(lang) || !slug || slug.startsWith('/')) {
throw new Error(`Invalid language/slug for ${entry.collection}/${entry.id}`);
}
const prefix = lang === 'en' ? '' : `/${lang}`;
const path = entry.collection === 'blog' ? `/library/${slug}`
: slug.replace(/\/+$/, '') === 'index' ? '/' : `/${slug}`;
return normalizeRoute(prefix + path);
}
function canonicalUrl(value: string): URL {
let url: URL;
try { url = new URL(value); } catch { throw new Error(`Invalid canonical URL: ${value}`); }
if (!/^https?:\/\//.test(value) || /\s/u.test(value) || url.username || url.password || url.hash) {
throw new Error(`Invalid canonical URL: ${value}`);
}
return url;
}
export function publicationFor(entry: PublicationEntry, site: string): Publication {
const origin = new URL(site).origin;
const route = contentRoute(entry);
if (/^\/(?:robots\.txt|sitemap\.xml)$/.test(route) || /^\/(?:assets|_astro)(?:\/|$)/.test(route)) {
throw new Error(`Content route collides with a reserved utility: ${route}`);
}
const url = new URL(route, origin).href;
const sourceCanonical = entry.data.canonical;
const supplied = sourceCanonical === undefined ? new URL(url) : canonicalUrl(sourceCanonical);
if (supplied.hostname.replace(/^www\./, '') === new URL(origin).hostname.replace(/^www\./, '') && supplied.origin !== origin) {
throw new Error(`Local canonical must use the public origin: ${sourceCanonical}`);
}
const externalCanonical = supplied.origin !== origin;
if (!externalCanonical && (supplied.search || normalizeRoute(supplied.pathname) !== route)) {
throw new Error(`Local canonical must match its route: ${route} -> ${sourceCanonical}`);
}
const eligible = entry.data.draft !== true;
const noindex = entry.data.noindex === true;
return {
route, url, canonical: externalCanonical ? supplied.href : url,
eligible, noindex, externalCanonical,
sitemapEligible: eligible && !noindex && !externalCanonical
};
}
/** Check every entry, including drafts, so publication cannot hide collisions. */
export function createPublicationManifest<T extends PublicationEntry>(entries: T[], site: string) {
const routes = new Map<string, string>();
const canonicals = new Map<string, string>();
return entries.map((entry) => {
const publication = publicationFor(entry, site);
const source = `${entry.collection}/${entry.id}`;
const previous = routes.get(publication.route);
if (previous) throw new Error(`Route collision: ${publication.route} (${previous}, ${source})`);
routes.set(publication.route, source);
if (publication.eligible) {
const duplicate = canonicals.get(publication.canonical);
if (duplicate) throw new Error(`Duplicate canonical: ${publication.canonical} (${duplicate}, ${source})`);
canonicals.set(publication.canonical, source);
}
return { ...publication, entry };
}).sort((a, b) => a.route.localeCompare(b.route, 'en'));
}
export function robotsFor(publication: Publication, allowIndexing: boolean): string {
if (!allowIndexing || !publication.eligible) return 'noindex,nofollow';
return publication.noindex ? 'noindex,follow' : 'index,follow';
}
export function sitemapUrls(manifest: Publication[], allowIndexing: boolean): string[] {
// Derive locations from local route URLs, never external canonical targets.
return allowIndexing ? manifest.filter((page) => page.sitemapEligible).map((page) => page.url) : [];
}
export function renderSitemap(manifest: Publication[], allowIndexing: boolean): string {
const escapeXml = (value: string) => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
const rows = sitemapUrls(manifest, allowIndexing).map((url) => ` <url><loc>${escapeXml(url)}</loc></url>`);
return ['<?xml version="1.0" encoding="UTF-8"?>', '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', ...rows, '</urlset>', ''].join('\n');
}
+17
View File
@@ -0,0 +1,17 @@
import { getCollection } from 'astro:content';
import site from '../data/site.json';
import { createPublicationManifest, normalizeRoute } from './publication-policy';
export const allowIndexing = import.meta.env.PUBLIC_ALLOW_INDEXING === 'true';
export async function getPublicationManifest() {
const [pages, posts] = await Promise.all([getCollection('pages'), getCollection('blog')]);
return createPublicationManifest([...pages, ...posts], site.url);
}
export async function getPublication(route: string) {
const normalized = normalizeRoute(route);
const publication = (await getPublicationManifest()).find((page) => page.route === normalized);
if (!publication?.eligible) throw new Error(`Route is not published: ${normalized}`);
return publication;
}