diff --git a/.gitignore b/.gitignore index 8d228cc01..c48e090b9 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ redirects.json # Generated at build-time (used by SSW.People) public/people-latest-rules.json + +# OG card preview output (pnpm verify:og) +og-preview/ diff --git a/__tests__/lib/authorImage.test.ts b/__tests__/lib/authorImage.test.ts new file mode 100644 index 000000000..8aa5ddecb --- /dev/null +++ b/__tests__/lib/authorImage.test.ts @@ -0,0 +1,35 @@ +import { githubAvatarUrl, profileImageUrl } from "@/lib/authorImage"; + +describe("profileImageUrl", () => { + it("title-cases the slug into a profile path", () => { + expect(profileImageUrl("https://www.ssw.com.au/people/adam-cogan")).toBe( + "https://raw.githubusercontent.com/SSWConsulting/SSW.People.Profiles/main/Adam-Cogan/Images/Adam-Cogan-Profile.jpg" + ); + expect(profileImageUrl("https://www.ssw.com.au/people/camilla-rosa-silva")).toContain("/Camilla-Rosa-Silva/Images/Camilla-Rosa-Silva-Profile.jpg"); + }); + + it("ignores trailing slashes, query strings and fragments", () => { + const expected = profileImageUrl("https://www.ssw.com.au/people/adam-cogan"); + expect(profileImageUrl("https://www.ssw.com.au/people/adam-cogan/")).toBe(expected); + expect(profileImageUrl("https://www.ssw.com.au/people/adam-cogan?utm=x")).toBe(expected); + expect(profileImageUrl("https://www.ssw.com.au/people/adam-cogan#bio")).toBe(expected); + }); + + it("only claims ssw.com.au people URLs", () => { + expect(profileImageUrl("https://github.com/some-people/repo")).toBeNull(); + expect(profileImageUrl("https://example.com/people/adam-cogan")).toBeNull(); + expect(profileImageUrl(undefined)).toBeNull(); + expect(profileImageUrl("")).toBeNull(); + }); +}); + +describe("githubAvatarUrl", () => { + it("builds an avatar URL from a github profile link", () => { + expect(githubAvatarUrl("https://github.com/octocat")).toBe("https://avatars.githubusercontent.com/octocat"); + }); + + it("returns null for anything else", () => { + expect(githubAvatarUrl("https://www.ssw.com.au/people/adam-cogan")).toBeNull(); + expect(githubAvatarUrl(undefined)).toBeNull(); + }); +}); diff --git a/__tests__/lib/ogCard.preview.test.tsx b/__tests__/lib/ogCard.preview.test.tsx new file mode 100644 index 000000000..6c2899bad --- /dev/null +++ b/__tests__/lib/ogCard.preview.test.tsx @@ -0,0 +1,65 @@ +/** + * @jest-environment node + * + * Renders the real cards to PNGs so they can be eyeballed. Hits the network for author + * photos, so it is opt-in and skipped by default: + * + * pnpm verify:og -> ./og-preview + * OG_PREVIEW_DIR=~/tmp pnpm verify:og + */ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { buildOgCard } from "@/lib/og/card"; +import { ogImageResponse } from "@/lib/og/response"; + +const outDir = process.env.OG_PREVIEW_DIR; +const P = (title: string, slug: string) => ({ title, url: `https://www.ssw.com.au/people/${slug}` }); +const TOTAL = 3802; + +const cases: Record[0]> = { + "1-single-author": { + title: "Do you know when to change the email subject (or appointment subject)?", + authors: [P("Adam Cogan", "adam-cogan")], + totalRules: TOTAL, + }, + // Igor's photo is a PNG named .jpg - regression case for the magic-byte sniffing + "2-many-authors": { + title: "Do you use the best tools for database schema changes?", + authors: [ + P("Adam Cogan", "adam-cogan"), + P("Igor Goldobin", "igor-goldobin"), + P("Adam Stephensen", "adam-stephensen"), + P("Thiago Passos", "thiago-passos"), + P("Brendan Richards", "brendan-richards"), + ], + totalRules: TOTAL, + }, + "3-no-authors": { title: "Do you know the rules to better unit tests?", authors: [], totalRules: TOTAL }, + "4-longest-content": { + title: "Do you create a Sprint Forecast email 📩? (aka Functionality to be developed per Sprint Planning)", + authors: [P("Christian Morford-Waite", "christian-morford-waite"), P("Sebastien Boissiere", "sebastien-boissiere"), P("Kosta Madorsky", "kosta-madorsky")], + totalRules: TOTAL, + }, + // Neither author has a profile photo - must fall back, not fail the image + "5-missing-photos": { + title: "Do you have a rule authored by someone with no profile photo?", + authors: [P("Toby Goodman", "toby-goodman"), P("Ryan Tee", "ryan-tee")], + totalRules: TOTAL, + }, + "6-homepage": { title: "Secret Ingredients to Quality Software", totalRules: TOTAL, isHub: true }, + "7-category": { title: "Rules to Better Interfaces (Forms)", totalRules: TOTAL, isHub: true }, + "8-category-longest": { title: "Rules to Better User Acceptance Tests (UAT) for Bug Management", totalRules: TOTAL, isHub: true }, +}; + +(outDir ? describe : describe.skip)("OG card preview", () => { + jest.setTimeout(60_000); + + it.each(Object.keys(cases))("renders %s", async (name) => { + await mkdir(outDir as string, { recursive: true }); + const res = await ogImageResponse(await buildOgCard(cases[name])); + const png = Buffer.from(await res.arrayBuffer()); + expect(png.subarray(0, 8)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + await writeFile(path.join(outDir as string, `${name}.png`), png); + }); +}); diff --git a/__tests__/lib/ogCard.test.tsx b/__tests__/lib/ogCard.test.tsx new file mode 100644 index 000000000..f79e703c3 --- /dev/null +++ b/__tests__/lib/ogCard.test.tsx @@ -0,0 +1,77 @@ +/** + * @jest-environment node + */ +import { buildOgCard } from "@/lib/og/card"; + +jest.mock("@/lib/og/images", () => ({ + loadPolygon: jest.fn(async () => "data:image/png;base64,POLYGON"), + loadAvatar: jest.fn(async (author: { url?: string }) => `data:image/jpeg;base64,${author.url}`), +})); + +/** Flattens the element tree to the text nodes Satori would draw. */ +const texts = (node: any): string[] => { + if (node == null || typeof node === "boolean") return []; + if (typeof node === "string" || typeof node === "number") return [String(node)]; + if (Array.isArray(node)) return node.flatMap(texts); + return texts(node.props?.children); +}; + +const find = (node: any, predicate: (n: any) => boolean): any[] => { + if (node == null || typeof node !== "object") return []; + if (Array.isArray(node)) return node.flatMap((n) => find(n, predicate)); + const self = predicate(node) ? [node] : []; + return [...self, ...find(node.props?.children, predicate)]; +}; + +// Avatar and ExtraChip are component elements, so their output is not in the tree - +// assert on the props they were handed instead. +const componentsNamed = (node: any, name: string) => find(node, (n) => typeof n.type === "function" && n.type.name === name); + +const author = (title: string) => ({ title, url: `https://www.ssw.com.au/people/${title.toLowerCase().replace(/ /g, "-")}` }); + +describe("buildOgCard", () => { + it("summarises contributors rather than listing names", async () => { + const card = await buildOgCard({ title: "A rule", authors: [author("Adam Cogan"), author("Igor Goldobin"), author("Kosta Madorsky")] }); + expect(texts(card)).toContain("3 contributors"); + expect(texts(card)).not.toContain("Adam Cogan"); + }); + + it("singularises a lone contributor", async () => { + const card = await buildOgCard({ title: "A rule", authors: [author("Adam Cogan")] }); + expect(texts(card)).toContain("1 contributor"); + }); + + it("caps faces at two and puts the remainder in a chip", async () => { + const card = await buildOgCard({ title: "A rule", authors: [author("A B"), author("C D"), author("E F"), author("G H")] }); + expect(componentsNamed(card, "Avatar")).toHaveLength(2); + expect(componentsNamed(card, "ExtraChip")[0]?.props.count).toBe(2); + expect(texts(card)).toContain("4 contributors"); + }); + + // Regression: `extra &&
` rendered a literal "0" on single-author cards + it("renders no chip when every contributor has a face", async () => { + const card = await buildOgCard({ title: "A rule", authors: [author("A B"), author("C D")] }); + expect(componentsNamed(card, "ExtraChip")).toHaveLength(0); + expect(texts(card)).toContain("2 contributors"); + }); + + it("leaves the byline empty rather than falling back to the site URL", async () => { + const card = await buildOgCard({ title: "A category", authors: [] }); + expect(texts(card)).not.toContain("0 contributors"); + expect(componentsNamed(card, "Avatar")).toHaveLength(0); + }); + + it("always shows the site URL, and the rule total only when given", async () => { + expect(texts(await buildOgCard({ title: "x", totalRules: 3802 }))).toEqual(expect.arrayContaining(["3,802 rules", "ssw.com.au/rules"])); + expect(texts(await buildOgCard({ title: "x" }))).toContain("ssw.com.au/rules"); + expect(texts(await buildOgCard({ title: "x" })).join(" ")).not.toContain("rules |"); + }); + + it("gives hub pages a larger title than rules", async () => { + const titleSize = async (isHub: boolean) => { + const card = await buildOgCard({ title: "T", isHub }); + return find(card, (n) => n.props?.style?.lineClamp === 3)[0]?.props?.style?.fontSize; + }; + expect(await titleSize(true)).toBeGreaterThan(await titleSize(false)); + }); +}); diff --git a/__tests__/lib/ogImages.test.ts b/__tests__/lib/ogImages.test.ts new file mode 100644 index 000000000..5ee7fc2bf --- /dev/null +++ b/__tests__/lib/ogImages.test.ts @@ -0,0 +1,29 @@ +import { sniffImageType } from "@/lib/og/images"; + +const pad = (header: number[]) => Buffer.concat([Buffer.from(header), Buffer.alloc(16)]); + +describe("sniffImageType", () => { + it("identifies formats from magic bytes, not the file extension", () => { + expect(sniffImageType(pad([0xff, 0xd8, 0xff, 0xe0]))).toBe("image/jpeg"); + expect(sniffImageType(pad([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))).toBe("image/png"); + expect(sniffImageType(pad([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]))).toBe("image/gif"); + }); + + it("identifies webp, which needs both the RIFF and WEBP markers", () => { + const webp = Buffer.concat([Buffer.from("RIFF"), Buffer.alloc(4), Buffer.from("WEBP"), Buffer.alloc(8)]); + expect(sniffImageType(webp)).toBe("image/webp"); + const riffOnly = Buffer.concat([Buffer.from("RIFF"), Buffer.alloc(4), Buffer.from("AVI "), Buffer.alloc(8)]); + expect(sniffImageType(riffOnly)).toBeNull(); + }); + + // The case this exists for: profile photos served as image/jpeg that are really PNGs + it("reports PNG bytes as PNG regardless of a .jpg name or jpeg content-type", () => { + expect(sniffImageType(pad([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))).not.toBe("image/jpeg"); + }); + + it("returns null for unrecognised or truncated input", () => { + expect(sniffImageType(Buffer.from(""))).toBeNull(); + expect(sniffImageType(Buffer.from([0xff, 0xd8]))).toBeNull(); + expect(sniffImageType(Buffer.alloc(0))).toBeNull(); + }); +}); diff --git a/__tests__/lib/ogTarget.test.ts b/__tests__/lib/ogTarget.test.ts new file mode 100644 index 000000000..8502a6b07 --- /dev/null +++ b/__tests__/lib/ogTarget.test.ts @@ -0,0 +1,67 @@ +/** + * @jest-environment node + */ +import { resolveOgTarget } from "@/lib/og/target"; +import client from "@/tina/__generated__/client"; + +jest.mock("next/cache", () => ({ unstable_cache: (fn: unknown) => fn })); +jest.mock("@/tina/__generated__/client", () => ({ __esModule: true, default: { queries: { mainCategoryQuery: jest.fn(), ruleDataBasic: jest.fn() } } })); + +const queries = (client as any).queries; + +const categories = (...filenames: string[]) => ({ + data: { category: { index: [{ top_category: { index: filenames.map((f) => ({ category: { _sys: { filename: f }, title: `Title of ${f}` } })) } }] } }, +}); + +const rule = (title: string, authors: { title: string; url: string }[] = []) => ({ data: { rule: { title, authors } } }); + +beforeEach(() => jest.resetAllMocks()); + +describe("resolveOgTarget", () => { + it("resolves a rule with its authors", async () => { + queries.mainCategoryQuery.mockResolvedValue(categories("some-category")); + queries.ruleDataBasic.mockResolvedValue(rule("Do you do the thing?", [{ title: "Adam Cogan", url: "x" }])); + + await expect(resolveOgTarget("do-the-thing")).resolves.toEqual({ + kind: "rule", + title: "Do you do the thing?", + authors: [{ title: "Adam Cogan", url: "x" }], + }); + }); + + it("resolves a category without querying for a rule", async () => { + queries.mainCategoryQuery.mockResolvedValue(categories("rules-to-better-x")); + + await expect(resolveOgTarget("rules-to-better-x")).resolves.toEqual({ kind: "category", title: "Title of rules-to-better-x" }); + expect(queries.ruleDataBasic).not.toHaveBeenCalled(); + }); + + it("falls back to the generic card on a genuine miss", async () => { + queries.mainCategoryQuery.mockResolvedValue(categories("other")); + queries.ruleDataBasic.mockRejectedValue(new Error("Unable to find record")); + + await expect(resolveOgTarget("no-such-page")).resolves.toEqual({ kind: "generic" }); + }); + + // page.tsx serves unresolved filenames, so a card must never be worse than plain + it("falls back to the generic card during an outage rather than failing", async () => { + queries.mainCategoryQuery.mockRejectedValue(new Error("ECONNREFUSED")); + queries.ruleDataBasic.mockRejectedValue(new Error("ECONNREFUSED")); + + await expect(resolveOgTarget("a-real-rule")).resolves.toEqual({ kind: "generic" }); + }); + + it("still returns the rule when only the category lookup is down", async () => { + queries.mainCategoryQuery.mockRejectedValue(new Error("ECONNREFUSED")); + queries.ruleDataBasic.mockResolvedValue(rule("A rule")); + + await expect(resolveOgTarget("a-real-rule")).resolves.toMatchObject({ kind: "rule", title: "A rule" }); + }); + + it("treats a rule with no title as not a rule", async () => { + queries.mainCategoryQuery.mockResolvedValue(categories("other")); + queries.ruleDataBasic.mockResolvedValue({ data: { rule: {} } }); + + await expect(resolveOgTarget("untitled")).resolves.toEqual({ kind: "generic" }); + }); +}); diff --git a/__tests__/lib/pageMetadata.test.ts b/__tests__/lib/pageMetadata.test.ts new file mode 100644 index 000000000..f934a2b36 --- /dev/null +++ b/__tests__/lib/pageMetadata.test.ts @@ -0,0 +1,41 @@ +import { pageMetadata } from "@/lib/pageMetadata"; +import { siteDescription, siteUrl } from "@/site-config"; + +describe("pageMetadata", () => { + // Next's merge iterates the source's own keys and assigns `metadata[key] ?? null`, + // so an explicit `description: undefined` NULLS the layout's rather than inheriting. + it("always emits a description, never undefined", () => { + const meta = pageMetadata({ title: "Search" }); + expect(meta.description).toBe(siteDescription); + expect(meta.openGraph?.description).toBe(siteDescription); + expect(meta.twitter?.description).toBe(siteDescription); + }); + + it("prefers a supplied description", () => { + const meta = pageMetadata({ title: "A rule", description: "Specific." }); + expect(meta.description).toBe("Specific."); + expect(meta.openGraph?.description).toBe("Specific."); + }); + + it("keeps og:title and twitter:title in step with the page title", () => { + const meta = pageMetadata({ title: "Latest Rules | SSW.Rules", path: "latest-rules" }); + expect(meta.openGraph?.title).toBe("Latest Rules | SSW.Rules"); + expect(meta.twitter?.title).toBe("Latest Rules | SSW.Rules"); + }); + + it("builds the canonical from the path, and the site root without one", () => { + expect(pageMetadata({ title: "x", path: "archived" }).alternates?.canonical).toBe(`${siteUrl}/archived`); + expect(pageMetadata({ title: "x" }).alternates?.canonical).toBe(`${siteUrl}/`); + }); + + // The opengraph-image routes supply the card; Next only merges it in when the + // page's own openGraph has no `images` key. + it("never sets openGraph.images", () => { + expect(pageMetadata({ title: "x" }).openGraph).not.toHaveProperty("images"); + }); + + it("only sets robots when asked", () => { + expect(pageMetadata({ title: "x" })).not.toHaveProperty("robots"); + expect(pageMetadata({ title: "x", robots: { index: false } }).robots).toEqual({ index: false }); + }); +}); diff --git a/app/(home)/categories/opengraph-image.tsx b/app/(home)/categories/opengraph-image.tsx new file mode 100644 index 000000000..d02a7f402 --- /dev/null +++ b/app/(home)/categories/opengraph-image.tsx @@ -0,0 +1,4 @@ +export { alt, contentType, default, size } from "@/lib/og/siteCard"; + +// Literal, not an expression - Next extracts segment config statically from the AST +export const revalidate = 86400; // 24 hours diff --git a/app/(home)/categories/page.tsx b/app/(home)/categories/page.tsx index 1c04a82fc..c0ca21479 100644 --- a/app/(home)/categories/page.tsx +++ b/app/(home)/categories/page.tsx @@ -2,8 +2,8 @@ import Link from "next/link"; import TinaHomepageWrapper from "@/app/(home)/TinaHomepageWrapper"; import CategoryActionButtons from "@/components/CategoryActionButtons"; import { Card } from "@/components/ui/card"; +import { pageMetadata } from "@/lib/pageMetadata"; import { fetchCategoryRuleCounts, fetchHomepageData, fetchLatestRules, fetchRuleCount } from "@/lib/services/rules"; -import { siteUrl } from "@/site-config"; import client from "@/tina/__generated__/client"; export const revalidate = 21600; // 6 hours @@ -47,16 +47,14 @@ export default async function CategoriesPage() {
    - {topCategory.index - ?.filter(isVisibleCategory) - ?.map((item: any, subIndex: number) => ( -
  1. -
    - {item.category.title} - {categoryRuleCounts[item.category._sys.filename] || 0} -
    -
  2. - ))} + {topCategory.index?.filter(isVisibleCategory)?.map((item: any, subIndex: number) => ( +
  3. +
    + {item.category.title} + {categoryRuleCounts[item.category._sys.filename] || 0} +
    +
  4. + ))}
))} @@ -67,10 +65,5 @@ export default async function CategoriesPage() { } export async function generateMetadata() { - return { - title: "SSW.Rules | Categories", - alternates: { - canonical: `${siteUrl}/categories`, - }, - }; + return pageMetadata({ title: "SSW.Rules | Categories", path: "categories" }); } diff --git a/app/(home)/opengraph-image.tsx b/app/(home)/opengraph-image.tsx new file mode 100644 index 000000000..d02a7f402 --- /dev/null +++ b/app/(home)/opengraph-image.tsx @@ -0,0 +1,4 @@ +export { alt, contentType, default, size } from "@/lib/og/siteCard"; + +// Literal, not an expression - Next extracts segment config statically from the AST +export const revalidate = 86400; // 24 hours diff --git a/app/(home)/page.tsx b/app/(home)/page.tsx index 6c83db4fc..7954f232d 100644 --- a/app/(home)/page.tsx +++ b/app/(home)/page.tsx @@ -1,8 +1,9 @@ import { redirect } from "next/navigation"; import { TinaActivityWrapper } from "@/app/(home)/TinaActivityWrapper"; +import { pageMetadata } from "@/lib/pageMetadata"; import { fetchDiscussionData } from "@/lib/services/github/discussions.service"; import { fetchActivityLatestRules, fetchHomepageData, fetchRuleCount } from "@/lib/services/rules"; -import { siteUrl } from "@/site-config"; +import { homepageTitle, siteTitle } from "@/site-config"; export const revalidate = 21600; // 6 hours @@ -31,10 +32,5 @@ export default async function Home() { } export async function generateMetadata() { - return { - title: "SSW.Rules | Secret Ingredients for Quality Software (Open Source on GitHub)", - alternates: { - canonical: `${siteUrl}/`, - }, - }; + return pageMetadata({ title: `${siteTitle} | ${homepageTitle}` }); } diff --git a/app/[filename]/opengraph-image.tsx b/app/[filename]/opengraph-image.tsx new file mode 100644 index 000000000..3b04456c9 --- /dev/null +++ b/app/[filename]/opengraph-image.tsx @@ -0,0 +1,27 @@ +import { buildOgCard, OG_CONTENT_TYPE, OG_SIZE } from "@/lib/og/card"; +import { ogImageResponse } from "@/lib/og/response"; +import { resolveOgTarget } from "@/lib/og/target"; +import { fetchRuleCount } from "@/lib/services/rules"; +import { tagline } from "@/site-config"; + +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; +export const alt = "SSW Rules"; + +// Must be a literal - Next statically extracts segment config from the AST and fails +// the build on an expression it cannot evaluate. +export const revalidate = 86400; // 24 hours + +export default async function OpengraphImage({ params }: { params: Promise<{ filename: string }> }) { + const { filename } = await params; + const [target, totalRules] = await Promise.all([resolveOgTarget(filename), fetchRuleCount()]); + + return ogImageResponse( + await buildOgCard({ + title: target.kind === "generic" ? tagline : target.title, + authors: target.kind === "rule" ? target.authors : [], + totalRules, + isHub: target.kind !== "rule", + }) + ); +} diff --git a/app/[filename]/page.tsx b/app/[filename]/page.tsx index c114d2347..6dc1b018d 100644 --- a/app/[filename]/page.tsx +++ b/app/[filename]/page.tsx @@ -2,7 +2,7 @@ import React from "react"; import categoryTitleIndex from "@/category-uri-title-map.json"; import { Section } from "@/components/layout/section"; import { extractBodyPreview } from "@/lib/bodyUtils"; -import { siteUrl } from "@/site-config"; +import { pageMetadata } from "@/lib/pageMetadata"; import client from "@/tina/__generated__/client"; import { CategoryWithRulesQueryDocument } from "@/tina/__generated__/types"; import ClientFallbackPage from "./ClientFallbackPage"; @@ -338,42 +338,26 @@ export async function generateMetadata({ params }: { params: Promise<{ filename: const category = await getCategoryData(filename); if (category?.data?.category && category.data.category.__typename === "CategoryCategory") { const categoryData = category.data.category as any; - const metadata: any = { + return pageMetadata({ title: `${categoryData.title} | SSW.Rules`, - alternates: { - canonical: `${siteUrl}/${filename}`, - }, - }; - - if (categoryData.seoDescription) { - metadata.description = categoryData.seoDescription; - } - - return metadata; + description: categoryData.seoDescription || undefined, + path: filename, + }); } const rule = await getRuleData(filename); if (rule?.data?.rule?.title) { - const metadata: any = { + return pageMetadata({ title: `${rule.data.rule.title} | SSW.Rules`, - alternates: { - canonical: `${siteUrl}/${filename}`, - }, - }; - - metadata.description = rule.data.rule.seoDescription || extractBodyPreview(rule.data.rule.body) || undefined; - - if (rule.data.rule.isArchived) { - metadata.robots = { index: false, follow: true }; - } - - return metadata; + description: rule.data.rule.seoDescription || extractBodyPreview(rule.data.rule.body) || undefined, + path: filename, + type: "article", + robots: rule.data.rule.isArchived ? { index: false, follow: true } : undefined, + }); } } catch (error) { console.error("Error generating metadata:", error); } - return { - title: "SSW.Rules", - }; + return pageMetadata({ title: "SSW.Rules", path: filename }); } diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts index 7ab7bf489..eefcd28fe 100644 --- a/app/api/revalidate/route.ts +++ b/app/api/revalidate/route.ts @@ -25,6 +25,7 @@ export async function POST(req: Request) { } const routesToRevalidate = new Set(); + let shouldRevalidateCategoryTitles = false; let shouldRevalidateLatestRules = false; let shouldRevalidateRuleCount = false; @@ -36,6 +37,9 @@ export async function POST(req: Request) { const slug = changedPath.replace("public/uploads/rules/", "").replace("/rule.mdx", "").replace(/\/+$/, ""); if (slug) { routesToRevalidate.add(`/${slug}`); + // Separate route from the page, so it needs purging explicitly or the card + // keeps a stale title / author list until its 24h revalidate expires + routesToRevalidate.add(`/${slug}/opengraph-image`); } // If change type is add then we also need to revalidate the /api/rules route if (eventType === TINA_CONTENT_CHANGE_TYPE.Added) { @@ -50,6 +54,8 @@ export async function POST(req: Request) { if (changedPath.startsWith("categories/")) { const rel = changedPath.replace("categories/", ""); routesToRevalidate.add("/"); + // Card titles come from a cached map, so purging the image path is not enough + shouldRevalidateCategoryTitles = true; // Ignore main/top index files like categories/index.mdx or categories//index.mdx if (!rel.endsWith("/index.mdx") && rel.endsWith(".mdx")) { const filename = rel @@ -58,6 +64,7 @@ export async function POST(req: Request) { .pop(); if (filename) { routesToRevalidate.add(`/${filename}`); + routesToRevalidate.add(`/${filename}/opengraph-image`); } } // If change type is add then we also need to revalidate the /api/categories route @@ -77,6 +84,12 @@ export async function POST(req: Request) { revalidateTag("rule-count", { expire: 0 }); } + if (shouldRevalidateCategoryTitles) { + revalidateTag("category-rule-data", { expire: 0 }); + // The root card carries the site-wide rule total + revalidatePath("/opengraph-image"); + } + for (const route of routesToRevalidate) { revalidatePath(route); } diff --git a/app/archived/opengraph-image.tsx b/app/archived/opengraph-image.tsx new file mode 100644 index 000000000..d02a7f402 --- /dev/null +++ b/app/archived/opengraph-image.tsx @@ -0,0 +1,4 @@ +export { alt, contentType, default, size } from "@/lib/og/siteCard"; + +// Literal, not an expression - Next extracts segment config statically from the AST +export const revalidate = 86400; // 24 hours diff --git a/app/archived/page.tsx b/app/archived/page.tsx index 24a0c382d..d9d853fd9 100644 --- a/app/archived/page.tsx +++ b/app/archived/page.tsx @@ -1,8 +1,8 @@ import React from "react"; import Breadcrumbs from "@/components/Breadcrumbs"; import { Section } from "@/components/layout/section"; +import { pageMetadata } from "@/lib/pageMetadata"; import { fetchAllArchivedRules, fetchHomepageData, fetchLatestRules } from "@/lib/services/rules"; -import { siteUrl } from "@/site-config"; import ArchivedClientPage from "./client-page"; export const revalidate = 300; @@ -21,11 +21,5 @@ export default async function ArchivedPage() { } export async function generateMetadata() { - return { - title: "Archived Rules | SSW Rules", - description: "Rules that have been archived", - alternates: { - canonical: `${siteUrl}/archived`, - }, - }; + return pageMetadata({ title: "Archived Rules | SSW Rules", description: "Rules that have been archived", path: "archived" }); } diff --git a/app/latest-rules/opengraph-image.tsx b/app/latest-rules/opengraph-image.tsx new file mode 100644 index 000000000..d02a7f402 --- /dev/null +++ b/app/latest-rules/opengraph-image.tsx @@ -0,0 +1,4 @@ +export { alt, contentType, default, size } from "@/lib/og/siteCard"; + +// Literal, not an expression - Next extracts segment config statically from the AST +export const revalidate = 86400; // 24 hours diff --git a/app/latest-rules/page.tsx b/app/latest-rules/page.tsx index 4d3aac732..cc103f93b 100644 --- a/app/latest-rules/page.tsx +++ b/app/latest-rules/page.tsx @@ -1,6 +1,6 @@ import { Section } from "@/components/layout/section"; +import { pageMetadata } from "@/lib/pageMetadata"; import { fetchLatestRules, fetchRuleCount } from "@/lib/services/rules"; -import { siteUrl } from "@/site-config"; import LatestRuleClientPage from "./client-page"; export const revalidate = 300; @@ -39,10 +39,5 @@ export default async function LatestRulePage({ searchParams }: LatestRulePagePro } export async function generateMetadata() { - return { - title: "Latest Rules | SSW.Rules", - alternates: { - canonical: `${siteUrl}/latest-rules`, - }, - }; + return pageMetadata({ title: "Latest Rules | SSW.Rules", path: "latest-rules" }); } diff --git a/app/layout.tsx b/app/layout.tsx index caa097c9a..284343c52 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,6 +8,8 @@ import "@/styles.css"; import UserClientProvider from "@/components/auth/UserClientProvider"; import AppInsightsProvider from "@/components/providers/AppInsightsProvider"; import { TailwindIndicator } from "@/components/ui/breakpoint-indicator"; +import { pageMetadata } from "@/lib/pageMetadata"; +import { homepageTitle, siteDescription, siteTitle, siteUrl } from "@/site-config"; const fontSans = FontSans({ subsets: ["latin"], @@ -25,10 +27,20 @@ const lato = Lato({ weight: "400", }); +const defaultTitle = `${siteTitle} | ${homepageTitle}`; + +// Canonical is dropped on purpose - every page sets its own via pageMetadata, and a +// page without one (e.g. /preview, not-found) should not inherit the home page's. +const { alternates: _pageCanonical, ...siteDefaults } = pageMetadata({ title: defaultTitle, description: siteDescription }); + export const metadata: Metadata = { - title: "SSW.Rules | Secret Ingredients to Quality Software (Open Source on GitHub)", - description: - "Secret Ingredients to Quality Software | SSW Rules provides best practices for developing secure, reliable, and efficient .NET, Azure, CRM, Angular, React, Dynamics, and AI applications. Learn more today!", + ...siteDefaults, + // Must include the /rules basePath. Next emits the image path WITHOUT basePath + // (e.g. /reply-done/opengraph-image) and resolveUrl joins metadataBase.pathname onto + // it, so an origin-only base drops /rules and the og:image 404s. Verified on a + // pr-deploy slot: with the origin it emitted www.ssw.com.au/reply-done/opengraph-image + // while the card actually lives at /rules/reply-done/opengraph-image. + metadataBase: new URL(siteUrl), }; const jsonLd = [ diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx new file mode 100644 index 000000000..d02a7f402 --- /dev/null +++ b/app/opengraph-image.tsx @@ -0,0 +1,4 @@ +export { alt, contentType, default, size } from "@/lib/og/siteCard"; + +// Literal, not an expression - Next extracts segment config statically from the AST +export const revalidate = 86400; // 24 hours diff --git a/app/orphaned/opengraph-image.tsx b/app/orphaned/opengraph-image.tsx new file mode 100644 index 000000000..d02a7f402 --- /dev/null +++ b/app/orphaned/opengraph-image.tsx @@ -0,0 +1,4 @@ +export { alt, contentType, default, size } from "@/lib/og/siteCard"; + +// Literal, not an expression - Next extracts segment config statically from the AST +export const revalidate = 86400; // 24 hours diff --git a/app/orphaned/page.tsx b/app/orphaned/page.tsx index 3da8685fe..a2d5399f4 100644 --- a/app/orphaned/page.tsx +++ b/app/orphaned/page.tsx @@ -1,10 +1,10 @@ import React from "react"; import Breadcrumbs from "@/components/Breadcrumbs"; import { Section } from "@/components/layout/section"; +import { pageMetadata } from "@/lib/pageMetadata"; import { OrphanedRulesData } from "@/models/OrphanedRule"; import { Rule } from "@/models/Rule"; import orphanedRulesData from "@/orphaned_rules.json"; -import { siteUrl } from "@/site-config"; import client from "@/tina/__generated__/client"; import OrphanedClientPage from "./client-page"; @@ -54,11 +54,5 @@ export default async function OrphanedPage() { } export async function generateMetadata() { - return { - title: "Orphaned Rules | SSW Rules", - description: "Rules that have no parent category", - alternates: { - canonical: `${siteUrl}/orphaned`, - }, - }; + return pageMetadata({ title: "Orphaned Rules | SSW Rules", description: "Rules that have no parent category", path: "orphaned" }); } diff --git a/app/preview/opengraph-image.tsx b/app/preview/opengraph-image.tsx new file mode 100644 index 000000000..d02a7f402 --- /dev/null +++ b/app/preview/opengraph-image.tsx @@ -0,0 +1,4 @@ +export { alt, contentType, default, size } from "@/lib/og/siteCard"; + +// Literal, not an expression - Next extracts segment config statically from the AST +export const revalidate = 86400; // 24 hours diff --git a/app/search/opengraph-image.tsx b/app/search/opengraph-image.tsx new file mode 100644 index 000000000..d02a7f402 --- /dev/null +++ b/app/search/opengraph-image.tsx @@ -0,0 +1,4 @@ +export { alt, contentType, default, size } from "@/lib/og/siteCard"; + +// Literal, not an expression - Next extracts segment config statically from the AST +export const revalidate = 86400; // 24 hours diff --git a/app/search/page.tsx b/app/search/page.tsx index 17fcf7871..daceac9e0 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -1,7 +1,8 @@ import { Suspense } from "react"; import { Section } from "@/components/layout/section"; +import { pageMetadata } from "@/lib/pageMetadata"; import { fetchLatestRules, fetchRuleCount } from "@/lib/services/rules"; -import { siteUrl } from "@/site-config"; +import { homepageTitle, siteTitle } from "@/site-config"; import RulesSearchClientPage from "./client-page"; export const revalidate = 300; @@ -19,10 +20,5 @@ export default async function RulesSearchPage() { } export async function generateMetadata() { - return { - title: "SSW.Rules | Secret Ingredients for Quality Software (Open Source on GitHub)", - alternates: { - canonical: `${siteUrl}/search`, - }, - }; + return pageMetadata({ title: `${siteTitle} | ${homepageTitle}`, path: "search" }); } diff --git a/app/user/opengraph-image.tsx b/app/user/opengraph-image.tsx new file mode 100644 index 000000000..d02a7f402 --- /dev/null +++ b/app/user/opengraph-image.tsx @@ -0,0 +1,4 @@ +export { alt, contentType, default, size } from "@/lib/og/siteCard"; + +// Literal, not an expression - Next extracts segment config statically from the AST +export const revalidate = 86400; // 24 hours diff --git a/app/user/page.tsx b/app/user/page.tsx index b17631cc6..c4590c01c 100644 --- a/app/user/page.tsx +++ b/app/user/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from "react"; import { Section } from "@/components/layout/section"; -import { siteUrl } from "@/site-config"; +import { pageMetadata } from "@/lib/pageMetadata"; import UserRulesClientPage from "./client-page"; export const revalidate = 300; @@ -16,10 +16,5 @@ export default async function UserRulesPage() { } export async function generateMetadata() { - return { - title: "Profile | SSW.Rules", - alternates: { - canonical: `${siteUrl}/user`, - }, - }; + return pageMetadata({ title: "Profile | SSW.Rules", path: "user" }); } diff --git a/components/AuthorsCard.tsx b/components/AuthorsCard.tsx index 30e7688bd..e05b5ca08 100644 --- a/components/AuthorsCard.tsx +++ b/components/AuthorsCard.tsx @@ -4,6 +4,7 @@ import Image from "next/image"; import { useCallback, useEffect, useMemo, useState } from "react"; import { tinaField } from "tinacms/dist/react"; import { Card } from "@/components/ui/card"; +import { authorImageUrl } from "@/lib/authorImage"; interface Author { title?: string; @@ -28,28 +29,7 @@ export default function AuthorsCard({ authors }: AuthorsCardProps) { if (img?.includes("http")) return img; - if (url?.includes("ssw.com.au/people")) { - // Extract the part after '/people/' - const match = url.match(/people\/([^/?#]+)/); - const slug = match ? match[1] : null; - - if (slug) { - // Capitalize each word - const formattedTitle = slug - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join("-"); - - return `https://raw.githubusercontent.com/SSWConsulting/SSW.People.Profiles/main/${formattedTitle}/Images/${formattedTitle}-Profile.jpg`; - } - } - - if (url?.includes("github.com/")) { - const gitHubUsername = url.split("github.com/").pop(); - return `https://avatars.githubusercontent.com/${gitHubUsername}`; - } - - return placeholderImg; + return authorImageUrl(url) ?? placeholderImg; }, [placeholderImg] ); diff --git a/lib/authorImage.ts b/lib/authorImage.ts new file mode 100644 index 000000000..7f8f8e437 --- /dev/null +++ b/lib/authorImage.ts @@ -0,0 +1,34 @@ +/** + * Resolves an author's profile link to a photo URL. + * + * SSW People profile images live in SSW.People.Profiles under a Title-Cased directory: + * https://www.ssw.com.au/people/adam-cogan + * -> .../main/Adam-Cogan/Images/Adam-Cogan-Profile.jpg + */ +const PROFILES_REPO = "https://raw.githubusercontent.com/SSWConsulting/SSW.People.Profiles/main"; + +export const profileImageUrl = (peopleUrl?: string | null): string | null => { + // Host-scoped on purpose: a bare /people/ match would also claim URLs like + // github.com/some-people/x, which belong to the GitHub avatar fallback. + if (!peopleUrl?.includes("ssw.com.au/people")) return null; + + const slug = peopleUrl.match(/people\/([^/?#]+)/)?.[1]; + if (!slug) return null; + + const dir = slug + .split("-") + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join("-"); + + return `${PROFILES_REPO}/${dir}/Images/${dir}-Profile.jpg`; +}; + +export const githubAvatarUrl = (url?: string | null): string | null => { + if (!url?.includes("github.com/")) return null; + const username = url.split("github.com/").pop(); + return username ? `https://avatars.githubusercontent.com/${username}` : null; +}; + +/** The full resolution chain, so callers do not each re-spell the precedence. */ +export const authorImageUrl = (url?: string | null): string | null => profileImageUrl(url) ?? githubAvatarUrl(url); diff --git a/lib/og/card.tsx b/lib/og/card.tsx new file mode 100644 index 000000000..679f89b8f --- /dev/null +++ b/lib/og/card.tsx @@ -0,0 +1,168 @@ +import { loadAvatar, loadPolygon } from "@/lib/og/images"; +import type { OgAuthor } from "@/lib/og/target"; + +export const OG_SIZE = { width: 1200, height: 630 }; +export const OG_CONTENT_TYPE = "image/png"; + +const MAX_FACES = 2; +const OVERLAP = 16; +const AVATAR = 72; +const FOOTER_SIZE = 26; + +// Satori renders standalone - no CSS runtime, no Tailwind, no access to styles.css - +// so the ssw-* classes cannot be used here. These are those tokens by value; keep them +// in step with styles.css. +const SSW_RED = "#cc4141"; // --color-ssw-red +const SSW_BLACK = "#333333"; // --color-ssw-black +const SSW_GRAY = "#797979"; // --color-ssw-gray +const RULE_LINE = "#c4c4c4"; +const SURFACE = "#ffffff"; + +const ExtraChip = ({ count }: { count: number }) => ( +
+ {`+${count}`} +
+); + +const Avatar = ({ src, index, total }: { src: string; index: number; total: number }) => ( +
+ +
+
+); + +interface OgCardOptions { + title: string; + authors?: OgAuthor[]; + totalRules?: number; + /** Categories and the home page get a larger title than individual rules. */ + isHub?: boolean; +} + +/** Not a component: Satori cannot render async ones, so call sites await this. */ +export async function buildOgCard({ title, authors = [], totalRules, isHub = false }: OgCardOptions) { + const named = authors.filter((a) => a?.title); + const authorCount = named.length; + const shown = named.slice(0, MAX_FACES); + const extra = authorCount - shown.length; + // The chip counts as a face for masking, so the avatar behind it gets a hole cut + const faceCount = shown.length + (extra > 0 ? 1 : 0); + + const [polygon, avatars] = await Promise.all([loadPolygon(), Promise.all(shown.map(loadAvatar))]); + + return ( +
+ + + {/* Flattens the polygon behind the title, whose facet edges fight the letterforms + at any contrast ratio. Separate from the scrim below because interpolating a + white stop into a black one produces a grey haze mid-transition. */} +
+
+ +
+
SSW.RULES
+
{title}
+
+ +
+ {shown.length > 0 ? ( +
+
+ {avatars.map((src, i) => ( + + ))} + {extra > 0 ? : null} +
+
+ {`${authorCount} ${authorCount === 1 ? "contributor" : "contributors"}`} +
+
+ ) : ( +
+ )} + +
+ {totalRules ?
{`${totalRules.toLocaleString("en-AU")} rules`}
: null} + {totalRules ?
|
: null} +
ssw.com.au/rules
+
+
+
+ ); +} diff --git a/lib/og/images.ts b/lib/og/images.ts new file mode 100644 index 000000000..c1067af80 --- /dev/null +++ b/lib/og/images.ts @@ -0,0 +1,61 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { authorImageUrl } from "@/lib/authorImage"; +import type { OgAuthor } from "@/lib/og/target"; + +const publicFile = (relative: string) => path.join(process.cwd(), "public", relative); + +const dataUri = (buffer: Buffer, mime: string) => `data:${mime};base64,${buffer.toString("base64")}`; + +/** + * Detects the image type from magic bytes rather than the response header. + * + * Several profile photos in SSW.People.Profiles are PNGs saved with a .jpg extension. + * raw.githubusercontent serves them as image/jpeg from the extension, and Satori throws + * "Invalid JPEG" if you believe it. Returns null for anything unrecognised so callers + * fall back rather than hand Satori bytes it cannot decode. + */ +export function sniffImageType(buffer: Buffer): string | null { + if (buffer.length < 12) return null; + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg"; + if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png"; + if (buffer.subarray(0, 3).toString("ascii") === "GIF") return "image/gif"; + if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp"; + return null; +} + +/** + * SSW's polygon background, inverted offline so it reads light. Fading the near-black + * brand asset down instead crushes its facet contrast to nothing. Regenerate with: + * + * magick polygonBackground.png -resize 1200x630^ -gravity center -extent 1200x630 \ + * -negate -normalize -sigmoidal-contrast 3,50% +level 79%,99.5% public/og-polygon.png + */ +const cachedFile = (relative: string, mime: string) => { + let cache: string | undefined; + return async (): Promise => (cache ??= dataUri(await readFile(publicFile(relative)), mime)); +}; + +export const loadPolygon = cachedFile("og-polygon.png", "image/png"); +const loadPlaceholder = cachedFile("uploads/ssw-employee-profile-placeholder-sketch.jpg", "image/jpeg"); + +/** Fetched rather than handed to Satori as a URL so one bad photo degrades to the placeholder. */ +export const loadAvatar = async (author: OgAuthor): Promise => { + const url = authorImageUrl(author.url); + + if (url) { + try { + // Bounded: a slow GitHub must not stall the render past a crawler deadline + const res = await fetch(url, { next: { revalidate: 60 * 60 * 24 }, signal: AbortSignal.timeout(3000) }); + if (res.ok) { + const buffer = Buffer.from(await res.arrayBuffer()); + const type = sniffImageType(buffer); + if (type) return dataUri(buffer, type); + } + } catch (error) { + console.warn(`[og] avatar fetch failed for ${url}:`, error); + } + } + + return loadPlaceholder(); +}; diff --git a/lib/og/response.ts b/lib/og/response.ts new file mode 100644 index 000000000..d30db601b --- /dev/null +++ b/lib/og/response.ts @@ -0,0 +1,36 @@ +import { ImageResponse } from "next/og"; +import { buildOgCard, OG_CONTENT_TYPE, OG_SIZE } from "@/lib/og/card"; +import { tagline } from "@/site-config"; + +// Matches what ImageResponse sets by default, since returning a plain Response below +// means its headers no longer apply. Deliberately not a long s-maxage: the Tina webhook +// can purge Next's cache but not the CDN's, so anything cached at the edge outlives it. +const CACHE_CONTROL = "public, max-age=0, must-revalidate"; + +/** + * Renders a card, falling back to the generic one if Satori fails. + * + * The buffering is load-bearing. `new ImageResponse(...)` returns a 200 immediately and + * renders while the body streams, so a try/catch around the constructor alone catches + * nothing - awaiting the buffer is what surfaces a render failure. Satori also fetches + * emoji glyphs from a CDN mid-render with no timeout of its own, and rule titles carry + * emoji often enough for that to matter. + */ +export async function ogImageResponse(element: React.ReactElement, fallbackTitle?: string) { + const render = async (el: React.ReactElement) => { + const res = new ImageResponse(el, OG_SIZE); + return Buffer.from(await res.arrayBuffer()); + }; + + let png: Buffer; + try { + png = await render(element); + } catch (error) { + console.error("[og] render failed, falling back to the generic card:", error); + png = await render(await buildOgCard({ title: fallbackTitle ?? tagline, isHub: true })); + } + + return new Response(new Uint8Array(png), { + headers: { "content-type": OG_CONTENT_TYPE, "cache-control": CACHE_CONTROL }, + }); +} diff --git a/lib/og/siteCard.tsx b/lib/og/siteCard.tsx new file mode 100644 index 000000000..af8c1576f --- /dev/null +++ b/lib/og/siteCard.tsx @@ -0,0 +1,20 @@ +import { buildOgCard, OG_CONTENT_TYPE, OG_SIZE } from "@/lib/og/card"; +import { ogImageResponse } from "@/lib/og/response"; +import { fetchRuleCount } from "@/lib/services/rules"; +import { tagline } from "@/site-config"; + +/** + * The generic card, shared by every segment that does not build its own. + * + * Next's metadata image files apply to the segment they sit in and are NOT inherited + * by nested segments, so a single app/opengraph-image.tsx covers only the root - every + * other route needs its own file re-exporting this one. Verified on a deployed slot: + * with only the root file, every page except rules and categories emitted no og:image. + */ +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; +export const alt = "SSW Rules"; + +export default async function siteOgImage() { + return ogImageResponse(await buildOgCard({ title: tagline, totalRules: await fetchRuleCount(), isHub: true })); +} diff --git a/lib/og/target.ts b/lib/og/target.ts new file mode 100644 index 000000000..a3f0b2120 --- /dev/null +++ b/lib/og/target.ts @@ -0,0 +1,72 @@ +import { unstable_cache } from "next/cache"; +import client from "@/tina/__generated__/client"; + +export interface OgAuthor { + title?: string | null; + url?: string | null; +} + +export type OgTarget = { kind: "rule"; title: string; authors: OgAuthor[] } | { kind: "category"; title: string } | { kind: "generic" }; + +/** + * Category titles keyed by URL filename. mainCategoryQuery returns every category in + * one un-paginated call, avoiding the pagination walk page.tsx needs for a full + * relativePath. Top categories are skipped - they live at /index.mdx so their + * filename is always "index" and never matches a URL segment. + * + * Tagged with rule-count as well as category-rule-data because the Tina webhook only + * fires the former. + */ +const getCategoryTitles = unstable_cache( + async (): Promise> => { + const res = await client.queries.mainCategoryQuery(); + const titles: Record = {}; + + for (const entry of (res?.data?.category as any)?.index ?? []) { + for (const child of entry?.top_category?.index ?? []) { + const filename = child?.category?._sys?.filename; + if (filename && child.category.title) titles[filename] = child.category.title; + } + } + + return titles; + }, + ["og-category-titles"], + { tags: ["category-rule-data", "rule-count"], revalidate: 60 * 60 * 24 } +); + +/** + * Resolves a URL segment to a rule, a category, or neither. Category first, matching + * page.tsx's precedence, so the card and the page cannot disagree about what a + * filename is. + * + * Anything unresolved returns "generic" rather than 404ing. page.tsx serves every + * unresolved filename via ClientFallbackPage, so a 404 here would mean a live page + * with a broken og:image - and a Tina outage is indistinguishable from a genuine miss + * anyway, because the category map is cached and will not throw when it is warm. + */ +export async function resolveOgTarget(filename: string): Promise { + try { + const title = (await getCategoryTitles())[filename]; + if (title) return { kind: "category", title }; + } catch (error) { + console.warn(`[og] category lookup failed for "${filename}":`, error); + } + + try { + const rule = await client.queries.ruleDataBasic({ relativePath: `${filename}/rule.mdx` }); + if (rule?.data?.rule?.title) { + return { + kind: "rule", + title: rule.data.rule.title, + authors: (rule.data.rule.authors ?? []).filter(Boolean) as OgAuthor[], + }; + } + } catch (error) { + // Tina throws for a missing record as well as for an outage, so this is only worth + // a debug line - a genuine miss is normal traffic. + console.debug(`[og] no rule for "${filename}":`, error); + } + + return { kind: "generic" }; +} diff --git a/lib/pageMetadata.ts b/lib/pageMetadata.ts new file mode 100644 index 000000000..22f1eac1b --- /dev/null +++ b/lib/pageMetadata.ts @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import { siteDescription, siteTitle, siteUrl, social } from "@/site-config"; + +interface PageMetadataOptions { + title: string; + description?: string; + /** Path below the site root, e.g. "latest-rules". Omit for the home page. */ + path?: string; + /** "article" for individual rules, "website" for everything else. */ + type?: "website" | "article"; + robots?: Metadata["robots"]; +} + +/** + * Builds a page's metadata with matching og: and twitter: tags. + * + * Next does not derive og:title from `title`, and does not deep-merge: a page setting + * `openGraph` replaces the layout's wholesale, so siteName/locale/handles are repeated + * here rather than inherited. Never sets openGraph.images - the opengraph-image routes + * supply the card, and Next only merges it when openGraph has no `images` key. + * + * `description` defaults rather than being left undefined: Next's merge iterates the + * source's own keys and assigns `metadata[key] ?? null`, so passing an explicit + * undefined would null out the layout's description instead of inheriting it. + */ +export function pageMetadata({ title, description = siteDescription, path = "", type = "website", robots }: PageMetadataOptions): Metadata { + const url = path ? `${siteUrl}/${path}` : `${siteUrl}/`; + + return { + title, + description, + alternates: { canonical: url }, + openGraph: { title, description, url, type, siteName: siteTitle, locale: "en_AU" }, + twitter: { card: "summary_large_image", title, description, site: `@${social.twitter}`, creator: `@${social.twitter}` }, + ...(robots ? { robots } : {}), + }; +} diff --git a/next.config.ts b/next.config.ts index 0b6188704..97c5f24b3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -20,11 +20,7 @@ const nextConfig: NextConfig = { // Exclude Application Insights from server-side bundling to avoid dynamic require issues // This tells Next.js to use the Node.js runtime version instead of bundling it - serverExternalPackages: [ - 'applicationinsights', - 'diagnostic-channel', - 'diagnostic-channel-publishers', - ], + serverExternalPackages: ["applicationinsights", "diagnostic-channel", "diagnostic-channel-publishers"], images: { remotePatterns: [ diff --git a/package.json b/package.json index 72ed8203c..166f0df39 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "dev:build": "next build", "prepare:content": "cross-env node ./scripts/prepare-content.js", "crawl-sitemap": "node ./scripts/crawl-sitemap.js", - "test": "jest" + "test": "jest", + "verify:og": "cross-env NODE_OPTIONS=--experimental-vm-modules OG_PREVIEW_DIR=og-preview jest ogCard.preview" }, "devDependencies": { "@biomejs/biome": "^2.4.14", diff --git a/public/og-polygon.png b/public/og-polygon.png new file mode 100644 index 000000000..d9c768900 Binary files /dev/null and b/public/og-polygon.png differ diff --git a/site-config.ts b/site-config.ts index 5753a96e6..e2f417053 100644 --- a/site-config.ts +++ b/site-config.ts @@ -1,9 +1,9 @@ const titles = { - '/latest-rules/': `Latest Rules`, - '/user/': `User Rules`, - '/orphaned/': `Orphaned Rules`, - '/archived/': `Archived Rules`, - '/profile/': `Profile`, + "/latest-rules/": `Latest Rules`, + "/user/": `User Rules`, + "/orphaned/": `Orphaned Rules`, + "/archived/": `Archived Rules`, + "/profile/": `Profile`, }; export const siteTitle = `SSW.Rules`; @@ -21,7 +21,8 @@ export const social = { }; export const parentSiteUrl = `https://www.ssw.com.au`; export const breadcrumbDefault = `SSW Rules`; -export const homepageTitle = `Secret Ingredients to Quality Software (Open Source on GitHub)`; +export const tagline = `Secret Ingredients to Quality Software`; +export const homepageTitle = `${tagline} (Open Source on GitHub)`; export const trailingSlash = `never`; export { titles }; @@ -38,9 +39,10 @@ const config = { social, parentSiteUrl, breadcrumbDefault, + tagline, homepageTitle, trailingSlash, titles, }; -export default config; \ No newline at end of file +export default config;