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", {
|
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