diff --git a/package-lock.json b/package-lock.json index 18cecec..1bcb4b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "astro": "^5.0.5", "astro-expressive-code": "^0.41.3", "astro-icon": "^1.1.5", + "astro-seo": "^0.8.4", "clsx": "^2.1.0", "eslint": "^9.32.0", "eslint-plugin-astro": "^1.3.1", @@ -4018,6 +4019,94 @@ "@iconify/utils": "^2.1.30" } }, + "node_modules/astro-seo": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/astro-seo/-/astro-seo-0.8.4.tgz", + "integrity": "sha512-Ou1vzQSXAxa0K8rtNtXNvSpYqOGEgMhh0immMxJeXmbVZac3UKCNWAoXWyOQDFYsZvBugCRSg0N1phBqPMVgCw==", + "license": "MIT", + "dependencies": { + "@astrojs/check": "^0.5.4" + } + }, + "node_modules/astro-seo/node_modules/@astrojs/check": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.5.10.tgz", + "integrity": "sha512-vliHXM9cu/viGeKiksUM4mXfO816ohWtawTl2ADPgTsd4nUMjFiyAl7xFZhF34yy4hq4qf7jvK1F2PlR3b5I5w==", + "license": "MIT", + "dependencies": { + "@astrojs/language-server": "^2.8.4", + "chokidar": "^3.5.3", + "fast-glob": "^3.3.1", + "kleur": "^4.1.5", + "yargs": "^17.7.2" + }, + "bin": { + "astro-check": "dist/bin.js" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } + }, + "node_modules/astro-seo/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/astro-seo/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/astro-seo/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/astro-seo/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/astrojs-compiler-sync": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/astrojs-compiler-sync/-/astrojs-compiler-sync-1.1.1.tgz", diff --git a/package.json b/package.json index 49e690c..ddb1047 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "astro": "^5.0.5", "astro-expressive-code": "^0.41.3", "astro-icon": "^1.1.5", + "astro-seo": "^0.8.4", "clsx": "^2.1.0", "eslint": "^9.32.0", "eslint-plugin-astro": "^1.3.1", diff --git a/src/components/Head.astro b/src/components/Head.astro index f6e36f7..8aff2dc 100644 --- a/src/components/Head.astro +++ b/src/components/Head.astro @@ -11,16 +11,38 @@ import lora600 from "@fontsource/lora/files/lora-latin-600-normal.woff2"; import { ClientRouter } from "astro:transitions"; import { SITE } from "@consts"; +import OpenGraphMeta from "@components/OpenGraphMeta.astro"; +import type { OpenGraphData } from "@lib/opengraph"; +import { generateTailgraphURL } from "@lib/opengraph"; interface Props { title: string; description: string; image?: string; + ogData?: OpenGraphData; } const canonicalURL = new URL(Astro.url.pathname, Astro.site); -const { title, description, image = "/nano.png" } = Astro.props; +const { title, description, image = "/nano.png", ogData } = Astro.props; + +// Create default OG data if not provided +const defaultOgData: OpenGraphData = ogData || { + title, + description, + type: "website", + url: canonicalURL.toString(), + siteName: SITE.NAME, + image: image.startsWith("http") ? image : generateTailgraphURL({ + title, + theme: "dark", + backgroundImage: "gradient", + logo: `${Astro.site}favicon-light.svg` + }), + twitter: { + card: "summary_large_image" + } +}; --- @@ -45,19 +67,12 @@ const { title, description, image = "/nano.png" } = Astro.props; - - - - - - - - - - - - - + +{ogData ? ( + +) : ( + +)} diff --git a/src/components/OpenGraphMeta.astro b/src/components/OpenGraphMeta.astro new file mode 100644 index 0000000..3b0b43b --- /dev/null +++ b/src/components/OpenGraphMeta.astro @@ -0,0 +1,51 @@ +--- +import { SEO } from "astro-seo"; +import type { OpenGraphData } from "@lib/opengraph"; + +interface Props { + data: OpenGraphData; +} + +const { data } = Astro.props; + +// Build OpenGraph tags +const openGraph = { + basic: { + title: data.title, + type: data.type, + image: data.image || "", + url: data.url, + }, + optional: { + description: data.description, + siteName: data.siteName, + }, + image: data.image ? { + url: data.image, + alt: data.imageAlt || data.title, + } : undefined, + article: data.type === "article" && data.article ? { + publishedTime: data.article.publishedTime?.toISOString(), + modifiedTime: data.article.modifiedTime?.toISOString(), + authors: data.article.author ? [data.article.author] : undefined, + section: data.article.section, + } : undefined, +}; + +// Build Twitter Card tags +const twitter = { + card: data.twitter?.card || "summary_large_image", + creator: data.twitter?.creator, + title: data.title, + description: data.description, + image: data.image, + imageAlt: data.imageAlt || data.title, +}; +--- + + \ No newline at end of file diff --git a/src/content/config.ts b/src/content/config.ts index a45462e..7beacc7 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -7,7 +7,14 @@ const blog = defineCollection({ cardTitle: z.string().optional(), description: z.string(), date: z.coerce.date(), - draft: z.boolean().optional() + draft: z.boolean().optional(), + // OpenGraph overrides + ogTitle: z.string().optional(), + ogDescription: z.string().optional(), + ogImage: z.string().optional(), + ogImageAlt: z.string().optional(), + noOgImage: z.boolean().optional(), + modifiedDate: z.coerce.date().optional() }).transform((data) => ({ ...data, cardTitle: data.cardTitle ?? data.title @@ -21,7 +28,14 @@ const briefs = defineCollection({ cardTitle: z.string().optional(), description: z.string(), date: z.coerce.date(), - draft: z.boolean().optional() + draft: z.boolean().optional(), + // OpenGraph overrides + ogTitle: z.string().optional(), + ogDescription: z.string().optional(), + ogImage: z.string().optional(), + ogImageAlt: z.string().optional(), + noOgImage: z.boolean().optional(), + modifiedDate: z.coerce.date().optional() }).transform((data) => ({ ...data, cardTitle: data.cardTitle ?? data.title @@ -37,7 +51,14 @@ const projects = defineCollection({ date: z.coerce.date(), draft: z.boolean().optional(), demoURL: z.string().optional(), - repoURL: z.string().optional() + repoURL: z.string().optional(), + // OpenGraph overrides + ogTitle: z.string().optional(), + ogDescription: z.string().optional(), + ogImage: z.string().optional(), + ogImageAlt: z.string().optional(), + noOgImage: z.boolean().optional(), + modifiedDate: z.coerce.date().optional() }).transform((data) => ({ ...data, cardTitle: data.cardTitle ?? data.title diff --git a/src/layouts/PageLayout.astro b/src/layouts/PageLayout.astro index c5d2156..b9a67a7 100644 --- a/src/layouts/PageLayout.astro +++ b/src/layouts/PageLayout.astro @@ -3,19 +3,21 @@ import Head from "@components/Head.astro"; import Header from "@components/Header.astro"; import Footer from "@components/Footer.astro"; import { SITE } from "@consts"; +import type { OpenGraphData } from "@lib/opengraph"; type Props = { title: string; description: string; + ogData?: OpenGraphData; }; -const { title, description } = Astro.props; +const { title, description, ogData } = Astro.props; --- - +
diff --git a/src/lib/opengraph.ts b/src/lib/opengraph.ts new file mode 100644 index 0000000..a11ec73 --- /dev/null +++ b/src/lib/opengraph.ts @@ -0,0 +1,258 @@ +import type { CollectionEntry } from "astro:content"; +import { SITE } from "@consts"; + +export interface OpenGraphData { + title: string; + description: string; + type: "website" | "article"; + url: string; + siteName: string; + image?: string; + imageAlt?: string; + article?: { + publishedTime?: Date; + modifiedTime?: Date; + author?: string; + section?: string; + }; + twitter?: { + card?: "summary" | "summary_large_image"; + creator?: string; + }; +} + +interface TailgraphParams { + title: string; + subtitle?: string; + author?: string; + theme?: "light" | "dark"; + backgroundImage?: string; + logo?: string; +} + +/** + * Generate a Tailgraph URL for dynamic OG images + */ +export function generateTailgraphURL(params: TailgraphParams): string { + const baseURL = "https://tailgraph.com/api/v1/og"; + const searchParams = new URLSearchParams(); + + searchParams.set("title", params.title); + + if (params.subtitle) { + searchParams.set("subtitle", params.subtitle); + } + + if (params.author) { + searchParams.set("author", params.author); + } + + searchParams.set("theme", params.theme || "dark"); + + if (params.backgroundImage) { + searchParams.set("backgroundImage", params.backgroundImage); + } + + if (params.logo) { + searchParams.set("logo", params.logo); + } + + return `${baseURL}?${searchParams.toString()}`; +} + +/** + * Get OpenGraph data for a blog post + */ +export function getPostOGData( + post: CollectionEntry<"blog">, + url: string, + siteUrl: string +): OpenGraphData { + const ogTitle = post.data.ogTitle || post.data.title; + const ogDescription = post.data.ogDescription || post.data.description; + + let ogImage = post.data.ogImage; + if (!ogImage && !post.data.noOgImage) { + ogImage = generateTailgraphURL({ + title: post.data.cardTitle || post.data.title, + subtitle: post.data.date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric" + }), + author: "Paul R. Berman", + theme: "dark", + backgroundImage: "gradient", + logo: `${siteUrl}/favicon-light.svg` + }); + } + + return { + title: ogTitle, + description: ogDescription, + type: "article", + url, + siteName: SITE.NAME, + image: ogImage, + imageAlt: post.data.ogImageAlt || `${ogTitle} - Blog Post`, + article: { + publishedTime: post.data.date, + modifiedTime: post.data.modifiedDate, + author: "Paul R. Berman", + section: "Blog" + }, + twitter: { + card: "summary_large_image", + creator: "@plxgithub" + } + }; +} + +/** + * Get OpenGraph data for a brief + */ +export function getBriefOGData( + brief: CollectionEntry<"briefs">, + category: { displayName: string; titlePrefix?: string } | null, + url: string, + siteUrl: string +): OpenGraphData { + const ogTitle = brief.data.ogTitle || brief.data.title; + const ogDescription = brief.data.ogDescription || brief.data.description; + + let ogImage = brief.data.ogImage; + if (!ogImage && !brief.data.noOgImage) { + ogImage = generateTailgraphURL({ + title: brief.data.cardTitle || brief.data.title, + subtitle: category?.titlePrefix || category?.displayName || "Brief", + author: "Paul R. Berman", + theme: "dark", + backgroundImage: "gradient", + logo: `${siteUrl}/favicon-light.svg` + }); + } + + return { + title: ogTitle, + description: ogDescription, + type: "article", + url, + siteName: SITE.NAME, + image: ogImage, + imageAlt: brief.data.ogImageAlt || `${ogTitle} - Brief`, + article: { + publishedTime: brief.data.date, + modifiedTime: brief.data.modifiedDate, + author: "Paul R. Berman", + section: category?.displayName || "Briefs" + }, + twitter: { + card: "summary_large_image", + creator: "@plxgithub" + } + }; +} + +/** + * Get OpenGraph data for a project + */ +export function getProjectOGData( + project: CollectionEntry<"projects">, + url: string, + siteUrl: string +): OpenGraphData { + const ogTitle = project.data.ogTitle || project.data.title; + const ogDescription = project.data.ogDescription || project.data.description; + + let ogImage = project.data.ogImage; + if (!ogImage && !project.data.noOgImage) { + ogImage = generateTailgraphURL({ + title: project.data.title, + subtitle: "Project", + author: "Paul R. Berman", + theme: "dark", + backgroundImage: "gradient", + logo: `${siteUrl}/favicon-light.svg` + }); + } + + return { + title: ogTitle, + description: ogDescription, + type: "website", + url, + siteName: SITE.NAME, + image: ogImage, + imageAlt: project.data.ogImageAlt || `${ogTitle} - Project`, + twitter: { + card: "summary_large_image", + creator: "@plxgithub" + } + }; +} + +/** + * Get OpenGraph data for list pages + */ +export function getListOGData( + title: string, + description: string, + pageType: "blog" | "briefs" | "projects", + itemCount: number, + url: string, + siteUrl: string +): OpenGraphData { + const subtitle = `${itemCount} ${pageType === "blog" ? "posts" : pageType}`; + + const ogImage = generateTailgraphURL({ + title, + subtitle, + theme: "dark", + backgroundImage: "gradient", + logo: `${siteUrl}/favicon-light.svg` + }); + + return { + title: `${title} | ${SITE.NAME}`, + description, + type: "website", + url, + siteName: SITE.NAME, + image: ogImage, + imageAlt: `${title} - ${SITE.NAME}`, + twitter: { + card: "summary_large_image", + creator: "@plxgithub" + } + }; +} + +/** + * Get OpenGraph data for the home page + */ +export function getHomeOGData( + url: string, + siteUrl: string +): OpenGraphData { + const ogImage = generateTailgraphURL({ + title: SITE.NAME, + subtitle: "Technical writing and projects", + theme: "dark", + backgroundImage: "gradient", + logo: `${siteUrl}/favicon-light.svg` + }); + + return { + title: SITE.NAME, + description: "Technical writing on Swift, performance optimization, and software engineering", + type: "website", + url, + siteName: SITE.NAME, + image: ogImage, + imageAlt: SITE.NAME, + twitter: { + card: "summary_large_image", + creator: "@plxgithub" + } + }; +} \ No newline at end of file diff --git a/src/pages/blog/[...slug].astro b/src/pages/blog/[...slug].astro index cbddad1..38d5e1c 100644 --- a/src/pages/blog/[...slug].astro +++ b/src/pages/blog/[...slug].astro @@ -5,6 +5,7 @@ import Container from "@components/Container.astro"; import FormattedDate from "@components/FormattedDate.astro"; import { readingTime } from "@lib/utils"; import BackToPrev from "@components/BackToPrev.astro"; +import { getPostOGData } from "@lib/opengraph"; export async function getStaticPaths() { const posts = (await getCollection("blog")) @@ -19,9 +20,11 @@ type Props = CollectionEntry<"blog">; const post = Astro.props; const { Content } = await post.render(); + +const ogData = getPostOGData(post, Astro.url.toString(), Astro.site?.toString() || ""); --- - +
diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index 0bf1264..56337d0 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -5,6 +5,7 @@ import Container from "@components/Container.astro"; import ContentCard from "@components/ContentCard.astro"; import { getBlogCardProps } from "@lib/contentCardHelpers"; import { BLOG } from "@consts"; +import { getListOGData } from "@lib/opengraph"; const data = (await getCollection("blog")) .filter(post => !post.data.draft) @@ -23,10 +24,19 @@ const posts = data.reduce((acc: Acc, post) => { return acc; }, {}); -const years = Object.keys(posts).sort((a, b) => parseInt(b) - parseInt(a)); +const years = Object.keys(posts).sort((a, b) => parseInt(b) - parseInt(a)); + +const ogData = getListOGData( + BLOG.TITLE, + BLOG.DESCRIPTION, + "blog", + data.length, + Astro.url.toString(), + Astro.site?.toString() || "" +); --- - +
diff --git a/src/pages/briefs/[...slug].astro b/src/pages/briefs/[...slug].astro index be7d456..d17d159 100644 --- a/src/pages/briefs/[...slug].astro +++ b/src/pages/briefs/[...slug].astro @@ -5,6 +5,7 @@ import Container from "@components/Container.astro"; import FormattedDate from "@components/FormattedDate.astro"; import BackToPrev from "@components/BackToPrev.astro"; import { extractCategoryFromSlug, getCategory } from "@lib/category"; +import { getBriefOGData } from "@lib/opengraph"; import { renderInlineMarkdown } from "@lib/markdown"; export async function getStaticPaths() { @@ -24,10 +25,11 @@ const { Content } = await brief.render(); // Extract category from the slug const categorySlug = extractCategoryFromSlug(brief.slug); const category = categorySlug ? getCategory(categorySlug, `src/content/briefs/${categorySlug}`) : null; +const ogData = getBriefOGData(brief, category, Astro.url.toString(), Astro.site?.toString() || ""); const renderedTitlePrefix = category?.titlePrefix ? renderInlineMarkdown(category.titlePrefix) : null; --- - +
diff --git a/src/pages/briefs/[category].astro b/src/pages/briefs/[category].astro index 220f3f0..fc6a197 100644 --- a/src/pages/briefs/[category].astro +++ b/src/pages/briefs/[category].astro @@ -6,6 +6,7 @@ import ContentCard from "@components/ContentCard.astro"; import { getBriefCardProps } from "@lib/contentCardHelpers"; import { getCategory, extractCategoryFromSlug } from "@lib/category"; import BackToPrev from "@components/BackToPrev.astro"; +import { getListOGData } from "@lib/opengraph"; export async function getStaticPaths() { const allBriefs = (await getCollection("briefs")) @@ -47,9 +48,18 @@ const renderedBriefs = await Promise.all( return { ...item, Content }; }) ); + +const ogData = getListOGData( + `${category.displayName} Briefs`, + category.description || `Briefs about ${category.displayName.toLowerCase()}`, + "briefs", + briefs.length, + Astro.url.toString(), + Astro.site?.toString() || "" +); --- - +
diff --git a/src/pages/briefs/index.astro b/src/pages/briefs/index.astro index 1c5104e..320d1b5 100644 --- a/src/pages/briefs/index.astro +++ b/src/pages/briefs/index.astro @@ -7,6 +7,7 @@ import Link from "@components/Link.astro"; import { getBriefCardProps } from "@lib/contentCardHelpers"; import { extractCategoryFromSlug, getCategory } from "@lib/category"; import { BRIEFS } from "@consts"; +import { getListOGData } from "@lib/opengraph"; const collection = (await getCollection("briefs")) .filter(brief => !brief.data.draft) @@ -65,9 +66,17 @@ const brief_categories = Object.keys(briefs_by_category) ); }); +const ogData = getListOGData( + BRIEFS.TITLE, + BRIEFS.DESCRIPTION, + "briefs", + collection.length, + Astro.url.toString(), + Astro.site?.toString() || "" +); --- - +
diff --git a/src/pages/index.astro b/src/pages/index.astro index 16bbb21..461a775 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -7,6 +7,7 @@ import { getBlogCardProps, getBriefCardProps, getProjectCardProps } from "@lib/c import Link from "@components/Link.astro"; // import { dateRange } from "@lib/utils"; import { SITE, HOME, SOCIALS } from "@consts"; +import { getHomeOGData } from "@lib/opengraph"; const blog = (await getCollection("blog")) .filter(post => !post.data.draft) @@ -29,9 +30,13 @@ const briefs = await Promise.all( }) ); +const ogData = getHomeOGData( + Astro.url.toString(), + Astro.site?.toString() || "" +); --- - +

Hi! 👋🏻 diff --git a/src/pages/projects/[...slug].astro b/src/pages/projects/[...slug].astro index 4160dfd..295c984 100644 --- a/src/pages/projects/[...slug].astro +++ b/src/pages/projects/[...slug].astro @@ -6,6 +6,7 @@ import FormattedDate from "@components/FormattedDate.astro"; import { readingTime } from "@lib/utils"; import BackToPrev from "@components/BackToPrev.astro"; import Link from "@components/Link.astro"; +import { getProjectOGData } from "@lib/opengraph"; export async function getStaticPaths() { const projects = (await getCollection("projects")) @@ -20,9 +21,11 @@ type Props = CollectionEntry<"projects">; const project = Astro.props; const { Content } = await project.render(); + +const ogData = getProjectOGData(project, Astro.url.toString(), Astro.site?.toString() || ""); --- - +
diff --git a/src/pages/projects/index.astro b/src/pages/projects/index.astro index cfa85b5..4291e14 100644 --- a/src/pages/projects/index.astro +++ b/src/pages/projects/index.astro @@ -5,13 +5,23 @@ import Container from "@components/Container.astro"; import ContentCard from "@components/ContentCard.astro"; import { getProjectCardProps } from "@lib/contentCardHelpers"; import { PROJECTS } from "@consts"; +import { getListOGData } from "@lib/opengraph"; const projects = (await getCollection("projects")) .filter(project => !project.data.draft) .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()); + +const ogData = getListOGData( + PROJECTS.TITLE, + PROJECTS.DESCRIPTION, + "projects", + projects.length, + Astro.url.toString(), + Astro.site?.toString() || "" +); --- - +