diff --git a/eslint.config.ts b/eslint.config.ts index 878adc269..6b3dd8f17 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -837,6 +837,27 @@ export default [ ], }, }, + { + files: [ + '**/*.astro', + ], + ignores: [ + 'src/pages/testing/**/*', + ], + rules: { + 'no-restricted-imports': [ + level, + { + paths: [ + { + name: '@lib/config/siteUrlServer', + message: 'Astro files must not import @lib/config/siteUrlServer. Resolve site URLs from Astro context or use a runtime-safe helper instead.', + }, + ], + }, + ], + }, + }, /** * ================================================================================================= * diff --git a/src/components/Head/Meta.astro b/src/components/Head/Meta.astro index 3b13cf3a0..70d5c3e89 100644 --- a/src/components/Head/Meta.astro +++ b/src/components/Head/Meta.astro @@ -1,8 +1,8 @@ --- -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' +import { resolveSiteUrl } from '@components/Head/server' import Seo from '@components/Head/Seo.astro' import Social from '@components/Head/Social.astro' import StructuredData from '@components/Head/StructuredData.astro' @@ -44,6 +44,7 @@ const { } = Astro.props const defaultTheme = getMetaThemeData() +const site = resolveSiteUrl(Astro) --- @@ -98,7 +99,7 @@ const defaultTheme = getMetaThemeData() {/* Web App Manifest */} diff --git a/src/components/Head/Social.astro b/src/components/Head/Social.astro index 8020daf87..e2bc4710f 100644 --- a/src/components/Head/Social.astro +++ b/src/components/Head/Social.astro @@ -2,8 +2,8 @@ /** * Open Graph / Facebook / X.com metadata */ -import { getSiteUrl } from '@lib/config/siteUrlServer' -import { getSocialImageLink } from '@components/Head/server' +import { absoluteUrl } from '@components/scripts/utils' +import { getSocialImageLink, resolveSiteUrl } from '@components/Head/server' import contactData from '@content/contact.json' export interface Props { @@ -13,16 +13,17 @@ export interface Props { } const { title, description, path } = Astro.props +const site = resolveSiteUrl(Astro) --- {/* Types include 'website', 'article', 'profile', and 'product' */} - + {/* Canonical URL of the page */} - + {/* Twitter metadata */} diff --git a/src/components/Head/server/__tests__/utils.spec.ts b/src/components/Head/server/__tests__/utils.spec.ts index 87238bb68..fb0895ef8 100644 --- a/src/components/Head/server/__tests__/utils.spec.ts +++ b/src/components/Head/server/__tests__/utils.spec.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getSocialImageLink } from '../utils' +import { getSocialImageLink, resolveSiteUrl } from '../utils' import { getSiteUrl } from '@lib/config/siteUrlServer' vi.mock('@lib/config/siteUrlServer', () => ({ @@ -36,4 +36,22 @@ describe('getSocialImageLink', () => { expect(url).toBe('https://custom.test/api/social-card?slug=about%2Fcompany') expect(mockedGetSiteUrl).toHaveBeenCalledTimes(1) }) + + it('uses an explicit request site without calling the build helper', () => { + const url = getSocialImageLink('/services/cloud', new URL('https://preview.webstackbuilders.com')) + + expect(url).toBe('https://preview.webstackbuilders.com/api/social-card?slug=services%2Fcloud') + expect(mockedGetSiteUrl).not.toHaveBeenCalled() + }) +}) + +describe('resolveSiteUrl', () => { + it('falls back to the current request origin when Astro.site is unavailable', () => { + const site = resolveSiteUrl({ + site: undefined, + url: new URL('https://preview.webstackbuilders.com/services/cloud'), + }) + + expect(site.href).toBe('https://preview.webstackbuilders.com/') + }) }) diff --git a/src/components/Head/server/index.ts b/src/components/Head/server/index.ts index 262dbc0dd..e017cdcd9 100644 --- a/src/components/Head/server/index.ts +++ b/src/components/Head/server/index.ts @@ -3,4 +3,4 @@ * Functions used during build process in Astro frontmatter */ export { getMetaThemeData } from './meta' -export { getSocialImageLink } from './utils' +export { getSocialImageLink, resolveSiteUrl } from './utils' diff --git a/src/components/Head/server/structuredData.ts b/src/components/Head/server/structuredData.ts index fb13da1a8..ad63733b5 100644 --- a/src/components/Head/server/structuredData.ts +++ b/src/components/Head/server/structuredData.ts @@ -12,7 +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' +import { getSocialImageLink, resolveSiteUrl } from '@components/Head/server' const STRUCTURED_DATA_FILE = 'src/components/Head/server/structuredData.ts' const LOGO_PATH = '/logo.png' @@ -91,16 +91,17 @@ const createSchemaContext = (params: StructuredDataParams): SchemaContext => { }) } + const site = resolveSiteUrl(astro) const normalizedPath = normalizePath(path) - const canonicalUrl = astro.url?.href ?? resolveRoute(normalizedPath, astro.site) + const canonicalUrl = astro.url?.href ?? resolveRoute(normalizedPath, site) const pageDescriptionFallback = pageDescription ?? contactData.company.description - const socialImageUrl = getSocialImageLink(path) + const socialImageUrl = getSocialImageLink(path, site) const context: SchemaContext = { path: normalizedPath, pageTitle, pageDescription: pageDescriptionFallback, - site: astro.site, + site, canonicalUrl, pathSegments: normalizedPath.split('/').filter(Boolean), image: socialImageUrl, diff --git a/src/components/Head/server/utils.ts b/src/components/Head/server/utils.ts index b09d23635..110d16c1f 100644 --- a/src/components/Head/server/utils.ts +++ b/src/components/Head/server/utils.ts @@ -1,7 +1,10 @@ +import type { AstroGlobal } from 'astro' import { getSiteUrl } from '@lib/config/siteUrlServer' const DEFAULT_SOCIAL_SLUG = 'home' +type SiteInput = URL | string | Pick | undefined + const normalizeSocialSlug = (rawPath?: string | null): string => { if (!rawPath) { return DEFAULT_SOCIAL_SLUG @@ -34,10 +37,30 @@ const normalizeSocialSlug = (rawPath?: string | null): string => { return slug || DEFAULT_SOCIAL_SLUG } -export const getSocialImageLink = (path?: string | null): string => { +export const resolveSiteUrl = (site?: SiteInput): URL => { + if (site instanceof URL) { + return site + } + + if (typeof site === 'string') { + return new URL(site) + } + + if (site?.site instanceof URL) { + return site.site + } + + if (site?.url instanceof URL) { + return new URL(site.url.origin) + } + + return new URL(getSiteUrl()) +} + +export const getSocialImageLink = (path?: string | null, site?: SiteInput): string => { const slug = normalizeSocialSlug(path) - const url = new URL('/api/social-card', getSiteUrl()) + const url = new URL('/api/social-card', resolveSiteUrl(site)) url.searchParams.set('slug', slug) return url.toString() }