diff --git a/.gitignore b/.gitignore index 33c81de..067a097 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,11 @@ npm-cache/ # Marketing book cover (raster; see docs/guides/static-assets.md) !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) !tests/e2e/visual-regression.spec.ts-snapshots/ !tests/e2e/visual-regression.spec.ts-snapshots/*.png diff --git a/app/(app)/create/templates/page.tsx b/app/(app)/create/templates/page.tsx index 02dc4ad..0691fed 100644 --- a/app/(app)/create/templates/page.tsx +++ b/app/(app)/create/templates/page.tsx @@ -1,13 +1,19 @@ import type { Metadata } from "next"; import messages from "../../../../messages/en/index"; import { listRuleTemplatesFromDb } from "../../../../lib/server/ruleTemplates"; -import { routeMetadata } from "../../../../lib/siteMetadata"; +import { + routeMetadata, + templatesShareImages, +} from "../../../../lib/siteMetadata"; import { gridEntriesForFullCatalogWithFallback } from "../../../../lib/templates/templateGridPresentation"; import { CREATE_ROUTES } from "../utils/createFlowPaths"; import { CreateFlowTemplatesPageClient } from "./CreateFlowTemplatesPageClient"; export const metadata: Metadata = routeMetadata(CREATE_ROUTES.templatesPicker, { title: messages.metadata.templates.title, + openGraph: { + images: templatesShareImages(), + }, }); /** In-flow template catalog after review “Create from template”. */ diff --git a/app/(marketing)/blog/[slug]/page.tsx b/app/(marketing)/blog/[slug]/page.tsx index d76dd6c..de90f28 100644 --- a/app/(marketing)/blog/[slug]/page.tsx +++ b/app/(marketing)/blog/[slug]/page.tsx @@ -8,7 +8,10 @@ import { getRelatedBlogPosts, } from "../../../../lib/content"; import { logger } from "../../../../lib/logger"; -import { routeMetadata } from "../../../../lib/siteMetadata"; +import { + articleShareImages, + routeMetadata, +} from "../../../../lib/siteMetadata"; import messages from "../../../../messages/en/index"; import ContentBanner from "../../../components/sections/ContentBanner"; 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}`, { title: post.frontmatter.title, description: post.frontmatter.description, @@ -82,6 +90,7 @@ export async function generateMetadata({ authors: [post.frontmatter.author], url: `/blog/${slug}`, siteName: messages.metadata.siteName, + ...(articleImages ? { images: articleImages } : {}), }, twitter: { card: "summary_large_image", diff --git a/app/(marketing)/templates/page.tsx b/app/(marketing)/templates/page.tsx index f0dcc26..344ae01 100644 --- a/app/(marketing)/templates/page.tsx +++ b/app/(marketing)/templates/page.tsx @@ -1,12 +1,18 @@ import type { Metadata } from "next"; import messages from "../../../messages/en/index"; import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates"; -import { routeMetadata } from "../../../lib/siteMetadata"; +import { + routeMetadata, + templatesShareImages, +} from "../../../lib/siteMetadata"; import { gridEntriesForFullCatalogWithFallback } from "../../../lib/templates/templateGridPresentation"; import TemplatesPageClient from "./TemplatesPageClient"; export const metadata: Metadata = routeMetadata("/templates", { title: messages.metadata.templates.title, + openGraph: { + images: templatesShareImages(), + }, }); export default async function TemplatesPage() { diff --git a/app/layout.tsx b/app/layout.tsx index a4f5705..dca1792 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,7 +3,10 @@ import type { Metadata, Viewport } from "next"; import type { ReactNode } from "react"; import messages from "../messages/en/index"; import { ASSETS, getAssetPath } from "../lib/assetUtils"; -import { PRODUCTION_SITE_ORIGIN } from "../lib/siteMetadata"; +import { + defaultShareImages, + metadataBaseUrl, +} from "../lib/siteMetadata"; import "./globals.css"; // Session chrome (`ConditionalNavigation`) lives in `(marketing)`, `(app)`, and @@ -68,7 +71,7 @@ export const metadata: Metadata = { address: false, telephone: false, }, - metadataBase: new URL(PRODUCTION_SITE_ORIGIN), + metadataBase: metadataBaseUrl(), icons: { icon: [ { url: getAssetPath(ASSETS.LOGO), type: "image/svg+xml" }, @@ -95,15 +98,17 @@ export const metadata: Metadata = { openGraph: { title: homeMeta.title, description: homeMeta.description, - url: PRODUCTION_SITE_ORIGIN, + url: "/", siteName: messages.metadata.siteName, locale: "en_US", type: "website", + images: defaultShareImages(), }, twitter: { card: "summary_large_image", title: homeMeta.title, description: homeMeta.description, + images: defaultShareImages().map((image) => image.url), }, robots: { index: true, diff --git a/docs/guides/static-assets.md b/docs/guides/static-assets.md index 46313e1..5af6730 100644 --- a/docs/guides/static-assets.md +++ b/docs/guides/static-assets.md @@ -11,7 +11,7 @@ public/ icons/ # UI chrome (close, help) + FeatureGrid card glyphs logos/ # Brand + social lockups 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) shapes/ # Decorative ornaments (stats, quotes, unions, content shapes) 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. - **Blog art** stays under `public/content/blog/` with `{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 `app/layout.tsx` metadata) plus generated root binaries for Safari/iOS: `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/avatar-*.svg` (×3) | Avatar / ASSETS | **Done** — SVG | | `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/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()`) | diff --git a/lib/assetUtils.ts b/lib/assetUtils.ts index 54de30d..0a4425c 100644 --- a/lib/assetUtils.ts +++ b/lib/assetUtils.ts @@ -153,6 +153,11 @@ export function contentBlogHorizontalPath(slug: string): string { 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). */ export function contentBlogSectionPath(slug: string): string { return `/content/blog/${slug}-section.svg`; @@ -250,6 +255,10 @@ export const ASSETS = { // Marketing 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 UNION_XSM: "assets/shapes/union-xsm.svg", diff --git a/lib/siteMetadata.ts b/lib/siteMetadata.ts index bd18c1f..46ec32c 100644 --- a/lib/siteMetadata.ts +++ b/lib/siteMetadata.ts @@ -1,8 +1,18 @@ 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"; +/** Open Graph / Twitter large-card raster size. */ +const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const; + const INDEXABLE_HOSTS = new Set([ "communityrule.com", "www.communityrule.com", @@ -66,6 +76,95 @@ export function resolveRequestHost( 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 * `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) || "/"; } -/** 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( pathname: string, 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 { ...metadata, alternates: { ...metadata.alternates, - canonical: canonicalPath(pathname), + canonical: path, + }, + openGraph: mergedOpenGraph, + twitter: { + card: "summary_large_image", + images: derivedTwitterImages, + ...twitter, }, }; } diff --git a/messages/en/metadata.json b/messages/en/metadata.json index 90072ee..5b2d879 100644 --- a/messages/en/metadata.json +++ b/messages/en/metadata.json @@ -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).", "siteName": "CommunityRule", "titleTemplate": "%s — CommunityRule", + "shareImage": { + "defaultAlt": "CommunityRule — build operating manuals for successful communities", + "templatesAlt": "CommunityRule governance templates" + }, "home": { "title": "CommunityRule - Build operating manuals for successful communities", "description": "Help your community make important decisions in a way that reflects its unique values.", diff --git a/package.json b/package.json index dcb7d88..dbb5cd3 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "db:deploy": "prisma migrate deploy", "migrate:smoke": "./scripts/migrate-smoke-local.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": { "@mdx-js/loader": "^3.1.1", diff --git a/public/assets/marketing/og-image-templates.png b/public/assets/marketing/og-image-templates.png new file mode 100644 index 0000000..e3a75ed Binary files /dev/null and b/public/assets/marketing/og-image-templates.png differ diff --git a/public/assets/marketing/og-image.png b/public/assets/marketing/og-image.png new file mode 100644 index 0000000..0b894a0 Binary files /dev/null and b/public/assets/marketing/og-image.png differ diff --git a/public/content/blog/avoiding-burnout-sustainability-in-the-ruins-og.png b/public/content/blog/avoiding-burnout-sustainability-in-the-ruins-og.png new file mode 100644 index 0000000..eadd541 Binary files /dev/null and b/public/content/blog/avoiding-burnout-sustainability-in-the-ruins-og.png differ diff --git a/public/content/blog/digital-mediation-and-the-death-of-nuance-og.png b/public/content/blog/digital-mediation-and-the-death-of-nuance-og.png new file mode 100644 index 0000000..20a00a2 Binary files /dev/null and b/public/content/blog/digital-mediation-and-the-death-of-nuance-og.png differ diff --git a/public/content/blog/how-chaos-concentrates-control-og.png b/public/content/blog/how-chaos-concentrates-control-og.png new file mode 100644 index 0000000..98960e4 Binary files /dev/null and b/public/content/blog/how-chaos-concentrates-control-og.png differ diff --git a/public/content/blog/integrating-new-members-without-dilution-og.png b/public/content/blog/integrating-new-members-without-dilution-og.png new file mode 100644 index 0000000..1a35bae Binary files /dev/null and b/public/content/blog/integrating-new-members-without-dilution-og.png differ diff --git a/public/content/blog/knowledge-management-and-institutional-amnesia-og.png b/public/content/blog/knowledge-management-and-institutional-amnesia-og.png new file mode 100644 index 0000000..90716b1 Binary files /dev/null and b/public/content/blog/knowledge-management-and-institutional-amnesia-og.png differ diff --git a/public/content/blog/making-decisions-without-hierarchy-og.png b/public/content/blog/making-decisions-without-hierarchy-og.png new file mode 100644 index 0000000..a3c2a75 Binary files /dev/null and b/public/content/blog/making-decisions-without-hierarchy-og.png differ diff --git a/public/content/blog/operational-security-mutual-aid-og.png b/public/content/blog/operational-security-mutual-aid-og.png new file mode 100644 index 0000000..e2e3129 Binary files /dev/null and b/public/content/blog/operational-security-mutual-aid-og.png differ diff --git a/public/content/blog/resolving-active-conflicts-og.png b/public/content/blog/resolving-active-conflicts-og.png new file mode 100644 index 0000000..33b1fd3 Binary files /dev/null and b/public/content/blog/resolving-active-conflicts-og.png differ diff --git a/scripts/generate-og-images.mjs b/scripts/generate-og-images.mjs new file mode 100644 index 0000000..81348b1 --- /dev/null +++ b/scripts/generate-og-images.mjs @@ -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 = ` + + + + + + +`; + +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); +}); diff --git a/tests/unit/siteMetadata.test.ts b/tests/unit/siteMetadata.test.ts index 60605e4..750723c 100644 --- a/tests/unit/siteMetadata.test.ts +++ b/tests/unit/siteMetadata.test.ts @@ -1,13 +1,17 @@ import { afterEach, describe, expect, it } from "vitest"; import metadataMessages from "../../messages/en/metadata.json"; import { + articleShareImages, buildRobots, + metadataBaseUrl, NO_INDEX_ROBOTS, + originFromHost, PRODUCTION_SITE_ORIGIN, resolveRequestHost, robotsForPublishedRule, routeMetadata, sitemapUrl, + templatesShareImages, } from "../../lib/siteMetadata"; const ORIGINAL_SITE_INDEXING = process.env.SITE_INDEXING; @@ -27,6 +31,13 @@ afterEach(() => { }); 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", () => { 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", () => { - expect( - routeMetadata("/learn", { title: "Learn", description: "Copy" }), - ).toEqual({ + const meta = routeMetadata("/learn", { title: "Learn", 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", () => { it("disallows crawlers on staging and unknown hosts", () => { expect(buildRobots("staging.communityrule.info")).toEqual({