diff --git a/README.md b/README.md index 1ffd9a13..3e23b1bf 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ pnpm preview Build, generate, and dev automatically create responsive image derivatives in `public/images/generated/` (ignored by Git). GitHub Pages deploys `.output/public`; it does not run a Nitro server. Legacy `/articles/…` and `/atom/` URLs therefore have generated HTML redirects as well as Nitro HTTP redirects. The feed is `/atom.xml` so static hosting serves it as XML. Robots and sitemap are generated from shared site metadata. +Section social images are also generated under `public/images/generated/og/`. They reuse the home image's frame and texture with labels such as `colton.foo | talks`. `SOCIAL_IMAGE_SECTIONS` in `shared/site.ts` controls the variants; nested pages inherit their section's image unless they supply an explicit cover. The home image stays unchanged. The generator uses the bundled DejaVu Sans Mono Bold font (license in `assets/fonts/LICENSE_DEJAVU`) rendered as SVG paths, so local and CI builds do not depend on installed system fonts. + ## Validation ```bash diff --git a/assets/fonts/DejaVuSansMono-Bold.ttf b/assets/fonts/DejaVuSansMono-Bold.ttf new file mode 100644 index 00000000..cbcdd31d Binary files /dev/null and b/assets/fonts/DejaVuSansMono-Bold.ttf differ diff --git a/assets/fonts/LICENSE_DEJAVU b/assets/fonts/LICENSE_DEJAVU new file mode 100644 index 00000000..8ecd49d6 --- /dev/null +++ b/assets/fonts/LICENSE_DEJAVU @@ -0,0 +1,99 @@ +Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. +Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below) + +Bitstream Vera Fonts Copyright +------------------------------ + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is +a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the fonts accompanying this license ("Fonts") and associated +documentation files (the "Font Software"), to reproduce and distribute the +Font Software, including without limitation the rights to use, copy, merge, +publish, distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to the +following conditions: + +The above copyright and trademark notices and this permission notice shall +be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional glyphs or characters may be added to the Fonts, only if the fonts +are renamed to names not containing either the words "Bitstream" or the word +"Vera". + +This License becomes null and void to the extent applicable to Fonts or Font +Software that has been modified and is distributed under the "Bitstream +Vera" names. + +The Font Software may be sold as part of a larger software package but no +copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME +FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING +ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE +FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome +Foundation, and Bitstream Inc., shall not be used in advertising or +otherwise to promote the sale, use or other dealings in this Font Software +without prior written authorization from the Gnome Foundation or Bitstream +Inc., respectively. For further information, contact: fonts at gnome dot +org. + +Arev Fonts Copyright +------------------------------ + +Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the fonts accompanying this license ("Fonts") and +associated documentation files (the "Font Software"), to reproduce +and distribute the modifications to the Bitstream Vera Font Software, +including without limitation the rights to use, copy, merge, publish, +distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to +the following conditions: + +The above copyright and trademark notices and this permission notice +shall be included in all copies of one or more of the Font Software +typefaces. + +The Font Software may be modified, altered, or added to, and in +particular the designs of glyphs or characters in the Fonts may be +modified and additional glyphs or characters may be added to the +Fonts, only if the fonts are renamed to names not containing either +the words "Tavmjong Bah" or the word "Arev". + +This License becomes null and void to the extent applicable to Fonts +or Font Software that has been modified and is distributed under the +"Tavmjong Bah Arev" names. + +The Font Software may be sold as part of a larger software package but +no copy of one or more of the Font Software typefaces may be sold by +itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL +TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the name of Tavmjong Bah shall not +be used in advertising or otherwise to promote the sale, use or other +dealings in this Font Software without prior written authorization +from Tavmjong Bah. For further information, contact: tavmjong @ free +. fr. + +$Id: LICENSE 2133 2007-11-28 02:46:28Z lechimp $ diff --git a/composables/usePageSeo.ts b/composables/usePageSeo.ts index acade023..75d5f207 100644 --- a/composables/usePageSeo.ts +++ b/composables/usePageSeo.ts @@ -13,7 +13,7 @@ export function usePageSeo({ image, }: PageSeoOptions) { const fullTitle = pageTitle(title); - const socialImage = imageUrl(image); + const socialImage = imageUrl(image, path); const canonical = absoluteUrl(path); useSeoMeta({ diff --git a/package.json b/package.json index 10726b84..a806b429 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "scripts": { "preinstall": "npx only-allow pnpm", "build": "pnpm images:generate && nuxt build", - "images:generate": "node scripts/generate-responsive-images.mjs", + "images:generate": "node scripts/generate-responsive-images.mjs && node --import tsx scripts/generate-social-images.ts", "test": "node --import tsx --test tests/unit/*.test.ts", "test:output": "node --import tsx --test tests/output/*.test.ts", "test:e2e": "playwright test", @@ -21,9 +21,11 @@ "@playwright/test": "^1.64.1", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.3.3", + "@types/opentype.js": "^1.3.10", "feed": "^6.0.0", "gray-matter": "^4.0.3", "nuxt": "^4.5.2", + "opentype.js": "^2.0.0", "prettier": "^3.9.9", "prettier-plugin-tailwindcss": "^0.8.1", "sharp": "^0.35.5", diff --git a/pages/blog/[...slug].vue b/pages/blog/[...slug].vue index 53ce3a80..e437aa1b 100644 --- a/pages/blog/[...slug].vue +++ b/pages/blog/[...slug].vue @@ -36,7 +36,7 @@ const canonical = computed(() => { ); }); const socialImage = computed(() => - imageUrl(page.value?.cover_image || page.value?.img), + imageUrl(page.value?.cover_image || page.value?.img, route.path), ); const publishedDate = computed(() => page.value?.date ? new Date(page.value.date).toISOString() : undefined, diff --git a/pages/experiments/index.vue b/pages/experiments/index.vue index 33d142f3..bdb8f5f0 100644 --- a/pages/experiments/index.vue +++ b/pages/experiments/index.vue @@ -7,7 +7,6 @@ usePageSeo({ title, description, path: "/experiments", - image: "/images/previews/conway.webp", }); diff --git a/pages/talks.vue b/pages/talks.vue index 298b4a0f..3610f977 100644 --- a/pages/talks.vue +++ b/pages/talks.vue @@ -3,21 +3,10 @@ const pageSeoTitle = "Talks"; const pageSeoDescription = "Talks, slide decks, videos, and demos from Colton Padden on data engineering, AI applications, Dagster, and developer education."; -useSeoMeta({ - title: pageTitle(pageSeoTitle), +usePageSeo({ + title: pageSeoTitle, description: pageSeoDescription, - ogTitle: pageTitle(pageSeoTitle), - ogDescription: pageSeoDescription, - ogImage: imageUrl(), - ogUrl: absoluteUrl("/talks"), - twitterCard: "summary_large_image", - twitterTitle: pageTitle(pageSeoTitle), - twitterDescription: pageSeoDescription, - twitterImage: imageUrl(), -}); - -useHead({ - link: [{ rel: "canonical", href: absoluteUrl("/talks") }], + path: "/talks", }); const talks = [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afb271b4..51ad665d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(vite@8.2.0(@types/node@24.19.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0)) + '@types/opentype.js': + specifier: ^1.3.10 + version: 1.3.10 feed: specifier: ^6.0.0 version: 6.0.0 @@ -36,6 +39,9 @@ importers: nuxt: specifier: ^4.5.2 version: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@netlify/blobs@9.1.2)(@oxc-project/types@0.142.0)(@types/node@24.19.0)(@vitejs/devtools-kit@0.4.10(cac@7.0.0)(srvx@0.11.22)(typescript@5.9.3)(vite@8.2.0(@types/node@24.19.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0)))(@vue/compiler-sfc@3.5.40)(better-sqlite3@12.4.1)(cac@7.0.0)(db0@0.3.4(better-sqlite3@12.4.1))(esbuild@0.28.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.4)(oxc-parser@0.140.0)(rolldown@1.2.1)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.1)(rollup@4.62.4))(rollup@4.62.4)(srvx@0.11.22)(terser@5.51.2)(tsx@4.23.15)(typescript@5.9.3)(vite@8.2.0(@types/node@24.19.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.0))(vue-tsc@3.3.11(typescript@5.9.3))(webpack@5.111.1(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25)(sharp@0.35.5(@types/node@24.19.0)))(yaml@2.9.0) + opentype.js: + specifier: ^2.0.0 + version: 2.0.0 prettier: specifier: ^3.9.9 version: 3.9.9 @@ -1387,6 +1393,9 @@ packages: '@types/node@24.19.0': resolution: {integrity: sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==} + '@types/opentype.js@1.3.10': + resolution: {integrity: sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ==} + '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -3257,6 +3266,10 @@ packages: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} + opentype.js@2.0.0: + resolution: {integrity: sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==} + hasBin: true + oxc-parser@0.140.0: resolution: {integrity: sha512-h6QFWd6lBMfjESqgQ27GjzrSDb0qbznp7VDQqp2zvgsrWut4vcchyMIzOVXvGQ2GMZgKw9RWrFNWv9WqGL0p7Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6070,6 +6083,8 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/opentype.js@1.3.10': {} + '@types/resolve@1.20.2': {} '@types/sax@1.2.7': @@ -8239,6 +8254,8 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 + opentype.js@2.0.0: {} + oxc-parser@0.140.0: dependencies: '@oxc-project/types': 0.140.0 diff --git a/scripts/generate-social-images.ts b/scripts/generate-social-images.ts new file mode 100644 index 00000000..dd883ca6 --- /dev/null +++ b/scripts/generate-social-images.ts @@ -0,0 +1,62 @@ +import { mkdir, readFile, rename, rm } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import sharp from "sharp"; +import opentype from "opentype.js"; +import { SOCIAL_IMAGE_SECTIONS, socialImagePath } from "../shared/site"; + +const source = resolve("public/images/og-index.webp"); +const font = opentype.parse( + new Uint8Array(await readFile("assets/fonts/DejaVuSansMono-Bold.ttf")).buffer, +); + +// Preserve the existing image's frame and texture exactly. Cover its wordmark +// with a clean strip of the same texture sampled from above the lettering. +const cleanTexture = await sharp(source) + .extract({ left: 12, top: 80, width: 1176, height: 150 }) + .toBuffer(); +const background = await sharp(source) + .composite([{ input: cleanTexture, left: 12, top: 240 }]) + .png() + .toBuffer(); + +for (const section of SOCIAL_IMAGE_SECTIONS) { + const label = `colton.foo | ${section}`; + // Match the original bold monospace wordmark; shrink longer labels to keep + // generous side margins without wrapping onto a second line. + const original = font.getPath(label, 0, 0, 96).getBoundingBox(); + const fontSize = 96 * Math.min(1, 1056 / (original.x2 - original.x1)); + const lettering = font.getPath(label, 0, 0, fontSize); + lettering.fill = "#ff6900"; + const bounds = lettering.getBoundingBox(); + const x = (1200 - (bounds.x2 - bounds.x1)) / 2 - bounds.x1; + const y = (630 - (bounds.y2 - bounds.y1)) / 2 - bounds.y1; + // SVG paths make rendering independent of fonts installed on the build host. + const text = Buffer.from( + `${lettering.toSVG(2)}`, + ); + + const destination = resolve( + "public", + socialImagePath(`/${section}/`).slice(1), + ); + const directory = resolve(destination, ".."); + await mkdir(directory, { recursive: true }); + const temporary = resolve(directory, `.${randomUUID()}.webp`); + try { + await sharp(background) + .composite([ + { + input: text, + left: 0, + top: 0, + }, + ]) + .webp({ quality: 90 }) + .toFile(temporary); + await rename(temporary, destination); + } finally { + await rm(temporary, { force: true }); + } +} +console.log(`Generated ${SOCIAL_IMAGE_SECTIONS.length} section social images`); diff --git a/shared/site.ts b/shared/site.ts index 6f725bfd..3859e1a3 100644 --- a/shared/site.ts +++ b/shared/site.ts @@ -1,6 +1,20 @@ // Public route metadata shared by rendering, feeds, sitemap, and build tooling. export const SITE_ORIGIN = "https://colton.foo"; export const SITE_NAME = "Colton Padden"; +export const DEFAULT_SOCIAL_IMAGE = "/images/og-index.webp"; +export const SOCIAL_IMAGE_SECTIONS = [ + "about", + "blog", + "experiments", + "talks", +] as const; + +export const socialImagePath = (path = "/") => { + const section = path.split(/[?#]/)[0]?.split("/").filter(Boolean)[0]; + return SOCIAL_IMAGE_SECTIONS.some((name) => name === section) + ? `/images/generated/og/${section}.webp` + : DEFAULT_SOCIAL_IMAGE; +}; export const PAGE_ROUTES = [ "/", "/about/", diff --git a/tests/output/generated.test.ts b/tests/output/generated.test.ts index 569731e5..ab43960f 100644 --- a/tests/output/generated.test.ts +++ b/tests/output/generated.test.ts @@ -3,7 +3,13 @@ import { test } from "node:test"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import { loadArticleSources } from "../../modules/content/manifest"; -import { PAGE_ROUTES, absoluteUrl } from "../../shared/site"; +import { + PAGE_ROUTES, + SOCIAL_IMAGE_SECTIONS, + absoluteUrl, + socialImagePath, +} from "../../shared/site"; +import sharp from "sharp"; const output = ".output/public"; const read = (path: string) => readFileSync(join(output, path), "utf8"); @@ -25,6 +31,37 @@ test("all public routes and published articles have HTML and current canonicals" } }); +test("static social images match page sections and preserve article covers", async () => { + const assertImages = (html: string, image: string, path: string) => { + assert.ok(html.includes(`property="og:image" content="${image}"`), path); + assert.ok(html.includes(`name="twitter:image" content="${image}"`), path); + }; + for (const path of PAGE_ROUTES) { + assertImages( + read(`${path}/index.html`), + absoluteUrl(socialImagePath(path)), + path, + ); + } + for (const article of loadArticleSources(process.cwd())) { + const { cover_image, img } = article.frontmatter; + const image = cover_image || img || socialImagePath(article.path); + assertImages( + read(`${article.path}/index.html`), + absoluteUrl(image), + article.path, + ); + } + for (const section of SOCIAL_IMAGE_SECTIONS) { + const path = join(output, socialImagePath(`/${section}/`)); + const metadata = await sharp(path).metadata(); + assert.equal(metadata.format, "webp"); + assert.equal(metadata.width, 1200); + assert.equal(metadata.height, 630); + assert.ok(statSync(path).size < 400_000, section); + } +}); + test("drafts leave no HTML, payload, legacy redirect, or serialized body in the output", () => { const drafts = loadArticleSources(process.cwd(), true).filter( (a) => a.frontmatter.draft, diff --git a/tests/unit/publication.test.ts b/tests/unit/publication.test.ts index b602c95b..1654b185 100644 --- a/tests/unit/publication.test.ts +++ b/tests/unit/publication.test.ts @@ -19,6 +19,7 @@ import { canonicalPath, PAGE_ROUTES, SITE_ORIGIN, + socialImagePath, } from "../../shared/site"; import { articleDescription, pageTitle, imageUrl } from "../../utils/seo"; @@ -43,6 +44,29 @@ test("canonical URLs use the production origin and static-host slash conventions assert.ok(PAGE_ROUTES.every((route) => canonicalPath(route) === route)); }); +test("social images follow page sections without replacing explicit covers", () => { + assert.equal(socialImagePath("/"), "/images/og-index.webp"); + assert.equal(socialImagePath("/unknown/"), "/images/og-index.webp"); + for (const section of ["about", "blog", "experiments", "talks"]) { + const expected = `/images/generated/og/${section}.webp`; + assert.equal(socialImagePath(`/${section}`), expected); + assert.equal(socialImagePath(`/${section}/?q=1#anchor`), expected); + assert.equal(socialImagePath(`/${section}/nested/`), expected); + assert.equal( + imageUrl(undefined, `/${section}/`), + `https://colton.foo${expected}`, + ); + } + assert.equal( + imageUrl("/images/custom-cover.jpg", "/blog/article/"), + "https://colton.foo/images/custom-cover.jpg", + ); + assert.equal( + imageUrl("https://example.com/cover.png", "/blog/article/"), + "https://example.com/cover.png", + ); +}); + test("publication policy excludes drafts before parsing or serializing their bodies", () => { const all = loadArticleSources(process.cwd(), true); const published = loadArticleSources(process.cwd()); diff --git a/utils/seo.ts b/utils/seo.ts index 565c898d..52677734 100644 --- a/utils/seo.ts +++ b/utils/seo.ts @@ -1,15 +1,15 @@ export { SITE_ORIGIN, SITE_NAME, absoluteUrl } from "#shared/site"; -import { absoluteUrl, SITE_NAME } from "#shared/site"; +import { absoluteUrl, SITE_NAME, socialImagePath } from "#shared/site"; +export { DEFAULT_SOCIAL_IMAGE } from "#shared/site"; export const DEFAULT_TITLE = "colton :: blog ∧ experiments"; export const DEFAULT_DESCRIPTION = "Technical writing, projects, and browser experiments by Colton Padden."; -export const DEFAULT_SOCIAL_IMAGE = "/images/og-index.webp"; export const pageTitle = (title?: string) => title && title !== DEFAULT_TITLE ? `${title} | ${SITE_NAME}` : DEFAULT_TITLE; -export const imageUrl = (path?: string) => - absoluteUrl(path || DEFAULT_SOCIAL_IMAGE); +export const imageUrl = (path?: string, pagePath = "/") => + absoluteUrl(path || socialImagePath(pagePath)); const truncateDescription = (value: string) => { const normalized = value.replace(/\s+/g, " ").trim();