fix(docs): repair canonical URLs, sitemap coverage and robots.txt (#6735)
* fix(docs): canonicalize pages to the URLs that are actually served Every docs page declared `https://www.heroui.com/docs/<slug>` as its canonical, but that URL answers with two permanent redirects: `www` -> apex, then `/docs/...` -> `/<lang>/docs/...`. Google therefore dropped the crawled page in favour of a redirect, and both `/en` and `/cn` variants pointed at the same English URL. The root layout also declared the home page as canonical, which every page without its own `alternates` inherited, self-excluding `/cn`, `/<lang>/themes` and `/<lang>/showcase` from the index. Canonicals now use the served locale-prefixed URL on the apex domain and carry an hreflang cluster (en / zh-Hans / x-default). Untranslated blog posts point at the default locale instead of self-canonicalizing a duplicate. * fix(docs): build the sitemap from the docs source instead of the prerender manifest `next-sitemap` derives its URL list from Next's prerender manifest, and every docs, blog and themes route is server-rendered on demand. The published sitemap therefore listed 19 URLs — no documentation pages at all — while advertising `llms-*.txt`, `manifest.webmanifest` and `rss.xml` as indexable pages. The App Router `sitemap.ts` enumerates the Fumadocs loader, the blog collection and the showcase registry instead, producing 501 canonical URLs with hreflang alternates, all of which answer 200 without a redirect. * fix(docs): serve robots.txt from the app and keep non-production hosts out of the index The committed `public/robots.txt` was the artefact of an earlier `next-sitemap` run, so its `Sitemap` and `Host` lines were pinned to whichever host generated them — canary still advertised `www.heroui.com`. Serving it from the app derives the sitemap URL from the deployed host and keeps the Content Signals directive that `MetadataRoute.Robots` cannot express. Every route stays allowed for every crawler; nothing indexable is blocked. Preview and development deployments opt out through `X-Robots-Tag` instead, which is host-scoped and cannot de-index production the way a stray `Disallow` would. * fix(docs): exclude untranslated blog fallbacks from sitemap --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: WK Wong <wingkwong.code@gmail.com>
This commit is contained in:
@@ -1,42 +0,0 @@
|
||||
/* eslint-disable import/no-anonymous-default-export */
|
||||
const normalizeSiteUrl = (siteUrl) => {
|
||||
const url = new URL(siteUrl);
|
||||
|
||||
if (url.hostname === "v3.heroui.com") {
|
||||
url.hostname = "heroui.com";
|
||||
}
|
||||
|
||||
return url.toString().replace(/\/$/, "");
|
||||
};
|
||||
|
||||
const contentSignalDirective = "Content-Signal: ai-train=yes, search=yes, ai-input=yes";
|
||||
|
||||
/** @type {import('next-sitemap').IConfig} */
|
||||
export default {
|
||||
autoLastmod: true,
|
||||
changefreq: "daily",
|
||||
exclude: ["/api/*", "/llms.mdx/*", "/llms.txt", "/llms-full.txt", "/og/*"],
|
||||
generateIndexSitemap: true,
|
||||
generateRobotsTxt: true,
|
||||
priority: 0.7,
|
||||
robotsTxtOptions: {
|
||||
additionalSitemaps: [],
|
||||
policies: [
|
||||
{
|
||||
allow: "/",
|
||||
userAgent: "*",
|
||||
},
|
||||
],
|
||||
transformRobotsTxt: async (_, robotsTxt) => {
|
||||
if (robotsTxt.includes(contentSignalDirective)) {
|
||||
return robotsTxt;
|
||||
}
|
||||
|
||||
return robotsTxt.replace("Allow: /\n", `Allow: /\n${contentSignalDirective}\n`);
|
||||
},
|
||||
},
|
||||
siteUrl: normalizeSiteUrl(
|
||||
process.env.NEXT_PUBLIC_SITE_URL || process.env.SITE_URL || "https://heroui.com",
|
||||
),
|
||||
sitemapSize: 5000,
|
||||
};
|
||||
@@ -10,6 +10,13 @@ import {getRedirects} from "./next-redirects";
|
||||
|
||||
const withMDX = createMDX();
|
||||
|
||||
// Preview and development deployments serve the same content as production on a
|
||||
// different host, so they must opt out of indexing. Anything other than an
|
||||
// explicit non-production value stays indexable: a missing env var must never
|
||||
// silently de-index the production site.
|
||||
const appEnv = process.env["NEXT_PUBLIC_APP_ENV"];
|
||||
const isIndexable = appEnv !== "preview" && appEnv !== "development";
|
||||
|
||||
const config: NextConfig = {
|
||||
compress: true,
|
||||
experimental: {
|
||||
@@ -30,7 +37,7 @@ const config: NextConfig = {
|
||||
headers: [
|
||||
{
|
||||
key: "X-Robots-Tag",
|
||||
value: "index, follow",
|
||||
value: isIndexable ? "index, follow" : "noindex, nofollow",
|
||||
},
|
||||
],
|
||||
source: "/:path*",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"dev": "next dev",
|
||||
"prebuild": "node scripts/build-skills.mjs && node scripts/build-theme-presets.mjs",
|
||||
"build": "next build",
|
||||
"postbuild": "next-sitemap",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix",
|
||||
@@ -51,7 +50,6 @@
|
||||
"motion": "12.23.26",
|
||||
"next": "16.2.6",
|
||||
"next-mdx-remote": "6.0.0",
|
||||
"next-sitemap": "4.2.3",
|
||||
"next-themes": "0.4.6",
|
||||
"nuqs": "2.8.6",
|
||||
"posthog-node": "5.21.0",
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# *
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Content-Signal: ai-train=yes, search=yes, ai-input=yes
|
||||
|
||||
# Host
|
||||
Host: https://heroui.com
|
||||
|
||||
# Sitemaps
|
||||
Sitemap: https://heroui.com/sitemap.xml
|
||||
@@ -1,3 +1,5 @@
|
||||
import type {Metadata} from "next";
|
||||
|
||||
import {buttonVariants} from "@heroui/react";
|
||||
import LinkRoot from "fumadocs-core/link";
|
||||
import {notFound} from "next/navigation";
|
||||
@@ -7,6 +9,7 @@ import {StarsCount} from "@/components/github-link";
|
||||
import {GitHubIcon} from "@/icons/github";
|
||||
import {getDictionary, hasLocale} from "@/lib/dictionaries";
|
||||
import {i18n} from "@/lib/i18n";
|
||||
import {getLocalizedAlternates} from "@/lib/seo";
|
||||
|
||||
import {DemoShowcase} from "./components/demo-showcase";
|
||||
import {ProBanner} from "./components/pro-banner";
|
||||
@@ -19,6 +22,18 @@ export function generateStaticParams() {
|
||||
return i18n.languages.map((lang) => ({lang}));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{lang: string}>;
|
||||
}): Promise<Metadata> {
|
||||
const {lang} = await params;
|
||||
|
||||
return {
|
||||
alternates: getLocalizedAlternates({locale: lang}),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function HomePage({params}: {params: Promise<{lang: string}>}) {
|
||||
const {lang} = await params;
|
||||
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import type {Metadata} from "next";
|
||||
import type {ReactNode} from "react";
|
||||
|
||||
import {notFound} from "next/navigation";
|
||||
|
||||
import {getDictionary, hasLocale} from "@/lib/dictionaries";
|
||||
import {getLocalizedAlternates} from "@/lib/seo";
|
||||
|
||||
// The showcase index itself is a client component, so its metadata is declared
|
||||
// here — without it the page would inherit the root layout's canonical.
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{lang: string}>;
|
||||
}): Promise<Metadata> {
|
||||
const {lang} = await params;
|
||||
const dict = hasLocale(lang) ? await getDictionary(lang) : await getDictionary("en");
|
||||
|
||||
return {
|
||||
alternates: getLocalizedAlternates({locale: lang, path: "/showcase"}),
|
||||
description: dict.showcase.description,
|
||||
title: dict.showcase.heading,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ShowcaseLayout({
|
||||
children,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {getAllBlogPosts, getBlogPost, getRelatedPosts} from "@/lib/blog";
|
||||
import {getDictionary, hasLocale} from "@/lib/dictionaries";
|
||||
import {i18n} from "@/lib/i18n";
|
||||
import {getTechArticleJsonLd} from "@/lib/json-ld";
|
||||
import {LOCALES, getLocalizedAlternates} from "@/lib/seo";
|
||||
import {getMDXComponents} from "@/mdx-components";
|
||||
|
||||
import {PostCard} from "../post-card";
|
||||
@@ -40,12 +41,17 @@ export async function generateMetadata({params}: BlogPostPageProps): Promise<Met
|
||||
|
||||
if (!post) return {};
|
||||
|
||||
const url = `/${lang}/blog/${slug}`;
|
||||
const path = `/blog/${slug}`;
|
||||
// Untranslated posts are served in every locale from the default-locale file,
|
||||
// so only locales with a real translation belong in the hreflang cluster.
|
||||
const translatedLocales = LOCALES.filter(
|
||||
(locale) => getBlogPost(slug, locale)?.locale === locale,
|
||||
);
|
||||
const alternates = getLocalizedAlternates({locale: lang, locales: translatedLocales, path});
|
||||
const url = alternates.canonical;
|
||||
|
||||
return {
|
||||
alternates: {
|
||||
canonical: url,
|
||||
},
|
||||
alternates,
|
||||
description: post.description,
|
||||
openGraph: {
|
||||
authors: [post.author],
|
||||
|
||||
@@ -8,6 +8,7 @@ import {getAllBlogPosts} from "@/lib/blog";
|
||||
import {getDictionary, hasLocale} from "@/lib/dictionaries";
|
||||
import {i18n} from "@/lib/i18n";
|
||||
import {getBlogJsonLd} from "@/lib/json-ld";
|
||||
import {getLocalizedAlternates} from "@/lib/seo";
|
||||
|
||||
import {BlogContent} from "./blog-content";
|
||||
|
||||
@@ -25,9 +26,7 @@ export async function generateMetadata({params}: BlogPageProps): Promise<Metadat
|
||||
const {blog} = dict;
|
||||
|
||||
return {
|
||||
alternates: {
|
||||
canonical: `/${lang}/blog`,
|
||||
},
|
||||
alternates: getLocalizedAlternates({locale: lang, path: "/blog"}),
|
||||
description: blog.metaDescription,
|
||||
openGraph: {
|
||||
description: blog.metaDescription,
|
||||
|
||||
@@ -27,6 +27,7 @@ import StatusChip from "@/components/status-chip";
|
||||
import {siteConfig} from "@/config/site";
|
||||
import {getComponentCount, getExampleCount} from "@/demos";
|
||||
import {getBreadcrumbJsonLd, getTechArticleJsonLd} from "@/lib/json-ld";
|
||||
import {getLocalizedAlternates, stripLocale} from "@/lib/seo";
|
||||
import {source} from "@/lib/source";
|
||||
import {getMDXComponents} from "@/mdx-components";
|
||||
import {
|
||||
@@ -190,12 +191,13 @@ export async function generateMetadata(props: {
|
||||
// Ensure absolute URL for Open Graph
|
||||
const imageUrl = image.startsWith("http") ? image : new URL(image, siteConfig.siteUrl).toString();
|
||||
|
||||
const url = `/docs/${(params.slug ?? []).join("/")}`;
|
||||
// `page.url` already carries the locale prefix (`/en/docs/...`), which is the
|
||||
// URL actually served — the unprefixed `/docs/...` form permanently redirects.
|
||||
const url = page.url;
|
||||
const alternates = getLocalizedAlternates({locale: params.lang, path: stripLocale(url)});
|
||||
|
||||
return {
|
||||
alternates: {
|
||||
canonical: url,
|
||||
},
|
||||
alternates,
|
||||
description: page.data.description,
|
||||
openGraph: {
|
||||
description: page.data.description,
|
||||
|
||||
@@ -65,8 +65,11 @@ export default async function Layout({
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
// No `canonical` here on purpose: layout metadata is inherited by every page
|
||||
// below it, so a canonical defined at this level would point localized and
|
||||
// non-home routes at the home page and exclude them from the index. Each page
|
||||
// declares its own canonical through `getLocalizedAlternates`.
|
||||
alternates: {
|
||||
canonical: __BASE_URL__.toString(),
|
||||
types: {
|
||||
"application/rss+xml": [
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ import {notFound} from "next/navigation";
|
||||
import {ShowcaseSource} from "@/components/showcase-source";
|
||||
import {hasLocale} from "@/lib/dictionaries";
|
||||
import {i18n} from "@/lib/i18n";
|
||||
import {getLocalizedAlternates} from "@/lib/seo";
|
||||
import {getAllShowcases, getShowcase} from "@/showcases";
|
||||
|
||||
import {ShowcaseCodePanel} from "./showcase-code-panel";
|
||||
@@ -30,15 +31,15 @@ export async function generateMetadata({params}: ShowcasePageProps): Promise<Met
|
||||
|
||||
if (!showcase) return {};
|
||||
|
||||
const alternates = getLocalizedAlternates({locale: lang, path: `/showcase/${id}`});
|
||||
|
||||
return {
|
||||
alternates: {
|
||||
canonical: `/${lang}/showcase/${id}`,
|
||||
},
|
||||
alternates,
|
||||
description: `Interactive demo of ${showcase.name} built with HeroUI components.`,
|
||||
openGraph: {
|
||||
description: `Interactive demo of ${showcase.name} built with HeroUI components.`,
|
||||
title: `${showcase.name} - HeroUI Showcase`,
|
||||
url: `/${lang}/showcase/${id}`,
|
||||
url: alternates.canonical,
|
||||
},
|
||||
title: `${showcase.name} - HeroUI Showcase`,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type {Metadata} from "next";
|
||||
|
||||
import {Suspense} from "react";
|
||||
|
||||
import {ProBanner} from "@/app/[lang]/(home)/components/pro-banner";
|
||||
import {CodePanelProvider} from "@/hooks/use-code-panel";
|
||||
import {DictionaryProvider} from "@/hooks/use-dictionary";
|
||||
import {getDictionary, hasLocale} from "@/lib/dictionaries";
|
||||
import {getLocalizedAlternates} from "@/lib/seo";
|
||||
|
||||
import {
|
||||
AccentColorSelector,
|
||||
@@ -18,6 +21,18 @@ import {Onboarding} from "./components/onboarding";
|
||||
import {ThemeBuilderContent} from "./components/theme-builder-content";
|
||||
import {THEME_BUILDER_PAGE_ID, formRadiusOptions, radiusOptions} from "./constants";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{lang: string}>;
|
||||
}): Promise<Metadata> {
|
||||
const {lang} = await params;
|
||||
|
||||
return {
|
||||
alternates: getLocalizedAlternates({locale: lang, path: "/themes"}),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ThemeBuilderPage({params}: {params: Promise<{lang: string}>}) {
|
||||
const {lang} = await params;
|
||||
const dict = hasLocale(lang) ? await getDictionary(lang) : await getDictionary("en");
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import {absoluteUrl} from "@/lib/seo";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
/**
|
||||
* Content Signals (https://contentsignals.org) is not expressible through
|
||||
* Next.js' `MetadataRoute.Robots` type, so `robots.txt` is served as a route
|
||||
* instead of a static file. Keeping it in the app means the `Sitemap` line can
|
||||
* never drift from the deployed canonical host.
|
||||
*/
|
||||
const CONTENT_SIGNAL = "Content-Signal: ai-train=yes, search=yes, ai-input=yes";
|
||||
|
||||
export const GET = () => {
|
||||
const body = [
|
||||
"# Every crawlable route is open to every crawler. Non-indexable endpoints",
|
||||
"# (preview deployments, machine-only routes) opt out via X-Robots-Tag or",
|
||||
"# page metadata instead, so nothing indexable is ever blocked here.",
|
||||
"User-agent: *",
|
||||
"Allow: /",
|
||||
CONTENT_SIGNAL,
|
||||
"",
|
||||
`Sitemap: ${absoluteUrl("/sitemap.xml")}`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {MetadataRoute} from "next";
|
||||
|
||||
import {getAllBlogPosts, getBlogPost} from "@/lib/blog";
|
||||
import {filterExcludedPages} from "@/lib/llms-utils";
|
||||
import {LOCALES, absoluteUrl, getSitemapAlternates, localizedPath, stripLocale} from "@/lib/seo";
|
||||
import {source} from "@/lib/source";
|
||||
import {getAllShowcases} from "@/showcases";
|
||||
|
||||
type SitemapEntry = MetadataRoute.Sitemap[number];
|
||||
|
||||
/**
|
||||
* Locale-agnostic paths mapped to the locales they are published in. Docs pages
|
||||
* come from the Fumadocs loader (they are rendered dynamically, so they are
|
||||
* invisible to any sitemap generator that only reads the prerender manifest).
|
||||
*/
|
||||
type LocalizedPaths = Map<string, {locales: string[]; lastModified?: Date}>;
|
||||
|
||||
function addPath(paths: LocalizedPaths, path: string, locale: string, lastModified?: Date): void {
|
||||
const existing = paths.get(path);
|
||||
|
||||
if (!existing) {
|
||||
paths.set(path, {lastModified, locales: [locale]});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existing.locales.includes(locale)) existing.locales.push(locale);
|
||||
if (lastModified && (!existing.lastModified || lastModified > existing.lastModified)) {
|
||||
existing.lastModified = lastModified;
|
||||
}
|
||||
}
|
||||
|
||||
function toEntries(paths: LocalizedPaths): SitemapEntry[] {
|
||||
return [...paths].flatMap(([path, {lastModified, locales}]) =>
|
||||
locales.map((locale) => ({
|
||||
alternates: {languages: getSitemapAlternates(path, locales)},
|
||||
url: absoluteUrl(localizedPath(locale, path)),
|
||||
...(lastModified ? {lastModified} : {}),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function parseDate(value: string | undefined): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
|
||||
const date = new Date(value);
|
||||
|
||||
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const paths: LocalizedPaths = new Map();
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
addPath(paths, "/", locale);
|
||||
addPath(paths, "/blog", locale);
|
||||
addPath(paths, "/showcase", locale);
|
||||
addPath(paths, "/themes", locale);
|
||||
|
||||
for (const page of filterExcludedPages(source.getPages(locale))) {
|
||||
addPath(paths, stripLocale(page.url), locale);
|
||||
}
|
||||
|
||||
for (const post of getAllBlogPosts(locale)) {
|
||||
if (getBlogPost(post.slug, locale)?.locale !== locale) continue;
|
||||
|
||||
addPath(paths, `/blog/${post.slug}`, locale, parseDate(post.date));
|
||||
}
|
||||
|
||||
for (const showcase of getAllShowcases()) {
|
||||
addPath(paths, `/showcase/${showcase.name}`, locale);
|
||||
}
|
||||
}
|
||||
|
||||
// Served outside `app/[lang]`, so it has no locale variants.
|
||||
const unlocalized: SitemapEntry[] = [{url: absoluteUrl("/docs/native-showcase/privacy-policy")}];
|
||||
|
||||
return [...toEntries(paths), ...unlocalized];
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {siteConfig} from "@/config/site";
|
||||
import {i18n} from "@/lib/i18n";
|
||||
|
||||
export interface LocalizedAlternates {
|
||||
canonical: string;
|
||||
languages: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `hreflang` values must be BCP 47 language tags. The content directories use
|
||||
* `cn` for Simplified Chinese, which is a region subtag rather than a language,
|
||||
* so it has to be translated before it reaches the markup.
|
||||
*/
|
||||
const HREFLANG_BY_LOCALE: Record<string, string> = {
|
||||
cn: "zh-Hans",
|
||||
en: "en",
|
||||
};
|
||||
|
||||
export const DEFAULT_LOCALE = i18n.defaultLanguage;
|
||||
export const LOCALES: readonly string[] = i18n.languages;
|
||||
|
||||
const LOCALE_PREFIX_PATTERN = new RegExp(`^/(${LOCALES.join("|")})(?=/|$)`);
|
||||
|
||||
export function normalizeLocale(locale: string | undefined): string {
|
||||
return locale && LOCALES.includes(locale) ? locale : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
/** Turns `/en/docs/react/button` into the locale-agnostic `/docs/react/button`. */
|
||||
export function stripLocale(path: string): string {
|
||||
return path.replace(LOCALE_PREFIX_PATTERN, "") || "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the URL actually served for `path` in `locale`.
|
||||
*
|
||||
* The default-locale home page is served at `/` — `/en` only exists as the
|
||||
* internal rewrite target of `proxy.ts`, so it must never be advertised as a
|
||||
* canonical URL. Every other route keeps its locale prefix because unprefixed
|
||||
* paths redirect (`/docs/...` -> `/en/docs/...`).
|
||||
*/
|
||||
export function localizedPath(locale: string, path = "/"): string {
|
||||
const normalizedPath = path === "/" ? "" : path.startsWith("/") ? path : `/${path}`;
|
||||
|
||||
if (!normalizedPath && normalizeLocale(locale) === DEFAULT_LOCALE) return "/";
|
||||
|
||||
return `/${normalizeLocale(locale)}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export function absoluteUrl(path: string): string {
|
||||
return new URL(path, siteConfig.siteUrl).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds `<link rel="canonical">` plus the `hreflang` cluster for a document
|
||||
* that exists in `locales`, from its locale-agnostic `path`.
|
||||
*
|
||||
* When the requested `locale` has no translation of its own, the route still
|
||||
* renders (fallback content), so it points at the default locale instead of
|
||||
* self-canonicalising untranslated duplicates.
|
||||
*/
|
||||
export function getLocalizedAlternates({
|
||||
locale,
|
||||
locales = LOCALES,
|
||||
path = "/",
|
||||
}: {
|
||||
locale: string;
|
||||
locales?: readonly string[];
|
||||
path?: string;
|
||||
}): LocalizedAlternates {
|
||||
const languages: Record<string, string> = {};
|
||||
|
||||
for (const candidate of locales) {
|
||||
const hreflang = HREFLANG_BY_LOCALE[candidate];
|
||||
|
||||
if (!hreflang) continue;
|
||||
|
||||
languages[hreflang] = localizedPath(candidate, path);
|
||||
}
|
||||
|
||||
if (locales.includes(DEFAULT_LOCALE)) {
|
||||
languages["x-default"] = localizedPath(DEFAULT_LOCALE, path);
|
||||
}
|
||||
|
||||
const canonicalLocale = locales.includes(normalizeLocale(locale))
|
||||
? normalizeLocale(locale)
|
||||
: DEFAULT_LOCALE;
|
||||
|
||||
return {
|
||||
canonical: localizedPath(canonicalLocale, path),
|
||||
languages,
|
||||
};
|
||||
}
|
||||
|
||||
/** Same as `getLocalizedAlternates`, but with absolute URLs for `sitemap.xml`. */
|
||||
export function getSitemapAlternates(
|
||||
path: string,
|
||||
locales: readonly string[] = LOCALES,
|
||||
): Record<string, string> {
|
||||
const languages: Record<string, string> = {};
|
||||
|
||||
for (const candidate of locales) {
|
||||
const hreflang = HREFLANG_BY_LOCALE[candidate];
|
||||
|
||||
if (!hreflang) continue;
|
||||
|
||||
languages[hreflang] = absoluteUrl(localizedPath(candidate, path));
|
||||
}
|
||||
|
||||
if (locales.includes(DEFAULT_LOCALE)) {
|
||||
languages["x-default"] = absoluteUrl(localizedPath(DEFAULT_LOCALE, path));
|
||||
}
|
||||
|
||||
return languages;
|
||||
}
|
||||
@@ -12,8 +12,9 @@ const getBaseURL = (): URL => {
|
||||
// preview
|
||||
if (__PREVIEW__) host = "v3.heroui.com";
|
||||
|
||||
// production
|
||||
if (__PROD__) host = "www.heroui.com";
|
||||
// production — the apex domain is canonical; `www` permanently redirects to it,
|
||||
// so pointing metadata at `www` would make every canonical URL a redirect.
|
||||
if (__PROD__) host = "heroui.com";
|
||||
|
||||
// protocol
|
||||
const protocol = host.startsWith("localhost") ? "http" : "https";
|
||||
|
||||
@@ -38,7 +38,6 @@ const config = defineConfig([
|
||||
"!.*.mjs",
|
||||
"!.*.ts",
|
||||
"!contentlayer.config.ts",
|
||||
"!next-sitemap.config.ts",
|
||||
],
|
||||
},
|
||||
...baseConfig,
|
||||
|
||||
Generated
+1
-40
@@ -264,9 +264,6 @@ importers:
|
||||
next-mdx-remote:
|
||||
specifier: 6.0.0
|
||||
version: 6.0.0(@types/react@19.2.14)(react@19.2.6)
|
||||
next-sitemap:
|
||||
specifier: 4.2.3
|
||||
version: 4.2.3(next@16.2.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))
|
||||
next-themes:
|
||||
specifier: 0.4.6
|
||||
version: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -860,9 +857,6 @@ packages:
|
||||
conventional-commits-parser:
|
||||
optional: true
|
||||
|
||||
'@corex/deepmerge@4.0.43':
|
||||
resolution: {integrity: sha512-N8uEMrMPL0cu/bdboEWpQYb/0i2K5Qn8eCsxzOmxSggJbbQte7ljMRoXm917AbntqTGOzdTu+vP3KOOzoC70HQ==}
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||
|
||||
@@ -1587,9 +1581,6 @@ packages:
|
||||
'@emnapi/core': ^1.7.1
|
||||
'@emnapi/runtime': ^1.7.1
|
||||
|
||||
'@next/env@13.5.11':
|
||||
resolution: {integrity: sha512-fbb2C7HChgM7CemdCY+y3N1n8pcTKdqtQLbC7/EQtPdLvlMUT9JX/dBYl8MMZAtYG4uVMyPFHXckb68q/NRwqg==}
|
||||
|
||||
'@next/env@16.2.6':
|
||||
resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
|
||||
|
||||
@@ -4575,10 +4566,6 @@ packages:
|
||||
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
||||
fast-json-stable-stringify@2.1.0:
|
||||
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
||||
|
||||
@@ -4830,6 +4817,7 @@ packages:
|
||||
git-raw-commits@5.0.1:
|
||||
resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==}
|
||||
engines: {node: '>=18'}
|
||||
deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.
|
||||
hasBin: true
|
||||
|
||||
github-slugger@2.0.0:
|
||||
@@ -5722,13 +5710,6 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=16'
|
||||
|
||||
next-sitemap@4.2.3:
|
||||
resolution: {integrity: sha512-vjdCxeDuWDzldhCnyFCQipw5bfpl4HmZA7uoo3GAaYGjGgfL4Cxb1CiztPuWGmS+auYs7/8OekRS8C2cjdAsjQ==}
|
||||
engines: {node: '>=14.18'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
next: '*'
|
||||
|
||||
next-themes@0.4.6:
|
||||
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
|
||||
peerDependencies:
|
||||
@@ -7835,8 +7816,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
conventional-commits-parser: 6.4.0
|
||||
|
||||
'@corex/deepmerge@4.0.43': {}
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
@@ -8381,8 +8360,6 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.2
|
||||
optional: true
|
||||
|
||||
'@next/env@13.5.11': {}
|
||||
|
||||
'@next/env@16.2.6': {}
|
||||
|
||||
'@next/eslint-plugin-next@16.1.1':
|
||||
@@ -11732,14 +11709,6 @@ snapshots:
|
||||
merge2: 1.4.1
|
||||
micromatch: 4.0.8
|
||||
|
||||
fast-glob@3.3.3:
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
'@nodelib/fs.walk': 1.2.8
|
||||
glob-parent: 5.1.2
|
||||
merge2: 1.4.1
|
||||
micromatch: 4.0.8
|
||||
|
||||
fast-json-stable-stringify@2.1.0: {}
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
@@ -13168,14 +13137,6 @@ snapshots:
|
||||
- '@types/react'
|
||||
- supports-color
|
||||
|
||||
next-sitemap@4.2.3(next@16.2.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)):
|
||||
dependencies:
|
||||
'@corex/deepmerge': 4.0.43
|
||||
'@next/env': 13.5.11
|
||||
fast-glob: 3.3.3
|
||||
minimist: 1.2.8
|
||||
next: 16.2.6(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
|
||||
next-themes@0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
|
||||
Reference in New Issue
Block a user