Replace the sitemap page with Yoast-style XML sitemaps: an index, one sitemap per kind of page, styled tables

This commit is contained in:
Alejandro Veras
2026-10-27 15:27:13 -04:00
parent ac82825cb5
commit 2d137fea3e
13 changed files with 146 additions and 225 deletions
+83
View File
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Shows the sitemap index and each sitemap as a plain table in the browser. Search engines read the XML and ignore this file. -->
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:sm="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml"
exclude-result-prefixes="sm xhtml">
<xsl:output method="html" encoding="UTF-8" indent="yes"/>
<xsl:template match="/">
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="robots" content="noindex, follow"/>
<title>XML Sitemap</title>
<style>
body { margin:0; background:#fff; color:#444; font:13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
#content { max-width:1000px; margin:0 auto; padding:24px 16px 48px; }
h1 { margin:0 0 8px; font-size:24px; font-weight:600; color:#222; }
p.desc { margin:0 0 4px; color:#555; }
p.desc a { color:#0073aa; }
p.count { margin:16px 0 12px; color:#555; }
table { width:100%; border-collapse:collapse; border:1px solid #e5e5e5; }
th { padding:8px 10px; background:#0073aa; color:#fff; text-align:left; font-weight:600; }
td { padding:7px 10px; border-bottom:1px solid #eee; vertical-align:top; }
tr:nth-child(even) td { background:#f6f8fa; }
td a { color:#0073aa; text-decoration:none; word-break:break-all; }
td a:hover { text-decoration:underline; }
td.n, th.n { width:40px; color:#888; }
td.date { width:160px; white-space:nowrap; }
td.langs a { margin-right:6px; text-transform:uppercase; font-size:11px; font-weight:600; }
@media (max-width:640px) { .hide-sm { display:none; } }
</style>
</head>
<body>
<div id="content">
<h1>XML Sitemap</h1>
<p class="desc">This is an XML Sitemap, meant for consumption by search engines.</p>
<p class="desc">You can find more information about XML sitemaps on <a href="https://www.sitemaps.org/" rel="noopener">sitemaps.org</a>.</p>
<xsl:apply-templates select="sm:sitemapindex | sm:urlset"/>
</div>
</body>
</html>
</xsl:template>
<xsl:template match="sm:sitemapindex">
<p class="count">This XML Sitemap Index file contains <strong><xsl:value-of select="count(sm:sitemap)"/></strong> sitemaps.</p>
<table>
<thead><tr><th class="n">#</th><th>Sitemap</th><th>Last Modified</th></tr></thead>
<tbody>
<xsl:for-each select="sm:sitemap">
<tr>
<td class="n"><xsl:value-of select="position()"/></td>
<td><a href="{sm:loc}"><xsl:value-of select="sm:loc"/></a></td>
<td class="date"><xsl:value-of select="sm:lastmod"/></td>
</tr>
</xsl:for-each>
</tbody>
</table>
</xsl:template>
<xsl:template match="sm:urlset">
<p class="count">This XML Sitemap contains <strong><xsl:value-of select="count(sm:url)"/></strong> URLs. <a href="/sitemap.xml">← Sitemap Index</a></p>
<table>
<thead><tr><th class="n">#</th><th>URL</th><th class="hide-sm">Languages</th><th>Last Mod.</th></tr></thead>
<tbody>
<xsl:for-each select="sm:url">
<tr>
<td class="n"><xsl:value-of select="position()"/></td>
<td><a href="{sm:loc}"><xsl:value-of select="sm:loc"/></a></td>
<td class="langs hide-sm">
<xsl:for-each select="xhtml:link[@hreflang != 'x-default'][@href != current()/sm:loc]">
<a href="{@href}"><xsl:value-of select="@hreflang"/></a>
</xsl:for-each>
</td>
<td class="date"><xsl:value-of select="sm:lastmod"/></td>
</tr>
</xsl:for-each>
</tbody>
</table>
</xsl:template>
</xsl:stylesheet>
-76
View File
@@ -1,76 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { locales, localeNames, localize } from "@/i18n/config";
import { industryLinks, serviceLinks, type NavLink } from "@/i18n/nav";
import { getUI } from "@/i18n/server";
import { localeAlternates } from "@/lib/metadata";
export async function generateMetadata(): Promise<Metadata> {
const { locale, t } = await getUI();
return { title: t.sitemap.metaTitle, description: t.sitemap.intro, alternates: localeAlternates("/sitemap", locale) };
}
/** A readable index of every page, for visitors; search engines use /sitemap.xml. */
export default async function SitemapPage() {
const { locale, t } = await getUI();
const groups: { title: string; links: NavLink[] }[] = [
{
title: t.sitemap.company,
links: [
{ label: t.sitemap.home, href: localize("/", locale) },
{ label: t.nav.about, href: localize("/about", locale) },
{ label: t.nav.contact, href: localize("/contact", locale) },
],
},
{
title: t.nav.services,
links: [{ label: t.nav.allServices, href: localize("/services", locale) }, ...serviceLinks(t, locale)],
},
{
title: t.nav.industries,
links: [{ label: t.nav.allIndustries, href: localize("/industries", locale) }, ...industryLinks(t, locale)],
},
{
title: t.sitemap.languages,
links: locales.map((l) => ({ label: localeNames[l], href: localize("/sitemap", l) })),
},
];
return (
<section className="section sitemap" aria-labelledby="page-title">
<div className="wrap">
<nav className="crumbs" aria-label={t.crumbs.label}>
<span>
<Link href={localize("/", locale)}>{t.crumbs.home}</Link>
<i aria-hidden="true">/</i>
</span>
<span aria-current="page">{t.sitemap.title}</span>
</nav>
<p className="eyebrow">{t.sitemap.title}</p>
<h1 id="page-title" className="display h2">
{t.sitemap.title}
</h1>
<p className="lead muted sitemap-intro">{t.sitemap.intro}</p>
<div className="sitemap-grid">
{groups.map((g) => (
<div key={g.title} className="sitemap-group">
<h2>
{g.title} <span>{String(g.links.length).padStart(2, "0")}</span>
</h2>
<ul>
{g.links.map((l) => (
<li key={l.href}>
<Link href={l.href} aria-current={l.href === localize("/sitemap", locale) ? "page" : undefined}>
{l.label}
<span className="arrow">→</span>
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</section>
);
}
-90
View File
@@ -2980,93 +2980,3 @@ html[lang="ar"] .nav a {
align-items: flex-start; align-items: flex-start;
} }
} }
/* ============================================================
Sitemap page
============================================================ */
.sitemap {
padding-top: 152px;
}
.sitemap h1 {
margin-top: 24px;
}
.sitemap-intro {
max-width: 46ch;
margin: 24px 0 0;
}
.sitemap-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 24px;
margin-top: 64px;
}
.sitemap-group {
padding-top: 20px;
border-top: 1px solid var(--grafite);
}
.sitemap-group h2 {
display: flex;
justify-content: space-between;
margin: 0 0 20px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.22em;
text-transform: uppercase;
}
.sitemap-group h2 span {
color: var(--rosso);
}
.sitemap-group ul {
list-style: none;
margin: 0;
padding: 0;
}
.sitemap-group a {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--grafite);
font-size: 15px;
color: var(--fumo);
transition: color 0.3s var(--ease);
}
.sitemap-group a .arrow {
opacity: 0;
transition: opacity 0.3s var(--ease);
}
.sitemap-group a:hover,
.sitemap-group a[aria-current] {
color: var(--bianco);
}
.sitemap-group a:hover .arrow {
opacity: 1;
color: var(--rosso);
}
[dir="rtl"] .sitemap-group a .arrow {
transform: scaleX(-1);
}
@media (max-width: 1023px) {
.sitemap-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 599px) {
.sitemap-grid {
grid-template-columns: 1fr;
}
}
+3
View File
@@ -0,0 +1,3 @@
import { sitemap } from "@/lib/sitemap";
export const GET = () => sitemap("industry");
+3
View File
@@ -0,0 +1,3 @@
import { sitemap } from "@/lib/sitemap";
export const GET = () => sitemap("page");
+3
View File
@@ -0,0 +1,3 @@
import { sitemap } from "@/lib/sitemap";
export const GET = () => sitemap("service");
-31
View File
@@ -1,31 +0,0 @@
import type { MetadataRoute } from "next";
import { locales, localize } from "@/i18n/config";
import { SITE_URL, industryNav, serviceNav } from "@/lib/routes";
export default function sitemap(): MetadataRoute.Sitemap {
const paths = [
"/",
"/about",
"/services",
...serviceNav.map((s) => `/services/${s.slug}`),
"/industries",
...industryNav.map((s) => `/industries/${s.slug}`),
"/contact",
"/sitemap",
];
const url = (p: string) => SITE_URL + (p === "/" ? "" : p);
// The day this deploy was built (set in next.config.ts), so dates only move when the site changes.
const lastModified = process.env.BUILD_DATE;
return locales.flatMap((locale) =>
paths.map((p) => ({
url: url(localize(p, locale)),
lastModified,
changeFrequency: "monthly" as const,
priority: p === "/" ? 1 : p === "/sitemap" ? 0.3 : p.split("/").length === 2 ? 0.8 : 0.6,
alternates: {
languages: { ...Object.fromEntries(locales.map((l) => [l, url(localize(p, l))])), "x-default": url(p) },
},
})),
);
}
+3
View File
@@ -0,0 +1,3 @@
import { sitemapIndex } from "@/lib/sitemap";
export const GET = () => sitemapIndex();
-1
View File
@@ -50,7 +50,6 @@ export default async function SiteFooter() {
{ label: t.nav.allServices, href: localize("/services", locale) }, { label: t.nav.allServices, href: localize("/services", locale) },
{ label: t.nav.allIndustries, href: localize("/industries", locale) }, { label: t.nav.allIndustries, href: localize("/industries", locale) },
{ label: t.nav.contact, href: localize("/contact", locale) }, { label: t.nav.contact, href: localize("/contact", locale) },
{ label: t.sitemap.nav, href: localize("/sitemap", locale) },
], ],
}, },
]; ];
-9
View File
@@ -53,15 +53,6 @@ export const ar: UI = {
rights: "© 2026 AI Growth Technologies. جميع الحقوق محفوظة.", rights: "© 2026 AI Growth Technologies. جميع الحقوق محفوظة.",
cities: "دبي · هيوستن · ميامي · نيويورك", cities: "دبي · هيوستن · ميامي · نيويورك",
}, },
sitemap: {
nav: "خريطة الموقع",
title: "خريطة الموقع",
metaTitle: "خريطة الموقع | AI Growth Technologies",
intro: "جميع صفحات الموقع في مكان واحد، مرتبة حسب القسم.",
company: "الشركة",
home: "الرئيسية",
languages: "اللغات",
},
crumbs: { label: "مسار التنقل", home: "الرئيسية" }, crumbs: { label: "مسار التنقل", home: "الرئيسية" },
faqTitle: "الأسئلة الشائعة", faqTitle: "الأسئلة الشائعة",
office: { phone: "الهاتف", email: "البريد الإلكتروني" }, office: { phone: "الهاتف", email: "البريد الإلكتروني" },
-9
View File
@@ -53,15 +53,6 @@ export const en = {
rights: "© 2026 AI Growth Technologies. All rights reserved.", rights: "© 2026 AI Growth Technologies. All rights reserved.",
cities: "Dubai · Houston · Miami · New York", cities: "Dubai · Houston · Miami · New York",
}, },
sitemap: {
nav: "Sitemap",
title: "Sitemap",
metaTitle: "Sitemap | AI Growth Technologies",
intro: "Every page on the site in one place, organized by section.",
company: "Company",
home: "Home",
languages: "Languages",
},
crumbs: { label: "Breadcrumb", home: "Home" }, crumbs: { label: "Breadcrumb", home: "Home" },
faqTitle: "Frequently Asked Questions", faqTitle: "Frequently Asked Questions",
office: { phone: "Phone", email: "Email" }, office: { phone: "Phone", email: "Email" },
-9
View File
@@ -53,15 +53,6 @@ export const es: UI = {
rights: "© 2026 AI Growth Technologies. Todos los derechos reservados.", rights: "© 2026 AI Growth Technologies. Todos los derechos reservados.",
cities: "Dubái · Houston · Miami · Nueva York", cities: "Dubái · Houston · Miami · Nueva York",
}, },
sitemap: {
nav: "Mapa del sitio",
title: "Mapa del sitio",
metaTitle: "Mapa del sitio | AI Growth Technologies",
intro: "Todas las páginas del sitio en un solo lugar, organizadas por sección.",
company: "Empresa",
home: "Inicio",
languages: "Idiomas",
},
crumbs: { label: "Ruta de navegación", home: "Inicio" }, crumbs: { label: "Ruta de navegación", home: "Inicio" },
faqTitle: "Preguntas frecuentes", faqTitle: "Preguntas frecuentes",
office: { phone: "Teléfono", email: "Correo" }, office: { phone: "Teléfono", email: "Correo" },
+51
View File
@@ -0,0 +1,51 @@
import { locales, localize } from "@/i18n/config";
import { SITE_URL, industryNav, serviceNav } from "./routes";
// Yoast-style sitemaps: /sitemap.xml is an index of one sitemap per kind of page. Each lists the
// English pages in site order, then their Spanish and Arabic copies, with every page's translations.
// public/sitemap-style.xml (an XSL stylesheet) shows them as tables in a browser; search engines ignore it.
export const sitemaps = {
page: ["/", "/about", "/contact"],
service: ["/services", ...serviceNav.map((s) => `/services/${s.slug}`)],
industry: ["/industries", ...industryNav.map((s) => `/industries/${s.slug}`)],
} as const;
// The day this deploy was built (set in next.config.ts), so dates only move when the site changes.
const lastmod = process.env.BUILD_DATE;
const url = (p: string) => SITE_URL + (p === "/" ? "" : p);
const head = `<?xml version="1.0" encoding="UTF-8"?>\n<?xml-stylesheet type="text/xsl" href="/sitemap-style.xml"?>\n`;
const xml = (body: string) =>
new Response(head + body, { headers: { "content-type": "application/xml; charset=utf-8" } });
export function sitemapIndex() {
const entries = Object.keys(sitemaps).map(
(name) => ` <sitemap>
<loc>${SITE_URL}/${name}-sitemap.xml</loc>
<lastmod>${lastmod}</lastmod>
</sitemap>`,
);
return xml(`<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${entries.join("\n")}
</sitemapindex>
`);
}
export function sitemap(name: keyof typeof sitemaps) {
const alternates = (p: string) =>
[...locales.map((l) => [l, localize(p, l)]), ["x-default", p]]
.map(([l, href]) => `\n <xhtml:link rel="alternate" hreflang="${l}" href="${url(href)}"/>`)
.join("");
const entries = locales.flatMap((locale) =>
sitemaps[name].map(
(p) => ` <url>
<loc>${url(localize(p, locale))}</loc>
<lastmod>${lastmod}</lastmod>${alternates(p)}
</url>`,
),
);
return xml(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
${entries.join("\n")}
</urlset>
`);
}