Merge pull request 'Give each route its own title and canonical, and add robots.txt plus a sitemap.' (#72) from adilallo/fix/CR-177-site-metadata into main
Reviewed-on: #72
This commit was merged in pull request #72.
This commit is contained in:
@@ -29,6 +29,11 @@ NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
|
||||
# Optional: URL shown on /monitor when using external storage (Grafana, Kibana, vendor RUM, etc.).
|
||||
# NEXT_PUBLIC_RUM_DASHBOARD_URL=
|
||||
|
||||
# Search indexing. Unset: only communityrule.com / communityrule.info are crawlable
|
||||
# (staging and local are disallowed in robots.txt). Set true/false to override.
|
||||
# SITE_INDEXING=true
|
||||
# SITE_INDEXING=false
|
||||
|
||||
# Writable directory for `POST /api/uploads` (community photo + custom-method attachments).
|
||||
# In production (e.g. Cloudron localstorage mount), set to the mounted path. Local dev example:
|
||||
# UPLOAD_ROOT="/absolute/path/to/community-rule/var/uploads"
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense, type ReactNode } from "react";
|
||||
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
|
||||
import { MessagesProvider } from "../contexts/MessagesContext";
|
||||
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
||||
import messages from "../../messages/en/index";
|
||||
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: NO_INDEX_ROBOTS,
|
||||
};
|
||||
|
||||
// `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16).
|
||||
// See `(app)/layout.tsx` for the matching `<Suspense fallback={null}>` rationale
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import messages from "../../../messages/en/index";
|
||||
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||
import MonitorPageContent from "./MonitorPageContent";
|
||||
|
||||
export const metadata: Metadata = routeMetadata("/monitor", {
|
||||
title: messages.metadata.monitor.title,
|
||||
});
|
||||
|
||||
export default function MonitorPage() {
|
||||
return <MonitorPageContent />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CreateFlowScreenView } from "../screens/CreateFlowScreenView";
|
||||
import { createFlowStepPath, CREATE_ROUTES } from "../utils/createFlowPaths";
|
||||
import { isValidStep } from "../utils/flowSteps";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
import { routeMetadata } from "../../../../lib/siteMetadata";
|
||||
|
||||
/**
|
||||
* Single dynamic route for the whole create wizard (every step in `FLOW_STEP_ORDER`).
|
||||
@@ -14,6 +17,14 @@ interface PageProps {
|
||||
params: Promise<{ screenId: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { screenId: raw } = await params;
|
||||
const path = isValidStep(raw)
|
||||
? createFlowStepPath(raw)
|
||||
: `${CREATE_ROUTES.createRoot}/${raw}`;
|
||||
return routeMetadata(path);
|
||||
}
|
||||
|
||||
export default async function CreateFlowScreenPage({ params }: PageProps) {
|
||||
const { screenId: raw } = await params;
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import CreateFlowLayoutGate from "./CreateFlowLayoutGate";
|
||||
import messages from "../../../messages/en/index";
|
||||
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||
|
||||
export const metadata: Metadata = routeMetadata("/create", {
|
||||
title: messages.metadata.create.title,
|
||||
});
|
||||
|
||||
export default function CreateFlowLayout({ children }: { children: ReactNode }) {
|
||||
return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense, type ReactNode } from "react";
|
||||
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
|
||||
import { MessagesProvider } from "../contexts/MessagesContext";
|
||||
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
||||
import messages from "../../messages/en/index";
|
||||
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: NO_INDEX_ROBOTS,
|
||||
};
|
||||
|
||||
// `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16).
|
||||
// `ConditionalNavigation` reads `cr_session` server-side (and `usePathname()`
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import messages from "../../../messages/en/index";
|
||||
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../lib/siteMetadata";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Log in · CommunityRule",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
export const metadata: Metadata = routeMetadata("/login", {
|
||||
title: messages.metadata.login.title,
|
||||
robots: NO_INDEX_ROBOTS,
|
||||
});
|
||||
|
||||
export default function LoginLayout({
|
||||
children,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import messages from "../../../messages/en/index";
|
||||
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../lib/siteMetadata";
|
||||
import ProfilePageClient from "./ProfilePageClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Profile · CommunityRule",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
export const metadata: Metadata = routeMetadata("/profile", {
|
||||
title: messages.metadata.profile.title,
|
||||
robots: NO_INDEX_ROBOTS,
|
||||
});
|
||||
|
||||
export default function ProfilePage() {
|
||||
return <ProfilePageClient />;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { MessagesProvider } from "../contexts/MessagesContext";
|
||||
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
||||
import messages from "../../messages/en/index";
|
||||
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: NO_INDEX_ROBOTS,
|
||||
};
|
||||
|
||||
// Development-only previews (e.g. `/components-preview`) — no public chrome.
|
||||
export default function DevLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import messages from "../../../messages/en/index";
|
||||
import {
|
||||
ASSETS,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
structureBeforeCrisisPath,
|
||||
} from "../../../lib/assetUtils";
|
||||
import { getTranslation } from "../../../lib/i18n/getTranslation";
|
||||
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||
import AboutHeader from "../../components/type/AboutHeader";
|
||||
import type { AboutHeaderSegment } from "../../components/type/AboutHeader";
|
||||
import Stats from "../../components/sections/Stats";
|
||||
@@ -18,6 +20,10 @@ import type { FaqAccordionItem } from "../../components/sections/Accordion";
|
||||
import QuoteBlock from "../../components/sections/QuoteBlock";
|
||||
import AskOrganizer from "../../components/sections/AskOrganizer";
|
||||
|
||||
export const metadata: Metadata = routeMetadata("/about", {
|
||||
title: messages.metadata.about.title,
|
||||
});
|
||||
|
||||
function asArray<T>(value: unknown): T[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
getRelatedBlogPosts,
|
||||
} from "../../../../lib/content";
|
||||
import { logger } from "../../../../lib/logger";
|
||||
import { routeMetadata } from "../../../../lib/siteMetadata";
|
||||
import messages from "../../../../messages/en/index";
|
||||
import ContentBanner from "../../../components/sections/ContentBanner";
|
||||
import AskOrganizer from "../../../components/sections/AskOrganizer";
|
||||
import ContentTemplateDecorativeShapes from "../../_components/ContentTemplateDecorativeShapes";
|
||||
@@ -62,13 +64,13 @@ export async function generateMetadata({
|
||||
const post = getBlogPostBySlug(slug);
|
||||
|
||||
if (!post) {
|
||||
return {
|
||||
title: "Post Not Found",
|
||||
description: "The requested blog post could not be found.",
|
||||
};
|
||||
return routeMetadata(`/blog/${slug}`, {
|
||||
title: messages.metadata.blog.notFoundTitle,
|
||||
description: messages.metadata.blog.fallbackDescription,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
return routeMetadata(`/blog/${slug}`, {
|
||||
title: post.frontmatter.title,
|
||||
description: post.frontmatter.description,
|
||||
authors: [{ name: post.frontmatter.author }],
|
||||
@@ -78,8 +80,8 @@ export async function generateMetadata({
|
||||
type: "article",
|
||||
publishedTime: post.frontmatter.date,
|
||||
authors: [post.frontmatter.author],
|
||||
url: `https://communityrule.com/blog/${slug}`,
|
||||
siteName: "CommunityRule",
|
||||
url: `/blog/${slug}`,
|
||||
siteName: messages.metadata.siteName,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
@@ -87,12 +89,12 @@ export async function generateMetadata({
|
||||
description: post.frontmatter.description,
|
||||
creator: "@communityrule",
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error generating metadata:", error);
|
||||
return {
|
||||
title: "Blog Post",
|
||||
description: "A blog post from our community.",
|
||||
title: messages.metadata.blog.fallbackTitle,
|
||||
description: messages.metadata.blog.fallbackDescription,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import { getAllBlogPosts } from "../../../lib/content";
|
||||
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
|
||||
import type { Metadata } from "next";
|
||||
import messages from "../../../messages/en/index";
|
||||
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog - CommunityRule",
|
||||
description:
|
||||
"Learn about community governance, decision-making, and building successful organizations.",
|
||||
const blogMeta = messages.metadata.blog;
|
||||
|
||||
export const metadata: Metadata = routeMetadata("/blog", {
|
||||
title: blogMeta.title,
|
||||
description: blogMeta.description,
|
||||
openGraph: {
|
||||
title: "Blog - CommunityRule",
|
||||
description:
|
||||
"Learn about community governance, decision-making, and building successful organizations.",
|
||||
url: "https://communityrule.com/blog",
|
||||
siteName: "CommunityRule",
|
||||
title: blogMeta.title,
|
||||
description: blogMeta.description,
|
||||
url: "/blog",
|
||||
siteName: messages.metadata.siteName,
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Blog - CommunityRule",
|
||||
description:
|
||||
"Learn about community governance, decision-making, and building successful organizations.",
|
||||
title: blogMeta.title,
|
||||
description: blogMeta.description,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default function BlogPage() {
|
||||
const posts = getAllBlogPosts();
|
||||
@@ -34,11 +35,10 @@ export default function BlogPage() {
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-content-default-primary)] mb-4">
|
||||
Blog
|
||||
{blogMeta.title}
|
||||
</h1>
|
||||
<p className="text-lg text-[var(--color-content-default-secondary)] max-w-2xl mx-auto">
|
||||
Learn about community governance, decision-making, and building
|
||||
successful organizations.
|
||||
{blogMeta.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import messages from "../../../messages/en/index";
|
||||
import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
|
||||
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||
import LegalDocumentPage, {
|
||||
legalPageMetadata,
|
||||
} from "../_components/LegalDocumentPage";
|
||||
@@ -13,7 +14,10 @@ const SLUG = "cookies" as const;
|
||||
const PATH = legalPathForSlug(SLUG);
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return legalPageMetadata(messages.metadata.cookies, messages.pages.cookies);
|
||||
return routeMetadata(
|
||||
PATH,
|
||||
legalPageMetadata(messages.metadata.cookies, messages.pages.cookies),
|
||||
);
|
||||
}
|
||||
|
||||
export default function CookiesPage() {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
buildHowItWorksSyntheticPost,
|
||||
HOW_IT_WORKS_SENTINEL_SLUG,
|
||||
} from "../../../lib/howItWorksSyntheticPost";
|
||||
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||
import ContentBanner from "../../components/sections/ContentBanner";
|
||||
import ContentTemplateDecorativeShapes from "../_components/ContentTemplateDecorativeShapes";
|
||||
import AskOrganizer from "../../components/sections/AskOrganizer";
|
||||
@@ -29,7 +30,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
const meta = messages.metadata.howItWorks;
|
||||
const page = messages.pages.howItWorks;
|
||||
|
||||
return {
|
||||
return routeMetadata("/how-it-works", {
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
keywords: meta.keywords,
|
||||
@@ -37,9 +38,9 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
title: page.banner.title,
|
||||
description: page.banner.description,
|
||||
type: "website",
|
||||
siteName: "CommunityRule",
|
||||
siteName: messages.metadata.siteName,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default function HowItWorksPage() {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import messages from "../../../messages/en/index";
|
||||
import { getTranslation } from "../../../lib/i18n/getTranslation";
|
||||
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
|
||||
import ContentLockup from "../../components/type/ContentLockup";
|
||||
import AskOrganizer from "../../components/sections/AskOrganizer";
|
||||
import { getAllBlogPosts } from "../../../lib/content";
|
||||
|
||||
export const metadata: Metadata = routeMetadata("/learn", {
|
||||
title: messages.metadata.learn.title,
|
||||
});
|
||||
|
||||
export default function LearnPage() {
|
||||
const allPosts = getAllBlogPosts();
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Suspense } from "react";
|
||||
import messages from "../../messages/en/index";
|
||||
import { getTranslation } from "../../lib/i18n/getTranslation";
|
||||
import { routeMetadata } from "../../lib/siteMetadata";
|
||||
import HeroBanner from "../components/sections/HeroBanner";
|
||||
import AskOrganizer from "../components/sections/AskOrganizer";
|
||||
import { MarketingRuleStackSection } from "./_components/MarketingRuleStackSection";
|
||||
|
||||
export const metadata: Metadata = routeMetadata("/");
|
||||
|
||||
// Code split below-the-fold components to reduce initial bundle size
|
||||
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
|
||||
loading: () => (
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import messages from "../../../messages/en/index";
|
||||
import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
|
||||
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||
import LegalDocumentPage, {
|
||||
legalPageMetadata,
|
||||
} from "../_components/LegalDocumentPage";
|
||||
@@ -13,7 +14,10 @@ const SLUG = "privacy" as const;
|
||||
const PATH = legalPathForSlug(SLUG);
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return legalPageMetadata(messages.metadata.privacy, messages.pages.privacy);
|
||||
return routeMetadata(
|
||||
PATH,
|
||||
legalPageMetadata(messages.metadata.privacy, messages.pages.privacy),
|
||||
);
|
||||
}
|
||||
|
||||
export default function PrivacyPage() {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
|
||||
import messages from "../../../../messages/en/index";
|
||||
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
|
||||
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
|
||||
import { routeMetadata } from "../../../../lib/siteMetadata";
|
||||
import CommunityRule from "../../../components/type/CommunityRule";
|
||||
import HeaderLockup from "../../../components/type/HeaderLockup";
|
||||
|
||||
@@ -25,22 +26,22 @@ export async function generateMetadata({
|
||||
typeof rule.summary === "string" && rule.summary.trim().length > 0
|
||||
? rule.summary
|
||||
: undefined;
|
||||
return {
|
||||
return routeMetadata(`/rules/${rule.id}`, {
|
||||
title: rule.title,
|
||||
description,
|
||||
openGraph: {
|
||||
title: rule.title,
|
||||
description,
|
||||
type: "article",
|
||||
url: `https://communityrule.com/rules/${rule.id}`,
|
||||
siteName: "CommunityRule",
|
||||
url: `/rules/${rule.id}`,
|
||||
siteName: messages.metadata.siteName,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: rule.title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default async function PublicRuleDetailPage({ params }: PageProps) {
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import messages from "../../../messages/en/index";
|
||||
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
|
||||
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||
import { gridEntriesForFullCatalogWithFallback } from "../../../lib/templates/templateGridPresentation";
|
||||
import TemplatesPageClient from "./TemplatesPageClient";
|
||||
|
||||
export const metadata: Metadata = routeMetadata("/templates", {
|
||||
title: messages.metadata.templates.title,
|
||||
});
|
||||
|
||||
export default async function TemplatesPage() {
|
||||
const rows = await listRuleTemplatesFromDb();
|
||||
const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import messages from "../../../messages/en/index";
|
||||
import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
|
||||
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||
import LegalDocumentPage, {
|
||||
legalPageMetadata,
|
||||
} from "../_components/LegalDocumentPage";
|
||||
@@ -13,7 +14,10 @@ const SLUG = "terms" as const;
|
||||
const PATH = legalPathForSlug(SLUG);
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return legalPageMetadata(messages.metadata.terms, messages.pages.terms);
|
||||
return routeMetadata(
|
||||
PATH,
|
||||
legalPageMetadata(messages.metadata.terms, messages.pages.terms),
|
||||
);
|
||||
}
|
||||
|
||||
export default function TermsPage() {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
USE_CASE_DETAIL_SLUGS,
|
||||
useCaseContentKeyForSlug,
|
||||
} from "../../../../lib/useCaseSyntheticPost";
|
||||
import { routeMetadata } from "../../../../lib/siteMetadata";
|
||||
import ContentBanner from "../../../components/sections/ContentBanner";
|
||||
import AskOrganizer from "../../../components/sections/AskOrganizer";
|
||||
import type { AskOrganizerVariant } from "../../../components/sections/AskOrganizer/AskOrganizer.types";
|
||||
@@ -34,7 +35,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
const contentKey = useCaseContentKeyForSlug(slug);
|
||||
const meta = messages.metadata.useCasesDetail[contentKey];
|
||||
|
||||
return {
|
||||
return routeMetadata(`/use-cases/${slug}`, {
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
keywords: meta.keywords,
|
||||
@@ -42,9 +43,9 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
type: "website",
|
||||
siteName: "CommunityRule",
|
||||
siteName: messages.metadata.siteName,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default async function UseCaseDetailPage({ params }: PageProps) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Suspense } from "react";
|
||||
import messages from "../../../messages/en/index";
|
||||
import { CONTENT_CATALOG_SLUG_ORDER } from "../../../lib/assetUtils";
|
||||
import { getAllBlogPosts, getRelatedBlogPosts } from "../../../lib/content";
|
||||
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||
import PageHeader from "../../components/type/PageHeader";
|
||||
import CaseStudy from "../../components/cards/CaseStudy";
|
||||
import UseCasesOrgs from "../../components/sections/UseCasesOrgs";
|
||||
@@ -60,7 +61,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
const description = messages.metadata.useCases.description;
|
||||
const keywords = messages.metadata.useCases.keywords;
|
||||
|
||||
return {
|
||||
return routeMetadata("/use-cases", {
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
@@ -68,9 +69,9 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
title,
|
||||
description,
|
||||
type: "website",
|
||||
siteName: "CommunityRule",
|
||||
siteName: messages.metadata.siteName,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default function UseCasesPage() {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import messages from "../../../../../messages/en/index";
|
||||
import { resolveUseCaseCompletedRule } from "../../../../../lib/useCaseCompletedRule";
|
||||
import { routeMetadata } from "../../../../../lib/siteMetadata";
|
||||
import {
|
||||
USE_CASE_DETAIL_SLUGS,
|
||||
useCaseContentKeyForSlug,
|
||||
@@ -33,7 +34,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
const contentKey = useCaseContentKeyForSlug(resolved.slug);
|
||||
const meta = messages.metadata.useCasesCompletedRule[contentKey];
|
||||
|
||||
return {
|
||||
return routeMetadata(`/use-cases/${resolved.slug}/rule`, {
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
keywords: meta.keywords,
|
||||
@@ -41,9 +42,9 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
type: "website",
|
||||
siteName: "CommunityRule",
|
||||
siteName: messages.metadata.siteName,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default async function UseCaseCompletedRulePage({ params }: PageProps) {
|
||||
|
||||
+8
-7
@@ -3,6 +3,7 @@ import type { Metadata, Viewport } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import messages from "../messages/en/index";
|
||||
import { ASSETS, getAssetPath } from "../lib/assetUtils";
|
||||
import { PRODUCTION_SITE_ORIGIN } from "../lib/siteMetadata";
|
||||
import "./globals.css";
|
||||
|
||||
// `force-dynamic` is now scoped to `(app)/layout.tsx` and `(admin)/layout.tsx`
|
||||
@@ -54,7 +55,10 @@ export const viewport: Viewport = {
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: homeMeta.title,
|
||||
title: {
|
||||
default: homeMeta.title,
|
||||
template: messages.metadata.titleTemplate,
|
||||
},
|
||||
description: homeMeta.description,
|
||||
keywords: [...homeMeta.keywords],
|
||||
authors: [{ name: "Media Economies Design Lab" }],
|
||||
@@ -65,7 +69,7 @@ export const metadata: Metadata = {
|
||||
address: false,
|
||||
telephone: false,
|
||||
},
|
||||
metadataBase: new URL("https://communityrule.com"),
|
||||
metadataBase: new URL(PRODUCTION_SITE_ORIGIN),
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: getAssetPath(ASSETS.LOGO), type: "image/svg+xml" },
|
||||
@@ -89,14 +93,11 @@ export const metadata: Metadata = {
|
||||
},
|
||||
],
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
openGraph: {
|
||||
title: homeMeta.title,
|
||||
description: homeMeta.description,
|
||||
url: "https://communityrule.com",
|
||||
siteName: "CommunityRule",
|
||||
url: PRODUCTION_SITE_ORIGIN,
|
||||
siteName: messages.metadata.siteName,
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
},
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import messages from "../messages/en/index";
|
||||
import { getTranslation } from "../lib/i18n/getTranslation";
|
||||
import { NO_INDEX_ROBOTS } from "../lib/siteMetadata";
|
||||
import { getGovernanceTemplateCatalogEntry } from "../lib/templates/governanceTemplateCatalog";
|
||||
import Icon from "./components/asset/icon";
|
||||
import Button from "./components/buttons/Button";
|
||||
import HeroDecor from "./components/sections/HeroBanner/HeroDecor";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: messages.metadata.notFound.title,
|
||||
robots: NO_INDEX_ROBOTS,
|
||||
};
|
||||
|
||||
const NOT_FOUND_TEMPLATE_SLUGS = [
|
||||
"consensus",
|
||||
"do-ocracy",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { buildRobots, resolveRequestHost } from "../lib/siteMetadata";
|
||||
|
||||
export default async function robots(): Promise<MetadataRoute.Robots> {
|
||||
const headerStore = await headers();
|
||||
const host = resolveRequestHost(
|
||||
headerStore.get("x-forwarded-host"),
|
||||
headerStore.get("host"),
|
||||
);
|
||||
return buildRobots(host);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { buildPublicSitemap } from "../lib/publicSitemap";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return buildPublicSitemap();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { getAllBlogPosts } from "./content";
|
||||
import { sitemapUrl } from "./siteMetadata";
|
||||
import { USE_CASE_DETAIL_SLUGS } from "./useCaseSyntheticPost";
|
||||
|
||||
const STATIC_PUBLIC_PATHS = [
|
||||
"/",
|
||||
"/about",
|
||||
"/learn",
|
||||
"/templates",
|
||||
"/blog",
|
||||
"/use-cases",
|
||||
"/how-it-works",
|
||||
"/privacy",
|
||||
"/terms",
|
||||
"/cookies",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Public marketing URLs for `/sitemap.xml`. Omits signed-in product routes
|
||||
* (`/create`, `/login`, `/profile`), admin, and `/rules/[id]` (those ids are
|
||||
* not known without the database).
|
||||
*/
|
||||
export function buildPublicSitemap(): MetadataRoute.Sitemap {
|
||||
const entries: MetadataRoute.Sitemap = STATIC_PUBLIC_PATHS.map((path) => ({
|
||||
url: sitemapUrl(path),
|
||||
}));
|
||||
|
||||
for (const post of getAllBlogPosts()) {
|
||||
entries.push({
|
||||
url: sitemapUrl(`/blog/${post.slug}`),
|
||||
lastModified: post.lastModified,
|
||||
});
|
||||
}
|
||||
|
||||
for (const slug of USE_CASE_DETAIL_SLUGS) {
|
||||
entries.push({ url: sitemapUrl(`/use-cases/${slug}`) });
|
||||
entries.push({ url: sitemapUrl(`/use-cases/${slug}/rule`) });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Metadata, MetadataRoute } from "next";
|
||||
|
||||
/** Production origin used for `metadataBase`, canonical resolution, and sitemap URLs. */
|
||||
export const PRODUCTION_SITE_ORIGIN = "https://communityrule.com";
|
||||
|
||||
const INDEXABLE_HOSTS = new Set([
|
||||
"communityrule.com",
|
||||
"www.communityrule.com",
|
||||
"communityrule.info",
|
||||
"www.communityrule.info",
|
||||
]);
|
||||
|
||||
export const NO_INDEX_ROBOTS = { index: false, follow: false } as const;
|
||||
|
||||
function hostnameFromHostHeader(host: string | null | undefined): string {
|
||||
if (!host) return "";
|
||||
return host.split(",")[0]?.trim().split(":")[0]?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
return (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "0.0.0.0" ||
|
||||
hostname === "::1"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the public hostname Cloudron's proxy forwards. Standalone binds
|
||||
* `0.0.0.0`, which is not a crawlable host — fall back to `CLOUDRON_APP_ORIGIN`.
|
||||
*/
|
||||
export function resolveRequestHost(
|
||||
forwardedHost: string | null,
|
||||
host: string | null,
|
||||
): string | null {
|
||||
if (forwardedHost) {
|
||||
const first = forwardedHost.split(",")[0]?.trim();
|
||||
if (first) return first;
|
||||
}
|
||||
|
||||
if (host) {
|
||||
const hostname = hostnameFromHostHeader(host);
|
||||
if (!isLoopbackHost(hostname)) return host;
|
||||
}
|
||||
|
||||
const origin = process.env.CLOUDRON_APP_ORIGIN?.trim();
|
||||
if (origin) {
|
||||
try {
|
||||
return new URL(origin).host;
|
||||
} catch {
|
||||
return host;
|
||||
}
|
||||
}
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Staging, restore-drill hosts, and local dev are not indexed. Override with
|
||||
* `SITE_INDEXING=true` / `false` when the hostname check is not enough.
|
||||
*/
|
||||
function isIndexableHost(host: string | null | undefined): boolean {
|
||||
const override = process.env.SITE_INDEXING?.trim().toLowerCase();
|
||||
if (override === "true") return true;
|
||||
if (override === "false") return false;
|
||||
return INDEXABLE_HOSTS.has(hostnameFromHostHeader(host));
|
||||
}
|
||||
|
||||
function canonicalPath(pathname: string): string {
|
||||
const trimmed = pathname.trim();
|
||||
if (trimmed === "" || trimmed === "/") return "/";
|
||||
const withLeading = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
const withoutQuery = withoutQueryOrHash(withLeading);
|
||||
if (withoutQuery.length > 1 && withoutQuery.endsWith("/")) {
|
||||
return withoutQuery.slice(0, -1);
|
||||
}
|
||||
return withoutQuery;
|
||||
}
|
||||
|
||||
function withoutQueryOrHash(pathname: string): string {
|
||||
const q = pathname.indexOf("?");
|
||||
const h = pathname.indexOf("#");
|
||||
let end = pathname.length;
|
||||
if (q !== -1) end = Math.min(end, q);
|
||||
if (h !== -1) end = Math.min(end, h);
|
||||
return pathname.slice(0, end) || "/";
|
||||
}
|
||||
|
||||
/** Merge `alternates.canonical` from the page path (resolved via `metadataBase`). */
|
||||
export function routeMetadata(
|
||||
pathname: string,
|
||||
metadata: Metadata = {},
|
||||
): Metadata {
|
||||
return {
|
||||
...metadata,
|
||||
alternates: {
|
||||
...metadata.alternates,
|
||||
canonical: canonicalPath(pathname),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRobots(
|
||||
host: string | null | undefined,
|
||||
): MetadataRoute.Robots {
|
||||
if (!isIndexableHost(host)) {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
disallow: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
},
|
||||
sitemap: `${PRODUCTION_SITE_ORIGIN}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
||||
export function sitemapUrl(pathname: string): string {
|
||||
const path = canonicalPath(pathname);
|
||||
if (path === "/") return PRODUCTION_SITE_ORIGIN;
|
||||
return `${PRODUCTION_SITE_ORIGIN}${path}`;
|
||||
}
|
||||
+45
-12
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"_comment": "Page metadata translations",
|
||||
"_comment": "Page metadata. `title` is the page segment; the root layout template appends the site name. Home `title` is the full default document title (not passed through the template).",
|
||||
"siteName": "CommunityRule",
|
||||
"titleTemplate": "%s — CommunityRule",
|
||||
"home": {
|
||||
"title": "CommunityRule - Build operating manuals for successful communities",
|
||||
"description": "Help your community make important decisions in a way that reflects its unique values.",
|
||||
@@ -10,8 +12,39 @@
|
||||
"operating manual"
|
||||
]
|
||||
},
|
||||
"about": {
|
||||
"title": "About"
|
||||
},
|
||||
"learn": {
|
||||
"title": "Learn"
|
||||
},
|
||||
"templates": {
|
||||
"title": "Templates"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create"
|
||||
},
|
||||
"blog": {
|
||||
"title": "Blog",
|
||||
"description": "Learn about community governance, decision-making, and building successful organizations.",
|
||||
"notFoundTitle": "Post not found",
|
||||
"fallbackTitle": "Blog post",
|
||||
"fallbackDescription": "A blog post from our community."
|
||||
},
|
||||
"login": {
|
||||
"title": "Log in"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profile"
|
||||
},
|
||||
"monitor": {
|
||||
"title": "Monitor"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Page not found"
|
||||
},
|
||||
"useCases": {
|
||||
"title": "Use cases — CommunityRule",
|
||||
"title": "Use cases",
|
||||
"description": "See how mutual aid groups, cooperatives, open source projects, and other communities use CommunityRule to define structure before they need it.",
|
||||
"keywords": [
|
||||
"use cases",
|
||||
@@ -22,40 +55,40 @@
|
||||
},
|
||||
"useCasesDetail": {
|
||||
"mutualAidColorado": {
|
||||
"title": "Mutual Aid Colorado — CommunityRule",
|
||||
"title": "Mutual Aid Colorado",
|
||||
"description": "How Mutual Aid Colorado used CommunityRule to clarify resource sharing, volunteer coordination, and decision-making.",
|
||||
"keywords": ["mutual aid", "use case", "community governance", "operating manual"]
|
||||
},
|
||||
"foodNotBombs": {
|
||||
"title": "Food Not Bombs Boulder — CommunityRule",
|
||||
"title": "Food Not Bombs Boulder",
|
||||
"description": "How Food Not Bombs Boulder used CommunityRule to translate implicit organizing norms into explicit democratic processes.",
|
||||
"keywords": ["food not bombs", "use case", "community governance", "operating manual"]
|
||||
},
|
||||
"boulderCountyStreetMedics": {
|
||||
"title": "Boulder County Street Medics — CommunityRule",
|
||||
"title": "Boulder County Street Medics",
|
||||
"description": "How CommunityRule helped Boulder County Street Medics define operational process on the streets and off.",
|
||||
"keywords": ["street medics", "use case", "community governance", "operating manual"]
|
||||
}
|
||||
},
|
||||
"useCasesCompletedRule": {
|
||||
"mutualAidColorado": {
|
||||
"title": "Mutual Aid Colorado community rule — CommunityRule",
|
||||
"title": "Mutual Aid Colorado community rule",
|
||||
"description": "Read the completed community rule Mutual Aid Colorado built with CommunityRule.",
|
||||
"keywords": ["mutual aid", "community rule", "operating manual", "use case"]
|
||||
},
|
||||
"foodNotBombs": {
|
||||
"title": "Food Not Bombs Boulder community rule — CommunityRule",
|
||||
"title": "Food Not Bombs Boulder community rule",
|
||||
"description": "Read the completed community rule Food Not Bombs Boulder built with CommunityRule.",
|
||||
"keywords": ["food not bombs", "community rule", "operating manual", "use case"]
|
||||
},
|
||||
"boulderCountyStreetMedics": {
|
||||
"title": "Boulder County Street Medics community rule — CommunityRule",
|
||||
"title": "Boulder County Street Medics community rule",
|
||||
"description": "Read the completed community rule Boulder County Street Medics built with CommunityRule.",
|
||||
"keywords": ["street medics", "community rule", "operating manual", "use case"]
|
||||
}
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "A Guide to CommunityRule — CommunityRule",
|
||||
"title": "A Guide to CommunityRule",
|
||||
"description": "CommunityRule is a modular governance toolkit designed to help democratic groups build, customize, and publish their own Operating Manual.",
|
||||
"keywords": [
|
||||
"how it works",
|
||||
@@ -65,17 +98,17 @@
|
||||
]
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacy Policy — CommunityRule",
|
||||
"title": "Privacy Policy",
|
||||
"description": "What we collect, where it is stored, and how template recommendations work.",
|
||||
"keywords": ["privacy", "personal information", "communityrule"]
|
||||
},
|
||||
"terms": {
|
||||
"title": "Terms of Service — CommunityRule",
|
||||
"title": "Terms of Service",
|
||||
"description": "How you may use CommunityRule, including licenses and the templates API.",
|
||||
"keywords": ["terms of service", "license", "communityrule"]
|
||||
},
|
||||
"cookies": {
|
||||
"title": "Cookies Settings — CommunityRule",
|
||||
"title": "Cookies Settings",
|
||||
"description": "CommunityRule uses one cookie, to keep you signed in.",
|
||||
"keywords": ["cookies", "session", "privacy", "communityrule"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getAllBlogPosts } from "../../lib/content";
|
||||
import { buildPublicSitemap } from "../../lib/publicSitemap";
|
||||
import { PRODUCTION_SITE_ORIGIN, sitemapUrl } from "../../lib/siteMetadata";
|
||||
import { USE_CASE_DETAIL_SLUGS } from "../../lib/useCaseSyntheticPost";
|
||||
|
||||
describe("buildPublicSitemap", () => {
|
||||
it("lists public marketing routes, blog posts, and use cases", () => {
|
||||
const urls = buildPublicSitemap().map((entry) => entry.url);
|
||||
|
||||
expect(urls).toContain(PRODUCTION_SITE_ORIGIN);
|
||||
expect(urls).toContain(sitemapUrl("/about"));
|
||||
expect(urls).toContain(sitemapUrl("/learn"));
|
||||
expect(urls).toContain(sitemapUrl("/templates"));
|
||||
expect(urls).toContain(sitemapUrl("/blog"));
|
||||
expect(urls).toContain(sitemapUrl("/use-cases"));
|
||||
expect(urls).toContain(sitemapUrl("/how-it-works"));
|
||||
expect(urls).toContain(sitemapUrl("/privacy"));
|
||||
expect(urls).toContain(sitemapUrl("/terms"));
|
||||
expect(urls).toContain(sitemapUrl("/cookies"));
|
||||
|
||||
for (const post of getAllBlogPosts()) {
|
||||
expect(urls).toContain(sitemapUrl(`/blog/${post.slug}`));
|
||||
}
|
||||
for (const slug of USE_CASE_DETAIL_SLUGS) {
|
||||
expect(urls).toContain(sitemapUrl(`/use-cases/${slug}`));
|
||||
expect(urls).toContain(sitemapUrl(`/use-cases/${slug}/rule`));
|
||||
}
|
||||
});
|
||||
|
||||
it("omits signed-in, admin, and API routes", () => {
|
||||
const urls = buildPublicSitemap().map((entry) => entry.url);
|
||||
expect(urls.some((url) => url.includes("/login"))).toBe(false);
|
||||
expect(urls.some((url) => url.includes("/profile"))).toBe(false);
|
||||
expect(urls.some((url) => url.includes("/create"))).toBe(false);
|
||||
expect(urls.some((url) => url.includes("/monitor"))).toBe(false);
|
||||
expect(urls.some((url) => url.includes("/api/"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import metadataMessages from "../../messages/en/metadata.json";
|
||||
import {
|
||||
buildRobots,
|
||||
PRODUCTION_SITE_ORIGIN,
|
||||
resolveRequestHost,
|
||||
routeMetadata,
|
||||
sitemapUrl,
|
||||
} from "../../lib/siteMetadata";
|
||||
|
||||
const ORIGINAL_SITE_INDEXING = process.env.SITE_INDEXING;
|
||||
const ORIGINAL_APP_ORIGIN = process.env.CLOUDRON_APP_ORIGIN;
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_SITE_INDEXING === undefined) {
|
||||
delete process.env.SITE_INDEXING;
|
||||
} else {
|
||||
process.env.SITE_INDEXING = ORIGINAL_SITE_INDEXING;
|
||||
}
|
||||
if (ORIGINAL_APP_ORIGIN === undefined) {
|
||||
delete process.env.CLOUDRON_APP_ORIGIN;
|
||||
} else {
|
||||
process.env.CLOUDRON_APP_ORIGIN = ORIGINAL_APP_ORIGIN;
|
||||
}
|
||||
});
|
||||
|
||||
describe("routeMetadata", () => {
|
||||
it("sets a path canonical, stripping trailing slashes and query strings", () => {
|
||||
expect(routeMetadata("/about").alternates?.canonical).toBe("/about");
|
||||
expect(routeMetadata("/about/").alternates?.canonical).toBe("/about");
|
||||
expect(routeMetadata("about").alternates?.canonical).toBe("/about");
|
||||
expect(routeMetadata("/").alternates?.canonical).toBe("/");
|
||||
expect(routeMetadata("/blog/post?x=1#top").alternates?.canonical).toBe(
|
||||
"/blog/post",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps caller metadata while overriding a parent canonical", () => {
|
||||
expect(
|
||||
routeMetadata("/learn", { title: "Learn", description: "Copy" }),
|
||||
).toEqual({
|
||||
title: "Learn",
|
||||
description: "Copy",
|
||||
alternates: { canonical: "/learn" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sitemapUrl", () => {
|
||||
it("resolves paths against the production origin", () => {
|
||||
expect(sitemapUrl("/")).toBe(PRODUCTION_SITE_ORIGIN);
|
||||
expect(sitemapUrl("/about")).toBe(`${PRODUCTION_SITE_ORIGIN}/about`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRequestHost", () => {
|
||||
it("prefers x-forwarded-host over Host", () => {
|
||||
expect(
|
||||
resolveRequestHost("staging.communityrule.info", "0.0.0.0:3000"),
|
||||
).toBe("staging.communityrule.info");
|
||||
});
|
||||
|
||||
it("uses CLOUDRON_APP_ORIGIN when Host is the standalone bind address", () => {
|
||||
process.env.CLOUDRON_APP_ORIGIN = "https://staging.communityrule.info";
|
||||
expect(resolveRequestHost(null, "0.0.0.0:3000")).toBe(
|
||||
"staging.communityrule.info",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRobots", () => {
|
||||
it("disallows crawlers on staging and unknown hosts", () => {
|
||||
expect(buildRobots("staging.communityrule.info")).toEqual({
|
||||
rules: { userAgent: "*", disallow: "/" },
|
||||
});
|
||||
expect(buildRobots("localhost:3000")).toEqual({
|
||||
rules: { userAgent: "*", disallow: "/" },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows production hosts and advertises the sitemap", () => {
|
||||
expect(buildRobots("communityrule.info")).toEqual({
|
||||
rules: { userAgent: "*", allow: "/" },
|
||||
sitemap: `${PRODUCTION_SITE_ORIGIN}/sitemap.xml`,
|
||||
});
|
||||
expect(buildRobots("communityrule.com")).toEqual({
|
||||
rules: { userAgent: "*", allow: "/" },
|
||||
sitemap: `${PRODUCTION_SITE_ORIGIN}/sitemap.xml`,
|
||||
});
|
||||
});
|
||||
|
||||
it("honors SITE_INDEXING overrides", () => {
|
||||
process.env.SITE_INDEXING = "false";
|
||||
expect(buildRobots("communityrule.info").rules).toEqual({
|
||||
userAgent: "*",
|
||||
disallow: "/",
|
||||
});
|
||||
|
||||
process.env.SITE_INDEXING = "true";
|
||||
expect(buildRobots("staging.communityrule.info").sitemap).toBe(
|
||||
`${PRODUCTION_SITE_ORIGIN}/sitemap.xml`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("page title segments", () => {
|
||||
it("uses a shared em-dash template and does not bake a site suffix into page titles", () => {
|
||||
expect(metadataMessages.titleTemplate).toBe("%s — CommunityRule");
|
||||
|
||||
const suffixes = [
|
||||
" — CommunityRule",
|
||||
" · CommunityRule",
|
||||
" - CommunityRule",
|
||||
];
|
||||
const titles: string[] = [];
|
||||
const walk = (value: unknown) => {
|
||||
if (value && typeof value === "object") {
|
||||
for (const [key, child] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
if (key === "title" && typeof child === "string") {
|
||||
titles.push(child);
|
||||
} else {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(metadataMessages);
|
||||
|
||||
for (const title of titles) {
|
||||
if (title === metadataMessages.home.title) continue;
|
||||
for (const suffix of suffixes) {
|
||||
expect(title.endsWith(suffix)).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user