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:
commit
96ca15d8e3
52 files changed
+363
-209
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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,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),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"), {
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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) => (
|
||||
<Step
|
||||
key={index}
|
||||
number={index + 1}
|
||||
text={item.text}
|
||||
iconShape={item.iconShape}
|
||||
iconColor={item.iconColor}
|
||||
/>
|
||||
<li key={item.text} className="min-w-0">
|
||||
<Step
|
||||
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}`}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -17,11 +17,6 @@ export interface SchemaWebSite {
|
||||
"@type": "WebSite";
|
||||
name: string;
|
||||
url: string;
|
||||
potentialAction?: {
|
||||
"@type": "SearchAction";
|
||||
target: string;
|
||||
"query-input": string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SchemaHowTo {
|
||||
@@ -108,14 +103,10 @@ export function useSchemaData(
|
||||
url: string;
|
||||
sameAs?: string[];
|
||||
}
|
||||
| {
|
||||
| {
|
||||
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":
|
||||
|
||||
@@ -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
@@ -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 || "",
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
{
|
||||
"avatarAlt": "Portrait of {author}"
|
||||
"_comment": "Quote portraits are decorative. The caption next to the photo names the person."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,6 @@
|
||||
"discord": "Discord",
|
||||
"email": "Email",
|
||||
"done": "Done",
|
||||
"closeDialogAriaLabel": "Close share dialog",
|
||||
"closeDialogAriaLabel": "Close",
|
||||
"moreOptionsAriaLabel": "More share options"
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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-/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 ?? {};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user