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()
}