Give every public route its own description, and keep canonicals and structured data on the host that serves the page.
Decorative images on the marketing pages stay quiet, and every dialog close button is named Close. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
0b6e8b00dd
commit
c180e79c38
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", {
|
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;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,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),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"), {
|
||||||
|
|||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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,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}`}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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";
|
} 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 || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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": {
|
"_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"
|
|
||||||
}
|
}
|
||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": ""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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-/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 ?? {};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
Reference in new issue
Block a user