Give each route its own title and canonical, and add robots.txt plus a sitemap.
The root layout was stamping the homepage title and production root onto every page. Routes now supply a page segment through one em-dash template, canonicals follow the request path, and non-production hosts disallow crawlers. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getAllBlogPosts } from "../../lib/content";
|
||||
import { buildPublicSitemap } from "../../lib/publicSitemap";
|
||||
import { PRODUCTION_SITE_ORIGIN, sitemapUrl } from "../../lib/siteMetadata";
|
||||
import { USE_CASE_DETAIL_SLUGS } from "../../lib/useCaseSyntheticPost";
|
||||
|
||||
describe("buildPublicSitemap", () => {
|
||||
it("lists public marketing routes, blog posts, and use cases", () => {
|
||||
const urls = buildPublicSitemap().map((entry) => entry.url);
|
||||
|
||||
expect(urls).toContain(PRODUCTION_SITE_ORIGIN);
|
||||
expect(urls).toContain(sitemapUrl("/about"));
|
||||
expect(urls).toContain(sitemapUrl("/learn"));
|
||||
expect(urls).toContain(sitemapUrl("/templates"));
|
||||
expect(urls).toContain(sitemapUrl("/blog"));
|
||||
expect(urls).toContain(sitemapUrl("/use-cases"));
|
||||
expect(urls).toContain(sitemapUrl("/how-it-works"));
|
||||
expect(urls).toContain(sitemapUrl("/privacy"));
|
||||
expect(urls).toContain(sitemapUrl("/terms"));
|
||||
expect(urls).toContain(sitemapUrl("/cookies"));
|
||||
|
||||
for (const post of getAllBlogPosts()) {
|
||||
expect(urls).toContain(sitemapUrl(`/blog/${post.slug}`));
|
||||
}
|
||||
for (const slug of USE_CASE_DETAIL_SLUGS) {
|
||||
expect(urls).toContain(sitemapUrl(`/use-cases/${slug}`));
|
||||
expect(urls).toContain(sitemapUrl(`/use-cases/${slug}/rule`));
|
||||
}
|
||||
});
|
||||
|
||||
it("omits signed-in, admin, and API routes", () => {
|
||||
const urls = buildPublicSitemap().map((entry) => entry.url);
|
||||
expect(urls.some((url) => url.includes("/login"))).toBe(false);
|
||||
expect(urls.some((url) => url.includes("/profile"))).toBe(false);
|
||||
expect(urls.some((url) => url.includes("/create"))).toBe(false);
|
||||
expect(urls.some((url) => url.includes("/monitor"))).toBe(false);
|
||||
expect(urls.some((url) => url.includes("/api/"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import metadataMessages from "../../messages/en/metadata.json";
|
||||
import {
|
||||
buildRobots,
|
||||
PRODUCTION_SITE_ORIGIN,
|
||||
resolveRequestHost,
|
||||
routeMetadata,
|
||||
sitemapUrl,
|
||||
} from "../../lib/siteMetadata";
|
||||
|
||||
const ORIGINAL_SITE_INDEXING = process.env.SITE_INDEXING;
|
||||
const ORIGINAL_APP_ORIGIN = process.env.CLOUDRON_APP_ORIGIN;
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_SITE_INDEXING === undefined) {
|
||||
delete process.env.SITE_INDEXING;
|
||||
} else {
|
||||
process.env.SITE_INDEXING = ORIGINAL_SITE_INDEXING;
|
||||
}
|
||||
if (ORIGINAL_APP_ORIGIN === undefined) {
|
||||
delete process.env.CLOUDRON_APP_ORIGIN;
|
||||
} else {
|
||||
process.env.CLOUDRON_APP_ORIGIN = ORIGINAL_APP_ORIGIN;
|
||||
}
|
||||
});
|
||||
|
||||
describe("routeMetadata", () => {
|
||||
it("sets a path canonical, stripping trailing slashes and query strings", () => {
|
||||
expect(routeMetadata("/about").alternates?.canonical).toBe("/about");
|
||||
expect(routeMetadata("/about/").alternates?.canonical).toBe("/about");
|
||||
expect(routeMetadata("about").alternates?.canonical).toBe("/about");
|
||||
expect(routeMetadata("/").alternates?.canonical).toBe("/");
|
||||
expect(routeMetadata("/blog/post?x=1#top").alternates?.canonical).toBe(
|
||||
"/blog/post",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps caller metadata while overriding a parent canonical", () => {
|
||||
expect(
|
||||
routeMetadata("/learn", { title: "Learn", description: "Copy" }),
|
||||
).toEqual({
|
||||
title: "Learn",
|
||||
description: "Copy",
|
||||
alternates: { canonical: "/learn" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sitemapUrl", () => {
|
||||
it("resolves paths against the production origin", () => {
|
||||
expect(sitemapUrl("/")).toBe(PRODUCTION_SITE_ORIGIN);
|
||||
expect(sitemapUrl("/about")).toBe(`${PRODUCTION_SITE_ORIGIN}/about`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRequestHost", () => {
|
||||
it("prefers x-forwarded-host over Host", () => {
|
||||
expect(
|
||||
resolveRequestHost("staging.communityrule.info", "0.0.0.0:3000"),
|
||||
).toBe("staging.communityrule.info");
|
||||
});
|
||||
|
||||
it("uses CLOUDRON_APP_ORIGIN when Host is the standalone bind address", () => {
|
||||
process.env.CLOUDRON_APP_ORIGIN = "https://staging.communityrule.info";
|
||||
expect(resolveRequestHost(null, "0.0.0.0:3000")).toBe(
|
||||
"staging.communityrule.info",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRobots", () => {
|
||||
it("disallows crawlers on staging and unknown hosts", () => {
|
||||
expect(buildRobots("staging.communityrule.info")).toEqual({
|
||||
rules: { userAgent: "*", disallow: "/" },
|
||||
});
|
||||
expect(buildRobots("localhost:3000")).toEqual({
|
||||
rules: { userAgent: "*", disallow: "/" },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows production hosts and advertises the sitemap", () => {
|
||||
expect(buildRobots("communityrule.info")).toEqual({
|
||||
rules: { userAgent: "*", allow: "/" },
|
||||
sitemap: `${PRODUCTION_SITE_ORIGIN}/sitemap.xml`,
|
||||
});
|
||||
expect(buildRobots("communityrule.com")).toEqual({
|
||||
rules: { userAgent: "*", allow: "/" },
|
||||
sitemap: `${PRODUCTION_SITE_ORIGIN}/sitemap.xml`,
|
||||
});
|
||||
});
|
||||
|
||||
it("honors SITE_INDEXING overrides", () => {
|
||||
process.env.SITE_INDEXING = "false";
|
||||
expect(buildRobots("communityrule.info").rules).toEqual({
|
||||
userAgent: "*",
|
||||
disallow: "/",
|
||||
});
|
||||
|
||||
process.env.SITE_INDEXING = "true";
|
||||
expect(buildRobots("staging.communityrule.info").sitemap).toBe(
|
||||
`${PRODUCTION_SITE_ORIGIN}/sitemap.xml`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("page title segments", () => {
|
||||
it("uses a shared em-dash template and does not bake a site suffix into page titles", () => {
|
||||
expect(metadataMessages.titleTemplate).toBe("%s — CommunityRule");
|
||||
|
||||
const suffixes = [
|
||||
" — CommunityRule",
|
||||
" · CommunityRule",
|
||||
" - CommunityRule",
|
||||
];
|
||||
const titles: string[] = [];
|
||||
const walk = (value: unknown) => {
|
||||
if (value && typeof value === "object") {
|
||||
for (const [key, child] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
if (key === "title" && typeof child === "string") {
|
||||
titles.push(child);
|
||||
} else {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(metadataMessages);
|
||||
|
||||
for (const title of titles) {
|
||||
if (title === metadataMessages.home.title) continue;
|
||||
for (const suffix of suffixes) {
|
||||
expect(title.endsWith(suffix)).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user