Give each route its own title and canonical, and add robots.txt plus a sitemap. #72

Merged
an.di merged 1 commits from adilallo/fix/CR-177-site-metadata into main 2026-09-09 22:48:12 +00:00
32 changed files with 573 additions and 72 deletions
Showing only changes of commit b2b272d2cc - Show all commits
+5
View File
@@ -29,6 +29,11 @@ NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
# Optional: URL shown on /monitor when using external storage (Grafana, Kibana, vendor RUM, etc.). # Optional: URL shown on /monitor when using external storage (Grafana, Kibana, vendor RUM, etc.).
# NEXT_PUBLIC_RUM_DASHBOARD_URL= # 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). # 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: # In production (e.g. Cloudron localstorage mount), set to the mounted path. Local dev example:
# UPLOAD_ROOT="/absolute/path/to/community-rule/var/uploads" # UPLOAD_ROOT="/absolute/path/to/community-rule/var/uploads"
+6
View File
@@ -1,8 +1,14 @@
import type { Metadata } from "next";
import { Suspense, type ReactNode } from "react"; import { Suspense, type ReactNode } from "react";
import ConditionalNavigation from "../components/navigation/ConditionalNavigation"; import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
import { MessagesProvider } from "../contexts/MessagesContext"; import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext"; import { AuthModalProvider } from "../contexts/AuthModalContext";
import messages from "../../messages/en/index"; 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). // `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16).
// See `(app)/layout.tsx` for the matching `<Suspense fallback={null}>` rationale // See `(app)/layout.tsx` for the matching `<Suspense fallback={null}>` rationale
+7
View File
@@ -1,5 +1,12 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { routeMetadata } from "../../../lib/siteMetadata";
import MonitorPageContent from "./MonitorPageContent"; import MonitorPageContent from "./MonitorPageContent";
export const metadata: Metadata = routeMetadata("/monitor", {
title: messages.metadata.monitor.title,
});
export default function MonitorPage() { export default function MonitorPage() {
return <MonitorPageContent />; return <MonitorPageContent />;
} }
+11
View File
@@ -1,7 +1,10 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { CreateFlowScreenView } from "../screens/CreateFlowScreenView"; import { CreateFlowScreenView } from "../screens/CreateFlowScreenView";
import { createFlowStepPath, CREATE_ROUTES } from "../utils/createFlowPaths";
import { isValidStep } from "../utils/flowSteps"; import { isValidStep } from "../utils/flowSteps";
import type { CreateFlowStep } from "../types"; import type { CreateFlowStep } from "../types";
import { routeMetadata } from "../../../../lib/siteMetadata";
/** /**
* Single dynamic route for the whole create wizard (every step in `FLOW_STEP_ORDER`). * Single dynamic route for the whole create wizard (every step in `FLOW_STEP_ORDER`).
@@ -14,6 +17,14 @@ interface PageProps {
params: Promise<{ screenId: string }>; 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) { export default async function CreateFlowScreenPage({ params }: PageProps) {
const { screenId: raw } = await params; const { screenId: raw } = await params;
+7
View File
@@ -1,5 +1,12 @@
import type { Metadata } from "next";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import CreateFlowLayoutGate from "./CreateFlowLayoutGate"; 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 }) { export default function CreateFlowLayout({ children }: { children: ReactNode }) {
return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>; return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>;
+6
View File
@@ -1,8 +1,14 @@
import type { Metadata } from "next";
import { Suspense, type ReactNode } from "react"; import { Suspense, type ReactNode } from "react";
import ConditionalNavigation from "../components/navigation/ConditionalNavigation"; import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
import { MessagesProvider } from "../contexts/MessagesContext"; import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext"; import { AuthModalProvider } from "../contexts/AuthModalContext";
import messages from "../../messages/en/index"; 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). // `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16).
// `ConditionalNavigation` reads `cr_session` server-side (and `usePathname()` // `ConditionalNavigation` reads `cr_session` server-side (and `usePathname()`
+6 -4
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../lib/siteMetadata";
export const metadata: Metadata = { export const metadata: Metadata = routeMetadata("/login", {
title: "Log in · CommunityRule", title: messages.metadata.login.title,
robots: { index: false, follow: false }, robots: NO_INDEX_ROBOTS,
}; });
export default function LoginLayout({ export default function LoginLayout({
children, children,
+6 -4
View File
@@ -1,10 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../lib/siteMetadata";
import ProfilePageClient from "./ProfilePageClient"; import ProfilePageClient from "./ProfilePageClient";
export const metadata: Metadata = { export const metadata: Metadata = routeMetadata("/profile", {
title: "Profile · CommunityRule", title: messages.metadata.profile.title,
robots: { index: false, follow: false }, robots: NO_INDEX_ROBOTS,
}; });
export default function ProfilePage() { export default function ProfilePage() {
return <ProfilePageClient />; return <ProfilePageClient />;
+6
View File
@@ -1,8 +1,14 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { MessagesProvider } from "../contexts/MessagesContext"; import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext"; import { AuthModalProvider } from "../contexts/AuthModalContext";
import messages from "../../messages/en/index"; 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. // Development-only previews (e.g. `/components-preview`) — no public chrome.
export default function DevLayout({ children }: { children: ReactNode }) { export default function DevLayout({ children }: { children: ReactNode }) {
+6
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { import {
ASSETS, ASSETS,
@@ -6,6 +7,7 @@ import {
structureBeforeCrisisPath, structureBeforeCrisisPath,
} from "../../../lib/assetUtils"; } from "../../../lib/assetUtils";
import { getTranslation } from "../../../lib/i18n/getTranslation"; import { getTranslation } from "../../../lib/i18n/getTranslation";
import { routeMetadata } from "../../../lib/siteMetadata";
import AboutHeader from "../../components/type/AboutHeader"; import AboutHeader from "../../components/type/AboutHeader";
import type { AboutHeaderSegment } from "../../components/type/AboutHeader"; import type { AboutHeaderSegment } from "../../components/type/AboutHeader";
import Stats from "../../components/sections/Stats"; import Stats from "../../components/sections/Stats";
@@ -18,6 +20,10 @@ import type { FaqAccordionItem } from "../../components/sections/Accordion";
import QuoteBlock from "../../components/sections/QuoteBlock"; import QuoteBlock from "../../components/sections/QuoteBlock";
import AskOrganizer from "../../components/sections/AskOrganizer"; import AskOrganizer from "../../components/sections/AskOrganizer";
export const metadata: Metadata = routeMetadata("/about", {
title: messages.metadata.about.title,
});
function asArray<T>(value: unknown): T[] { function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? value : []; return Array.isArray(value) ? value : [];
} }
+12 -10
View File
@@ -8,6 +8,8 @@ import {
getRelatedBlogPosts, getRelatedBlogPosts,
} from "../../../../lib/content"; } from "../../../../lib/content";
import { logger } from "../../../../lib/logger"; import { logger } from "../../../../lib/logger";
import { routeMetadata } from "../../../../lib/siteMetadata";
import messages from "../../../../messages/en/index";
import ContentBanner from "../../../components/sections/ContentBanner"; import ContentBanner from "../../../components/sections/ContentBanner";
import AskOrganizer from "../../../components/sections/AskOrganizer"; import AskOrganizer from "../../../components/sections/AskOrganizer";
import ContentTemplateDecorativeShapes from "../../_components/ContentTemplateDecorativeShapes"; import ContentTemplateDecorativeShapes from "../../_components/ContentTemplateDecorativeShapes";
@@ -62,13 +64,13 @@ export async function generateMetadata({
const post = getBlogPostBySlug(slug); const post = getBlogPostBySlug(slug);
if (!post) { if (!post) {
return { return routeMetadata(`/blog/${slug}`, {
title: "Post Not Found", title: messages.metadata.blog.notFoundTitle,
description: "The requested blog post could not be found.", description: messages.metadata.blog.fallbackDescription,
}; });
} }
return { return routeMetadata(`/blog/${slug}`, {
title: post.frontmatter.title, title: post.frontmatter.title,
description: post.frontmatter.description, description: post.frontmatter.description,
authors: [{ name: post.frontmatter.author }], authors: [{ name: post.frontmatter.author }],
@@ -78,8 +80,8 @@ export async function generateMetadata({
type: "article", type: "article",
publishedTime: post.frontmatter.date, publishedTime: post.frontmatter.date,
authors: [post.frontmatter.author], authors: [post.frontmatter.author],
url: `https://communityrule.com/blog/${slug}`, url: `/blog/${slug}`,
siteName: "CommunityRule", siteName: messages.metadata.siteName,
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
@@ -87,12 +89,12 @@ export async function generateMetadata({
description: post.frontmatter.description, description: post.frontmatter.description,
creator: "@communityrule", creator: "@communityrule",
}, },
}; });
} catch (error) { } catch (error) {
logger.error("Error generating metadata:", error); logger.error("Error generating metadata:", error);
return { return {
title: "Blog Post", title: messages.metadata.blog.fallbackTitle,
description: "A blog post from our community.", description: messages.metadata.blog.fallbackDescription,
}; };
} }
} }
+16 -16
View File
@@ -1,26 +1,27 @@
import { getAllBlogPosts } from "../../../lib/content"; import { getAllBlogPosts } from "../../../lib/content";
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate"; import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
import type { Metadata } from "next"; import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { routeMetadata } from "../../../lib/siteMetadata";
export const metadata: Metadata = { const blogMeta = messages.metadata.blog;
title: "Blog - CommunityRule",
description: export const metadata: Metadata = routeMetadata("/blog", {
"Learn about community governance, decision-making, and building successful organizations.", title: blogMeta.title,
description: blogMeta.description,
openGraph: { openGraph: {
title: "Blog - CommunityRule", title: blogMeta.title,
description: description: blogMeta.description,
"Learn about community governance, decision-making, and building successful organizations.", url: "/blog",
url: "https://communityrule.com/blog", siteName: messages.metadata.siteName,
siteName: "CommunityRule",
type: "website", type: "website",
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "Blog - CommunityRule", title: blogMeta.title,
description: description: blogMeta.description,
"Learn about community governance, decision-making, and building successful organizations.",
}, },
}; });
export default function BlogPage() { export default function BlogPage() {
const posts = getAllBlogPosts(); 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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12"> <div className="text-center mb-12">
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-content-default-primary)] mb-4"> <h1 className="text-4xl md:text-5xl font-bold text-[var(--color-content-default-primary)] mb-4">
Blog {blogMeta.title}
</h1> </h1>
<p className="text-lg text-[var(--color-content-default-secondary)] max-w-2xl mx-auto"> <p className="text-lg text-[var(--color-content-default-secondary)] max-w-2xl mx-auto">
Learn about community governance, decision-making, and building {blogMeta.description}
successful organizations.
</p> </p>
</div> </div>
+5 -1
View File
@@ -5,6 +5,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { legalPathForSlug } from "../../../lib/legalSyntheticPost"; import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
import { routeMetadata } from "../../../lib/siteMetadata";
import LegalDocumentPage, { import LegalDocumentPage, {
legalPageMetadata, legalPageMetadata,
} from "../_components/LegalDocumentPage"; } from "../_components/LegalDocumentPage";
@@ -13,7 +14,10 @@ const SLUG = "cookies" as const;
const PATH = legalPathForSlug(SLUG); const PATH = legalPathForSlug(SLUG);
export function generateMetadata(): Metadata { 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() { export default function CookiesPage() {
+4 -3
View File
@@ -10,6 +10,7 @@ import {
buildHowItWorksSyntheticPost, buildHowItWorksSyntheticPost,
HOW_IT_WORKS_SENTINEL_SLUG, HOW_IT_WORKS_SENTINEL_SLUG,
} from "../../../lib/howItWorksSyntheticPost"; } from "../../../lib/howItWorksSyntheticPost";
import { routeMetadata } from "../../../lib/siteMetadata";
import ContentBanner from "../../components/sections/ContentBanner"; import ContentBanner from "../../components/sections/ContentBanner";
import ContentTemplateDecorativeShapes from "../_components/ContentTemplateDecorativeShapes"; import ContentTemplateDecorativeShapes from "../_components/ContentTemplateDecorativeShapes";
import AskOrganizer from "../../components/sections/AskOrganizer"; import AskOrganizer from "../../components/sections/AskOrganizer";
@@ -29,7 +30,7 @@ export async function generateMetadata(): Promise<Metadata> {
const meta = messages.metadata.howItWorks; const meta = messages.metadata.howItWorks;
const page = messages.pages.howItWorks; const page = messages.pages.howItWorks;
return { return routeMetadata("/how-it-works", {
title: meta.title, title: meta.title,
description: meta.description, description: meta.description,
keywords: meta.keywords, keywords: meta.keywords,
@@ -37,9 +38,9 @@ export async function generateMetadata(): Promise<Metadata> {
title: page.banner.title, title: page.banner.title,
description: page.banner.description, description: page.banner.description,
type: "website", type: "website",
siteName: "CommunityRule", siteName: messages.metadata.siteName,
}, },
}; });
} }
export default function HowItWorksPage() { export default function HowItWorksPage() {
+6
View File
@@ -1,10 +1,16 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { getTranslation } from "../../../lib/i18n/getTranslation"; import { getTranslation } from "../../../lib/i18n/getTranslation";
import { routeMetadata } from "../../../lib/siteMetadata";
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate"; import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
import ContentLockup from "../../components/type/ContentLockup"; import ContentLockup from "../../components/type/ContentLockup";
import AskOrganizer from "../../components/sections/AskOrganizer"; import AskOrganizer from "../../components/sections/AskOrganizer";
import { getAllBlogPosts } from "../../../lib/content"; import { getAllBlogPosts } from "../../../lib/content";
export const metadata: Metadata = routeMetadata("/learn", {
title: messages.metadata.learn.title,
});
export default function LearnPage() { export default function LearnPage() {
const allPosts = getAllBlogPosts(); const allPosts = getAllBlogPosts();
+4
View File
@@ -1,11 +1,15 @@
import type { Metadata } from "next";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { Suspense } from "react"; import { Suspense } from "react";
import messages from "../../messages/en/index"; import messages from "../../messages/en/index";
import { getTranslation } from "../../lib/i18n/getTranslation"; import { getTranslation } from "../../lib/i18n/getTranslation";
import { routeMetadata } from "../../lib/siteMetadata";
import HeroBanner from "../components/sections/HeroBanner"; import HeroBanner from "../components/sections/HeroBanner";
import AskOrganizer from "../components/sections/AskOrganizer"; import AskOrganizer from "../components/sections/AskOrganizer";
import { MarketingRuleStackSection } from "./_components/MarketingRuleStackSection"; import { MarketingRuleStackSection } from "./_components/MarketingRuleStackSection";
export const metadata: Metadata = routeMetadata("/");
// Code split below-the-fold components to reduce initial bundle size // Code split below-the-fold components to reduce initial bundle size
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), { const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
loading: () => ( loading: () => (
+5 -1
View File
@@ -5,6 +5,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { legalPathForSlug } from "../../../lib/legalSyntheticPost"; import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
import { routeMetadata } from "../../../lib/siteMetadata";
import LegalDocumentPage, { import LegalDocumentPage, {
legalPageMetadata, legalPageMetadata,
} from "../_components/LegalDocumentPage"; } from "../_components/LegalDocumentPage";
@@ -13,7 +14,10 @@ const SLUG = "privacy" as const;
const PATH = legalPathForSlug(SLUG); const PATH = legalPathForSlug(SLUG);
export function generateMetadata(): Metadata { 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() { export default function PrivacyPage() {
+5 -4
View File
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
import messages from "../../../../messages/en/index"; import messages from "../../../../messages/en/index";
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules"; import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections"; import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
import { routeMetadata } from "../../../../lib/siteMetadata";
import CommunityRule from "../../../components/type/CommunityRule"; import CommunityRule from "../../../components/type/CommunityRule";
import HeaderLockup from "../../../components/type/HeaderLockup"; import HeaderLockup from "../../../components/type/HeaderLockup";
@@ -25,22 +26,22 @@ export async function generateMetadata({
typeof rule.summary === "string" && rule.summary.trim().length > 0 typeof rule.summary === "string" && rule.summary.trim().length > 0
? rule.summary ? rule.summary
: undefined; : undefined;
return { return routeMetadata(`/rules/${rule.id}`, {
title: rule.title, title: rule.title,
description, description,
openGraph: { openGraph: {
title: rule.title, title: rule.title,
description, description,
type: "article", type: "article",
url: `https://communityrule.com/rules/${rule.id}`, url: `/rules/${rule.id}`,
siteName: "CommunityRule", siteName: messages.metadata.siteName,
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: rule.title, title: rule.title,
description, description,
}, },
}; });
} }
export default async function PublicRuleDetailPage({ params }: PageProps) { export default async function PublicRuleDetailPage({ params }: PageProps) {
+7
View File
@@ -1,7 +1,14 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates"; import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
import { routeMetadata } from "../../../lib/siteMetadata";
import { gridEntriesForFullCatalogWithFallback } from "../../../lib/templates/templateGridPresentation"; import { gridEntriesForFullCatalogWithFallback } from "../../../lib/templates/templateGridPresentation";
import TemplatesPageClient from "./TemplatesPageClient"; import TemplatesPageClient from "./TemplatesPageClient";
export const metadata: Metadata = routeMetadata("/templates", {
title: messages.metadata.templates.title,
});
export default async function TemplatesPage() { export default async function TemplatesPage() {
const rows = await listRuleTemplatesFromDb(); const rows = await listRuleTemplatesFromDb();
const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows); const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows);
+5 -1
View File
@@ -5,6 +5,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { legalPathForSlug } from "../../../lib/legalSyntheticPost"; import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
import { routeMetadata } from "../../../lib/siteMetadata";
import LegalDocumentPage, { import LegalDocumentPage, {
legalPageMetadata, legalPageMetadata,
} from "../_components/LegalDocumentPage"; } from "../_components/LegalDocumentPage";
@@ -13,7 +14,10 @@ const SLUG = "terms" as const;
const PATH = legalPathForSlug(SLUG); const PATH = legalPathForSlug(SLUG);
export function generateMetadata(): Metadata { 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() { export default function TermsPage() {
+4 -3
View File
@@ -12,6 +12,7 @@ import {
USE_CASE_DETAIL_SLUGS, USE_CASE_DETAIL_SLUGS,
useCaseContentKeyForSlug, useCaseContentKeyForSlug,
} from "../../../../lib/useCaseSyntheticPost"; } from "../../../../lib/useCaseSyntheticPost";
import { routeMetadata } from "../../../../lib/siteMetadata";
import ContentBanner from "../../../components/sections/ContentBanner"; import ContentBanner from "../../../components/sections/ContentBanner";
import AskOrganizer from "../../../components/sections/AskOrganizer"; import AskOrganizer from "../../../components/sections/AskOrganizer";
import type { AskOrganizerVariant } from "../../../components/sections/AskOrganizer/AskOrganizer.types"; 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 contentKey = useCaseContentKeyForSlug(slug);
const meta = messages.metadata.useCasesDetail[contentKey]; const meta = messages.metadata.useCasesDetail[contentKey];
return { return routeMetadata(`/use-cases/${slug}`, {
title: meta.title, title: meta.title,
description: meta.description, description: meta.description,
keywords: meta.keywords, keywords: meta.keywords,
@@ -42,9 +43,9 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
title: meta.title, title: meta.title,
description: meta.description, description: meta.description,
type: "website", type: "website",
siteName: "CommunityRule", siteName: messages.metadata.siteName,
}, },
}; });
} }
export default async function UseCaseDetailPage({ params }: PageProps) { export default async function UseCaseDetailPage({ params }: PageProps) {
+4 -3
View File
@@ -5,6 +5,7 @@ import { Suspense } from "react";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { CONTENT_CATALOG_SLUG_ORDER } from "../../../lib/assetUtils"; import { CONTENT_CATALOG_SLUG_ORDER } from "../../../lib/assetUtils";
import { getAllBlogPosts, getRelatedBlogPosts } from "../../../lib/content"; import { getAllBlogPosts, getRelatedBlogPosts } from "../../../lib/content";
import { routeMetadata } from "../../../lib/siteMetadata";
import PageHeader from "../../components/type/PageHeader"; import PageHeader from "../../components/type/PageHeader";
import CaseStudy from "../../components/cards/CaseStudy"; import CaseStudy from "../../components/cards/CaseStudy";
import UseCasesOrgs from "../../components/sections/UseCasesOrgs"; import UseCasesOrgs from "../../components/sections/UseCasesOrgs";
@@ -60,7 +61,7 @@ export async function generateMetadata(): Promise<Metadata> {
const description = messages.metadata.useCases.description; const description = messages.metadata.useCases.description;
const keywords = messages.metadata.useCases.keywords; const keywords = messages.metadata.useCases.keywords;
return { return routeMetadata("/use-cases", {
title, title,
description, description,
keywords, keywords,
@@ -68,9 +69,9 @@ export async function generateMetadata(): Promise<Metadata> {
title, title,
description, description,
type: "website", type: "website",
siteName: "CommunityRule", siteName: messages.metadata.siteName,
}, },
}; });
} }
export default function UseCasesPage() { export default function UseCasesPage() {
@@ -6,6 +6,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import messages from "../../../../../messages/en/index"; import messages from "../../../../../messages/en/index";
import { resolveUseCaseCompletedRule } from "../../../../../lib/useCaseCompletedRule"; import { resolveUseCaseCompletedRule } from "../../../../../lib/useCaseCompletedRule";
import { routeMetadata } from "../../../../../lib/siteMetadata";
import { import {
USE_CASE_DETAIL_SLUGS, USE_CASE_DETAIL_SLUGS,
useCaseContentKeyForSlug, useCaseContentKeyForSlug,
@@ -33,7 +34,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const contentKey = useCaseContentKeyForSlug(resolved.slug); const contentKey = useCaseContentKeyForSlug(resolved.slug);
const meta = messages.metadata.useCasesCompletedRule[contentKey]; const meta = messages.metadata.useCasesCompletedRule[contentKey];
return { return routeMetadata(`/use-cases/${resolved.slug}/rule`, {
title: meta.title, title: meta.title,
description: meta.description, description: meta.description,
keywords: meta.keywords, keywords: meta.keywords,
@@ -41,9 +42,9 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
title: meta.title, title: meta.title,
description: meta.description, description: meta.description,
type: "website", type: "website",
siteName: "CommunityRule", siteName: messages.metadata.siteName,
}, },
}; });
} }
export default async function UseCaseCompletedRulePage({ params }: PageProps) { export default async function UseCaseCompletedRulePage({ params }: PageProps) {
+8 -7
View File
@@ -3,6 +3,7 @@ import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import messages from "../messages/en/index"; import messages from "../messages/en/index";
import { ASSETS, getAssetPath } from "../lib/assetUtils"; import { ASSETS, getAssetPath } from "../lib/assetUtils";
import { PRODUCTION_SITE_ORIGIN } from "../lib/siteMetadata";
import "./globals.css"; import "./globals.css";
// `force-dynamic` is now scoped to `(app)/layout.tsx` and `(admin)/layout.tsx` // `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 = { export const metadata: Metadata = {
title: homeMeta.title, title: {
default: homeMeta.title,
template: messages.metadata.titleTemplate,
},
description: homeMeta.description, description: homeMeta.description,
keywords: [...homeMeta.keywords], keywords: [...homeMeta.keywords],
authors: [{ name: "Media Economies Design Lab" }], authors: [{ name: "Media Economies Design Lab" }],
@@ -65,7 +69,7 @@ export const metadata: Metadata = {
address: false, address: false,
telephone: false, telephone: false,
}, },
metadataBase: new URL("https://communityrule.com"), metadataBase: new URL(PRODUCTION_SITE_ORIGIN),
icons: { icons: {
icon: [ icon: [
{ url: getAssetPath(ASSETS.LOGO), type: "image/svg+xml" }, { url: getAssetPath(ASSETS.LOGO), type: "image/svg+xml" },
@@ -89,14 +93,11 @@ export const metadata: Metadata = {
}, },
], ],
}, },
alternates: {
canonical: "/",
},
openGraph: { openGraph: {
title: homeMeta.title, title: homeMeta.title,
description: homeMeta.description, description: homeMeta.description,
url: "https://communityrule.com", url: PRODUCTION_SITE_ORIGIN,
siteName: "CommunityRule", siteName: messages.metadata.siteName,
locale: "en_US", locale: "en_US",
type: "website", type: "website",
}, },
+7
View File
@@ -1,11 +1,18 @@
import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import messages from "../messages/en/index"; import messages from "../messages/en/index";
import { getTranslation } from "../lib/i18n/getTranslation"; import { getTranslation } from "../lib/i18n/getTranslation";
import { NO_INDEX_ROBOTS } from "../lib/siteMetadata";
import { getGovernanceTemplateCatalogEntry } from "../lib/templates/governanceTemplateCatalog"; import { getGovernanceTemplateCatalogEntry } from "../lib/templates/governanceTemplateCatalog";
import Icon from "./components/asset/icon"; import Icon from "./components/asset/icon";
import Button from "./components/buttons/Button"; import Button from "./components/buttons/Button";
import HeroDecor from "./components/sections/HeroBanner/HeroDecor"; 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 = [ const NOT_FOUND_TEMPLATE_SLUGS = [
"consensus", "consensus",
"do-ocracy", "do-ocracy",
+12
View File
@@ -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);
}
+6
View File
@@ -0,0 +1,6 @@
import type { MetadataRoute } from "next";
import { buildPublicSitemap } from "../lib/publicSitemap";
export default function sitemap(): MetadataRoute.Sitemap {
return buildPublicSitemap();
}
+42
View File
@@ -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;
}
+129
View File
@@ -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
View File
@@ -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": { "home": {
"title": "CommunityRule - Build operating manuals for successful communities", "title": "CommunityRule - Build operating manuals for successful communities",
"description": "Help your community make important decisions in a way that reflects its unique values.", "description": "Help your community make important decisions in a way that reflects its unique values.",
@@ -10,8 +12,39 @@
"operating manual" "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": { "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.", "description": "See how mutual aid groups, cooperatives, open source projects, and other communities use CommunityRule to define structure before they need it.",
"keywords": [ "keywords": [
"use cases", "use cases",
@@ -22,40 +55,40 @@
}, },
"useCasesDetail": { "useCasesDetail": {
"mutualAidColorado": { "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.", "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"] "keywords": ["mutual aid", "use case", "community governance", "operating manual"]
}, },
"foodNotBombs": { "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.", "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"] "keywords": ["food not bombs", "use case", "community governance", "operating manual"]
}, },
"boulderCountyStreetMedics": { "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.", "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"] "keywords": ["street medics", "use case", "community governance", "operating manual"]
} }
}, },
"useCasesCompletedRule": { "useCasesCompletedRule": {
"mutualAidColorado": { "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.", "description": "Read the completed community rule Mutual Aid Colorado built with CommunityRule.",
"keywords": ["mutual aid", "community rule", "operating manual", "use case"] "keywords": ["mutual aid", "community rule", "operating manual", "use case"]
}, },
"foodNotBombs": { "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.", "description": "Read the completed community rule Food Not Bombs Boulder built with CommunityRule.",
"keywords": ["food not bombs", "community rule", "operating manual", "use case"] "keywords": ["food not bombs", "community rule", "operating manual", "use case"]
}, },
"boulderCountyStreetMedics": { "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.", "description": "Read the completed community rule Boulder County Street Medics built with CommunityRule.",
"keywords": ["street medics", "community rule", "operating manual", "use case"] "keywords": ["street medics", "community rule", "operating manual", "use case"]
} }
}, },
"howItWorks": { "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.", "description": "CommunityRule is a modular governance toolkit designed to help democratic groups build, customize, and publish their own Operating Manual.",
"keywords": [ "keywords": [
"how it works", "how it works",
@@ -65,17 +98,17 @@
] ]
}, },
"privacy": { "privacy": {
"title": "Privacy Policy — CommunityRule", "title": "Privacy Policy",
"description": "What we collect, where it is stored, and how template recommendations work.", "description": "What we collect, where it is stored, and how template recommendations work.",
"keywords": ["privacy", "personal information", "communityrule"] "keywords": ["privacy", "personal information", "communityrule"]
}, },
"terms": { "terms": {
"title": "Terms of Service — CommunityRule", "title": "Terms of Service",
"description": "How you may use CommunityRule, including licenses and the templates API.", "description": "How you may use CommunityRule, including licenses and the templates API.",
"keywords": ["terms of service", "license", "communityrule"] "keywords": ["terms of service", "license", "communityrule"]
}, },
"cookies": { "cookies": {
"title": "Cookies Settings — CommunityRule", "title": "Cookies Settings",
"description": "CommunityRule uses one cookie, to keep you signed in.", "description": "CommunityRule uses one cookie, to keep you signed in.",
"keywords": ["cookies", "session", "privacy", "communityrule"] "keywords": ["cookies", "session", "privacy", "communityrule"]
} }
+39
View File
@@ -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);
});
});
+138
View File
@@ -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);
}
}
});
});