Give Create rule a ghost hover, swap the header portraits, and add sharing preview images #84

Merged
an.di merged 2 commits from adilallo/fix/CR-215-header-images into main 2026-09-29 21:45:53 +00:00
22 changed files with 340 additions and 16 deletions
Showing only changes of commit 751f2e9343 - Show all commits

No files matched your search

+5
View File
@@ -50,6 +50,11 @@ npm-cache/
# Marketing book cover (raster; see docs/guides/static-assets.md) # Marketing book cover (raster; see docs/guides/static-assets.md)
!public/assets/marketing/community-rules-cover.png !public/assets/marketing/community-rules-cover.png
# Open Graph 1200×630 share images (`npm run generate:og-images`)
!public/assets/marketing/og-image.png
!public/assets/marketing/og-image-templates.png
!public/content/blog/*-og.png
# Visual regression snapshots (allow these) # Visual regression snapshots (allow these)
!tests/e2e/visual-regression.spec.ts-snapshots/ !tests/e2e/visual-regression.spec.ts-snapshots/
!tests/e2e/visual-regression.spec.ts-snapshots/*.png !tests/e2e/visual-regression.spec.ts-snapshots/*.png
+7 -1
View File
@@ -1,13 +1,19 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import messages from "../../../../messages/en/index"; import messages from "../../../../messages/en/index";
import { listRuleTemplatesFromDb } from "../../../../lib/server/ruleTemplates"; import { listRuleTemplatesFromDb } from "../../../../lib/server/ruleTemplates";
import { routeMetadata } from "../../../../lib/siteMetadata"; import {
routeMetadata,
templatesShareImages,
} from "../../../../lib/siteMetadata";
import { gridEntriesForFullCatalogWithFallback } from "../../../../lib/templates/templateGridPresentation"; import { gridEntriesForFullCatalogWithFallback } from "../../../../lib/templates/templateGridPresentation";
import { CREATE_ROUTES } from "../utils/createFlowPaths"; import { CREATE_ROUTES } from "../utils/createFlowPaths";
import { CreateFlowTemplatesPageClient } from "./CreateFlowTemplatesPageClient"; import { CreateFlowTemplatesPageClient } from "./CreateFlowTemplatesPageClient";
export const metadata: Metadata = routeMetadata(CREATE_ROUTES.templatesPicker, { export const metadata: Metadata = routeMetadata(CREATE_ROUTES.templatesPicker, {
title: messages.metadata.templates.title, title: messages.metadata.templates.title,
openGraph: {
images: templatesShareImages(),
},
}); });
/** In-flow template catalog after review “Create from template”. */ /** In-flow template catalog after review “Create from template”. */
+10 -1
View File
@@ -8,7 +8,10 @@ import {
getRelatedBlogPosts, getRelatedBlogPosts,
} from "../../../../lib/content"; } from "../../../../lib/content";
import { logger } from "../../../../lib/logger"; import { logger } from "../../../../lib/logger";
import { routeMetadata } from "../../../../lib/siteMetadata"; import {
articleShareImages,
routeMetadata,
} from "../../../../lib/siteMetadata";
import messages from "../../../../messages/en/index"; import messages from "../../../../messages/en/index";
import ContentBanner from "../../../components/sections/ContentBanner"; import ContentBanner from "../../../components/sections/ContentBanner";
import AskOrganizer from "../../../components/sections/AskOrganizer"; import AskOrganizer from "../../../components/sections/AskOrganizer";
@@ -70,6 +73,11 @@ export async function generateMetadata({
}); });
} }
const articleImages = articleShareImages(
slug,
post.frontmatter.title,
);
return routeMetadata(`/blog/${slug}`, { return routeMetadata(`/blog/${slug}`, {
title: post.frontmatter.title, title: post.frontmatter.title,
description: post.frontmatter.description, description: post.frontmatter.description,
@@ -82,6 +90,7 @@ export async function generateMetadata({
authors: [post.frontmatter.author], authors: [post.frontmatter.author],
url: `/blog/${slug}`, url: `/blog/${slug}`,
siteName: messages.metadata.siteName, siteName: messages.metadata.siteName,
...(articleImages ? { images: articleImages } : {}),
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
+7 -1
View File
@@ -1,12 +1,18 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates"; import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
import { routeMetadata } from "../../../lib/siteMetadata"; import {
routeMetadata,
templatesShareImages,
} from "../../../lib/siteMetadata";
import { gridEntriesForFullCatalogWithFallback } from "../../../lib/templates/templateGridPresentation"; import { gridEntriesForFullCatalogWithFallback } from "../../../lib/templates/templateGridPresentation";
import TemplatesPageClient from "./TemplatesPageClient"; 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,
openGraph: {
images: templatesShareImages(),
},
}); });
export default async function TemplatesPage() { export default async function TemplatesPage() {
+8 -3
View File
@@ -3,7 +3,10 @@ import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import messages from "../messages/en/index"; import messages from "../messages/en/index";
import { ASSETS, getAssetPath } from "../lib/assetUtils"; import { ASSETS, getAssetPath } from "../lib/assetUtils";
import { PRODUCTION_SITE_ORIGIN } from "../lib/siteMetadata"; import {
defaultShareImages,
metadataBaseUrl,
} from "../lib/siteMetadata";
import "./globals.css"; import "./globals.css";
// Session chrome (`ConditionalNavigation`) lives in `(marketing)`, `(app)`, and // Session chrome (`ConditionalNavigation`) lives in `(marketing)`, `(app)`, and
@@ -68,7 +71,7 @@ export const metadata: Metadata = {
address: false, address: false,
telephone: false, telephone: false,
}, },
metadataBase: new URL(PRODUCTION_SITE_ORIGIN), metadataBase: metadataBaseUrl(),
icons: { icons: {
icon: [ icon: [
{ url: getAssetPath(ASSETS.LOGO), type: "image/svg+xml" }, { url: getAssetPath(ASSETS.LOGO), type: "image/svg+xml" },
@@ -95,15 +98,17 @@ export const metadata: Metadata = {
openGraph: { openGraph: {
title: homeMeta.title, title: homeMeta.title,
description: homeMeta.description, description: homeMeta.description,
url: PRODUCTION_SITE_ORIGIN, url: "/",
siteName: messages.metadata.siteName, siteName: messages.metadata.siteName,
locale: "en_US", locale: "en_US",
type: "website", type: "website",
images: defaultShareImages(),
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: homeMeta.title, title: homeMeta.title,
description: homeMeta.description, description: homeMeta.description,
images: defaultShareImages().map((image) => image.url),
}, },
robots: { robots: {
index: true, index: true,
+6 -2
View File
@@ -11,7 +11,7 @@ public/
icons/ # UI chrome (close, help) + FeatureGrid card glyphs icons/ # UI chrome (close, help) + FeatureGrid card glyphs
logos/ # Brand + social lockups logos/ # Brand + social lockups
partners/ # Logo wall partner SVGs (kebab org slug) partners/ # Logo wall partner SVGs (kebab org slug)
marketing/ # Hero, feature panels, section numbers, avatars, banners, book cover marketing/ # Hero, OG share images, feature panels, section numbers, avatars, banners, book cover
case-study/ # CaseStudy card SVG artwork (canonical) case-study/ # CaseStudy card SVG artwork (canonical)
shapes/ # Decorative ornaments (stats, quotes, unions, content shapes) shapes/ # Decorative ornaments (stats, quotes, unions, content shapes)
vector/ # Use-case group marks vector/ # Use-case group marks
@@ -40,7 +40,8 @@ Do not duplicate the same glyph in both places unless migrating between systems.
`/assets/...` strings in components. `/assets/...` strings in components.
- **Blog art** stays under `public/content/blog/` with - **Blog art** stays under `public/content/blog/` with
`{slug}-vertical.svg`, `-horizontal.svg`, `-section.svg`, `-tag.svg`, `{slug}-vertical.svg`, `-horizontal.svg`, `-section.svg`, `-tag.svg`,
and `-ornament-right.png` / `-ornament-left.png` (body gutter shapes). `-og.png` (1200×630 share image), and `-ornament-right.png` /
`-ornament-left.png` (body gutter shapes).
- **Favicon** reuses `assets/logos/community-rule.svg` (`ASSETS.LOGO` in - **Favicon** reuses `assets/logos/community-rule.svg` (`ASSETS.LOGO` in
`app/layout.tsx` metadata) plus generated root binaries for Safari/iOS: `app/layout.tsx` metadata) plus generated root binaries for Safari/iOS:
`public/favicon.ico`, `favicon-16x16.png`, `favicon-32x32.png`, and `public/favicon.ico`, `favicon-16x16.png`, `favicon-32x32.png`, and
@@ -66,6 +67,9 @@ stage. Raster → SVG conversion is tracked in
| `marketing/section-number-*.svg` (×3) | SectionNumber | **Done** — SVG | | `marketing/section-number-*.svg` (×3) | SectionNumber | **Done** — SVG |
| `marketing/avatar-*.svg` (×3) | Avatar / ASSETS | **Done** — SVG | | `marketing/avatar-*.svg` (×3) | Avatar / ASSETS | **Done** — SVG |
| `marketing/hero-image.png` | HeroBanner | **Design review** — likely keep raster | | `marketing/hero-image.png` | HeroBanner | **Design review** — likely keep raster |
| `marketing/og-image.png` | Default `og:image` / `twitter:image` | **Done** — 1200×630 hero crop (`ASSETS.OG_IMAGE`; `npm run generate:og-images`) |
| `marketing/og-image-templates.png` | Templates `og:image` | **Done** — 1200×630 (`ASSETS.OG_IMAGE_TEMPLATES`) |
| `content/blog/*-og.png` | Article `og:image` | **Done** — 1200×630 from `{slug}-section.svg` (`contentBlogOgPath`) |
| `marketing/communityrules-cover.svg` | About / Book | **Done** — Structure Before Crisis cover (`ASSETS.STRUCTURE_BEFORE_CRISIS_COVER`; Figma **22137:891197**) | | `marketing/communityrules-cover.svg` | About / Book | **Done** — Structure Before Crisis cover (`ASSETS.STRUCTURE_BEFORE_CRISIS_COVER`; Figma **22137:891197**) |
| `marketing/community-rules-cover.png` | About / Book | **Done** — Community Rules (2021) cover (`ASSETS.COMMUNITY_RULES_COVER`; gitignore exception) | | `marketing/community-rules-cover.png` | About / Book | **Done** — Community Rules (2021) cover (`ASSETS.COMMUNITY_RULES_COVER`; gitignore exception) |
| `marketing/governance-booklet.pdf` | About / Book | **Done** — Community Rules (2021) PDF (`governanceBookletPath()`) | | `marketing/governance-booklet.pdf` | About / Book | **Done** — Community Rules (2021) PDF (`governanceBookletPath()`) |
+9
View File
@@ -153,6 +153,11 @@ export function contentBlogHorizontalPath(slug: string): string {
return `/content/blog/${slug}-horizontal.svg`; return `/content/blog/${slug}-horizontal.svg`;
} }
/** 1200×630 social preview rasterized from the article's section banner. */
export function contentBlogOgPath(slug: string): string {
return `/content/blog/${slug}-og.png`;
}
/** Wide banner background for ContentBanner at md+ (Figma Section, 1920×672). */ /** Wide banner background for ContentBanner at md+ (Figma Section, 1920×672). */
export function contentBlogSectionPath(slug: string): string { export function contentBlogSectionPath(slug: string): string {
return `/content/blog/${slug}-section.svg`; return `/content/blog/${slug}-section.svg`;
@@ -250,6 +255,10 @@ export const ASSETS = {
// Marketing // Marketing
HERO_IMAGE: "assets/marketing/hero-image.png", HERO_IMAGE: "assets/marketing/hero-image.png",
/** Default 1200×630 Open Graph image (hero crop). */
OG_IMAGE: "assets/marketing/og-image.png",
/** Templates-index 1200×630 Open Graph image. */
OG_IMAGE_TEMPLATES: "assets/marketing/og-image-templates.png",
// Top nav union ornaments // Top nav union ornaments
UNION_XSM: "assets/shapes/union-xsm.svg", UNION_XSM: "assets/shapes/union-xsm.svg",
+133 -3
View File
@@ -1,8 +1,18 @@
import type { Metadata, MetadataRoute } from "next"; import type { Metadata, MetadataRoute } from "next";
import {
ASSETS,
CONTENT_CATALOG_SLUG_ORDER,
contentBlogOgPath,
getAssetPath,
} from "./assetUtils";
import metadataMessages from "../messages/en/metadata.json";
/** Production origin used for `metadataBase`, canonical resolution, and sitemap URLs. */ /** Production origin used for sitemap URLs and as the metadata-host fallback. */
export const PRODUCTION_SITE_ORIGIN = "https://communityrule.com"; export const PRODUCTION_SITE_ORIGIN = "https://communityrule.com";
/** Open Graph / Twitter large-card raster size. */
const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const;
const INDEXABLE_HOSTS = new Set([ const INDEXABLE_HOSTS = new Set([
"communityrule.com", "communityrule.com",
"www.communityrule.com", "www.communityrule.com",
@@ -66,6 +76,95 @@ export function resolveRequestHost(
return host; return host;
} }
/**
* Absolute origin for canonical / `og:url` resolution. Uses the public
* hostname of this instance (`CLOUDRON_APP_ORIGIN`, the same source
* `resolveRequestHost` uses when the process binds to a loopback address)
* so staging shares do not advertise production.
*/
export function originFromHost(host: string | null | undefined): string {
const fromEnv = (): string | undefined => {
const origin = process.env.CLOUDRON_APP_ORIGIN?.trim();
if (!origin) return undefined;
try {
return new URL(origin).origin;
} catch {
return undefined;
}
};
if (!host) {
return fromEnv() ?? PRODUCTION_SITE_ORIGIN;
}
const hostname = hostnameFromHostHeader(host);
if (!hostname || isLoopbackHost(hostname)) {
if (hostname === "localhost") {
const port = host.includes(":")
? host.slice(host.indexOf(":") + 1).split("/")[0]
: "";
return port ? `http://localhost:${port}` : "http://localhost:3000";
}
return fromEnv() ?? PRODUCTION_SITE_ORIGIN;
}
return `https://${hostname}`;
}
export function metadataBaseUrl(): URL {
return new URL(originFromHost(null));
}
type ShareImage = {
url: string;
width: number;
height: number;
alt: string;
};
function shareImage(url: string, alt: string): ShareImage {
return {
url,
width: OG_IMAGE_SIZE.width,
height: OG_IMAGE_SIZE.height,
alt,
};
}
export function defaultShareImages(): ShareImage[] {
return [
shareImage(
getAssetPath(ASSETS.OG_IMAGE),
metadataMessages.shareImage.defaultAlt,
),
];
}
export function templatesShareImages(): ShareImage[] {
return [
shareImage(
getAssetPath(ASSETS.OG_IMAGE_TEMPLATES),
metadataMessages.shareImage.templatesAlt,
),
];
}
export function articleShareImages(
slug: string,
alt: string,
): ShareImage[] | undefined {
if (
!(CONTENT_CATALOG_SLUG_ORDER as readonly string[]).includes(slug)
) {
return undefined;
}
return [shareImage(contentBlogOgPath(slug), alt)];
}
function twitterImageUrls(images: ShareImage[]): string[] {
return images.map((image) => image.url);
}
/** /**
* Staging, restore-drill hosts, and local dev are not indexed. Override with * Staging, restore-drill hosts, and local dev are not indexed. Override with
* `SITE_INDEXING=true` / `false` when the hostname check is not enough. * `SITE_INDEXING=true` / `false` when the hostname check is not enough.
@@ -97,16 +196,47 @@ function withoutQueryOrHash(pathname: string): string {
return pathname.slice(0, end) || "/"; return pathname.slice(0, end) || "/";
} }
/** Merge `alternates.canonical` from the page path (resolved via `metadataBase`). */ /**
* Merge path canonical + `og:url` (resolved against `metadataBase`) and a
* default 1200×630 share image. Callers override `openGraph.images` /
* `twitter.images` for templates and articles.
*/
export function routeMetadata( export function routeMetadata(
pathname: string, pathname: string,
metadata: Metadata = {}, metadata: Metadata = {},
): Metadata { ): Metadata {
const path = canonicalPath(pathname);
const defaultImages = defaultShareImages();
const openGraph = metadata.openGraph ?? {};
const twitter = metadata.twitter ?? {};
const mergedOpenGraph = {
images: defaultImages,
...openGraph,
url: openGraph.url ?? path,
};
const ogImages = mergedOpenGraph.images;
const derivedTwitterImages = Array.isArray(ogImages)
? ogImages
.map((image) => {
if (typeof image === "string") return image;
if (image instanceof URL) return image.href;
const url = image.url;
return typeof url === "string" ? url : url.href;
})
.filter((url) => url.length > 0)
: twitterImageUrls(defaultImages);
return { return {
...metadata, ...metadata,
alternates: { alternates: {
...metadata.alternates, ...metadata.alternates,
canonical: canonicalPath(pathname), canonical: path,
},
openGraph: mergedOpenGraph,
twitter: {
card: "summary_large_image",
images: derivedTwitterImages,
...twitter,
}, },
}; };
} }
+4
View File
@@ -2,6 +2,10 @@
"_comment": "Page metadata. `title` is the page segment; the root layout template appends the site name. Home `title` is the full default document title (not passed through the template).", "_comment": "Page metadata. `title` is the page segment; the root layout template appends the site name. Home `title` is the full default document title (not passed through the template).",
"siteName": "CommunityRule", "siteName": "CommunityRule",
"titleTemplate": "%s — CommunityRule", "titleTemplate": "%s — CommunityRule",
"shareImage": {
"defaultAlt": "CommunityRule — build operating manuals for successful communities",
"templatesAlt": "CommunityRule governance templates"
},
"home": { "home": {
"title": "CommunityRule - Build operating manuals for successful communities", "title": "CommunityRule - Build operating manuals for successful communities",
"description": "Help your community make important decisions in a way that reflects its unique values.", "description": "Help your community make important decisions in a way that reflects its unique values.",
+2 -1
View File
@@ -44,7 +44,8 @@
"db:deploy": "prisma migrate deploy", "db:deploy": "prisma migrate deploy",
"migrate:smoke": "./scripts/migrate-smoke-local.sh", "migrate:smoke": "./scripts/migrate-smoke-local.sh",
"docker:release": "./scripts/docker-release.sh", "docker:release": "./scripts/docker-release.sh",
"generate:favicons": "node scripts/generate-favicons.mjs" "generate:favicons": "node scripts/generate-favicons.mjs",
"generate:og-images": "node scripts/generate-og-images.mjs"
}, },
"dependencies": { "dependencies": {
"@mdx-js/loader": "^3.1.1", "@mdx-js/loader": "^3.1.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
/**
* Raster 1200×630 Open Graph images from the marketing hero, a templates
* lockup, and each catalog article's section banner.
*
* Run: npm run generate:og-images
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const PUBLIC = path.join(ROOT, "public");
const OG_WIDTH = 1200;
const OG_HEIGHT = 630;
const CATALOG_SLUGS = [
"resolving-active-conflicts",
"operational-security-mutual-aid",
"making-decisions-without-hierarchy",
"integrating-new-members-without-dilution",
"avoiding-burnout-sustainability-in-the-ruins",
"how-chaos-concentrates-control",
"digital-mediation-and-the-death-of-nuance",
"knowledge-management-and-institutional-amnesia",
];
const TEMPLATES_LOCKUP = `<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
<rect width="1200" height="630" fill="#000000"/>
<g transform="translate(80 195) scale(8.5)">
<path d="M27.5948 13.9998L0.549194 0.477051L7.31736 13.9998L0.549194 27.5226L27.5948 13.9998ZM14.0855 11.1905C14.6377 11.1938 15.1766 11.3603 15.6343 11.6689C16.092 11.9776 16.4481 12.4146 16.6577 12.925C16.8673 13.4353 16.9211 13.9962 16.8123 14.5371C16.7036 15.0779 16.437 15.5746 16.0463 15.9644C15.6556 16.3543 15.1582 16.6199 14.6166 16.728C14.0751 16.836 13.5137 16.7816 13.0031 16.5715C12.4924 16.3615 12.0554 16.0053 11.747 15.5477C11.4386 15.09 11.2726 14.5515 11.27 13.9998C11.2682 13.6301 11.3398 13.2637 11.4807 12.9218C11.6216 12.58 11.829 12.2694 12.0908 12.0081C12.3527 11.7468 12.6638 11.54 13.0062 11.3997C13.3486 11.2594 13.7154 11.1882 14.0855 11.1905Z" fill="#FFFDD2"/>
</g>
</svg>
`;
async function coverPng(input, output, options = {}) {
await sharp(input, options)
.resize(OG_WIDTH, OG_HEIGHT, { fit: "cover", position: "centre" })
.png()
.toFile(output);
}
async function main() {
const defaultOut = path.join(PUBLIC, "assets/marketing/og-image.png");
const templatesOut = path.join(PUBLIC, "assets/marketing/og-image-templates.png");
await coverPng(
path.join(PUBLIC, "assets/marketing/hero-image.png"),
defaultOut,
);
console.log("Wrote", path.relative(ROOT, defaultOut));
await sharp(Buffer.from(TEMPLATES_LOCKUP))
.png()
.toFile(templatesOut);
console.log("Wrote", path.relative(ROOT, templatesOut));
for (const slug of CATALOG_SLUGS) {
const svgPath = path.join(PUBLIC, "content/blog", `${slug}-section.svg`);
const outPath = path.join(PUBLIC, "content/blog", `${slug}-og.png`);
await coverPng(svgPath, outPath, { density: 300 });
console.log("Wrote", path.relative(ROOT, outPath));
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+79 -4
View File
@@ -1,13 +1,17 @@
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 {
articleShareImages,
buildRobots, buildRobots,
metadataBaseUrl,
NO_INDEX_ROBOTS, NO_INDEX_ROBOTS,
originFromHost,
PRODUCTION_SITE_ORIGIN, PRODUCTION_SITE_ORIGIN,
resolveRequestHost, resolveRequestHost,
robotsForPublishedRule, robotsForPublishedRule,
routeMetadata, routeMetadata,
sitemapUrl, sitemapUrl,
templatesShareImages,
} from "../../lib/siteMetadata"; } from "../../lib/siteMetadata";
const ORIGINAL_SITE_INDEXING = process.env.SITE_INDEXING; const ORIGINAL_SITE_INDEXING = process.env.SITE_INDEXING;
@@ -27,6 +31,13 @@ afterEach(() => {
}); });
describe("routeMetadata", () => { describe("routeMetadata", () => {
const defaultImage = {
url: "/assets/marketing/og-image.png",
width: 1200,
height: 630,
alt: metadataMessages.shareImage.defaultAlt,
};
it("sets a path canonical, stripping trailing slashes and query strings", () => { 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("/about/").alternates?.canonical).toBe("/about");
@@ -37,14 +48,54 @@ describe("routeMetadata", () => {
); );
}); });
it("sets og:url from the same path so metadataBase can add the host", () => {
expect(routeMetadata("/about").openGraph?.url).toBe("/about");
expect(routeMetadata("/").openGraph?.url).toBe("/");
});
it("ships a 1200×630 default share image on og and twitter", () => {
const meta = routeMetadata("/learn");
expect(meta.openGraph?.images).toEqual([defaultImage]);
expect(meta.twitter?.images).toEqual([defaultImage.url]);
});
it("keeps caller metadata while overriding a parent canonical", () => { it("keeps caller metadata while overriding a parent canonical", () => {
expect( const meta = routeMetadata("/learn", {
routeMetadata("/learn", { title: "Learn", description: "Copy" }),
).toEqual({
title: "Learn", title: "Learn",
description: "Copy", description: "Copy",
alternates: { canonical: "/learn" },
}); });
expect(meta.title).toBe("Learn");
expect(meta.description).toBe("Copy");
expect(meta.alternates?.canonical).toBe("/learn");
expect(meta.openGraph?.url).toBe("/learn");
});
it("lets templates and articles replace the default share image", () => {
const templates = templatesShareImages();
const templateMeta = routeMetadata("/templates", {
openGraph: { images: templates },
});
expect(templateMeta.openGraph?.images).toEqual(templates);
expect(templateMeta.twitter?.images).toEqual([templates[0]?.url]);
const article = articleShareImages(
"how-chaos-concentrates-control",
"How Chaos Concentrates Control",
);
const articleMeta = routeMetadata("/blog/how-chaos-concentrates-control", {
openGraph: { images: article },
});
expect(articleMeta.openGraph?.images).toEqual([
{
url: "/content/blog/how-chaos-concentrates-control-og.png",
width: 1200,
height: 630,
alt: "How Chaos Concentrates Control",
},
]);
expect(articleMeta.twitter?.images).toEqual([
"/content/blog/how-chaos-concentrates-control-og.png",
]);
}); });
}); });
@@ -70,6 +121,30 @@ describe("resolveRequestHost", () => {
}); });
}); });
describe("originFromHost", () => {
it("uses CLOUDRON_APP_ORIGIN when no host is given", () => {
process.env.CLOUDRON_APP_ORIGIN = "https://staging.communityrule.info";
expect(originFromHost(null)).toBe("https://staging.communityrule.info");
expect(metadataBaseUrl().origin).toBe("https://staging.communityrule.info");
});
it("falls back to production when neither host nor app origin is set", () => {
delete process.env.CLOUDRON_APP_ORIGIN;
expect(originFromHost(null)).toBe(PRODUCTION_SITE_ORIGIN);
});
it("builds https origins from public hostnames", () => {
expect(originFromHost("staging.communityrule.info")).toBe(
"https://staging.communityrule.info",
);
expect(originFromHost("communityrule.com")).toBe(PRODUCTION_SITE_ORIGIN);
});
it("keeps http for localhost so local previews resolve", () => {
expect(originFromHost("localhost:3000")).toBe("http://localhost:3000");
});
});
describe("buildRobots", () => { describe("buildRobots", () => {
it("disallows crawlers on staging and unknown hosts", () => { it("disallows crawlers on staging and unknown hosts", () => {
expect(buildRobots("staging.communityrule.info")).toEqual({ expect(buildRobots("staging.communityrule.info")).toEqual({