Give every public route its own description #92

Merged
an.di merged 1 commits from adilallo/fix/CR-262-route-metadata into main 2026-11-04 20:49:21 +00:00
52 changed files with 356 additions and 202 deletions
Showing only changes of commit c180e79c38 - Show all commits

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ import { routeMetadata } from "../../../lib/siteMetadata";
export const metadata: Metadata = routeMetadata("/create", {
title: messages.metadata.create.title,
description: messages.metadata.create.description,
});
export default function CreateFlowLayout({ children }: { children: ReactNode }) {
@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import messages from "../../../../../messages/en/index";
import { routeMetadata } from "../../../../../lib/siteMetadata";
import { getRuleTemplateBySlug } from "../../../../../lib/server/ruleTemplates";
import { templateReviewDocument } from "../../../../../lib/templates/governanceTemplateCatalog";
type LayoutProps = {
children: ReactNode;
params: Promise<{ slug: string }>;
};
export async function generateMetadata({
params,
}: LayoutProps): Promise<Metadata> {
const { slug: raw } = await params;
const slug = decodeURIComponent(raw);
const row = await getRuleTemplateBySlug(slug);
const document = templateReviewDocument(slug, row);
const title = document?.title ?? messages.metadata.create.title;
const description =
document?.description || messages.metadata.create.description;
return routeMetadata(`/create/review-template/${encodeURIComponent(slug)}`, {
title: {
absolute: messages.metadata.titleTemplate.replace("%s", title),
},
description,
openGraph: { title, description },
twitter: { title, description },
});
}
export default function TemplateReviewLayout({ children }: LayoutProps) {
return children;
}
+9 -10
View File
@@ -1,19 +1,18 @@
import dynamic from "next/dynamic";
import type { ReactNode } from "react";
import { Suspense, type ReactNode } from "react";
import FooterWithOrigin from "../../components/navigation/FooterWithOrigin";
/** Profile uses the public marketing footer; other `(app)` routes stay footer-free. */
const Footer = dynamic(() => import("../../components/navigation/Footer"), {
loading: () => (
<footer className="w-full min-h-[200px] bg-[var(--color-surface-default-primary)]" />
),
ssr: true,
});
export default function ProfileLayout({ children }: { children: ReactNode }) {
return (
<>
{children}
<Footer />
<Suspense
fallback={
<footer className="min-h-[200px] w-full bg-[var(--color-surface-default-primary)]" />
}
>
<FooterWithOrigin />
</Suspense>
</>
);
}
@@ -6,6 +6,8 @@ import {
type LegalDocumentMessages,
type LegalDocumentSlug,
} from "../../../lib/legalSyntheticPost";
import { getRequestSiteOrigin } from "../../../lib/requestSiteOrigin";
import { absoluteSiteUrl } from "../../../lib/siteMetadata";
import ContentBanner from "../../components/sections/ContentBanner";
type LegalDocumentPageProps = {
@@ -97,11 +99,12 @@ export function legalPageMetadata(
* articles, Ask Organizer, and decorative shapes.
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=19003-23305
*/
export default function LegalDocumentPage({
export default async function LegalDocumentPage({
slug,
path,
page,
}: LegalDocumentPageProps) {
const origin = await getRequestSiteOrigin();
const post = buildLegalSyntheticPost(slug, page);
const structuredData = {
@@ -109,12 +112,12 @@ export default function LegalDocumentPage({
"@type": "WebPage",
name: page.banner.title,
description: page.banner.description,
url: `https://communityrule.com${path}`,
url: absoluteSiteUrl(path, origin),
dateModified: page.banner.date,
publisher: {
"@type": "Organization",
name: "CommunityRule",
url: "https://communityrule.com",
url: absoluteSiteUrl("/", origin),
},
};
@@ -126,13 +129,13 @@ export default function LegalDocumentPage({
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://communityrule.com",
item: absoluteSiteUrl("/", origin),
},
{
"@type": "ListItem",
position: 2,
name: page.banner.title,
item: `https://communityrule.com${path}`,
item: absoluteSiteUrl(path, origin),
},
],
};
+11 -7
View File
@@ -8,7 +8,9 @@ import {
getRelatedBlogPosts,
} from "../../../../lib/content";
import { logger } from "../../../../lib/logger";
import { getRequestSiteOrigin } from "../../../../lib/requestSiteOrigin";
import {
absoluteSiteUrl,
articleShareImages,
routeMetadata,
} from "../../../../lib/siteMetadata";
@@ -130,6 +132,8 @@ export default async function BlogPostPage({ params }: PageProps) {
3,
);
const origin = await getRequestSiteOrigin();
// Generate structured data for search engines
const structuredData = {
"@context": "https://schema.org",
@@ -143,19 +147,19 @@ export default async function BlogPostPage({ params }: PageProps) {
publisher: {
"@type": "Organization",
name: "CommunityRule",
url: "https://communityrule.com",
url: absoluteSiteUrl("/", origin),
logo: {
"@type": "ImageObject",
url: "https://communityrule.com/assets/logos/community-rule.svg",
url: absoluteSiteUrl("/assets/logos/community-rule.svg", origin),
},
},
datePublished: post.frontmatter.date,
dateModified: post.frontmatter.date,
mainEntityOfPage: {
"@type": "WebPage",
"@id": `https://communityrule.com/blog/${post.slug}`,
"@id": absoluteSiteUrl(`/blog/${post.slug}`, origin),
},
url: `https://communityrule.com/blog/${post.slug}`,
url: absoluteSiteUrl(`/blog/${post.slug}`, origin),
articleSection: "Community Building",
keywords: ["community", "governance", "decision making", "collaboration"],
};
@@ -169,19 +173,19 @@ export default async function BlogPostPage({ params }: PageProps) {
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://communityrule.com",
item: absoluteSiteUrl("/", origin),
},
{
"@type": "ListItem",
position: 2,
name: "Blog",
item: "https://communityrule.com/blog",
item: absoluteSiteUrl("/blog", origin),
},
{
"@type": "ListItem",
position: 3,
name: post.frontmatter.title,
item: `https://communityrule.com/blog/${post.slug}`,
item: absoluteSiteUrl(`/blog/${post.slug}`, origin),
},
],
};
+10 -8
View File
@@ -10,7 +10,8 @@ import {
buildHowItWorksSyntheticPost,
HOW_IT_WORKS_SENTINEL_SLUG,
} from "../../../lib/howItWorksSyntheticPost";
import { routeMetadata } from "../../../lib/siteMetadata";
import { getRequestSiteOrigin } from "../../../lib/requestSiteOrigin";
import { absoluteSiteUrl, routeMetadata } from "../../../lib/siteMetadata";
import ContentBanner from "../../components/sections/ContentBanner";
import ContentTemplateDecorativeShapes from "../_components/ContentTemplateDecorativeShapes";
import AskOrganizer from "../../components/sections/AskOrganizer";
@@ -36,14 +37,15 @@ export async function generateMetadata(): Promise<Metadata> {
keywords: meta.keywords,
openGraph: {
title: page.banner.title,
description: page.banner.description,
description: meta.description,
type: "website",
siteName: messages.metadata.siteName,
},
});
}
export default function HowItWorksPage() {
export default async function HowItWorksPage() {
const origin = await getRequestSiteOrigin();
const page = messages.pages.howItWorks;
const syntheticPost = buildHowItWorksSyntheticPost(page);
@@ -61,12 +63,12 @@ export default function HowItWorksPage() {
"@context": "https://schema.org",
"@type": "WebPage",
name: page.banner.title,
description: page.banner.description,
url: "https://communityrule.com/how-it-works",
description: messages.metadata.howItWorks.description,
url: absoluteSiteUrl("/how-it-works", origin),
publisher: {
"@type": "Organization",
name: "CommunityRule",
url: "https://communityrule.com",
url: absoluteSiteUrl("/", origin),
},
};
@@ -78,13 +80,13 @@ export default function HowItWorksPage() {
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://communityrule.com",
item: absoluteSiteUrl("/", origin),
},
{
"@type": "ListItem",
position: 2,
name: page.banner.title,
item: "https://communityrule.com/how-it-works",
item: absoluteSiteUrl("/how-it-works", origin),
},
],
};
+8 -12
View File
@@ -1,22 +1,12 @@
import dynamic from "next/dynamic";
import { Suspense, type ReactNode } from "react";
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
import FooterWithOrigin from "../components/navigation/FooterWithOrigin";
import SkipToContent from "../components/navigation/SkipToContent";
import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext";
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
import marketingMessages from "../../messages/en/marketing";
// Site footer is part of the public marketing chrome only — not rendered for
// signed-in product surfaces, admin dashboards, or dev previews. See
// `.cursor/rules/routes.mdc` for the full chrome composition map.
const Footer = dynamic(() => import("../components/navigation/Footer"), {
loading: () => (
<footer className="bg-[var(--color-surface-default-primary)] w-full min-h-[200px]" />
),
ssr: true,
});
export default function MarketingLayout({ children }: { children: ReactNode }) {
return (
<MessagesProvider messages={marketingMessages}>
@@ -37,7 +27,13 @@ export default function MarketingLayout({ children }: { children: ReactNode }) {
>
{children}
</main>
<Footer />
<Suspense
fallback={
<footer className="min-h-[200px] w-full bg-[var(--color-surface-default-primary)]" />
}
>
<FooterWithOrigin />
</Suspense>
</AuthModalProvider>
</MessagesProvider>
);
+12 -1
View File
@@ -8,7 +8,18 @@ import HeroBanner from "../components/sections/HeroBanner";
import AskOrganizer from "../components/sections/AskOrganizer";
import { MarketingRuleStackSection } from "./_components/MarketingRuleStackSection";
export const metadata: Metadata = routeMetadata("/");
export const metadata: Metadata = routeMetadata("/", {
title: { absolute: messages.metadata.home.title },
description: messages.metadata.home.description,
openGraph: {
title: messages.metadata.home.title,
description: messages.metadata.home.description,
},
twitter: {
title: messages.metadata.home.title,
description: messages.metadata.home.description,
},
});
// Code split below-the-fold components to reduce initial bundle size
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
+1
View File
@@ -10,6 +10,7 @@ import TemplatesPageClient from "./TemplatesPageClient";
export const metadata: Metadata = routeMetadata("/templates", {
title: messages.metadata.templates.title,
description: messages.metadata.templates.description,
openGraph: {
images: templatesShareImages(),
},
+8 -6
View File
@@ -12,7 +12,8 @@ import {
USE_CASE_DETAIL_SLUGS,
useCaseContentKeyForSlug,
} from "../../../../lib/useCaseSyntheticPost";
import { routeMetadata } from "../../../../lib/siteMetadata";
import { getRequestSiteOrigin } from "../../../../lib/requestSiteOrigin";
import { absoluteSiteUrl, 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";
@@ -56,6 +57,7 @@ export default async function UseCaseDetailPage({ params }: PageProps) {
notFound();
}
const origin = await getRequestSiteOrigin();
const detail = messages.pages.useCasesDetail;
const entry = getUseCaseDetailEntry(slug, detail);
const syntheticPost = buildUseCaseSyntheticPost(slug, detail);
@@ -69,11 +71,11 @@ export default async function UseCaseDetailPage({ params }: PageProps) {
"@type": "WebPage",
name: entry.banner.title,
description: entry.banner.description,
url: `https://communityrule.com/use-cases/${slug}`,
url: absoluteSiteUrl(`/use-cases/${slug}`, origin),
publisher: {
"@type": "Organization",
name: "CommunityRule",
url: "https://communityrule.com",
url: absoluteSiteUrl("/", origin),
},
};
@@ -85,19 +87,19 @@ export default async function UseCaseDetailPage({ params }: PageProps) {
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://communityrule.com",
item: absoluteSiteUrl("/", origin),
},
{
"@type": "ListItem",
position: 2,
name: "Learn",
item: "https://communityrule.com/learn",
item: absoluteSiteUrl("/learn", origin),
},
{
"@type": "ListItem",
position: 3,
name: entry.banner.title,
item: `https://communityrule.com/use-cases/${slug}`,
item: absoluteSiteUrl(`/use-cases/${slug}`, origin),
},
],
};
@@ -42,6 +42,7 @@ function CaseStudyView({
className = "",
}: CaseStudyProps) {
const Art = SURFACE_ART[surface];
const named = imageAlt.trim().length > 0;
return (
<div
data-figma-node="21993-32352"
@@ -52,8 +53,9 @@ function CaseStudyView({
) : (
<div className="absolute inset-0">
<Art
role="img"
aria-label={imageAlt}
role={named ? "img" : undefined}
aria-label={named ? imageAlt : undefined}
aria-hidden={named ? undefined : true}
data-case-study-art={SURFACE_ART_DATA_KEY[surface]}
width="100%"
height="100%"
@@ -50,8 +50,7 @@ const ContentContainerContainer = memo<ContentContainerProps>(
};
const iconImage = leadingImageSrc ?? getIconImage(post.slug);
const iconAlt =
leadingImageAlt ?? `Icon for ${post.frontmatter.title}`;
const iconAlt = leadingImageAlt ?? "";
const formattedDate = new Date(post.frontmatter.date).toLocaleDateString(
"en-US",
@@ -1,4 +1,5 @@
import { getNavAuthSignedIn } from "../../../lib/server/navAuth";
import { getRequestSiteOrigin } from "../../../lib/requestSiteOrigin";
import ConditionalNavigationClient from "./ConditionalNavigationClient";
/**
@@ -7,5 +8,11 @@ import ConditionalNavigationClient from "./ConditionalNavigationClient";
*/
export default async function ConditionalNavigation() {
const initialSignedIn = await getNavAuthSignedIn();
return <ConditionalNavigationClient initialSignedIn={initialSignedIn} />;
const siteOrigin = await getRequestSiteOrigin();
return (
<ConditionalNavigationClient
initialSignedIn={initialSignedIn}
siteOrigin={siteOrigin}
/>
);
}
@@ -7,6 +7,7 @@ import TopWithPathname from "./Top/TopWithPathname";
export type ConditionalNavigationClientProps = {
initialSignedIn: boolean;
siteOrigin: string;
};
/**
@@ -14,14 +15,16 @@ export type ConditionalNavigationClientProps = {
* parent Server Component (`ConditionalNavigation`) via `initialSignedIn`.
*/
const ConditionalNavigationClient = memo(
({ initialSignedIn }: ConditionalNavigationClientProps) => {
({ initialSignedIn, siteOrigin }: ConditionalNavigationClientProps) => {
const pathname = usePathname();
if (isChromelessNavigationPath(pathname)) {
return null;
}
return <TopWithPathname initialSignedIn={initialSignedIn} />;
return (
<TopWithPathname initialSignedIn={initialSignedIn} siteOrigin={siteOrigin} />
);
},
);
+3 -2
View File
@@ -6,13 +6,14 @@ import Link from "next/link";
import Logo from "../asset/Logo";
import Divider from "../utility/Divider";
import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
import { PRODUCTION_SITE_ORIGIN } from "../../../lib/siteMetadata";
/**
* Figma: "Navigation / Footer" (18411-62917).
* Tiers: smallest viewports (below `md`), `md` through `lg`, `lg` and up.
* Matches `--breakpoint-md: 640px`, `--breakpoint-lg: 1024px` in `app/tailwind.css`.
*/
const Footer = memo(() => {
const Footer = memo(({ siteOrigin = PRODUCTION_SITE_ORIGIN }: { siteOrigin?: string }) => {
const t = useTranslation("footer");
const tChrome = useTranslation("controlsChrome");
@@ -36,7 +37,7 @@ const Footer = memo(() => {
"@type": "Organization",
name: t("organization.name"),
email: t("organization.email"),
url: t("organization.url"),
url: siteOrigin,
sameAs: [
t("social.bluesky.url"),
t("social.gitea.url"),
@@ -0,0 +1,8 @@
import Footer from "./Footer";
import { getRequestSiteOrigin } from "../../../lib/requestSiteOrigin";
/** Footer JSON-LD uses the request origin, same host as the page canonical. */
export default async function FooterWithOrigin() {
const siteOrigin = await getRequestSiteOrigin();
return <Footer siteOrigin={siteOrigin} />;
}
+14 -21
View File
@@ -14,6 +14,7 @@ import Button from "../../buttons/Button";
import AvatarContainer from "../../asset/AvatarContainer";
import Avatar from "../../asset/Avatar";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
import { PRODUCTION_SITE_ORIGIN, websiteJsonLd } from "../../../../lib/siteMetadata";
import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
import { TopView } from "./Top.view";
import type { TopProps } from "./Top.types";
@@ -24,10 +25,10 @@ export const avatarImageSources = [
getAssetPath(ASSETS.AVATAR_1),
] as const;
/** @deprecated Use `avatarImageSources` — alts are resolved in `TopContainer` via `topNav` messages. */
export const avatarImages = avatarImageSources.map((src, index) => ({
/** @deprecated Use `avatarImageSources`. The portraits are decorative. */
export const avatarImages = avatarImageSources.map((src) => ({
src,
alt: `Avatar ${3 - index}`,
alt: "",
}));
/** Padding/type that tracks lg/xl Menu sizes without cloning the items per breakpoint. */
@@ -44,12 +45,17 @@ const FOLDER_CREATE_RULE_RESPONSIVE_CLASS =
"md:p-[var(--space-200)] md:gap-[var(--space-050)] md:text-x-small-label lg:p-[var(--space-300)] lg:gap-[var(--space-150)] lg:text-medium-label xl:p-[var(--space-400)] xl:gap-[var(--space-200)] xl:text-x-large-label";
const TopContainer = memo<TopProps>(
({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => {
({
folderTop = false,
loggedIn = false,
profile = false,
logIn = true,
siteOrigin = PRODUCTION_SITE_ORIGIN,
}) => {
const pathname = usePathname();
const router = useRouter();
const { openLogin } = useAuthModal();
const t = useTranslation("header");
const tTopNav = useTranslation("topNav");
/**
* `Top` is hidden on `/create` routes by ConditionalNavigationClient, so
@@ -64,20 +70,7 @@ const TopContainer = memo<TopProps>(
router.push("/create/informational");
}, [loggedIn, router]);
const schemaData = {
"@context": "https://schema.org",
"@type": "WebSite",
name: "CommunityRule",
url: "https://communityrule.com",
...(folderTop && {
description: tTopNav("schemaDescription"),
}),
potentialAction: {
"@type": "SearchAction",
target: "https://communityrule.com/search?q={search_term_string}",
"query-input": "required name=search_term_string",
},
};
const schemaData = websiteJsonLd(siteOrigin);
const logoSize = folderTop ? "topNavFolderTop" : "topNavHeader";
@@ -188,11 +181,11 @@ const TopContainer = memo<TopProps>(
: "max-sm:hidden lg:-space-x-[var(--space-250)] xl:-space-x-[13px]"
}
>
{avatarImageSources.map((src, index) => (
{avatarImageSources.map((src) => (
<Avatar
key={src}
src={src}
alt={tTopNav(`avatarAlts.${3 - index}`)}
alt=""
size="small"
className={
folderTop
+4 -8
View File
@@ -7,6 +7,8 @@ export interface TopProps {
folderTop?: boolean;
profile?: boolean;
logIn?: boolean;
/** Absolute origin for the WebSite JSON-LD block. */
siteOrigin?: string;
}
export interface TopViewProps {
@@ -15,16 +17,10 @@ export interface TopViewProps {
profile: boolean;
logIn: boolean;
schemaData: {
"@context": string;
"@type": string;
"@context": "https://schema.org";
"@type": "WebSite";
name: string;
url: string;
description?: string;
potentialAction: {
"@type": string;
target: string;
"query-input": string;
};
};
logoSize: "topNavFolderTop" | "topNavHeader";
renderNavigationItems: () => React.ReactNode;
+1 -3
View File
@@ -1,7 +1,6 @@
"use client";
import { memo } from "react";
import Script from "next/script";
import { useTranslation } from "../../../contexts/MessagesContext";
import { ASSETS, getAssetPath } from "../../../../lib/assetUtils";
import Menu from "../Menu";
@@ -32,8 +31,7 @@ function TopView({
if (folderTop) {
return (
<>
<Script
id="top-navigation-schema"
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
/>
@@ -35,17 +35,18 @@ function CardStepsView({
/>
</div>
<div className="grid grid-cols-1 gap-y-[var(--space-600)] lg:grid-cols-3 lg:gap-[var(--space-600)]">
<ol className="m-0 grid list-none grid-cols-1 gap-y-[var(--space-600)] p-0 lg:grid-cols-3 lg:gap-[var(--space-600)]">
{steps.map((item, index) => (
<li key={item.text} className="min-w-0">
<Step
key={index}
number={index + 1}
text={item.text}
iconShape={item.iconShape}
iconColor={item.iconColor}
/>
</li>
))}
</div>
</ol>
<div className="text-center">
<Button
@@ -2,7 +2,6 @@
import { memo } from "react";
import Image from "next/image";
import { useTranslation } from "../../../contexts/MessagesContext";
import QuoteDecor from "./QuoteDecor";
import QuoteStatementDecor from "./QuoteStatementDecor";
import type { QuoteBlockViewProps } from "./QuoteBlock.types";
@@ -21,9 +20,6 @@ function QuoteBlockView({
currentAvatarSrc,
onImageError,
}: QuoteBlockViewProps) {
const t = useTranslation("quoteBlock");
const avatarAlt = t("avatarAlt").replace("{author}", author);
if (config.citationLayout) {
return (
<section
@@ -147,7 +143,7 @@ function QuoteBlockView({
{!imageError ? (
<Image
src={currentAvatarSrc}
alt={avatarAlt}
alt=""
width={64}
height={64}
className={`filter sepia ${config.avatar}`}
+2 -2
View File
@@ -18,11 +18,11 @@ const SectionNumber = memo<SectionNumberProps>(({ number }) => {
};
return (
<div className="relative size-[40px] overflow-visible">
<div className="relative size-[40px] overflow-visible" aria-hidden>
{/* eslint-disable-next-line @next/next/no-img-element -- dynamic src from getImageSrc */}
<img
src={getImageSrc(number)}
alt={`Section ${number}`}
alt=""
className="absolute left-1/2 top-1/2 size-[47.37px] max-w-none -translate-x-1/2 -translate-y-1/2 rotate-15"
/>
<div className="absolute inset-0 flex items-center justify-center">
@@ -38,8 +38,9 @@ function TripleStepView({
>
{heading}
</h2>
<ol className="m-0 flex list-none flex-col gap-[var(--space-1000)] p-0">
{steps.map((step, index) => (
<div
<li
key={step.title}
className="flex flex-col items-start gap-[var(--space-400)]"
>
@@ -58,8 +59,9 @@ function TripleStepView({
{step.body}
</p>
</div>
</div>
</li>
))}
</ol>
<div className="flex justify-start">
<Button
href={ctaHref}
-16
View File
@@ -17,11 +17,6 @@ export interface SchemaWebSite {
"@type": "WebSite";
name: string;
url: string;
potentialAction?: {
"@type": "SearchAction";
target: string;
"query-input": string;
};
}
export interface SchemaHowTo {
@@ -112,10 +107,6 @@ export function useSchemaData(
type: "WebSite";
name: string;
url: string;
potentialAction?: {
target: string;
"query-input": string;
};
}
| {
type: "HowTo";
@@ -168,13 +159,6 @@ export function useSchemaData(
"@type": "WebSite",
name: config.name,
url: config.url,
...(config.potentialAction && {
potentialAction: {
"@type": "SearchAction",
target: config.potentialAction.target,
"query-input": config.potentialAction["query-input"],
},
}),
} as SchemaWebSite;
case "HowTo":
+12
View File
@@ -0,0 +1,12 @@
import { headers } from "next/headers";
import { originFromHost, resolveRequestHost } from "./siteMetadata";
/** Public origin of this request. Prerender falls back inside `originFromHost`. */
export async function getRequestSiteOrigin(): Promise<string> {
const headerStore = await headers();
const host = resolveRequestHost(
headerStore.get("x-forwarded-host"),
headerStore.get("host"),
);
return originFromHost(host);
}
+39 -6
View File
@@ -7,8 +7,11 @@ import {
} from "./assetUtils";
import metadataMessages from "../messages/en/metadata.json";
/** Production origin used for sitemap URLs and as the metadata-host fallback. */
export const PRODUCTION_SITE_ORIGIN = "https://communityrule.com";
/**
* Production origin. Sitemap URLs and prerendered canonicals use this when
* no request host and no `CLOUDRON_APP_ORIGIN` are available.
*/
export const PRODUCTION_SITE_ORIGIN = "https://communityrule.info";
/** Open Graph / Twitter large-card raster size. */
const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const;
@@ -115,6 +118,31 @@ export function metadataBaseUrl(): URL {
return new URL(originFromHost(null));
}
/** Origin baked into prerendered pages: app origin when set, otherwise production. */
export function configuredSiteOrigin(): string {
return originFromHost(null);
}
/** Absolute URL for canonicals and JSON-LD. Same origin as `metadataBase`. */
export function absoluteSiteUrl(
pathname: string,
origin: string = configuredSiteOrigin(),
): string {
const path = canonicalPath(pathname);
if (path === "/") return origin;
return `${origin}${path}`;
}
/** WebSite block. No SearchAction — the site has no search page. */
export function websiteJsonLd(origin: string) {
return {
"@context": "https://schema.org" as const,
"@type": "WebSite" as const,
name: "CommunityRule",
url: origin,
};
}
type ShareImage = {
url: string;
width: number;
@@ -207,8 +235,13 @@ export function routeMetadata(
const defaultImages = defaultShareImages();
const openGraph = metadata.openGraph ?? {};
const twitter = metadata.twitter ?? {};
const pageTitle = typeof metadata.title === "string" ? metadata.title : undefined;
const pageDescription =
typeof metadata.description === "string" ? metadata.description : undefined;
const mergedOpenGraph = {
images: defaultImages,
...(pageTitle ? { title: pageTitle } : {}),
...(pageDescription ? { description: pageDescription } : {}),
...openGraph,
url: openGraph.url ?? path,
};
@@ -232,7 +265,9 @@ export function routeMetadata(
},
openGraph: mergedOpenGraph,
twitter: {
card: "summary_large_image",
card: "summary_large_image" as const,
...(pageTitle ? { title: pageTitle } : {}),
...(pageDescription ? { description: pageDescription } : {}),
images: derivedTwitterImages,
...twitter,
},
@@ -276,7 +311,5 @@ export function buildRobots(
}
export function sitemapUrl(pathname: string): string {
const path = canonicalPath(pathname);
if (path === "/") return PRODUCTION_SITE_ORIGIN;
return `${PRODUCTION_SITE_ORIGIN}${path}`;
return absoluteSiteUrl(pathname, PRODUCTION_SITE_ORIGIN);
}
@@ -165,3 +165,21 @@ export function getGovernanceTemplateCatalogEntry(
): GovernanceTemplateCatalogEntry | undefined {
return bySlug.get(slug);
}
/**
* Title and description for a template review document. A saved row wins;
* otherwise the static catalog. Unknown slugs return null so the page can
* fall back to the create-flow title.
*/
export function templateReviewDocument(
slug: string,
db: { title?: string | null; description?: string | null } | null,
): { title: string; description: string } | null {
const catalog = bySlug.get(slug);
const title = db?.title?.trim() || catalog?.title;
if (!title) return null;
return {
title,
description: db?.description?.trim() || catalog?.description || "",
};
}
+3 -3
View File
@@ -15,14 +15,14 @@
"hintDefault": "Hint text here",
"toggleOption": "Toggle option",
"toggleSwitch": "Toggle switch",
"closeDialog": "Close dialog",
"closeDialog": "Close",
"moreOptions": "More options",
"moreOptionsMenu": "More options menu",
"closeAlert": "Close alert",
"closeAlert": "Close",
"helpIconAlt": "Help",
"footerAriaLabel": "Footer",
"createFlowFooterAriaLabel": "Create Flow Footer",
"logoAlt": "CommunityRule Logo",
"logoAlt": "CommunityRule",
"logoText": "CommunityRule",
"loadingTemplates": "Loading templates",
"loadingCreateFlow": "Loading create flow",
+1 -1
View File
@@ -3,7 +3,7 @@
"organization": {
"name": "Media Economies Design Lab",
"email": "medlab@colorado.edu",
"url": "https://communityrule.com"
"url": "https://communityrule.info"
},
"social": {
"bluesky": {
+1 -1
View File
@@ -1,3 +1,3 @@
{
"avatarAlt": "Portrait of {author}"
"_comment": "Quote portraits are decorative. The caption next to the photo names the person."
}
+1 -6
View File
@@ -1,8 +1,3 @@
{
"avatarAlts": {
"1": "Avatar 1",
"2": "Avatar 2",
"3": "Avatar 3"
},
"schemaDescription": "Build operating manuals for successful communities"
"_comment": "Create-rule portraits are decorative. The button label names the control, so the photos have no alt text."
}
+9 -7
View File
@@ -3,12 +3,12 @@
"siteName": "CommunityRule",
"titleTemplate": "%s — CommunityRule",
"shareImage": {
"defaultAlt": "CommunityRule — build operating manuals for successful communities",
"defaultAlt": "CommunityRule — build structure before you need it",
"templatesAlt": "CommunityRule governance templates"
},
"home": {
"title": "CommunityRule - Build operating manuals for successful communities",
"description": "Help your community make important decisions in a way that reflects its unique values.",
"title": "Build structure before you need it — CommunityRule",
"description": "Strong communities agree on four things early. They settle how they talk, who belongs, how they decide and what happens when they disagree. Write yours down together.",
"keywords": [
"community",
"governance",
@@ -32,10 +32,12 @@
"description": "See how groups use CommunityRule, who it is for, and practical guides for organizing together."
},
"templates": {
"title": "Templates"
"title": "Templates",
"description": "These are popular patterns for making decisions in mutual aid and open source communities. You can use them as they are or as a starting place for customizing your own CommunityRule."
},
"create": {
"title": "Create"
"title": "Create",
"description": "This flow will give you recommendations to improve your community and help you put together a proposal for your group to consider."
},
"blog": {
"title": "Blog",
@@ -132,7 +134,7 @@
},
"howItWorks": {
"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": "Sketch your Rule in CommunityRule, then make it your community's own over five short meetings.",
"keywords": [
"how it works",
"community governance",
@@ -152,7 +154,7 @@
},
"cookies": {
"title": "Cookies Settings",
"description": "CommunityRule uses one cookie, to keep you signed in.",
"description": "CommunityRule uses cookies to keep you signed in and to let you claim a rule you published without an account.",
"keywords": ["cookies", "session", "privacy", "communityrule"]
}
}
+1 -1
View File
@@ -9,6 +9,6 @@
"discord": "Discord",
"email": "Email",
"done": "Done",
"closeDialogAriaLabel": "Close share dialog",
"closeDialogAriaLabel": "Close",
"moreOptionsAriaLabel": "More share options"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"banner": {
"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": "Sketch your Rule in CommunityRule, then make it your community's own over five short meetings.",
"author": "CommunityRule",
"date": "2026-01-15"
},
+3 -3
View File
@@ -4,9 +4,9 @@
"title": ["See how groups use", "CommunityRule"]
},
"caseStudyTiles": {
"mutualAidColoradoAlt": "Mutual Aid Colorado logo",
"foodNotBombsAlt": "Food Not Bombs logo",
"boulderStreetMedicsAlt": "Boulder Street Medics logo",
"mutualAidColoradoAlt": "",
"foodNotBombsAlt": "",
"boulderStreetMedicsAlt": "",
"links": [
{
"href": "/use-cases/mutual-aid-colorado",
+2 -2
View File
@@ -50,14 +50,14 @@
"name": "Nathan Schneider",
"bio": "Nathan Schneider started CommunityRule and wrote Everything for Everyone, a book about the co-op tradition.",
"image": "/assets/marketing/workshops/nathan-schneider.png",
"imageAlt": "Nathan Schneider"
"imageAlt": ""
},
{
"id": "dani",
"name": "Dani Shipley",
"bio": "Dani Shipley led experience design on CommunityRule and organizes with co-ops and mutual aid communities in Colorado.",
"image": "/assets/marketing/workshops/dani-shipley.png",
"imageAlt": "Dani Shipley"
"imageAlt": ""
}
]
},
+3 -3
View File
@@ -36,7 +36,7 @@ describe("Alert dismiss control", () => {
it("omits close button when onClose is absent", () => {
render(<Alert title="T" description="D" />);
expect(
screen.queryByRole("button", { name: "Close alert" }),
screen.queryByRole("button", { name: "Close" }),
).not.toBeInTheDocument();
});
@@ -44,7 +44,7 @@ describe("Alert dismiss control", () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<Alert title="T" onClose={onClose} />);
await user.click(screen.getByRole("button", { name: "Close alert" }));
await user.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).toHaveBeenCalledTimes(1);
});
@@ -54,7 +54,7 @@ describe("Alert dismiss control", () => {
<Alert title="T" onClose={onClose} hasTrailingIcon={false} />,
);
expect(
screen.queryByRole("button", { name: "Close alert" }),
screen.queryByRole("button", { name: "Close" }),
).not.toBeInTheDocument();
});
});
@@ -32,7 +32,7 @@ describe("ConfirmStakeholdersScreen", () => {
expect(
screen.getByText(/Congratulations! You've drafted your CommunityRule!/i),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Close alert" }));
await user.click(screen.getByRole("button", { name: "Close" }));
expect(
screen.queryByText(
/Congratulations! You've drafted your CommunityRule!/i,
+2 -2
View File
@@ -55,7 +55,7 @@ describe("Create", () => {
it("calls onClose when close button is clicked", () => {
const onClose = vi.fn();
renderWithProviders(<Create {...defaultProps} onClose={onClose} />);
const closeButton = screen.getByLabelText("Close dialog");
const closeButton = screen.getByLabelText("Close");
fireEvent.click(closeButton);
expect(onClose).toHaveBeenCalledTimes(1);
});
@@ -269,7 +269,7 @@ describe("Create", () => {
</Create>,
);
const closeButton = screen.getByLabelText("Close dialog");
const closeButton = screen.getByLabelText("Close");
screen.getByLabelText("Test Input"); // Verify input is rendered
// Focus should start on first focusable element (close button)
+1 -1
View File
@@ -114,7 +114,7 @@ describe("Dialog", () => {
it("calls onClose when close control is activated", () => {
const onClose = vi.fn();
renderWithProviders(<Dialog {...defaultProps} onClose={onClose} />);
fireEvent.click(screen.getByLabelText("Close dialog"));
fireEvent.click(screen.getByLabelText("Close"));
expect(onClose).toHaveBeenCalledTimes(1);
});
+4 -4
View File
@@ -440,9 +440,9 @@ describe("FinalReviewScreen — chip detail modal", () => {
expect(
within(dialog).getByRole("button", { name: "Save" }),
).toBeInTheDocument();
expect(
within(dialog).queryByRole("button", { name: "Close" }),
).not.toBeInTheDocument();
expect(within(dialog).getAllByRole("button", { name: "Close" })).toHaveLength(
1,
);
});
it("opens Save for a values chip (use-without-changes seeded snapshot)", async () => {
@@ -579,7 +579,7 @@ describe("FinalReviewScreen — chip detail modal", () => {
expect(
within(dialog).queryByRole("button", { name: "Back" }),
).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByLabelText("Close dialog"));
fireEvent.click(within(dialog).getByLabelText("Close"));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
+2 -2
View File
@@ -92,7 +92,7 @@ describe("Login", () => {
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
fireEvent.click(screen.getByLabelText("Close dialog"));
fireEvent.click(screen.getByLabelText("Close"));
expect(onClose).toHaveBeenCalledTimes(1);
});
@@ -169,7 +169,7 @@ describe("Login", () => {
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
expect(screen.getByLabelText("Close dialog")).toBeInTheDocument();
expect(screen.getByLabelText("Close")).toBeInTheDocument();
expect(screen.queryByLabelText("More options")).not.toBeInTheDocument();
});
});
+5 -5
View File
@@ -31,14 +31,14 @@ componentTestSuite<LogoProps>(config);
describe("Logo (behavioral tests)", () => {
it("renders as a link to home", () => {
render(<Logo />);
const logo = screen.getByRole("link", { name: /communityrule logo/i });
const logo = screen.getByRole("link", { name: "CommunityRule" });
expect(logo).toHaveAttribute("href", "/");
expect(logo).toHaveAttribute("aria-label", "CommunityRule Logo");
expect(logo).toHaveAttribute("aria-label", "CommunityRule");
});
it("animates the mark on hover without scaling the wordmark", () => {
render(<Logo />);
const logo = screen.getByRole("link", { name: /communityrule logo/i });
const logo = screen.getByRole("link", { name: "CommunityRule" });
const wordmark = screen.getByText("CommunityRule");
const mark = logo.querySelector("img");
expect(logo).toHaveClass("group");
@@ -48,7 +48,7 @@ describe("Logo (behavioral tests)", () => {
it("renders logo icon", () => {
render(<Logo />);
expect(screen.getByAltText("CommunityRule Logo")).toBeInTheDocument();
expect(screen.getByAltText("CommunityRule")).toBeInTheDocument();
});
it("renders text by default", () => {
@@ -60,7 +60,7 @@ describe("Logo (behavioral tests)", () => {
const { container } = render(<Logo wordmark={false} />);
const textElement = container.querySelector(".hidden");
expect(textElement).toBeInTheDocument();
expect(screen.getByAltText("CommunityRule Logo")).toBeInTheDocument();
expect(screen.getByAltText("CommunityRule")).toBeInTheDocument();
});
it("applies inverse palette styling when palette is inverse", () => {
+10 -7
View File
@@ -1,19 +1,22 @@
import { render, screen } from "@testing-library/react";
import { render } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import "@testing-library/jest-dom/vitest";
import SectionNumber from "../../app/components/sections/SectionNumber";
describe("SectionNumber", () => {
it("rotates the shape and keeps the numeral upright", () => {
render(<SectionNumber number={1} />);
const { container } = render(<SectionNumber number={1} />);
const numeral = screen.getByText("1");
const numeral = container.querySelector("span");
expect(numeral).toHaveTextContent("1");
expect(numeral).toHaveClass("text-large-label");
expect(numeral.className).not.toMatch(/rotate/);
expect(numeral?.className).not.toMatch(/rotate/);
const shape = screen.getByAltText("Section 1");
const shape = container.querySelector("img");
expect(shape).toHaveAttribute("alt", "");
expect(shape).toHaveClass("rotate-15");
expect(shape.parentElement).not.toHaveClass("rotate-15");
expect(shape.parentElement?.className).not.toMatch(/-rotate-/);
expect(shape?.parentElement).toHaveAttribute("aria-hidden", "true");
expect(shape?.parentElement).not.toHaveClass("rotate-15");
expect(shape?.parentElement?.className).not.toMatch(/-rotate-/);
});
});
+1 -1
View File
@@ -143,7 +143,7 @@ test.describe("Critical User Journeys", () => {
await expect(page.locator("header")).toBeVisible();
// Test logo click
const logoLinks = page.locator('a[aria-label="CommunityRule Logo"]');
const logoLinks = page.locator('a[aria-label="CommunityRule"]');
const logoCount = await logoLinks.count();
expect(logoCount).toBeGreaterThan(0);
+1 -1
View File
@@ -429,7 +429,7 @@ describe("Create flow communication-methods page", () => {
expect(within(dialog).getByText("Guidelines")).toBeInTheDocument();
expect(within(dialog).getByRole("textbox")).toHaveValue("Enter norms here");
await user.click(
within(dialog).getByRole("button", { name: "Close dialog" }),
within(dialog).getByRole("button", { name: "Close" }),
);
const metaAfterClose = latest.customMethodCardMetaById ?? {};
+3 -3
View File
@@ -483,7 +483,7 @@ describe("Create flow decision-approaches page", () => {
screen.getByRole("checkbox", { name: "Steward finances" }),
).not.toBeChecked();
await user.click(
within(doocracyDialog).getByRole("button", { name: "Close dialog" }),
within(doocracyDialog).getByRole("button", { name: "Close" }),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@@ -541,7 +541,7 @@ describe("Create flow decision-approaches page", () => {
}),
).toBeInTheDocument();
await user.click(
within(doocracyDialog).getByRole("button", { name: "Close dialog" }),
within(doocracyDialog).getByRole("button", { name: "Close" }),
);
await user.click(await screen.findByRole("button", { name: "Discard" }));
await waitFor(() => {
@@ -639,7 +639,7 @@ describe("Create flow decision-approaches page", () => {
}),
).toBeInTheDocument();
await user.click(
within(lazyAgain).getByRole("button", { name: "Close dialog" }),
within(lazyAgain).getByRole("button", { name: "Close" }),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+16 -11
View File
@@ -26,6 +26,13 @@ vi.mock("../../lib/assetUtils", () => ({
}));
// Mock blog post data
function decorativeIcon() {
const icon = document.querySelector("img");
expect(icon).toBeTruthy();
expect(icon).toHaveAttribute("alt", "");
return icon;
}
const mockPost = {
slug: "test-article",
frontmatter: {
@@ -57,7 +64,7 @@ describe("ContentContainer", () => {
it("displays the icon correctly", () => {
render(<ContentContainer post={mockPost} />);
const icon = screen.getByAltText("Icon for Test Article Title");
const icon = decorativeIcon();
expect(icon).toBeInTheDocument();
expect(icon).toHaveAttribute(
"src",
@@ -125,9 +132,7 @@ describe("ContentContainer", () => {
it("has proper spacing between icon and text", () => {
render(<ContentContainer post={mockPost} />);
const iconContainer = screen
.getByAltText("Icon for Test Article Title")
.closest("div");
const iconContainer = decorativeIcon().closest("div");
const textContainer = screen.getByText("Test Article Title").closest("div");
// Check the content container (parent of icon)
@@ -169,7 +174,7 @@ describe("ContentContainer", () => {
it("uses per-article tag assets for catalog slugs", () => {
const { rerender } = render(<ContentContainer post={mockPost} />);
let icon = screen.getByAltText("Icon for Test Article Title");
let icon = decorativeIcon();
expect(icon).toHaveAttribute(
"src",
"/content/blog/resolving-active-conflicts-tag.svg",
@@ -178,7 +183,7 @@ describe("ContentContainer", () => {
const post2 = { ...mockPost, slug: "operational-security-mutual-aid" };
rerender(<ContentContainer post={post2} />);
icon = screen.getByAltText("Icon for Test Article Title");
icon = decorativeIcon();
expect(icon).toHaveAttribute(
"src",
"/content/blog/operational-security-mutual-aid-tag.svg",
@@ -187,7 +192,7 @@ describe("ContentContainer", () => {
const post3 = { ...mockPost, slug: "making-decisions-without-hierarchy" };
rerender(<ContentContainer post={post3} />);
icon = screen.getByAltText("Icon for Test Article Title");
icon = decorativeIcon();
expect(icon).toHaveAttribute(
"src",
"/content/blog/making-decisions-without-hierarchy-tag.svg",
@@ -211,7 +216,7 @@ describe("ContentContainer", () => {
it("applies correct responsive sizing for xs breakpoint", () => {
render(<ContentContainer post={mockPost} size="xs" />);
const icon = screen.getByAltText("Icon for Test Article Title");
const icon = decorativeIcon();
expect(icon).toHaveClass("w-[60px]", "h-[30px]");
const title = screen.getByText("Test Article Title");
@@ -224,7 +229,7 @@ describe("ContentContainer", () => {
it("applies correct responsive sizing for responsive breakpoint", () => {
render(<ContentContainer post={mockPost} size="responsive" />);
const icon = screen.getByAltText("Icon for Test Article Title");
const icon = decorativeIcon();
expect(icon).toHaveClass("w-[60px]", "h-[30px]");
const title = screen.getByText("Test Article Title");
@@ -237,8 +242,8 @@ describe("ContentContainer", () => {
it("has proper accessibility attributes", () => {
render(<ContentContainer post={mockPost} />);
const icon = screen.getByAltText("Icon for Test Article Title");
expect(icon).toHaveAttribute("alt", "Icon for Test Article Title");
const icon = decorativeIcon();
expect(icon).toHaveAttribute("alt", "");
});
it("handles long titles gracefully", () => {
+1 -1
View File
@@ -21,7 +21,7 @@ describe("QuoteBlock Component", () => {
expect(
screen.getByText("The Tyranny of Structurelessness"),
).toBeInTheDocument();
expect(screen.getByAltText("Portrait of Jo Freeman")).toBeInTheDocument();
expect(document.querySelector("img")).toHaveAttribute("alt", "");
});
test("renders with custom props", () => {
@@ -3,6 +3,7 @@ import {
GOVERNANCE_TEMPLATE_CATALOG,
GOVERNANCE_TEMPLATE_HOME_SLUGS,
getGovernanceTemplatesForHome,
templateReviewDocument,
} from "../../lib/templates/governanceTemplateCatalog";
/**
@@ -48,4 +49,19 @@ describe("governanceTemplateCatalog (Figma 21764-16435)", () => {
]);
expect(getGovernanceTemplatesForHome()).toHaveLength(4);
});
it("puts the template name in the review document title", () => {
expect(templateReviewDocument("consensus", null)).toEqual({
title: "Consensus",
description:
"Important decisions require unanimous agreement. Proposals pass only if no serious objections remain.",
});
expect(
templateReviewDocument("consensus", {
title: "Saved Consensus",
description: "Saved description",
})?.title,
).toBe("Saved Consensus");
expect(templateReviewDocument("missing-slug", null)).toBeNull();
});
});
+2 -10
View File
@@ -24,16 +24,12 @@ describe("useSchemaData", () => {
});
});
test("generates WebSite schema", () => {
test("generates WebSite schema without a search action", () => {
const { result } = renderHook(() =>
useSchemaData({
type: "WebSite",
name: "Test Site",
url: "https://example.com",
potentialAction: {
target: "https://example.com/search?q={search_term_string}",
"query-input": "required name=search_term_string",
},
}),
);
@@ -42,12 +38,8 @@ describe("useSchemaData", () => {
"@type": "WebSite",
name: "Test Site",
url: "https://example.com",
potentialAction: {
"@type": "SearchAction",
target: "https://example.com/search?q={search_term_string}",
"query-input": "required name=search_term_string",
},
});
expect(result.current).not.toHaveProperty("potentialAction");
});
test("generates HowTo schema", () => {
+34 -1
View File
@@ -1,8 +1,10 @@
import { afterEach, describe, expect, it } from "vitest";
import metadataMessages from "../../messages/en/metadata.json";
import {
absoluteSiteUrl,
articleShareImages,
buildRobots,
configuredSiteOrigin,
metadataBaseUrl,
NO_INDEX_ROBOTS,
originFromHost,
@@ -12,6 +14,7 @@ import {
routeMetadata,
sitemapUrl,
templatesShareImages,
websiteJsonLd,
} from "../../lib/siteMetadata";
const ORIGINAL_SITE_INDEXING = process.env.SITE_INDEXING;
@@ -66,6 +69,9 @@ describe("routeMetadata", () => {
});
expect(meta.title).toBe("Learn");
expect(meta.description).toBe("Copy");
expect(meta.openGraph?.description).toBe("Copy");
expect(meta.openGraph?.title).toBe("Learn");
expect(meta.twitter?.description).toBe("Copy");
expect(meta.alternates?.canonical).toBe("/learn");
expect(meta.openGraph?.url).toBe("/learn");
});
@@ -99,6 +105,32 @@ describe("routeMetadata", () => {
});
});
describe("absoluteSiteUrl", () => {
it("uses the configured origin, which is production when no app origin is set", () => {
delete process.env.CLOUDRON_APP_ORIGIN;
expect(PRODUCTION_SITE_ORIGIN).toBe("https://communityrule.info");
expect(configuredSiteOrigin()).toBe(PRODUCTION_SITE_ORIGIN);
expect(absoluteSiteUrl("/")).toBe("https://communityrule.info");
expect(absoluteSiteUrl("/about")).toBe("https://communityrule.info/about");
});
it("follows CLOUDRON_APP_ORIGIN so a staging build does not advertise production", () => {
process.env.CLOUDRON_APP_ORIGIN = "https://staging.communityrule.info";
expect(absoluteSiteUrl("/learn")).toBe(
"https://staging.communityrule.info/learn",
);
});
it("builds a WebSite block with no search action", () => {
expect(websiteJsonLd("https://communityrule.info")).toEqual({
"@context": "https://schema.org",
"@type": "WebSite",
name: "CommunityRule",
url: "https://communityrule.info",
});
});
});
describe("sitemapUrl", () => {
it("resolves paths against the production origin", () => {
expect(sitemapUrl("/")).toBe(PRODUCTION_SITE_ORIGIN);
@@ -137,7 +169,8 @@ describe("originFromHost", () => {
expect(originFromHost("staging.communityrule.info")).toBe(
"https://staging.communityrule.info",
);
expect(originFromHost("communityrule.com")).toBe(PRODUCTION_SITE_ORIGIN);
expect(originFromHost("communityrule.info")).toBe(PRODUCTION_SITE_ORIGIN);
expect(originFromHost("communityrule.com")).toBe("https://communityrule.com");
});
it("keeps http for localhost so local previews resolve", () => {