Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
35 changes: 35 additions & 0 deletions __tests__/lib/authorImage.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
65 changes: 65 additions & 0 deletions __tests__/lib/ogCard.preview.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, Parameters<typeof buildOgCard>[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);
});
});
77 changes: 77 additions & 0 deletions __tests__/lib/ogCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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 && <div/>` 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));
});
});
29 changes: 29 additions & 0 deletions __tests__/lib/ogImages.test.ts
Original file line number Diff line number Diff line change
@@ -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("<!DOCTYPE html><html>"))).toBeNull();
expect(sniffImageType(Buffer.from([0xff, 0xd8]))).toBeNull();
expect(sniffImageType(Buffer.alloc(0))).toBeNull();
});
});
67 changes: 67 additions & 0 deletions __tests__/lib/ogTarget.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
27 changes: 10 additions & 17 deletions app/(home)/categories/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,16 +47,14 @@ export default async function CategoriesPage() {
</span>
</h2>
<ol className="text-lg mb-0">
{topCategory.index
?.filter(isVisibleCategory)
?.map((item: any, subIndex: number) => (
<li key={subIndex} className="mb-4 last:mb-2">
<div className="flex justify-between">
<Link href={`/${item.category._sys.filename}`}>{item.category.title}</Link>
<span className="text-gray-300">{categoryRuleCounts[item.category._sys.filename] || 0}</span>
</div>
</li>
))}
{topCategory.index?.filter(isVisibleCategory)?.map((item: any, subIndex: number) => (
<li key={subIndex} className="mb-4 last:mb-2">
<div className="flex justify-between">
<Link href={`/${item.category._sys.filename}`}>{item.category.title}</Link>
<span className="text-gray-300">{categoryRuleCounts[item.category._sys.filename] || 0}</span>
</div>
</li>
))}
</ol>
</Card>
))}
Expand All @@ -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" });
}
10 changes: 3 additions & 7 deletions app/(home)/page.tsx
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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}` });
}
25 changes: 25 additions & 0 deletions app/[filename]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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";

export const revalidate = 60 * 60 * 24;

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",
})
);
}
Loading