diff --git a/app/(marketing)/_components/LegalDocumentPage.tsx b/app/(marketing)/_components/LegalDocumentPage.tsx
new file mode 100644
index 0000000..1e28c3b
--- /dev/null
+++ b/app/(marketing)/_components/LegalDocumentPage.tsx
@@ -0,0 +1,202 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import type { ReactNode } from "react";
+import {
+ buildLegalSyntheticPost,
+ type LegalDocumentMessages,
+ type LegalDocumentSlug,
+} from "../../../lib/legalSyntheticPost";
+import ContentBanner from "../../components/sections/ContentBanner";
+
+type LegalDocumentPageProps = {
+ slug: LegalDocumentSlug;
+ path: string;
+ page: LegalDocumentMessages;
+};
+
+const INLINE_LINK = /\[([^\]]+)\]\(([^)]+)\)/g;
+
+const ARTICLE_COLUMN_CLASS =
+ "mx-auto flex w-full flex-col gap-[var(--space-800,32px)] font-inter text-[var(--color-content-default-primary)] text-[16px] leading-[24px] sm:max-w-[390px] sm:text-[18px] sm:leading-[130%] md:max-w-[472px] lg:max-w-[700px] lg:text-[24px] lg:leading-[32px] xl:max-w-[904px] xl:text-[32px] xl:leading-[40px]";
+
+const LINK_CLASS = "underline decoration-solid underline-offset-2";
+
+function isSafeHref(href: string): boolean {
+ return (
+ (href.startsWith("/") && !href.startsWith("//")) ||
+ href.startsWith("https://") ||
+ href.startsWith("http://") ||
+ href.startsWith("mailto:")
+ );
+}
+
+function LegalInlineText({ text }: { text: string }) {
+ const nodes: ReactNode[] = [];
+ let lastIndex = 0;
+ let key = 0;
+
+ for (const match of text.matchAll(INLINE_LINK)) {
+ const matchIndex = match.index ?? 0;
+ if (matchIndex > lastIndex) {
+ nodes.push(text.slice(lastIndex, matchIndex));
+ }
+
+ const label = match[1];
+ const href = match[2];
+ if (isSafeHref(href)) {
+ const isInternal = href.startsWith("/") && !href.startsWith("//");
+ nodes.push(
+ isInternal ? (
+
+ {label}
+
+ ) : (
+
+ {label}
+
+ ),
+ );
+ key += 1;
+ } else {
+ nodes.push(label);
+ }
+
+ lastIndex = matchIndex + match[0].length;
+ }
+
+ if (lastIndex < text.length) {
+ nodes.push(text.slice(lastIndex));
+ }
+
+ return nodes;
+}
+
+export function legalPageMetadata(
+ meta: { title: string; description: string; keywords: string[] },
+ page: { banner: { title: string; description: string } },
+): Metadata {
+ return {
+ title: meta.title,
+ description: meta.description,
+ keywords: meta.keywords,
+ openGraph: {
+ title: page.banner.title,
+ description: page.banner.description,
+ type: "website",
+ siteName: "CommunityRule",
+ },
+ };
+}
+
+/**
+ * Public legal documents (`/privacy`, `/terms`, `/cookies`).
+ *
+ * Figma: "Content page Template" (19003:23305) — ContentBanner guide
+ * (22078:806964) + article column (19003:23574). Body stacks follow
+ * Type / Text Block (22001:29793): heading + paragraphs. Omits Related
+ * articles, Ask Organizer, and decorative shapes.
+ * https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=19003-23305
+ */
+export default function LegalDocumentPage({
+ slug,
+ path,
+ page,
+}: LegalDocumentPageProps) {
+ const post = buildLegalSyntheticPost(slug, page);
+
+ const structuredData = {
+ "@context": "https://schema.org",
+ "@type": "WebPage",
+ name: page.banner.title,
+ description: page.banner.description,
+ url: `https://communityrule.com${path}`,
+ dateModified: page.banner.date,
+ publisher: {
+ "@type": "Organization",
+ name: "CommunityRule",
+ url: "https://communityrule.com",
+ },
+ };
+
+ const breadcrumbData = {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ itemListElement: [
+ {
+ "@type": "ListItem",
+ position: 1,
+ name: "Home",
+ item: "https://communityrule.com",
+ },
+ {
+ "@type": "ListItem",
+ position: 2,
+ name: page.banner.title,
+ item: `https://communityrule.com${path}`,
+ },
+ ],
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ {page.banner.author}
+ {page.updated}
+
+
+ {page.intro.length > 0 ? (
+
+ {page.intro.map((paragraph) => (
+
+
+
+ ))}
+
+ ) : null}
+
+ {page.sections.map((section) => (
+
+ {section.heading}
+
+ {section.paragraphs.map((paragraph) => (
+
+
+
+ ))}
+
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/app/(marketing)/cookies/page.tsx b/app/(marketing)/cookies/page.tsx
new file mode 100644
index 0000000..03cb0f5
--- /dev/null
+++ b/app/(marketing)/cookies/page.tsx
@@ -0,0 +1,27 @@
+/**
+ * Figma: "Content page Template" (19003:23305)
+ * https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=19003-23305
+ */
+import type { Metadata } from "next";
+import messages from "../../../messages/en/index";
+import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
+import LegalDocumentPage, {
+ legalPageMetadata,
+} from "../_components/LegalDocumentPage";
+
+const SLUG = "cookies" as const;
+const PATH = legalPathForSlug(SLUG);
+
+export function generateMetadata(): Metadata {
+ return legalPageMetadata(messages.metadata.cookies, messages.pages.cookies);
+}
+
+export default function CookiesPage() {
+ return (
+
+ );
+}
diff --git a/app/(marketing)/privacy/page.tsx b/app/(marketing)/privacy/page.tsx
new file mode 100644
index 0000000..a6d1e80
--- /dev/null
+++ b/app/(marketing)/privacy/page.tsx
@@ -0,0 +1,27 @@
+/**
+ * Figma: "Content page Template" (19003:23305)
+ * https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=19003-23305
+ */
+import type { Metadata } from "next";
+import messages from "../../../messages/en/index";
+import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
+import LegalDocumentPage, {
+ legalPageMetadata,
+} from "../_components/LegalDocumentPage";
+
+const SLUG = "privacy" as const;
+const PATH = legalPathForSlug(SLUG);
+
+export function generateMetadata(): Metadata {
+ return legalPageMetadata(messages.metadata.privacy, messages.pages.privacy);
+}
+
+export default function PrivacyPage() {
+ return (
+
+ );
+}
diff --git a/app/(marketing)/terms/page.tsx b/app/(marketing)/terms/page.tsx
new file mode 100644
index 0000000..a3b5a03
--- /dev/null
+++ b/app/(marketing)/terms/page.tsx
@@ -0,0 +1,27 @@
+/**
+ * Figma: "Content page Template" (19003:23305)
+ * https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=19003-23305
+ */
+import type { Metadata } from "next";
+import messages from "../../../messages/en/index";
+import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
+import LegalDocumentPage, {
+ legalPageMetadata,
+} from "../_components/LegalDocumentPage";
+
+const SLUG = "terms" as const;
+const PATH = legalPathForSlug(SLUG);
+
+export function generateMetadata(): Metadata {
+ return legalPageMetadata(messages.metadata.terms, messages.pages.terms);
+}
+
+export default function TermsPage() {
+ return (
+
+ );
+}
diff --git a/lib/legalSyntheticPost.ts b/lib/legalSyntheticPost.ts
new file mode 100644
index 0000000..878ada4
--- /dev/null
+++ b/lib/legalSyntheticPost.ts
@@ -0,0 +1,53 @@
+import type { BlogPost } from "./content";
+
+export type LegalDocumentSlug = "privacy" | "terms" | "cookies";
+
+export type LegalDocumentSection = {
+ heading: string;
+ paragraphs: string[];
+};
+
+export type LegalDocumentMessages = {
+ banner: {
+ title: string;
+ description: string;
+ author: string;
+ date: string;
+ };
+ updated: string;
+ intro: string[];
+ sections: LegalDocumentSection[];
+};
+
+const PATH_BY_SLUG: Record = {
+ privacy: "/privacy",
+ terms: "/terms",
+ cookies: "/cookies",
+};
+
+export function legalPathForSlug(slug: LegalDocumentSlug): string {
+ return PATH_BY_SLUG[slug];
+}
+
+/**
+ * Builds a {@link BlogPost}-shaped object so legal pages can reuse
+ * `ContentBanner` (guide variant) without a markdown file.
+ */
+export function buildLegalSyntheticPost(
+ slug: LegalDocumentSlug,
+ page: LegalDocumentMessages,
+): BlogPost {
+ return {
+ slug: `__legal__:${slug}`,
+ frontmatter: {
+ title: page.banner.title,
+ description: page.banner.description,
+ author: page.banner.author,
+ date: page.banner.date,
+ },
+ content: "",
+ htmlContent: "",
+ filePath: `messages/en/pages/${slug}.json`,
+ lastModified: new Date(page.banner.date),
+ };
+}
diff --git a/messages/en/components/footer.json b/messages/en/components/footer.json
index 6702beb..59ee8a2 100644
--- a/messages/en/components/footer.json
+++ b/messages/en/components/footer.json
@@ -28,13 +28,12 @@
"about": "About"
},
"legal": {
- "_comment": "privacyPolicyHref, termsOfServiceHref, cookiesSettingsHref are stubs until legal pages ship.",
"privacyPolicy": "Privacy Policy",
- "privacyPolicyHref": "#",
+ "privacyPolicyHref": "/privacy",
"termsOfService": "Terms of Service",
- "termsOfServiceHref": "#",
+ "termsOfServiceHref": "/terms",
"cookiesSettings": "Cookies Settings",
- "cookiesSettingsHref": "#"
+ "cookiesSettingsHref": "/cookies"
},
"license": {
"_comment": "Replaces Figma's '© All right reserved'. Covers user-facing content (guides, template copy, marketing) per README — not GPL-3.0 application source. Published user-authored rules are a separate grant.",
diff --git a/messages/en/index.ts b/messages/en/index.ts
index bef9bb2..487d1b3 100644
--- a/messages/en/index.ts
+++ b/messages/en/index.ts
@@ -24,6 +24,9 @@ import useCasesDetail from "./pages/useCasesDetail.json";
import useCasesCompletedRules from "./pages/useCasesCompletedRules.json";
import useCasesCompletedRule from "./pages/useCasesCompletedRule.json";
import howItWorks from "./pages/howItWorks.json";
+import privacy from "./pages/privacy.json";
+import terms from "./pages/terms.json";
+import cookies from "./pages/cookies.json";
import monitor from "./pages/monitor.json";
import login from "./pages/login.json";
import profile from "./pages/profile.json";
@@ -95,6 +98,9 @@ export default {
useCasesCompletedRules,
useCasesCompletedRule,
howItWorks,
+ privacy,
+ terms,
+ cookies,
monitor,
login,
profile,
diff --git a/messages/en/marketing.ts b/messages/en/marketing.ts
index ac7846f..560a625 100644
--- a/messages/en/marketing.ts
+++ b/messages/en/marketing.ts
@@ -40,6 +40,9 @@ import useCasesDetail from "./pages/useCasesDetail.json";
import useCasesCompletedRules from "./pages/useCasesCompletedRules.json";
import useCasesCompletedRule from "./pages/useCasesCompletedRule.json";
import howItWorks from "./pages/howItWorks.json";
+import privacy from "./pages/privacy.json";
+import terms from "./pages/terms.json";
+import cookies from "./pages/cookies.json";
import monitor from "./pages/monitor.json";
import login from "./pages/login.json";
import profile from "./pages/profile.json";
@@ -80,6 +83,9 @@ const marketingMessages = {
useCasesCompletedRules,
useCasesCompletedRule,
howItWorks,
+ privacy,
+ terms,
+ cookies,
monitor,
login,
profile,
diff --git a/messages/en/metadata.json b/messages/en/metadata.json
index 195ca3a..80f52f4 100644
--- a/messages/en/metadata.json
+++ b/messages/en/metadata.json
@@ -63,5 +63,20 @@
"operating manual",
"decision-making"
]
+ },
+ "privacy": {
+ "title": "Privacy Policy — CommunityRule",
+ "description": "What we collect, where it is stored, and how template recommendations work.",
+ "keywords": ["privacy", "personal information", "communityrule"]
+ },
+ "terms": {
+ "title": "Terms of Service — CommunityRule",
+ "description": "How you may use CommunityRule, including licenses and the templates API.",
+ "keywords": ["terms of service", "license", "communityrule"]
+ },
+ "cookies": {
+ "title": "Cookies Settings — CommunityRule",
+ "description": "CommunityRule uses one cookie, to keep you signed in.",
+ "keywords": ["cookies", "session", "privacy", "communityrule"]
}
}
diff --git a/messages/en/pages/cookies.json b/messages/en/pages/cookies.json
new file mode 100644
index 0000000..075e490
--- /dev/null
+++ b/messages/en/pages/cookies.json
@@ -0,0 +1,28 @@
+{
+ "_comment": "Cookies Settings. Figma Content page Template (19003:23305): banner + labeled body stacks. There are no optional cookies to toggle.",
+ "banner": {
+ "title": "Cookies Settings",
+ "description": "CommunityRule uses one cookie, to keep you signed in.",
+ "author": "Media Economies Design Lab",
+ "date": "2026-08-15"
+ },
+ "updated": "Last updated August 15, 2026.",
+ "intro": [
+ "CommunityRule sets one cookie, named cr_session, after you sign in with a magic link. It keeps you signed in for up to 30 days. There are no advertising or analytics cookies to turn off."
+ ],
+ "sections": [
+ {
+ "heading": "The session cookie",
+ "paragraphs": [
+ "Sign out to clear it on this browser. Blocking it in your browser will sign you out and keep you from staying signed in. Browsing templates does not set this cookie."
+ ]
+ },
+ {
+ "heading": "Local drafts",
+ "paragraphs": [
+ "Drafts in progress may also be stored in your browser’s local storage, which is not a cookie. Clearing this site’s data removes those local drafts.",
+ "See the [Privacy Policy](/privacy) for what else we collect."
+ ]
+ }
+ ]
+}
diff --git a/messages/en/pages/privacy.json b/messages/en/pages/privacy.json
new file mode 100644
index 0000000..993e777
--- /dev/null
+++ b/messages/en/pages/privacy.json
@@ -0,0 +1,43 @@
+{
+ "_comment": "Privacy policy. Owns personal data, hosting, and how ranking works. API usage and licenses live on Terms. Grounded in MEDLab Cloudron + SES mail relay + facet-matrix recommendations. Not a substitute for university counsel review.",
+ "banner": {
+ "title": "Privacy Policy",
+ "description": "What we collect, where it is stored, and how template recommendations work.",
+ "author": "Media Economies Design Lab",
+ "date": "2026-08-15"
+ },
+ "updated": "Last updated August 15, 2026.",
+ "intro": [
+ "This page is about CommunityRule. The campus [Privacy Statement](https://www.colorado.edu/compliance/policies/privacy-statement) also applies. CommunityRule is a project of the [Media Economies Design Lab](https://www.colorado.edu/lab/medlab/) at the University of Colorado Boulder."
+ ],
+ "sections": [
+ {
+ "heading": "What we collect",
+ "paragraphs": [
+ "Your email, to sign you in, change your email, or invite a stakeholder. In-progress drafts in your browser and, after you save, on our servers. Files you attach to a Rule. Messages you send through the organizer form, which go to MEDLab by email. Page performance metrics (URL, timing, and browser), which are written to our server logs and are not tied to your account.",
+ "We do not sell this information or use it for advertising. As a public university project, some records may be subject to the Colorado Open Records Act."
+ ]
+ },
+ {
+ "heading": "Where it is stored",
+ "paragraphs": [
+ "Accounts, drafts, published Rules, uploads, and the recommendation table live on servers MEDLab operates. The app is not hosted on a third-party cloud app platform.",
+ "Sign-in and invite emails are sent through MEDLab’s mail system, which relays through Amazon SES to deliver them. Ordinary server logs may include the request URL and IP address."
+ ]
+ },
+ {
+ "heading": "Recommendations",
+ "paragraphs": [
+ "When you choose community details in the wizard (such as size or organization type), we use those answers to reorder templates and methods. The same ranking is available when those filters are sent to the templates API.",
+ "Ranking is a matching table we maintain. It is not an AI or machine-learning model, it does not train on your drafts, and we do not send your data to an AI service."
+ ]
+ },
+ {
+ "heading": "Your account",
+ "paragraphs": [
+ "You can sign out, delete a Rule you published, or delete your account from your profile. Deleting your account removes your login and draft. Published Rules are public and stay public unless you delete them first.",
+ "Questions: [medlab@colorado.edu](mailto:medlab@colorado.edu)."
+ ]
+ }
+ ]
+}
diff --git a/messages/en/pages/terms.json b/messages/en/pages/terms.json
new file mode 100644
index 0000000..0da0a73
--- /dev/null
+++ b/messages/en/pages/terms.json
@@ -0,0 +1,36 @@
+{
+ "_comment": "Terms of service. Owns licenses, API use, and acceptable use. Privacy owns data, hosting, and ranking. Not a substitute for university counsel review.",
+ "banner": {
+ "title": "Terms of Service",
+ "description": "How you may use CommunityRule, including licenses and the templates API.",
+ "author": "Media Economies Design Lab",
+ "date": "2026-08-15"
+ },
+ "updated": "Last updated August 15, 2026.",
+ "intro": [
+ "CommunityRule is a toolkit for drafting and publishing community governance documents (“Rules”). It is a project of the [Media Economies Design Lab](https://www.colorado.edu/lab/medlab/) at the University of Colorado Boulder. It is not legal advice."
+ ],
+ "sections": [
+ {
+ "heading": "Licenses",
+ "paragraphs": [
+ "Guides, template copy, and other materials we publish here, and Rules you publish, are licensed [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/). Do not publish material you cannot share under that license.",
+ "CommunityRule is open source. Application source code is [GPL-3.0 or later](https://www.gnu.org/licenses/gpl-3.0.html). You can read and fork it on [Gitea](https://git.medlab.host/CommunityRule/community-rule)."
+ ]
+ },
+ {
+ "heading": "Templates API",
+ "paragraphs": [
+ "The curated template library is available as JSON without signing in. [GET /api/templates](/api/templates) lists templates; GET /api/templates/{slug} returns one template. Reuse that copy under CC BY-SA.",
+ "Do not overload this hosted API. If you need your own copy, run the GPL-licensed source yourself."
+ ]
+ },
+ {
+ "heading": "Using the service",
+ "paragraphs": [
+ "Do not break, overload, or misuse the site or its APIs. We may suspend access to protect it or other people. The service is offered as-is for research and education; to the extent the law allows, MEDLab and the University of Colorado are not liable for your use of it or of a Rule created here.",
+ "Questions: [medlab@colorado.edu](mailto:medlab@colorado.edu)."
+ ]
+ }
+ ]
+}
diff --git a/tests/components/Footer.test.tsx b/tests/components/Footer.test.tsx
index e6812aa..eb5e9df 100644
--- a/tests/components/Footer.test.tsx
+++ b/tests/components/Footer.test.tsx
@@ -110,13 +110,16 @@ describe("Footer (behavioral tests)", () => {
expect(screen.queryByText(/all right reserved/i)).not.toBeInTheDocument();
});
- it("renders legal links", () => {
+ it("renders legal links to privacy, terms, and cookies pages", () => {
render();
expect(
- screen.getAllByRole("link", { name: "Privacy Policy" }).length,
- ).toBeGreaterThan(0);
+ screen.getAllByRole("link", { name: "Privacy Policy" })[0],
+ ).toHaveAttribute("href", "/privacy");
expect(
- screen.getAllByRole("link", { name: "Terms of Service" }).length,
- ).toBeGreaterThan(0);
+ screen.getAllByRole("link", { name: "Terms of Service" })[0],
+ ).toHaveAttribute("href", "/terms");
+ expect(
+ screen.getAllByRole("link", { name: "Cookies Settings" })[0],
+ ).toHaveAttribute("href", "/cookies");
});
});
diff --git a/tests/components/LoginForm.test.tsx b/tests/components/LoginForm.test.tsx
index fcdd154..aa8ccea 100644
--- a/tests/components/LoginForm.test.tsx
+++ b/tests/components/LoginForm.test.tsx
@@ -76,6 +76,16 @@ describe("LoginForm", () => {
).toBeInTheDocument();
});
+ it("links terms and privacy to the legal pages", () => {
+ renderLoginForm();
+ expect(
+ screen.getByRole("link", { name: "Terms of Service" }),
+ ).toHaveAttribute("href", "/terms");
+ expect(
+ screen.getByRole("link", { name: "Privacy Policy" }),
+ ).toHaveAttribute("href", "/privacy");
+ });
+
it("shows validation error when email is invalid", async () => {
const user = userEvent.setup();
renderLoginForm();
diff --git a/tests/pages/legal.test.tsx b/tests/pages/legal.test.tsx
new file mode 100644
index 0000000..24db6fc
--- /dev/null
+++ b/tests/pages/legal.test.tsx
@@ -0,0 +1,112 @@
+import { describe, expect, test, vi } from "vitest";
+import { screen } from "@testing-library/react";
+import { renderWithProviders as render } from "../utils/test-utils";
+import PrivacyPage from "../../app/(marketing)/privacy/page";
+import TermsPage from "../../app/(marketing)/terms/page";
+import CookiesPage from "../../app/(marketing)/cookies/page";
+import messages from "../../messages/en/index";
+
+vi.mock("../../app/components/sections/ContentBanner", () => ({
+ default: ({ post, variant }) => (
+
+ {post.frontmatter.title}
+ {post.frontmatter.description}
+
+ ),
+}));
+
+describe("legal document pages", () => {
+ test("privacy page covers hosting, recommendations, and campus privacy", () => {
+ render();
+ const page = messages.pages.privacy;
+
+ expect(screen.getByTestId("content-banner")).toHaveAttribute(
+ "data-variant",
+ "guide",
+ );
+ expect(
+ screen.getByRole("heading", { level: 1, name: page.banner.title }),
+ ).toBeInTheDocument();
+ expect(screen.getByText(page.banner.description)).toBeInTheDocument();
+ expect(screen.getByText(page.updated)).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { level: 2, name: "What we collect" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Where it is stored" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Recommendations" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Your account" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/servers MEDLab operates/)).toBeInTheDocument();
+ expect(screen.getByText(/Amazon SES/)).toBeInTheDocument();
+ expect(
+ screen.getByText(/not an AI or machine-learning model/),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("link", { name: "Media Economies Design Lab" }),
+ ).toHaveAttribute("href", "https://www.colorado.edu/lab/medlab/");
+ expect(
+ screen.getByRole("link", { name: "Privacy Statement" }),
+ ).toHaveAttribute(
+ "href",
+ "https://www.colorado.edu/compliance/policies/privacy-statement",
+ );
+ });
+
+ test("terms page covers licenses and the templates API, not ranking", () => {
+ render();
+ const page = messages.pages.terms;
+
+ expect(
+ screen.getByRole("heading", { level: 1, name: page.banner.title }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Licenses" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Templates API" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("heading", { name: "Recommendations" }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("link", { name: "CC BY-SA 4.0" }),
+ ).toHaveAttribute(
+ "href",
+ "https://creativecommons.org/licenses/by-sa/4.0/",
+ );
+ expect(
+ screen.getByRole("link", { name: "GPL-3.0 or later" }),
+ ).toHaveAttribute("href", "https://www.gnu.org/licenses/gpl-3.0.html");
+ expect(screen.getByRole("link", { name: "Gitea" })).toHaveAttribute(
+ "href",
+ "https://git.medlab.host/CommunityRule/community-rule",
+ );
+ expect(
+ screen.getByRole("link", { name: "GET /api/templates" }),
+ ).toHaveAttribute("href", "/api/templates");
+ });
+
+ test("cookies settings page explains there are no optional cookies", () => {
+ render();
+ const page = messages.pages.cookies;
+
+ expect(
+ screen.getByRole("heading", { level: 1, name: page.banner.title }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { level: 2, name: "The session cookie" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/There are no advertising or analytics cookies to turn off/),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/cr_session/)).toBeInTheDocument();
+ expect(
+ screen.getAllByRole("link", { name: "Privacy Policy" })[0],
+ ).toHaveAttribute("href", "/privacy");
+ });
+});
diff --git a/tests/unit/legalSyntheticPost.test.ts b/tests/unit/legalSyntheticPost.test.ts
new file mode 100644
index 0000000..f7f7391
--- /dev/null
+++ b/tests/unit/legalSyntheticPost.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildLegalSyntheticPost,
+ legalPathForSlug,
+} from "../../lib/legalSyntheticPost";
+
+describe("legalSyntheticPost", () => {
+ it("maps slugs to public paths", () => {
+ expect(legalPathForSlug("privacy")).toBe("/privacy");
+ expect(legalPathForSlug("terms")).toBe("/terms");
+ expect(legalPathForSlug("cookies")).toBe("/cookies");
+ });
+
+ it("builds a guide-shaped post from page messages", () => {
+ const post = buildLegalSyntheticPost("privacy", {
+ banner: {
+ title: "Privacy Policy",
+ description: "How we handle information.",
+ author: "Media Economies Design Lab",
+ date: "2026-08-15",
+ },
+ updated: "Last updated August 15, 2026.",
+ intro: ["Hello."],
+ sections: [{ heading: "What we collect", paragraphs: ["Email."] }],
+ });
+
+ expect(post.slug).toBe("__legal__:privacy");
+ expect(post.frontmatter.title).toBe("Privacy Policy");
+ expect(post.filePath).toBe("messages/en/pages/privacy.json");
+ });
+});