diff --git a/.cache/pages.json b/.cache/pages.json index 5f4910f3c..a5936162f 100644 --- a/.cache/pages.json +++ b/.cache/pages.json @@ -22,10 +22,16 @@ "contact", "offline", { - "privacy": ["my-data"] + "privacy": [ + "my-data" + ] }, { - "services": ["consulting", "overview", "web-development"] + "services": [ + "consulting", + "overview", + "web-development" + ] }, { "tags": [ @@ -47,4 +53,4 @@ "typescript" ] } -] +] \ No newline at end of file diff --git a/_TODO.md b/_TODO.md index 20cd25da0..078cbddc0 100644 --- a/_TODO.md +++ b/_TODO.md @@ -60,6 +60,8 @@ text-white, other default Tailwind colors ## Typing client-side API calls and SSR API endpoints +Use Astro Actions + Shared Types vs Swagger / Keeping Docs in Sync 1. Type-only sharing (current approach) @@ -117,21 +119,6 @@ fatal: /home/kevin/Repos/Webstack: '/home/kevin/Repos/Webstack' is outside repos āš ļø Privacy policy version fallback applied: 2025-12-08 -## @TODO: Add Check HTML Links to test workflow - -npm i -D check-html-links -npx check-html-links _site -`https://github.com/modernweb-dev/rocket/tree/main/packages/check-html-links` - -22:38:43 [ERROR] [astro-link-validator] āŒ Found 2 broken links: -\nšŸ“„ index.html: - šŸ”— /services/web-development - File not found: services/web-development - Text: "Improve Your Product" - šŸ”— /services/consulting - File not found: services/consulting - Text: "Start from scratch" - ## Tags Can we add a markdown page to explain what each tag is, and use those pages instead of src/content/_tagList.ts to define the tags available? @@ -140,106 +127,32 @@ Can we add a markdown page to explain what each tag is, and use those pages inst Looking at the social-card endpoint implementation, it's designed to work with third-party screenshot services, not the social networks themselves. -Here's how it works: - -The Two Formats -HTML format (format=html or default): Returns a full HTML page with inline CSS styled as a 1200x630px card - the standard Open Graph image dimensions. - -OG format (format=og): Returns JSON with Open Graph meta tags, where the og:image URL points back to the HTML version of the card. - -How Social Networks Actually Work -Social networks like Twitter, Facebook, LinkedIn, etc. don't screenshot HTML pages. They expect: - -- Direct image URLs (PNG, JPEG, etc.) -- Standard dimensions (1200x630px for most platforms) - -**The Intended Workflow** - -This endpoint is designed to integrate with screenshot services like: - -- Puppeteer or Playwright - Run your own screenshot service -- Vercel OG Image Generation - Vercel's built-in service -- Cloudinary - Can fetch and screenshot URLs -- ScreenshotOne or ApiFlash - Dedicated screenshot APIs -- Satori - Convert HTML/CSS to SVG/PNG - -**Current Limitation** - -As implemented, this endpoint would need an additional step to be useful for social sharing: - -Your endpoint → Screenshot service → Image file → Social networks - -Better Approaches - -For a production Astro site, you'd typically: - -- Use @vercel/og or Satori to generate actual images server-side -- Pre-generate images at build time for static content -- Use a screenshot service that can be called from your endpoint to return actual images - -**Social Media Preview Generators** - -There are several integrations available that vary based on the library they use to create an image file to snapshot, whether they allow the template for generating the image to be modified, and what options they provide for output. - -[`astro-og-canvas`](https://www.npmjs.com/package/astro-og-canvas) - -- Most popular option (~660 weekly d/l). Generates images at **run time**. -- Uses **`canvaskit-wasm`** for rendering -- Uses plain color or gradient background. Provide title, description, and logo (displayed at top left of card). -- Can't set size of final card. - -[`astro-satori`](https://www.npmjs.com/package/astro-satori) - -- Moderately popular option (~230 weekly d/l). Generates images at **run time**. -- Uses Vercel's **Satori** library for rendering (entirely done in JS with limitations on what CSS can be used). Satori is a library for generating SVG strings from pure HTML and CSS. -- Size of final card can be set. -- Seems opinionated, but it might be possible to have a lot of control (not sure). -- Output format? - -[`astro-opengraph-image`](https://www.npmjs.com/package/@altano/astro-opengraph-image#fn-filename-change) - -- Uses **Satori**. Has dependencies on [`@resvg/resvg-wasm`](https://www.npmjs.com/package/@resvg/resvg-wasm) and Sharp. Middleware integration. Generates images at **run time**. -- Provides element to add OG tags in document ``. -- Very flexible, you can provide the Astro template to generate the card. - -[Astro Open Graph Image](https://www.npmjs.com/package/astro-og-image) - -- Uses **Puppeteer**. Generates images at **build time**. -- You can provide the Astro template to generate the card. -- Requires providing a `baseHead` property in page templates. - -[Astro Open Graph Image Generator](https://www.npmjs.com/package/@cyberkoalastudios/og-image-generator) - -- Uses **Puppeteer**. Has dependencies on [`canvaskit-wasm`](https://www.npmjs.com/package/@resvg/resvg-wasm) and Sharp. -- You can set the background image. No option to set the size of the card. -- Manually add OF properties on `` element. Flexible. - -## @TODO: Use Confetti on CTA forms +## Use Confetti on CTA forms `canvas-confetti` `https://github.com/catdad/canvas-confetti` `https://www.kirilv.com/canvas-confetti/` -## @TODO: Use the Page Visibility API to pause videos, image carousels, and animations +## Use the Page Visibility API to pause videos, image carousels, and animations Stop unnecessary processes when the user doesn't see the page or inversely to perform background actions. -## @TODO: "Add to Calendar" button +## "Add to Calendar" button Google Calendar, Apple Calendar, Yahoo Calender, Microsoft 365, Outlook, and Teams, and generate iCal/ics files (for all other calendars and cases). `https://github.com/add2cal/add-to-calendar-button` `https://add-to-calendar-button.com/` -## @TODO: Set up webmentions +## Set up webmentions Needs to add real API key and test -## @TODO: SCSS Use clothoid corners with border-radius +## SCSS Use clothoid corners with border-radius `https://onotakehiko.dev/clothoid/` -`@TODO: SCSS Make sure accent-color or styling for checkboxes/radio button groups is set up. Sets the colour used by checkboxes and radio buttons, as well as range fields and progress indicators. The accent colour is inherited` +`SCSS Make sure accent-color or styling for checkboxes/radio button groups is set up. Sets the colour used by checkboxes and radio buttons, as well as range fields and progress indicators. The accent colour is inherited` ```scss :root{ @@ -247,11 +160,11 @@ Needs to add real API key and test } ``` -## @TODO: Refactor modals +## Refactor modals Modals should be wrapped in the `` element and use programmatic methods to display - `showModal()` to disable the area outside of the modal (handles `esc` keypress natively) and `show()` to allow interaction outside the modal, along with `close()`. -## @TODO: Add for iOS +## Add for iOS Specifying a Launch Screen Image @@ -383,33 +296,6 @@ Adds some custom directives: Allows any Mastodon instance to discover your Mastodon profile directly from your own domain. -## Preload hero images, usually loaded after stylesheets and fonts - -```font - - - - - - ... - -``` - ## Refactor social neworks in Authors collection to Contact collection format The contact data collection uses an array of social networks, with keys: diff --git a/astro.config.ts b/astro.config.ts index 2ef4bc6fd..ee5f3de6c 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -103,6 +103,12 @@ export default defineConfig({ /** API routes are marked in their files for SSR */ output: 'static', prefetch: true, + image: { + remotePatterns: [ + { protocol: 'https' }, + { protocol: 'http' }, + ], + }, /** Change URL between development and production environments */ site: getSiteUrl(), trailingSlash: 'never', diff --git a/package-lock.json b/package-lock.json index ff28cbcb5..aeb0b4eb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "astro": "5.16.4", "astro-icon": "^1.1.5", "astro-link-validator": "github:rodgtr1/astro-link-validator", + "astro-og-canvas": "^0.7.2", "astro-vtbot": "^2.1.9", "cross-env": "^10.1.0", "dotenv": "17.2.3", @@ -9269,6 +9270,12 @@ "license": "BSD-3-Clause", "peer": true }, + "node_modules/@webgpu/types": { + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.21.tgz", + "integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==", + "license": "BSD-3-Clause" + }, "node_modules/abbrev": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", @@ -9843,6 +9850,35 @@ "astro": "^4.0.0 || ^5.0.0" } }, + "node_modules/astro-og-canvas": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/astro-og-canvas/-/astro-og-canvas-0.7.2.tgz", + "integrity": "sha512-i7sWbdpl2MMF7wUZqSfzMnhW4c7GQz6s7T4BUJ7E06MqasZRHjJytn0n6D0JJkGDeTpZnU7E00A6Ffx0pRB4kg==", + "license": "MIT", + "dependencies": { + "canvaskit-wasm": "^0.40.0", + "deterministic-object-hash": "^2.0.2", + "entities": "^7.0.0" + }, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "astro": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, + "node_modules/astro-og-canvas/node_modules/entities": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/astro-vtbot": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/astro-vtbot/-/astro-vtbot-2.1.9.tgz", @@ -10677,6 +10713,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvaskit-wasm": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz", + "integrity": "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==", + "license": "BSD-3-Clause", + "dependencies": { + "@webgpu/types": "0.1.21" + } + }, "node_modules/caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", diff --git a/package.json b/package.json index 3c0cd57f8..ce84e727f 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "astro": "5.16.4", "astro-icon": "^1.1.5", "astro-link-validator": "github:rodgtr1/astro-link-validator", + "astro-og-canvas": "^0.7.2", "astro-vtbot": "^2.1.9", "cross-env": "^10.1.0", "dotenv": "17.2.3", diff --git a/public/assets/images/webmention-avatar-default.svg b/src/assets/images/avatars/webmention-avatar-default.svg similarity index 100% rename from public/assets/images/webmention-avatar-default.svg rename to src/assets/images/avatars/webmention-avatar-default.svg diff --git a/src/components/Head/Meta.astro b/src/components/Head/Meta.astro index f58aa4dc6..658183005 100644 --- a/src/components/Head/Meta.astro +++ b/src/components/Head/Meta.astro @@ -1,4 +1,5 @@ --- +import { getSiteUrl } from '@lib/config/siteUrlServer' import contactData from '@content/contact.json' import { absoluteUrl } from '@components/scripts/utils' import { getMetaThemeData } from '@components/Head/server/meta' @@ -13,7 +14,7 @@ export interface Props { contentType?: 'article' | 'website' /** Meta description */ description?: string - image?: string + /** Last modified date */ modifiedDate?: Date /** Whether search engines should ignore the page, default: false */ noindex?: boolean @@ -33,7 +34,6 @@ const { author, contentType, description, - image, modifiedDate, noindex = false, pageTitle, @@ -43,7 +43,7 @@ const { tags, } = Astro.props -const { slug, defaultTheme } = getMetaThemeData(path) +const defaultTheme = getMetaThemeData() --- @@ -64,15 +64,13 @@ const { slug, defaultTheme } = getMetaThemeData(path) {/* JSON-LD Structured Data for search engines */} {/* Web App Manifest */} diff --git a/src/components/Head/Social.astro b/src/components/Head/Social.astro index 54f26989f..c3ea454da 100644 --- a/src/components/Head/Social.astro +++ b/src/components/Head/Social.astro @@ -2,44 +2,32 @@ /** * Open Graph / Facebook / X.com metadata */ +import { getSiteUrl } from '@lib/config/siteUrlServer' +import { getSocialImageLink } from '@components/Head/server' import contactData from '@content/contact.json' -import { getSocialMetadata, type SocialMetadataOptions } from '@components/Head/server' export interface Props { - /** Meta description */ - description?: string - image?: string - slug?: string // @TODO: Should this be named "path"? Or rename "path" to "slug"? - title?: string + description: string + path: string + title: string } -const { title, description, slug, image } = Astro.props +const { title, description, path } = Astro.props -/** Assertion on Astro.site because it's guaranteed to be present or build throws */ -const options: SocialMetadataOptions = { url: Astro.url.href, baseUrl: Astro.site!.origin } -if (title !== undefined) options.title = title -if (description !== undefined) options.description = description -if (slug !== undefined) options.slug = slug -if (image !== undefined) options.image = image -const socialMetadata = getSocialMetadata(options) --- {/* Types include 'website', 'article', 'profile', and 'product' */} - - - - + + + + {/* Canonical URL of the page */} - + {/* Twitter metadata */} - - - - diff --git a/src/components/Head/StructuredData.astro b/src/components/Head/StructuredData.astro index fdffef117..d6a1ea97a 100644 --- a/src/components/Head/StructuredData.astro +++ b/src/components/Head/StructuredData.astro @@ -10,7 +10,6 @@ const { publishDate, modifiedDate, author, - image, } = Astro.props const schemas = getSchemas({ @@ -22,7 +21,6 @@ const schemas = getSchemas({ ...(publishDate && { publishDate }), ...(modifiedDate && { modifiedDate }), ...(author && { author }), - ...(image && { image }), }) --- diff --git a/src/components/Head/index.astro b/src/components/Head/index.astro index 0881486db..a482414da 100644 --- a/src/components/Head/index.astro +++ b/src/components/Head/index.astro @@ -9,7 +9,6 @@ export interface Props { contentType?: 'article' | 'website' /** Meta description */ description?: string - image?: string /** Last modified date */ modifiedDate?: Date /** Whether search engines should ignore the page, default: false */ @@ -30,7 +29,6 @@ const { author, contentType, description, - image, modifiedDate, noindex, pageTitle, @@ -53,7 +51,6 @@ const { pageTitle={pageTitle} path={path} {...description && { description }} - {...image && { image }} {...noindex && { noindex }} {...contentType && { contentType }} {...publishDate && { publishDate }} diff --git a/src/components/Head/server/__fixtures__/structuredData.fixture.ts b/src/components/Head/server/__fixtures__/structuredData.fixture.ts index f87b4ef5a..88728e867 100644 --- a/src/components/Head/server/__fixtures__/structuredData.fixture.ts +++ b/src/components/Head/server/__fixtures__/structuredData.fixture.ts @@ -38,9 +38,5 @@ export const createStructuredDataParams = ( if (overrides.author !== undefined) { params.author = overrides.author } - if (overrides.image !== undefined) { - params.image = overrides.image - } - return params } diff --git a/src/components/Head/server/__tests__/index.spec.ts b/src/components/Head/server/__tests__/index.spec.ts index 9a57c9897..af21ecfc4 100644 --- a/src/components/Head/server/__tests__/index.spec.ts +++ b/src/components/Head/server/__tests__/index.spec.ts @@ -1,56 +1,32 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as siteUrlServer from '@lib/config/siteUrlServer' import * as headServer from '../index' -describe('getSocialImage', () => { - it('encodes parameters and strips trailing slashes from base URL', () => { - const url = headServer.getSocialImage( - 'https://example.com/', - 'Launch Announcement', - 'See what is new!', - 'news/update' - ) - - expect(url).toBe( - 'https://example.com/api/social-card?title=Launch%20Announcement&description=See%20what%20is%20new!&slug=news%2Fupdate&format=html' - ) - }) +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('getSocialImageLink', () => { + const mockSiteUrl = () => vi.spyOn(siteUrlServer, 'getSiteUrl').mockReturnValue('https://example.com') - it('falls back to default values when inputs are missing', () => { - const url = headServer.getSocialImage('https://webstackbuilders.com') + it('normalizes whitespace and slashes within the slug', () => { + mockSiteUrl() + const url = headServer.getSocialImageLink(' /articles//new-release/ ') - expect(url).toBe( - 'https://webstackbuilders.com/api/social-card?title=Webstack%20Builders&description=Professional%20Web%20Development%20Services&slug=home&format=html' - ) + expect(url).toBe('https://example.com/og/articles/new-release.png') }) -}) -describe('getSocialMetadata', () => { - it('generates metadata from normalized base URL when no image is provided', () => { - const metadata = headServer.getSocialMetadata({ - title: 'Custom Title', - description: 'Custom Description', - slug: 'launch/post', - baseUrl: 'https://example.com/', - }) - - expect(metadata.ogImage).toBe( - 'https://example.com/api/social-card?title=Custom%20Title&description=Custom%20Description&slug=launch%2Fpost&format=html' - ) - expect(metadata.ogUrl).toBe('https://example.com/launch/post') + it('derives the slug from an absolute URL and slugifies spaces', () => { + mockSiteUrl() + const url = headServer.getSocialImageLink('https://webstackbuilders.com/services/cloud consulting/') + + expect(url).toBe('https://example.com/og/services/cloud-consulting.png') }) - it('prefers the provided image and URL overrides', () => { - const metadata = headServer.getSocialMetadata({ - title: 'Provided Image', - description: 'Provided Description', - slug: 'ignored', - image: 'https://cdn.example.com/card.png', - url: 'https://example.com/custom', - baseUrl: 'https://example.com/', - }) - - expect(metadata.ogImage).toBe('https://cdn.example.com/card.png') - expect(metadata.ogUrl).toBe('https://example.com/custom') - expect(metadata.twitterImage).toBe('https://cdn.example.com/card.png') + it('falls back to the default slug when path is missing', () => { + mockSiteUrl() + const url = headServer.getSocialImageLink(undefined) + + expect(url).toBe('https://example.com/og/home.png') }) }) diff --git a/src/components/Head/server/__tests__/meta.spec.ts b/src/components/Head/server/__tests__/meta.spec.ts new file mode 100644 index 000000000..5861239ee --- /dev/null +++ b/src/components/Head/server/__tests__/meta.spec.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +const loadMetaModule = async () => { + const module = await import('../meta') + return module.getMetaThemeData +} + +const loadBuildError = async () => { + const module = await import('@lib/errors/BuildError') + return module.BuildError +} + +describe('getMetaThemeData', () => { + afterEach(() => { + vi.resetModules() + vi.doUnmock('@content/themes.json') + }) + + it('returns the theme that matches the configured default id', async () => { + vi.doMock('@content/themes.json', () => ({ + default: { + defaultTheme: { id: 'midnight', prefersDarkScheme: true }, + themes: [ + { id: 'sunrise', colors: { backgroundOffset: '#ffeeaa' } }, + { id: 'midnight', colors: { backgroundOffset: '#001133' } }, + ], + }, + })) + + const getMetaThemeData = await loadMetaModule() + const theme = getMetaThemeData() + + expect(theme.id).toBe('midnight') + expect(theme.colors?.backgroundOffset).toBe('#001133') + }) + + it('falls back to the first configured theme when the default id is missing', async () => { + vi.doMock('@content/themes.json', () => ({ + default: { + defaultTheme: { id: 'nonexistent', prefersDarkScheme: false }, + themes: [ + { id: 'primary', colors: { backgroundOffset: '#ffffff' } }, + { id: 'secondary', colors: { backgroundOffset: '#000000' } }, + ], + }, + })) + + const getMetaThemeData = await loadMetaModule() + expect(getMetaThemeData().id).toBe('primary') + }) + + it('throws a BuildError when no themes are configured', async () => { + vi.doMock('@content/themes.json', () => ({ + default: { + defaultTheme: { id: 'anything', prefersDarkScheme: false }, + themes: [], + }, + })) + + const getMetaThemeData = await loadMetaModule() + const BuildError = await loadBuildError() + const call = () => getMetaThemeData() + + expect(call).toThrow(BuildError) + expect(call).toThrow('No themes configured in themes.json; unable to set meta theme-color.') + }) +}) diff --git a/src/components/Head/server/__tests__/utils.spec.ts b/src/components/Head/server/__tests__/utils.spec.ts new file mode 100644 index 000000000..546b55655 --- /dev/null +++ b/src/components/Head/server/__tests__/utils.spec.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getSocialImageLink } from '../utils' +import { getSiteUrl } from '@lib/config/siteUrlServer' + +vi.mock('@lib/config/siteUrlServer', () => ({ + getSiteUrl: vi.fn(() => 'https://site.test'), +})) + +const mockedGetSiteUrl = vi.mocked(getSiteUrl) + +describe('getSocialImageLink', () => { + beforeEach(() => { + mockedGetSiteUrl.mockClear() + mockedGetSiteUrl.mockReturnValue('https://site.test') + }) + + it('falls back to the home slug when no usable path is provided', () => { + expect(getSocialImageLink()).toBe('https://site.test/og/home.png') + expect(getSocialImageLink(' ')).toBe('https://site.test/og/home.png') + expect(mockedGetSiteUrl).toHaveBeenCalledTimes(2) + }) + + it('normalizes relative paths with redundant separators and whitespace', () => { + const url = getSocialImageLink(' /Articles//My Feature / ') + expect(url).toBe('https://site.test/og/Articles/My-Feature.png') + }) + + it('parses absolute URLs and decodes their path segments', () => { + const url = getSocialImageLink('https://preview.example.com/services/Launch%20Plan///?src=home') + expect(url).toBe('https://site.test/og/services/Launch-Plan.png') + }) + + it('uses the site url value returned at call time', () => { + mockedGetSiteUrl.mockReturnValueOnce('https://custom.test') + const url = getSocialImageLink('about/company') + expect(url).toBe('https://custom.test/og/about/company.png') + expect(mockedGetSiteUrl).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/Head/server/index.ts b/src/components/Head/server/index.ts index b0dac3ecb..262dbc0dd 100644 --- a/src/components/Head/server/index.ts +++ b/src/components/Head/server/index.ts @@ -2,11 +2,5 @@ * Server-side utilities for Head component * Functions used during build process in Astro frontmatter */ -export { - getSocialImage, - getSocialMetadata, - type SocialMetadataOptions, - type SocialMetadata, -} from './social' - export { getMetaThemeData } from './meta' +export { getSocialImageLink } from './utils' diff --git a/src/components/Head/server/meta.ts b/src/components/Head/server/meta.ts index 6f5eb726d..13520f700 100644 --- a/src/components/Head/server/meta.ts +++ b/src/components/Head/server/meta.ts @@ -1,7 +1,7 @@ import { BuildError } from '@lib/errors/BuildError' import themeConfig from '@content/themes.json' -export const getMetaThemeData = (path: string) => { +export const getMetaThemeData = () => { const defaultThemeId = themeConfig.defaultTheme.id const defaultTheme = themeConfig.themes.find(theme => theme.id === defaultThemeId) || themeConfig.themes[0] @@ -9,10 +9,6 @@ export const getMetaThemeData = (path: string) => { if (!defaultTheme) { throw new BuildError('No themes configured in themes.json; unable to set meta theme-color.') } - const slug = path.replace(/^\//, '').replace(/\/$/, '') || 'home' - return { - slug, - defaultTheme, - } + return defaultTheme } diff --git a/src/components/Head/server/social.ts b/src/components/Head/server/social.ts deleted file mode 100644 index a01cf1879..000000000 --- a/src/components/Head/server/social.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { BuildError } from '@lib/errors/BuildError' - -export interface SocialMetadataOptions { - title?: string - description?: string - slug?: string - image?: string - url?: string - baseUrl: string -} - -export interface SocialMetadata { - ogTitle: string - ogDescription: string - ogImage: string - ogImageAlt: string - ogUrl: string - twitterTitle: string - twitterDescription: string - twitterImage: string - twitterImageAlt: string -} -/** - * Get social share image URL using dynamic generation - * Usage: getSocialImage(baseUrl, title, description, slug) - */ -const normalizeBaseUrl = (value?: string): string => { - if (!value || value.trim() === '') { - throw new BuildError('Base URL is required to generate social metadata.') - } - - const base = value.trim() - return base.replace(/\/+$/, '') -} - -export function getSocialImage( - baseUrl: string, - title?: string, - description?: string, - slug?: string -): string { - const baseURL = normalizeBaseUrl(baseUrl) - const encodedTitle = encodeURIComponent(title || 'Webstack Builders') - const encodedDescription = encodeURIComponent( - description || 'Professional Web Development Services' - ) - const encodedSlug = encodeURIComponent(slug || 'home') - - // For development, you can use a service like htmlcsstoimage.com or similar - // For production, integrate with services like Puppeteer, Playwright, or @vercel/og - return `${baseURL}/api/social-card?title=${encodedTitle}&description=${encodedDescription}&slug=${encodedSlug}&format=html` -} - -/** - * Get social share metadata for Open Graph tags - * Usage: getSocialMetadata({ title, description, slug, url }) - */ -export function getSocialMetadata(options: SocialMetadataOptions): SocialMetadata { - const { - title = 'Webstack Builders', - description = 'Professional Web Development Services', - slug = 'home', - image, - url, - baseUrl, - } = options - - const normalizedBaseUrl = normalizeBaseUrl(baseUrl) - const socialImage = image || getSocialImage(normalizedBaseUrl, title, description, slug) - const socialUrl = url || `${normalizedBaseUrl}/${slug}` - const imageAlt = `Social card for ${title}` - - return { - ogTitle: title, - ogDescription: description, - ogImage: socialImage, - ogImageAlt: imageAlt, - ogUrl: socialUrl, - twitterTitle: title, - twitterDescription: description, - twitterImage: socialImage, - twitterImageAlt: imageAlt, - } -} diff --git a/src/components/Head/server/structuredData.ts b/src/components/Head/server/structuredData.ts index 720c91ff6..141865a90 100644 --- a/src/components/Head/server/structuredData.ts +++ b/src/components/Head/server/structuredData.ts @@ -12,6 +12,7 @@ import type { import contactData from '@content/contact.json' import { absoluteUrl } from '@components/scripts/utils/absoluteUrl' import { BuildError } from '@lib/errors/BuildError' +import { getSocialImageLink } from '@components/Head/server' const STRUCTURED_DATA_FILE = 'src/components/Head/server/structuredData.ts' const LOGO_PATH = '/logo.png' @@ -27,7 +28,6 @@ export interface StructuredDataProps { publishDate?: Date modifiedDate?: Date author?: string - image?: string } export interface StructuredDataParams extends StructuredDataProps { @@ -69,7 +69,7 @@ export const getSchemas = (params: StructuredDataParams): string[] => { } const createSchemaContext = (params: StructuredDataParams): SchemaContext => { - const { astro, path, pageTitle, description, contentType, publishDate, modifiedDate, author, image } = params + const { astro, path, pageTitle, description, contentType, publishDate, modifiedDate, author } = params if (!astro) { throw new BuildError('Astro context is required to generate structured data', { @@ -98,7 +98,7 @@ const createSchemaContext = (params: StructuredDataParams): SchemaContext => { const normalizedPath = normalizePath(path) const canonicalUrl = astro.url?.href ?? resolveRoute(normalizedPath, astro.site) const descriptionFallback = description ?? contactData.company.description - const resolvedImage = resolveOptionalAssetUrl(image, astro.site) + const socialImageUrl = getSocialImageLink(path) const context: SchemaContext = { path: normalizedPath, @@ -107,6 +107,7 @@ const createSchemaContext = (params: StructuredDataParams): SchemaContext => { site: astro.site, canonicalUrl, pathSegments: normalizedPath.split('/').filter(Boolean), + image: socialImageUrl, } if (contentType) { @@ -127,10 +128,6 @@ const createSchemaContext = (params: StructuredDataParams): SchemaContext => { context.author = author } - if (resolvedImage) { - context.image = resolvedImage - } - return context } @@ -318,14 +315,6 @@ const resolveAssetUrl = (asset: string, site: URL): string => { } } -const resolveOptionalAssetUrl = (asset: string | undefined, site: URL): string | undefined => { - if (!asset) { - return undefined - } - - return resolveAssetUrl(asset, site) -} - const resolveRoute = (route: string, site: URL): string => { try { return absoluteUrl(route, site) @@ -339,3 +328,4 @@ const resolveRoute = (route: string, site: URL): string => { const formatSegmentName = (segment: string): string => { return segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ') } + diff --git a/src/components/Head/server/utils.ts b/src/components/Head/server/utils.ts new file mode 100644 index 000000000..0f8006e0c --- /dev/null +++ b/src/components/Head/server/utils.ts @@ -0,0 +1,40 @@ +import { getSiteUrl } from '@lib/config/siteUrlServer' + +const DEFAULT_SOCIAL_SLUG = 'home' + +const normalizeSocialSlug = (rawPath?: string | null): string => { + if (!rawPath) { + return DEFAULT_SOCIAL_SLUG + } + + let workingPath = rawPath.trim() + if (!workingPath) { + return DEFAULT_SOCIAL_SLUG + } + + try { + const asUrl = new URL(workingPath) + workingPath = decodeURIComponent(asUrl.pathname) + } catch { + // Ignore parse errors for relative paths + } + + const withoutEdges = workingPath.replace(/^\/+/u, '').replace(/\/+$/u, '').replace(/\/+/gu, '/') + const collapsedSegments = withoutEdges + .split('/') + .map(segment => segment.trim()) + .filter(Boolean) + .join('/') + + if (!collapsedSegments) { + return DEFAULT_SOCIAL_SLUG + } + + const slug = collapsedSegments.replace(/\s+/gu, '-').replace(/-{2,}/gu, '-') + return slug || DEFAULT_SOCIAL_SLUG +} + +export const getSocialImageLink = (path?: string | null): string => { + const slug = normalizeSocialSlug(path) + return `${getSiteUrl()}/og/${slug}.png` +} diff --git a/src/components/Hero/Default/content.astro b/src/components/Hero/Default/content.astro deleted file mode 100644 index b8c53c026..000000000 --- a/src/components/Hero/Default/content.astro +++ /dev/null @@ -1,116 +0,0 @@ ---- -/** - * Hero Content Column - Default Variant - */ -import Icon from '@components/Icon/index.astro' - -export interface Props { - pretitle?: string - title?: string - benefits?: Array<{ - text: string - highlight: string - }> - primaryLink?: { - href: string - text: string - } - secondaryLink?: { - href: string - text: string - } -} - -const { pretitle, title, benefits, primaryLink, secondaryLink } = Astro.props as Props ---- - -{/* Content Column */} -
- {/* Pretitle */} - - {pretitle} - - - {/* Main Title */} -

- {title} -

- - {/* Benefits Section */} - { - benefits && benefits.length > 0 && ( -
-
- Up to: -
-
    - {benefits.map(benefit => ( -
  • - - - - - {benefit.highlight}{' '} - {benefit.text} - -
  • - ))} -
-
- ) - } - - {/* CTA Links */} -
- { - primaryLink && ( - - {primaryLink.text} - - - - - ) - } - { - secondaryLink && ( - - {secondaryLink.text} - - - - - ) - } -
-
diff --git a/src/components/Hero/Default/ctaLinks.astro b/src/components/Hero/Default/ctaLinks.astro deleted file mode 100644 index a4cd9bc6e..000000000 --- a/src/components/Hero/Default/ctaLinks.astro +++ /dev/null @@ -1,57 +0,0 @@ ---- -/** - * Hero CTA Links - Default Variant - */ -export interface Props { - primaryLink: { - href: string - text: string - } - secondaryLink: { - href: string - text: string - } -} - -const { primaryLink, secondaryLink } = Astro.props as Props ---- - -{/* CTA Links */} - diff --git a/src/components/Hero/Default/image.astro b/src/components/Hero/Default/image.astro deleted file mode 100644 index d519243b3..000000000 --- a/src/components/Hero/Default/image.astro +++ /dev/null @@ -1,34 +0,0 @@ ---- -/** - * Hero Image Column - Default Variant - */ -import { Image as AstroImage } from 'astro:assets' -import type { ImageMetadata } from 'astro' - -export interface Props { - image?: { - src: ImageMetadata - alt: string - } -} - -const { image } = Astro.props as Props ---- - -{/* Image Column */} -{ - image && ( -
-
- -
-
- ) -} diff --git a/src/components/Hero/Default/index.astro b/src/components/Hero/Default/index.astro deleted file mode 100644 index afba98231..000000000 --- a/src/components/Hero/Default/index.astro +++ /dev/null @@ -1,57 +0,0 @@ ---- -import Content from './content.astro' -import ImageBlock from './image.astro' -import type { Props as ImageBlockProps } from './image.astro' -import type { ImageMetadata } from 'astro' - -interface Benefit { - text: string - highlight: string -} - -interface Link { - href: string - text: string -} - -export interface Props { - pretitle?: string - title?: string - benefits?: Benefit[] - primaryLink?: Link - secondaryLink?: Link - image?: { - src: ImageMetadata - alt: string - } -} - -const { - pretitle = 'Client-Focused Web Application Developer', - title = 'Building Modern Web Solutions That Drive Results', - benefits = [ - { text: 'increase in sales', highlight: '57%' }, - { text: 'ROI for every $1', highlight: '$114' }, - { text: 'conversion', highlight: 'Up to 400%' }, - ], - primaryLink = { href: '/services/web-development', text: 'Improve Your Product' }, - secondaryLink = { href: '/services/consulting', text: 'Start from scratch' }, - image, -} = Astro.props - -const imageProps: Partial = image ? { image } : {} ---- - -
-
- - - -
-
diff --git a/src/components/Social/Cards/TODO.md b/src/components/Social/Cards/TODO.md deleted file mode 100644 index 39c71f6e1..000000000 --- a/src/components/Social/Cards/TODO.md +++ /dev/null @@ -1,15 +0,0 @@ -# How cards are provided currently - -index.astro is included in every layout; it renders Meta.astro, which in turn renders Social.astro. - -Social.astro builds the full Open Graph + Twitter meta block. It calls getSocialMetadata from index.ts with the current page title, description, slug, and optional image. - -getSocialMetadata normalizes the site base URL, then either uses the page's explicit image (if provided) or calls getSocialImage. That helper composes a URL to the serverless endpoint index.ts. - -The /api/social-card endpoint accepts title, description, slug, etc., and returns an HTML template styled as our share card; that URL is what ends up in og:image / twitter:image. Social platforms fetch the URL declared in those meta tags, so the card they render comes entirely from the metadata emitted by Head/Social. - -The Social/Shares component (Shares.astro) doesn't generate cards; it just renders on-page share buttons. Each platform config in platforms.ts produces a share URL (or, for Mastodon, opens our modal) using the already-published page URL and text. Those buttons rely on the card metadata above so platforms show the correct preview when the shared link is opened. -Next steps (optional) - -If you need richer cards (dynamic screenshots, branded variants), enhance /api/social-card or point getSocialImage at an external image generator. -To customize per-page artwork, pass an image prop from frontmatter so Head/Social uses that instead of the auto-generated URL. diff --git a/src/components/Social/Cards/index.astro b/src/components/Social/Cards/index.astro deleted file mode 100644 index aacc98d12..000000000 --- a/src/components/Social/Cards/index.astro +++ /dev/null @@ -1,41 +0,0 @@ ---- -/** - * Social Card Component - * Generates a social media card for sharing - */ - -export interface Props { - /** Card title */ - title: string - /** Card description */ - description?: string - /** Publication date */ - publishedDate?: string -} - -const { title, description, publishedDate } = Astro.props ---- - -
- -
- -
- -
-
- - -
-

{title}

- {description &&

{description}

} - {publishedDate &&
Published on {publishedDate}
} -
- - -
- Webstack Builders -
-
diff --git a/src/components/WebMentions/index.astro b/src/components/WebMentions/index.astro index 287c645e5..cb6a1afc2 100644 --- a/src/components/WebMentions/index.astro +++ b/src/components/WebMentions/index.astro @@ -6,9 +6,10 @@ * @see https://webmention.io/ * @see https://brid.gy/ for social media integration */ -// import { Picture } from 'astro:assets' +import { Image } from 'astro:assets' import { fetchWebmentions } from '@components/WebMentions/server' import WebMentionItem from './item.astro' +import { buildAvatarImageProps } from './server/avatarImage' export interface Props { /** The absolute URL of the page to fetch webmentions for */ @@ -91,12 +92,13 @@ const hasInteractions = likes.length > 0 || reposts.length > 0 .slice(0, facepileLimit) .reverse() .map((mention) => ( - {mention.author?.name ))} {displayMentions.length > facepileLimit && ( diff --git a/src/components/WebMentions/item.astro b/src/components/WebMentions/item.astro index bd92a8426..34b405a7c 100644 --- a/src/components/WebMentions/item.astro +++ b/src/components/WebMentions/item.astro @@ -2,8 +2,9 @@ /** * Individual WebMention item display */ -// import { Picture } from 'astro:assets' +import { Image } from 'astro:assets' import type { Webmention } from '@components/WebMentions/@types' +import { buildAvatarImageProps } from './server/avatarImage' export interface Props { mention: Webmention @@ -39,21 +40,23 @@ const formatDate = (dateString: string) => { target="_blank" rel="noopener noreferrer" > - {mention.author.name {mention.author.name} ) : ( - Anonymous Anonymous diff --git a/src/components/WebMentions/server/avatarImage.ts b/src/components/WebMentions/server/avatarImage.ts new file mode 100644 index 000000000..f66dec02b --- /dev/null +++ b/src/components/WebMentions/server/avatarImage.ts @@ -0,0 +1,28 @@ +import type { ImageMetadata } from 'astro' +import defaultAvatar from '@assets/images/avatars/webmention-avatar-default.svg' + +type LocalAvatarImageProps = { + src: ImageMetadata +} + +type RemoteAvatarImageProps = { + src: string + inferSize: true +} + +export type AvatarImageProps = LocalAvatarImageProps | RemoteAvatarImageProps + +/** + * Normalize avatar photo URLs into Image component props with remote size inference support. + */ +export const buildAvatarImageProps = (photo?: string | null): AvatarImageProps => { + const normalizedPhoto = photo?.trim() + if (normalizedPhoto?.length) { + return { + src: normalizedPhoto, + inferSize: true, + } + } + + return { src: defaultAvatar } +} diff --git a/src/content/contact.json b/src/content/contact.json index 4dfbff734..11544ee6a 100644 --- a/src/content/contact.json +++ b/src/content/contact.json @@ -43,10 +43,10 @@ { "network": "x", "name": "WebstackDev", - "url": "https://twitter.com/WebstackDev", + "url": "https://x.com/WebstackDev", "order": 3, - "displayName": "Twitter", - "iconName": "twitter", + "displayName": "X", + "iconName": "x", "blurb": "Follow for interesting info and opinions on development" }, { @@ -57,6 +57,24 @@ "displayName": "CodePen", "iconName": "codepen", "blurb": "Share and modify our front-end test cases and code snippets" + }, + { + "network": "bluesky", + "name": "webstackdev.bsky.social", + "url": "https://bsky.app/profile/webstackdev.bsky.social", + "order": 5, + "displayName": "BlueSky", + "iconName": "bluesky", + "blurb": "Share and modify our front-end test cases and code snippets" + }, + { + "network": "mastodon", + "name": "@webstackdev", + "url": "https://k8s.social/@webstackdev", + "order": 6, + "displayName": "Mastodon", + "iconName": "mastodon", + "blurb": "Share and modify our front-end test cases and code snippets" } ] } diff --git a/src/icons/twitter.svg b/src/icons/twitter.svg deleted file mode 100644 index 047f3d81e..000000000 --- a/src/icons/twitter.svg +++ /dev/null @@ -1,4 +0,0 @@ - - twitter icon - - diff --git a/src/icons/x.svg b/src/icons/x.svg new file mode 100644 index 000000000..d2bb718d0 --- /dev/null +++ b/src/icons/x.svg @@ -0,0 +1,4 @@ + + x.com + + \ No newline at end of file diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 4467f798f..d249b1c3f 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -25,8 +25,6 @@ export interface Props { contentType?: 'article' | 'website' /** Meta description */ description: string - // @TODO: Used in Head/Seo, factor out to social cards - image?: string /** Last modified date */ modifiedDate?: Date /** Whether search engines should ignore the page, default: false */ @@ -50,7 +48,6 @@ const { author, contentType, description, - image, modifiedDate, noindex, pageTitle, @@ -70,7 +67,6 @@ const { pageTitle={pageTitle} path={path} description={ description } - {...image && { image }} {...noindex && { noindex }} {...contentType && { contentType }} {...publishDate && { publishDate }} diff --git a/src/layouts/MarkdownLayout.astro b/src/layouts/MarkdownLayout.astro index 437cf5f7e..497d801b2 100644 --- a/src/layouts/MarkdownLayout.astro +++ b/src/layouts/MarkdownLayout.astro @@ -52,8 +52,6 @@ export interface Props { modifiedDate?: Date /** Publication date for articles */ publishDate?: Date - /** Social share image */ - image: string /** Estimated reading time */ readingTime?: string /** Specifies the main, high-level category or theme of the article */ @@ -72,7 +70,6 @@ const { contentClass = '', contentType = 'article', description, - image, modifiedDate, pageTitle, path, @@ -103,7 +100,6 @@ const shouldRenderToc = showToc !== false && tocHeadings.length > 0 {...modifiedDate && { modifiedDate }} {...author && { author }} {...tags && { tags }} - {...image && { image }} >
+ +const collections: { + articles: CollectionFixture + caseStudies: CollectionFixture + services: CollectionFixture + downloads: CollectionFixture +} = { + articles: [], + caseStudies: [], + services: [], + downloads: [], +} +const generateOpenGraphImageMock = vi.hoisted(() => + vi.fn(async () => Buffer.from('mock-image')), +) + +vi.mock('astro:content', () => ({ + getCollection: vi.fn(async (collection: string) => { + const key = collection as keyof typeof collections + return collections[key] ?? [] + }), +})) + +vi.mock('astro-og-canvas', () => ({ + generateOpenGraphImage: generateOpenGraphImageMock, +})) + +const buildRequest = (url: string) => + GET({ + request: new Request(url), + clientAddress: '127.0.0.1', + cookies: { get: () => undefined }, + } as any) + +const seedCollections = () => { + collections.articles = [ + { + id: 'sample-article', + data: { + title: 'Sample Article', + description: 'Article Description', + }, + }, + ] + + collections.caseStudies = [ + { + id: 'case-study', + data: { + title: 'Case Study', + description: 'Case study summary', + }, + }, + ] + + collections.services = [] + collections.downloads = [] +} + describe('Social Card API - GET /api/social-card', () => { - it('should return HTML template with default values', async () => { - const request = new Request('http://localhost/api/social-card') - - const response = await GET({ request } as any) - const html = await response.text() - - expect(response.status).toBe(200) - expect(response.headers.get('Content-Type')).toBe('text/html') - expect(response.headers.get('Cache-Control')).toBe('public, max-age=3600') - expect(html).toContain('Webstack Builders') - expect(html).toContain('Professional Web Development Services') - expect(html).toContain('') - }) - - it('should return HTML template with custom title and description', async () => { - const request = new Request( - 'http://localhost/api/social-card?title=Custom%20Title&description=Custom%20Description' - ) - - const response = await GET({ request } as any) - const html = await response.text() - - expect(response.status).toBe(200) - expect(html).toContain('Custom Title') - expect(html).toContain('Custom Description') - expect(html).toContain('

Custom Title

') - }) - - it('should include date when provided', async () => { - const request = new Request( - 'http://localhost/api/social-card?title=Article&description=Description&date=January%201,%202025' - ) - - const response = await GET({ request } as any) - const html = await response.text() - - expect(response.status).toBe(200) - expect(html).toContain('Published on January 1, 2025') - expect(html).toContain('
') - }) - - it('should handle slug parameter', async () => { - const request = new Request( - 'http://localhost/api/social-card?slug=my-article&title=Article%20Title' - ) - - const response = await GET({ request } as any) - - expect(response.status).toBe(200) - // Slug is used for routing but doesn't affect HTML content directly - expect(response.headers.get('Content-Type')).toBe('text/html') - }) - - it('should return Open Graph JSON when format=og', async () => { - const request = new Request( - 'http://localhost/api/social-card?format=og&slug=my-article&title=Article%20Title&description=Article%20Description' - ) - - const response = await GET({ request } as any) - const data = await response.json() - - expect(response.status).toBe(200) - expect(response.headers.get('Content-Type')).toBe('application/json') - expect(response.headers.get('Cache-Control')).toBe('public, max-age=3600') - expect(data).toHaveProperty('og:title', 'Article Title') - expect(data).toHaveProperty('og:description', 'Article Description') - expect(data).toHaveProperty('og:url', 'http://localhost/my-article') - expect(data).toHaveProperty('twitter:card', 'summary_large_image') - }) - - it('should generate correct image URL in OG format', async () => { - const request = new Request( - 'http://localhost/api/social-card?format=og&slug=test&title=Test%20Title&description=Test%20Desc' - ) - - const response = await GET({ request } as any) - const data = await response.json() - - expect(data['og:image']).toContain('/api/social-card') - expect(data['og:image']).toContain('slug=test') - expect(data['og:image']).toContain('title=Test%20Title') - expect(data['og:image']).toContain('description=Test%20Desc') - expect(data['og:image']).toContain('format=html') - expect(data['twitter:image']).toBe(data['og:image']) - }) - - it('should include proper HTML structure and styles', async () => { - const request = new Request('http://localhost/api/social-card') - - const response = await GET({ request } as any) - const html = await response.text() - - expect(html).toContain('') - expect(html).toContain('') - expect(html).toContain('') - expect(html).toContain('width: 1200px') - expect(html).toContain('height: 630px') - expect(html).toContain('background: linear-gradient') - }) - - it('should handle special characters in title and description', async () => { - const request = new Request( - 'http://localhost/api/social-card?title=Title%20%26%20Special%20%22Chars%22&description=Description%20%3Cwith%3E%20tags' - ) - - const response = await GET({ request } as any) - const html = await response.text() - - expect(response.status).toBe(200) - expect(html).toContain('Title & Special "Chars"') - expect(html).toContain('Description tags') - }) - - it('should include decorative elements', async () => { - const request = new Request('http://localhost/api/social-card') - - const response = await GET({ request } as any) - const html = await response.text() - - expect(html).toContain('
') - expect(html).toContain('
Webstack Builders
') - }) - - it('should use default format as html when format parameter is missing', async () => { - const request = new Request( - 'http://localhost/api/social-card?title=Test' - ) - - const response = await GET({ request } as any) - const contentType = response.headers.get('Content-Type') - - expect(contentType).toBe('text/html') - }) - - it('should handle all parameters together', async () => { - const request = new Request( - 'http://localhost/api/social-card?slug=comprehensive-test&title=Complete%20Test&description=Full%20description%20text&date=October%2026,%202025' - ) - - const response = await GET({ request } as any) - const html = await response.text() - - expect(response.status).toBe(200) - expect(html).toContain('Complete Test') - expect(html).toContain('Full description text') - expect(html).toContain('Published on October 26, 2025') - }) + beforeEach(() => { + generateOpenGraphImageMock.mockReset() + generateOpenGraphImageMock.mockResolvedValue(Buffer.from('mock-image')) + seedCollections() + }) + + it('returns a PNG image for a known slug', async () => { + const response = await buildRequest( + 'http://localhost/api/social-card?slug=articles/sample-article' + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('image/png') + expect(generateOpenGraphImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Sample Article', + description: 'Article Description', + }) + ) + + const buffer = Buffer.from(await response.arrayBuffer()).toString() + expect(buffer).toBe('mock-image') + }) + + it('falls back to provided metadata when slug is unknown', async () => { + const response = await buildRequest( + 'http://localhost/api/social-card?slug=unknown&page=1&title=Preview&description=Shared%20preview' + ) + + expect(response.status).toBe(200) + expect(generateOpenGraphImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Preview', + description: 'Shared preview', + }) + ) + }) + + it('returns an error response when generation fails', async () => { + generateOpenGraphImageMock.mockRejectedValueOnce(new Error('boom')) + + const response = await buildRequest('http://localhost/api/social-card?slug=articles/foo') + const body = await response.json() + + expect(response.status).toBe(500) + expect(body.error.message).toBe('Unable to generate social card') + }) }) diff --git a/src/pages/api/social-card/index.ts b/src/pages/api/social-card/index.ts index bfd7b981d..a9a1fb097 100644 --- a/src/pages/api/social-card/index.ts +++ b/src/pages/api/social-card/index.ts @@ -1,15 +1,111 @@ -/** - * API endpoint to generate social media cards using HTML template - * This returns HTML that can be screenshot by external services or used in OG tags - * Usage: GET /api/social-card?slug=article-title&title=Article Title&description=Article description - */ +import { fileURLToPath } from 'node:url' import type { APIRoute } from 'astro' +import { getCollection } from 'astro:content' +import { generateOpenGraphImage } from 'astro-og-canvas' import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' export const prerender = false const ROUTE = '/api/social-card' +const DEFAULT_TITLE = 'Webstack Builders' +const DEFAULT_DESCRIPTION = 'Professional Web Development Services' +const LOGO_PATH = fileURLToPath( + new URL('../../../../assets/images/site/logo.png', import.meta.url) +) + +type CollectionKey = 'articles' | 'caseStudies' | 'services' | 'downloads' +type PaletteKey = 'articles' | 'case-studies' | 'services' | 'downloads' | 'default' + +interface SocialCardEntry { + slug: string + title: string + description?: string + palette: PaletteKey +} + +const collectionConfig = [ + { collection: 'articles', pathPrefix: 'articles', palette: 'articles' }, + { collection: 'caseStudies', pathPrefix: 'case-studies', palette: 'case-studies' }, + { collection: 'services', pathPrefix: 'services', palette: 'services' }, + { collection: 'downloads', pathPrefix: 'downloads', palette: 'downloads' }, +] as const satisfies ReadonlyArray<{ + collection: CollectionKey + pathPrefix: string + palette: PaletteKey +}> + +const gradientPalette: Record = { + articles: [ + [10, 37, 64], + [59, 130, 246], + ], + 'case-studies': [ + [47, 27, 70], + [156, 163, 175], + ], + services: [ + [6, 95, 70], + [45, 212, 191], + ], + downloads: [ + [30, 64, 175], + [147, 197, 253], + ], + default: [ + [2, 6, 23], + [15, 118, 110], + ], +} + +/** Normalize slug parameters to a consistent format */ +const normalizeSlug = (value: string | null): string => { + if (!value) return 'home' + const trimmed = value.trim().replace(/^\/+/, '').replace(/\/$/, '') + return trimmed || 'home' +} + +const sanitizeText = (value: string): string => value.replace(/\s+/g, ' ').trim() + +const truncateText = (value: string, maxLength: number): string => { + if (value.length <= maxLength) return value + return `${value.slice(0, maxLength - 1)}…` +} + +const resolveText = ( + candidates: Array, + fallback: string, + maxLength: number, +): string => { + for (const candidate of candidates) { + if (typeof candidate === 'string') { + const sanitized = sanitizeText(candidate) + if (sanitized) { + return truncateText(sanitized, maxLength) + } + } + } + return truncateText(fallback, maxLength) +} + +const buildContentIndex = async (): Promise> => { + const collectionEntries = await Promise.all( + collectionConfig.map(async config => { + const entries = await getCollection(config.collection) + return entries.map(entry => ({ + slug: `${config.pathPrefix}/${entry.id}`, + title: entry.data.title, + description: entry.data.description, + palette: config.palette, + })) + }) + ) + + return collectionEntries.flat().reduce>((acc, entry) => { + acc[entry.slug] = entry + return acc + }, {}) +} export const GET: APIRoute = async ({ request, clientAddress, cookies }) => { const { context: apiContext } = createApiFunctionContext({ @@ -22,143 +118,50 @@ export const GET: APIRoute = async ({ request, clientAddress, cookies }) => { try { const url = new URL(request.url) - const slug = url.searchParams.get('slug') || 'home' - const title = url.searchParams.get('title') || 'Webstack Builders' - const description = url.searchParams.get('description') || 'Professional Web Development Services' - const date = url.searchParams.get('date') || new Date().toLocaleDateString() - const format = url.searchParams.get('format') || 'html' // html or og - - // If requesting Open Graph meta tags - if (format === 'og') { - const imageUrl = `${url.origin}/api/social-card?slug=${encodeURIComponent(slug)}&title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&format=html` - - return new Response( - JSON.stringify({ - 'og:title': title, - 'og:description': description, - 'og:image': imageUrl, - 'og:url': `${url.origin}/${slug}`, - 'twitter:card': 'summary_large_image', - 'twitter:title': title, - 'twitter:description': description, - 'twitter:image': imageUrl, - }), - { - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'public, max-age=3600', - }, - } - ) - } + const slug = normalizeSlug(url.searchParams.get('slug')) + const titleOverride = url.searchParams.get('title') + const descriptionOverride = url.searchParams.get('description') + + const contentIndex = await buildContentIndex() + const matchedEntry = contentIndex[slug] + + const title = resolveText([matchedEntry?.title, titleOverride], DEFAULT_TITLE, 120) + const description = resolveText( + [matchedEntry?.description, descriptionOverride], + DEFAULT_DESCRIPTION, + 200, + ) + + const palette = matchedEntry?.palette ?? 'default' + + const imageBuffer = await generateOpenGraphImage({ + title, + description, + bgGradient: gradientPalette[palette], + padding: 80, + logo: { + path: LOGO_PATH, + size: [180], + }, + font: { + title: { + size: 86, + lineHeight: 96, + }, + description: { + size: 48, + lineHeight: 58, + color: [226, 232, 240], + }, + }, + format: 'PNG', + }) + + const payload = new Uint8Array(imageBuffer) - // HTML template for social card (can be screenshot by external services) - const htmlTemplate = ` - - - - - - - - - -
-
-
-

${title}

-

${description}

- ${date ? `
Published on ${date}
` : ''} -
-
Webstack Builders
- - - ` - - return new Response(htmlTemplate, { + return new Response(payload, { headers: { - 'Content-Type': 'text/html', + 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600', }, }) diff --git a/src/pages/articles/[...slug].astro b/src/pages/articles/[...slug].astro index 35bc27932..1e6393f48 100644 --- a/src/pages/articles/[...slug].astro +++ b/src/pages/articles/[...slug].astro @@ -23,8 +23,8 @@ export async function getStaticPaths() { } const { article } = Astro.props - const author = await getEntry(article.data.author) + const path = `/articles/${article.id}` const section = `Articles` --- @@ -33,7 +33,6 @@ const section = `Articles` author={author.data.name} collectionItem={article} contentType="article" - image={article.data.cover.src} description={article.data.description} pageTitle={article.data.title} path={path} diff --git a/src/pages/case-studies/[slug].astro b/src/pages/case-studies/[slug].astro index a24b20f76..018d3ec9c 100644 --- a/src/pages/case-studies/[slug].astro +++ b/src/pages/case-studies/[slug].astro @@ -24,7 +24,6 @@ const section = 'Case Studies' collectionItem={caseStudy} contentType="article" description={caseStudy.data.description} - image={caseStudy.data.cover.src} pageTitle={caseStudy.data.title} path={path} publishDate={caseStudy.data.publishDate} diff --git a/src/pages/downloads/[...slug].astro b/src/pages/downloads/[...slug].astro index ada77b19b..214815995 100644 --- a/src/pages/downloads/[...slug].astro +++ b/src/pages/downloads/[...slug].astro @@ -30,7 +30,6 @@ const section = 'Downloads' collectionItem={download} contentType="article" description={download.data.description} - image={download.data.cover.src} pageTitle={download.data.title} path={path} publishDate={download.data.publishDate} diff --git a/src/pages/services/[slug].astro b/src/pages/services/[slug].astro index dc6fcb82e..36a9d6fa1 100644 --- a/src/pages/services/[slug].astro +++ b/src/pages/services/[slug].astro @@ -25,7 +25,6 @@ const section = 'Downloads' collectionItem={service} contentType="article" description={service.data.description} - image={service.data.cover.src} pageTitle={pageTitle} path={path} publishDate={service.data.publishDate}