diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/-/about/api/opengraph-image.tsx b/src/app/-/about/api/opengraph-image.tsx new file mode 100644 index 00000000..35ce3fde --- /dev/null +++ b/src/app/-/about/api/opengraph-image.tsx @@ -0,0 +1,36 @@ +import { + contentType, + OpenGraphImage, + size, +} from "^/app/_components/OpenGraphImage"; + +export const alt = "API – npm-diff.app"; +export { size, contentType }; + +export default async function Image() { + return OpenGraphImage({ + tag: "Documentation", + title: "API", + icon: ( + + + + + ), + }); +} diff --git a/src/app/-/about/api/page.tsx b/src/app/-/about/api/page.tsx index e90ee7a3..07a1d3ad 100644 --- a/src/app/-/about/api/page.tsx +++ b/src/app/-/about/api/page.tsx @@ -21,7 +21,20 @@ const EXAMPLE_ABSOLUTE_URL = `${DOMAIN}${EXAMPLE_RELATIVE_LINK}` as const; export const metadata: Metadata = { title: "API", - description: "API documentation for npm-diff.app", + description: + "API documentation for npm-diff.app. Access programmatic diff capabilities for npm packages using the same API that powers the web interface.", + openGraph: { + title: "npm-diff.app API", + description: + "API documentation for npm-diff.app. Access programmatic diff capabilities for npm packages.", + url: "https://npm-diff.app/-/about/api", + }, + twitter: { + card: "summary_large_image", + title: "npm-diff.app API", + description: + "API documentation for npm-diff.app. Access programmatic diff capabilities for npm packages.", + }, }; const AboutApiPage = async () => { diff --git a/src/app/-/about/opengraph-image.tsx b/src/app/-/about/opengraph-image.tsx new file mode 100644 index 00000000..56aaed89 --- /dev/null +++ b/src/app/-/about/opengraph-image.tsx @@ -0,0 +1,37 @@ +import { + contentType, + OpenGraphImage, + size, +} from "^/app/_components/OpenGraphImage"; + +export const alt = "About – npm-diff.app"; +export { size, contentType }; + +export default async function Image() { + return OpenGraphImage({ + tag: "About", + title: "npm-diff.app", + icon: ( + + + + + + ), + }); +} diff --git a/src/app/-/about/page.tsx b/src/app/-/about/page.tsx index bbe3a18b..3b7aa727 100644 --- a/src/app/-/about/page.tsx +++ b/src/app/-/about/page.tsx @@ -11,6 +11,20 @@ import { export const metadata: Metadata = { title: "About", + description: + "Learn how npm-diff.app works. Compare npm packages using the official libnpmdiff library, visualize differences, and analyze bundle sizes with bundlephobia and packagephobia integrations.", + openGraph: { + title: "About npm-diff.app", + description: + "Learn how npm-diff.app compares npm packages using the official libnpmdiff library and visualizes the differences for safer dependency upgrades.", + url: "https://npm-diff.app/-/about", + }, + twitter: { + card: "summary_large_image", + title: "About npm-diff.app", + description: + "Learn how npm-diff.app compares npm packages using the official libnpmdiff library and visualizes the differences for safer dependency upgrades.", + }, }; export default function AboutPage() { diff --git a/src/app/-/about/source-trust/opengraph-image.tsx b/src/app/-/about/source-trust/opengraph-image.tsx index 6219c115..bb2b785d 100644 --- a/src/app/-/about/source-trust/opengraph-image.tsx +++ b/src/app/-/about/source-trust/opengraph-image.tsx @@ -1,86 +1,17 @@ -import { ImageResponse } from "next/og"; -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { + contentType, + OpenGraphImage, + size, +} from "^/app/_components/OpenGraphImage"; export const alt = "Source & Trust – npm-diff.app"; -export const size = { - width: 1200, - height: 630, -}; - -export const contentType = "image/png"; +export { size, contentType }; export default async function Image() { - const logoData = await readFile(join(process.cwd(), "src/app/icon.png")); - const logoSrc = Uint8Array.from(logoData).buffer; - - return new ImageResponse( -
- npm-diff.app icon -
-
- Feature -
-

- Source & Trust -

-
+ return OpenGraphImage({ + tag: "Feature", + title: "Source & Trust", + icon: ( -
- npm-diff.app -
-
, - { - ...size, - }, - ); + ), + }); } diff --git a/src/app/[...parts]/page.tsx b/src/app/[...parts]/page.tsx index e7f113c7..f2928e50 100644 --- a/src/app/[...parts]/page.tsx +++ b/src/app/[...parts]/page.tsx @@ -26,13 +26,41 @@ export async function generateMetadata({ params, }: DiffPageProps): Promise { const { parts } = await params; + const partsStr = Array.isArray(parts) ? parts.join("/") : parts; const specs = splitParts(decodeParts(parts)); const [a, b] = specs.map((spec) => createSimplePackageSpec(spec)); + const packageNameA = simplePackageSpecToString(a); + const packageNameB = simplePackageSpecToString(b); + const title = `Comparing ${packageNameA}...${packageNameB}`; + const description = `View the detailed diff between npm packages ${packageNameA} and ${packageNameB}. See file changes, additions, deletions, bundle size comparisons, and trust signals including provenance and trusted publishing information.`; + + // Generate OpenGraph image URL + const ogImageUrl = `/api/-/og?parts=${encodeURIComponent(partsStr)}`; + return { - title: `Comparing ${simplePackageSpecToString(a)}...${simplePackageSpecToString(b)}`, - description: `A diff between the npm packages "${simplePackageSpecToString(a)}" and "${simplePackageSpecToString(b)}"`, + title, + description, + openGraph: { + title, + description, + type: "article", + images: [ + { + url: ogImageUrl, + width: 1200, + height: 630, + alt: `Diff between ${packageNameA} and ${packageNameB}`, + }, + ], + }, + twitter: { + card: "summary_large_image", + title, + description, + images: [ogImageUrl], + }, }; } diff --git a/src/app/_components/OpenGraphImage.tsx b/src/app/_components/OpenGraphImage.tsx new file mode 100644 index 00000000..2affc71e --- /dev/null +++ b/src/app/_components/OpenGraphImage.tsx @@ -0,0 +1,131 @@ +import { ImageResponse } from "next/og"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { ReactNode } from "react"; + +export const size = { + width: 1200, + height: 630, +}; + +export const contentType = "image/png"; + +export interface OpenGraphImageProps { + /** + * Tag to display (e.g., "Feature", "About", "API") + */ + tag: string; + /** + * Main title text + */ + title: string; + /** + * Icon to display on the right side (SVG element) + */ + icon: ReactNode; + /** + * Background color (default: "#0d1117") + */ + backgroundColor?: string; +} + +/** + * Reusable OpenGraph image component for npm-diff.app + * Creates a consistent OG image with a tag, title, and icon + */ +export async function OpenGraphImage({ + tag, + title, + icon, + backgroundColor = "#0d1117", +}: OpenGraphImageProps) { + const logoData = await readFile(join(process.cwd(), "src/app/icon.png")); + const logoSrc = Uint8Array.from(logoData).buffer; + + return new ImageResponse( +
+ {/* eslint-disable-next-line @next/next/no-img-element -- OpenGraph images require standard img tag */} + npm-diff.app icon +
+
+ {tag} +
+

+ {title} +

+
+ {icon} +
+ npm-diff.app +
+
, + { + ...size, + }, + ); +} diff --git a/src/app/api/-/og/route.tsx b/src/app/api/-/og/route.tsx new file mode 100644 index 00000000..6c6f34f2 --- /dev/null +++ b/src/app/api/-/og/route.tsx @@ -0,0 +1,340 @@ +import { ImageResponse } from "next/og"; +import { type NextRequest } from "next/server"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { getSourceInformation } from "^/lib/api/npm/sourceInformation"; +import { createSimplePackageSpec } from "^/lib/createSimplePackageSpec"; +import destination from "^/lib/destination"; +import { gitDiffParse } from "^/lib/gitDiff"; +import npmDiff from "^/lib/npmDiff"; +import countChanges from "^/lib/utils/countChanges"; +import decodeParts from "^/lib/utils/decodeParts"; +import splitParts from "^/lib/utils/splitParts"; + +function formatNumber(num: number): string { + if (num >= 1000) { + return (num / 1000).toFixed(1) + "k"; + } + return num.toString(); +} + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const parts = searchParams.get("parts"); + + if (!parts) { + return new Response("Missing parts parameter", { status: 400 }); + } + + const specsOrVersions = splitParts(decodeParts(parts)); + const { canonicalSpecs } = await destination(specsOrVersions); + const [a, b] = canonicalSpecs.map((spec) => createSimplePackageSpec(spec)); + + // Get diff statistics + const diff = await npmDiff(canonicalSpecs, {}); + const files = gitDiffParse(diff); + const changes = files.map((file) => countChanges(file.hunks)); + const additions = changes + .map(({ additions }) => additions) + .reduce((a, b) => a + b, 0); + const deletions = changes + .map(({ deletions }) => deletions) + .reduce((a, b) => a + b, 0); + + // Get source information for badges + const [sourceA, sourceB] = await Promise.all([ + getSourceInformation({ name: a.name, version: a.version }).catch( + () => null, + ), + getSourceInformation({ name: b.name, version: b.version }).catch( + () => null, + ), + ]); + + const hasProvenance = !!(sourceA || sourceB); + const hasTrustedPublisher = !!( + sourceA?.hasTrustedPublisher || sourceB?.hasTrustedPublisher + ); + + const logoData = await readFile(join(process.cwd(), "src/app/icon.png")); + const logoSrc = Uint8Array.from(logoData).buffer; + + const packageName = a.name; + const versionCompare = `${a.version} → ${b.version}`; + + return new ImageResponse( +
+ {/* Logo */} + {/* eslint-disable-next-line @next/next/no-img-element -- OpenGraph images require standard img tag */} + npm-diff.app icon + + {/* Main content */} +
+ {/* Package name */} +
+ {packageName} +
+ + {/* Version comparison */} +
+ {versionCompare} +
+ + {/* Stats */} +
+ {/* Files changed */} +
+
+ {files.length} +
+
+ files +
+
+ + {/* Additions */} +
+
+ +{formatNumber(additions)} +
+
+ additions +
+
+ + {/* Deletions */} +
+
+ -{formatNumber(deletions)} +
+
+ deletions +
+
+
+ + {/* Badges */} + {!!(hasProvenance || hasTrustedPublisher) && ( +
+ {!!hasProvenance && ( +
+ + + + + + Provenance + +
+ )} + {!!hasTrustedPublisher && ( +
+ + + + + Trusted Publisher + +
+ )} +
+ )} +
+ + {/* Footer */} +
+ npm-diff.app +
+
, + { + width: 1200, + height: 630, + }, + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e2e43b7a..9863b089 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,18 +8,56 @@ import Header from "./_layout/Header"; import "./globals.css"; export const metadata: Metadata = { + metadataBase: new URL( + process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : process.env.NODE_ENV === "development" + ? "http://localhost:3000" + : "https://npm-diff.app", + ), applicationName: "npm-diff.app", title: { default: "npm-diff.app 📦🔃", template: "%s • npm-diff.app 📦🔃", }, - description: "Inspect changes between npm packages in a webapp", + description: + "Inspect and compare changes between npm package versions in a webapp. View diffs, analyze bundle size changes, and audit trust signals like provenance and trusted publishing for safer dependency upgrades.", manifest: "/site.webmanifest", formatDetection: { email: false, address: false, telephone: false, }, + keywords: [ + "npm", + "diff", + "package comparison", + "dependency audit", + "npm packages", + "version diff", + "bundle size", + "npm security", + "provenance", + "trusted publishing", + ], + authors: [{ name: "Oscar Busk", url: "https://github.com/oBusk" }], + creator: "Oscar Busk", + openGraph: { + type: "website", + locale: "en_US", + url: "https://npm-diff.app", + siteName: "npm-diff.app", + title: "npm-diff.app 📦🔃", + description: + "Inspect and compare changes between npm package versions. View diffs, analyze bundle sizes, and audit trust signals for safer dependency upgrades.", + }, + twitter: { + card: "summary_large_image", + title: "npm-diff.app 📦🔃", + description: + "Inspect and compare changes between npm package versions. View diffs, analyze bundle sizes, and audit trust signals for safer dependency upgrades.", + creator: "@oBusk", + }, }; export const viewport = {