Merge pull request 'Give every public route its own description' (#92) from adilallo/fix/CR-262-route-metadata into main

Reviewed-on: #92
This commit was merged in pull request #92.
This commit is contained in:
an.di committed 2026-11-04 20:49:19 +00:00
commit 96ca15d8e3
52 files changed
+363 -209

No files matched your search

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