diff --git a/.env.example b/.env.example index f243b7e..a711771 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx index 62d58c3..d6bf65f 100644 --- a/app/(admin)/layout.tsx +++ b/app/(admin)/layout.tsx @@ -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 `` rationale diff --git a/app/(admin)/monitor/page.tsx b/app/(admin)/monitor/page.tsx index fe924de..cd8bb9d 100644 --- a/app/(admin)/monitor/page.tsx +++ b/app/(admin)/monitor/page.tsx @@ -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 ; } diff --git a/app/(app)/create/[screenId]/page.tsx b/app/(app)/create/[screenId]/page.tsx index f1262c3..74bf370 100644 --- a/app/(app)/create/[screenId]/page.tsx +++ b/app/(app)/create/[screenId]/page.tsx @@ -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 { + 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; diff --git a/app/(app)/create/layout.tsx b/app/(app)/create/layout.tsx index bca407c..fddfe16 100644 --- a/app/(app)/create/layout.tsx +++ b/app/(app)/create/layout.tsx @@ -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 {children}; diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 8797597..07a30f5 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -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()` diff --git a/app/(app)/login/layout.tsx b/app/(app)/login/layout.tsx index 311edbb..8fa4dca 100644 --- a/app/(app)/login/layout.tsx +++ b/app/(app)/login/layout.tsx @@ -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, diff --git a/app/(app)/profile/page.tsx b/app/(app)/profile/page.tsx index 6ed307f..98e5c48 100644 --- a/app/(app)/profile/page.tsx +++ b/app/(app)/profile/page.tsx @@ -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 ; diff --git a/app/(dev)/layout.tsx b/app/(dev)/layout.tsx index 699928e..4319258 100644 --- a/app/(dev)/layout.tsx +++ b/app/(dev)/layout.tsx @@ -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 }) { diff --git a/app/(marketing)/about/page.tsx b/app/(marketing)/about/page.tsx index bc3e882..db50a7c 100644 --- a/app/(marketing)/about/page.tsx +++ b/app/(marketing)/about/page.tsx @@ -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(value: unknown): T[] { return Array.isArray(value) ? value : []; } diff --git a/app/(marketing)/blog/[slug]/page.tsx b/app/(marketing)/blog/[slug]/page.tsx index 6735c88..d76dd6c 100644 --- a/app/(marketing)/blog/[slug]/page.tsx +++ b/app/(marketing)/blog/[slug]/page.tsx @@ -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, }; } } diff --git a/app/(marketing)/blog/page.tsx b/app/(marketing)/blog/page.tsx index c6f633a..fb27427 100644 --- a/app/(marketing)/blog/page.tsx +++ b/app/(marketing)/blog/page.tsx @@ -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() {

- Blog + {blogMeta.title}

- Learn about community governance, decision-making, and building - successful organizations. + {blogMeta.description}

diff --git a/app/(marketing)/cookies/page.tsx b/app/(marketing)/cookies/page.tsx index 03cb0f5..f3d274c 100644 --- a/app/(marketing)/cookies/page.tsx +++ b/app/(marketing)/cookies/page.tsx @@ -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() { diff --git a/app/(marketing)/how-it-works/page.tsx b/app/(marketing)/how-it-works/page.tsx index e5b02b4..5f2903d 100644 --- a/app/(marketing)/how-it-works/page.tsx +++ b/app/(marketing)/how-it-works/page.tsx @@ -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 { 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 { title: page.banner.title, description: page.banner.description, type: "website", - siteName: "CommunityRule", + siteName: messages.metadata.siteName, }, - }; + }); } export default function HowItWorksPage() { diff --git a/app/(marketing)/learn/page.tsx b/app/(marketing)/learn/page.tsx index 271cdb5..dd5b1db 100644 --- a/app/(marketing)/learn/page.tsx +++ b/app/(marketing)/learn/page.tsx @@ -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(); diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index 4be91a2..97d33f3 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -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: () => ( diff --git a/app/(marketing)/privacy/page.tsx b/app/(marketing)/privacy/page.tsx index a6d1e80..35efafc 100644 --- a/app/(marketing)/privacy/page.tsx +++ b/app/(marketing)/privacy/page.tsx @@ -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() { diff --git a/app/(marketing)/rules/[id]/page.tsx b/app/(marketing)/rules/[id]/page.tsx index 6f71ce8..d551a3b 100644 --- a/app/(marketing)/rules/[id]/page.tsx +++ b/app/(marketing)/rules/[id]/page.tsx @@ -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) { diff --git a/app/(marketing)/templates/page.tsx b/app/(marketing)/templates/page.tsx index 29c005f..f0dcc26 100644 --- a/app/(marketing)/templates/page.tsx +++ b/app/(marketing)/templates/page.tsx @@ -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); diff --git a/app/(marketing)/terms/page.tsx b/app/(marketing)/terms/page.tsx index a3b5a03..8465534 100644 --- a/app/(marketing)/terms/page.tsx +++ b/app/(marketing)/terms/page.tsx @@ -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() { diff --git a/app/(marketing)/use-cases/[slug]/page.tsx b/app/(marketing)/use-cases/[slug]/page.tsx index 301dc52..0c6e78d 100644 --- a/app/(marketing)/use-cases/[slug]/page.tsx +++ b/app/(marketing)/use-cases/[slug]/page.tsx @@ -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 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 title: meta.title, description: meta.description, type: "website", - siteName: "CommunityRule", + siteName: messages.metadata.siteName, }, - }; + }); } export default async function UseCaseDetailPage({ params }: PageProps) { diff --git a/app/(marketing)/use-cases/page.tsx b/app/(marketing)/use-cases/page.tsx index 217941f..29104c3 100644 --- a/app/(marketing)/use-cases/page.tsx +++ b/app/(marketing)/use-cases/page.tsx @@ -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 { 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 { title, description, type: "website", - siteName: "CommunityRule", + siteName: messages.metadata.siteName, }, - }; + }); } export default function UseCasesPage() { diff --git a/app/(marketing-case-study)/use-cases/[slug]/rule/page.tsx b/app/(marketing-case-study)/use-cases/[slug]/rule/page.tsx index fa5438b..855b84f 100644 --- a/app/(marketing-case-study)/use-cases/[slug]/rule/page.tsx +++ b/app/(marketing-case-study)/use-cases/[slug]/rule/page.tsx @@ -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 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 title: meta.title, description: meta.description, type: "website", - siteName: "CommunityRule", + siteName: messages.metadata.siteName, }, - }; + }); } export default async function UseCaseCompletedRulePage({ params }: PageProps) { diff --git a/app/layout.tsx b/app/layout.tsx index 6302c81..e0eaa9e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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", }, diff --git a/app/not-found.tsx b/app/not-found.tsx index 959bf83..636eae9 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -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", diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..0ba18c4 --- /dev/null +++ b/app/robots.ts @@ -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 { + const headerStore = await headers(); + const host = resolveRequestHost( + headerStore.get("x-forwarded-host"), + headerStore.get("host"), + ); + return buildRobots(host); +} diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..d07698c --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,6 @@ +import type { MetadataRoute } from "next"; +import { buildPublicSitemap } from "../lib/publicSitemap"; + +export default function sitemap(): MetadataRoute.Sitemap { + return buildPublicSitemap(); +} diff --git a/lib/publicSitemap.ts b/lib/publicSitemap.ts new file mode 100644 index 0000000..8d155e4 --- /dev/null +++ b/lib/publicSitemap.ts @@ -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; +} diff --git a/lib/siteMetadata.ts b/lib/siteMetadata.ts new file mode 100644 index 0000000..c8db317 --- /dev/null +++ b/lib/siteMetadata.ts @@ -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}`; +} diff --git a/messages/en/metadata.json b/messages/en/metadata.json index 80f52f4..90072ee 100644 --- a/messages/en/metadata.json +++ b/messages/en/metadata.json @@ -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"] } diff --git a/tests/unit/publicSitemap.test.ts b/tests/unit/publicSitemap.test.ts new file mode 100644 index 0000000..2b247b7 --- /dev/null +++ b/tests/unit/publicSitemap.test.ts @@ -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); + }); +}); diff --git a/tests/unit/siteMetadata.test.ts b/tests/unit/siteMetadata.test.ts new file mode 100644 index 0000000..cb8a84d --- /dev/null +++ b/tests/unit/siteMetadata.test.ts @@ -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, + )) { + 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); + } + } + }); +});