diff --git a/examples/astro/astro.config.mjs b/examples/astro/astro.config.mjs index 5035260e..b129cc63 100644 --- a/examples/astro/astro.config.mjs +++ b/examples/astro/astro.config.mjs @@ -1,8 +1,8 @@ -import galactiks, { integrationsPreset } from '@galactiks/astro-integration'; import path from 'path'; - process.env.CONTENT_PATH = path.join(process.cwd(), '../contentlayer'); +import galactiks, { integrationsPreset } from '@galactiks/astro-integration'; + // https://astro.build/config export default /** @type {import('astro').AstroUserConfig} */ { integrations: [integrationsPreset(), galactiks()], diff --git a/examples/contentlayer/galactiks.config.json b/examples/contentlayer/galactiks.config.json index 092a218b..1a47c868 100644 --- a/examples/contentlayer/galactiks.config.json +++ b/examples/contentlayer/galactiks.config.json @@ -4,6 +4,17 @@ "default": "en", "available": ["en", "fr"] }, + "openGraph": { + "siteName": "Galactiks" + }, + "facebook": { + "appId": "123456789" + }, + "twitter": { + "handle": "@galactiks", + "site": "@galactiks", + "cardType": "summary_large_image" + }, "pages": { "articles": { "path": "/{+inLanguage}/a/{identifier}/" diff --git a/packages/adapters/astro/src/components/Head.astro b/packages/adapters/astro/src/components/Head.astro index c345b5ed..a2ac344a 100644 --- a/packages/adapters/astro/src/components/Head.astro +++ b/packages/adapters/astro/src/components/Head.astro @@ -30,6 +30,11 @@ const { analytics } = getConfig(); )) } + { + content.headers?.facebook?.map(({ property, content, ...props }) => ( + + )) + } { content.headers?.twitterCard?.map(({ name, content, ...props }) => ( diff --git a/packages/config/src/config.ts b/packages/config/src/config.ts index d1188a5d..130240df 100644 --- a/packages/config/src/config.ts +++ b/packages/config/src/config.ts @@ -6,6 +6,7 @@ import { z } from 'zod'; import { analyticsConfigSchema } from './analytics.js'; import { getDefaultConfig } from './default.js'; +import { facebookConfigSchema, openGraphConfigSchema, twitterConfigSchema } from './socials.js'; import type { WebManifest } from './webmanifest.config.js'; const localesSchema = z.object({ @@ -29,6 +30,9 @@ const galactiksConfigFileSchema = z.object({ locales: localesSchema.optional(), template: z.string(), analytics: analyticsConfigSchema.optional(), + openGraph: openGraphConfigSchema.optional(), + facebook: facebookConfigSchema.optional(), + twitter: twitterConfigSchema.optional(), trailingSlash: z.enum(['ignore', 'always', 'never']).optional(), pages: z .object({ diff --git a/packages/config/src/socials.ts b/packages/config/src/socials.ts new file mode 100644 index 00000000..9f14b6dd --- /dev/null +++ b/packages/config/src/socials.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const openGraphConfigSchema = z.object({ + siteName: z.string().optional(), +}); + +export const facebookConfigSchema = z.object({ + appId: z.string().optional(), +}); + +export const twitterConfigSchema = z.object({ + creator: z.string().optional(), + site: z.string().optional(), +}); diff --git a/packages/explorer/src/content/hydrate/missing-fields.ts b/packages/explorer/src/content/hydrate/missing-fields.ts index 6014de2f..77c17ccd 100644 --- a/packages/explorer/src/content/hydrate/missing-fields.ts +++ b/packages/explorer/src/content/hydrate/missing-fields.ts @@ -13,6 +13,7 @@ import { breadcrumbBuilder } from '../metadata/breadcrumb.js'; import { getBasicHeaders } from '../metadata/headers.js'; import { getTwitterCard } from '../metadata/twitter.js'; import { getOpenGraphObjects } from '../metadata/ogp/og.js'; +import { getFacebookObjects } from '../metadata/facebook.js'; import { getStructuredDataSchemas } from '../metadata/schemas/structured-data.js'; export const computeMissingFields = @@ -81,6 +82,7 @@ export const computeMissingFields = contentWithoutHeaders ), openGraph: getOpenGraphObjects(contentWithoutHeaders), + facebook: getFacebookObjects(), twitterCard: getTwitterCard(contentWithoutHeaders), }, }; diff --git a/packages/explorer/src/content/metadata/facebook.spec.ts b/packages/explorer/src/content/metadata/facebook.spec.ts new file mode 100644 index 00000000..28e84d6c --- /dev/null +++ b/packages/explorer/src/content/metadata/facebook.spec.ts @@ -0,0 +1,31 @@ +import { getFacebookObjects } from './facebook'; +import { getConfig } from '@galactiks/config'; + +jest.mock('@galactiks/config'); + +describe('getFacebookObjects', () => { + it('should return an empty array if facebook config is not present', () => { + (getConfig as jest.Mock).mockReturnValue({}); + + const result = getFacebookObjects(); + + expect(result).toEqual([]); + }); + + it('should return an array with fb:app_id if facebook appId is present', () => { + const mockAppId = '1234567890'; + (getConfig as jest.Mock).mockReturnValue({ facebook: { appId: mockAppId } }); + + const result = getFacebookObjects(); + + expect(result).toEqual([{ property: 'fb:app_id', content: mockAppId }]); + }); + + it('should return an empty array if facebook config is present but appId is not', () => { + (getConfig as jest.Mock).mockReturnValue({ facebook: {} }); + + const result = getFacebookObjects(); + + expect(result).toEqual([]); + }); +}); diff --git a/packages/explorer/src/content/metadata/facebook.ts b/packages/explorer/src/content/metadata/facebook.ts new file mode 100644 index 00000000..b402adea --- /dev/null +++ b/packages/explorer/src/content/metadata/facebook.ts @@ -0,0 +1,12 @@ +import { getConfig } from '@galactiks/config'; +import type { MetadataHeaders } from '../../types/index.js'; + +export const getFacebookObjects = (): MetadataHeaders['facebook'] => { + const { facebook } = getConfig(); + const headers = []; + if (facebook?.appId) { + headers.push({ property: 'fb:app_id', content: facebook.appId }); + } + + return headers; +}; diff --git a/packages/explorer/src/content/metadata/headers.spec.ts b/packages/explorer/src/content/metadata/headers.spec.ts index 1603d4a3..b5653581 100644 --- a/packages/explorer/src/content/metadata/headers.spec.ts +++ b/packages/explorer/src/content/metadata/headers.spec.ts @@ -1,7 +1,14 @@ +import { getConfig } from '@galactiks/config'; import { Content } from '../../types/index.js'; import { getBasicHeaders } from './headers'; +jest.mock('@galactiks/config'); + describe('getBasicHeaders', () => { + beforeAll(() => { + (getConfig as jest.Mock).mockReturnValue({}); + }); + it('should return correct basic headers', () => { const entry = { name: 'Test Document', diff --git a/packages/explorer/src/content/metadata/ogp/og.ts b/packages/explorer/src/content/metadata/ogp/og.ts index 1075b840..dc0a4e3b 100644 --- a/packages/explorer/src/content/metadata/ogp/og.ts +++ b/packages/explorer/src/content/metadata/ogp/og.ts @@ -1,14 +1,27 @@ +import { getConfig } from '@galactiks/config'; import type { Content, MetadataHeaders } from '../../../types/index.js'; import { getArticle } from './article.js'; import { getWebsite } from './website.js'; -export const getOpenGraphObjects = ( - document: Content -): MetadataHeaders['openGraph'] => { +const getOpenGraphByType = (document: Content): MetadataHeaders['openGraph'] => { if (document.type === 'Article') { return getArticle(document); } return getWebsite(document); +} + +export const getOpenGraphObjects = ( + document: Content +): MetadataHeaders['openGraph'] => { + const headers = getOpenGraphByType(document) || []; + + const { webManifest, openGraph } = getConfig(); + const siteName = openGraph?.siteName || webManifest?.name; + if (siteName) { + headers.push({ property: 'og:siteName', content: siteName }); + } + + return headers; }; diff --git a/packages/explorer/src/content/metadata/twitter.spec.ts b/packages/explorer/src/content/metadata/twitter.spec.ts index 8eb74550..87df26c9 100644 --- a/packages/explorer/src/content/metadata/twitter.spec.ts +++ b/packages/explorer/src/content/metadata/twitter.spec.ts @@ -1,7 +1,14 @@ +import { getConfig } from '@galactiks/config'; import { Content } from '../../types/index.js'; import { getTwitterCard } from './twitter'; +jest.mock('@galactiks/config'); + describe('getTwitterCard', () => { + beforeAll(() => { + (getConfig as jest.Mock).mockReturnValue({}); + }); + it('should return correct headers when document has an image', () => { const document = { name: 'Test Document', @@ -37,4 +44,72 @@ describe('getTwitterCard', () => { { name: 'twitter:card', content: 'summary' }, ]); }); + + it('should include twitter:site if configured', () => { + (getConfig as jest.Mock).mockReturnValue({ + twitter: { + site: '@testsite', + }, + }); + + const document = { + name: 'Test Document', + description: 'Test Description', + } as Content; + + const result = getTwitterCard(document); + + expect(result).toEqual([ + { name: 'twitter:title', content: 'Test Document' }, + { name: 'twitter:description', content: 'Test Description' }, + { name: 'twitter:site', content: '@testsite' }, + { name: 'twitter:card', content: 'summary' }, + ]); + }); + + it('should include twitter:creator if configured', () => { + (getConfig as jest.Mock).mockReturnValue({ + twitter: { + creator: '@testcreator', + }, + }); + + const document = { + name: 'Test Document', + description: 'Test Description', + } as Content; + + const result = getTwitterCard(document); + + expect(result).toEqual([ + { name: 'twitter:title', content: 'Test Document' }, + { name: 'twitter:description', content: 'Test Description' }, + { name: 'twitter:creator', content: '@testcreator' }, + { name: 'twitter:card', content: 'summary' }, + ]); + }); + + it('should include both twitter:site and twitter:creator if configured', () => { + (getConfig as jest.Mock).mockReturnValue({ + twitter: { + creator: '@testcreator', + site: '@testsite', + }, + }); + + const document = { + name: 'Test Document', + description: 'Test Description', + } as Content; + + const result = getTwitterCard(document); + + expect(result).toEqual([ + { name: 'twitter:title', content: 'Test Document' }, + { name: 'twitter:description', content: 'Test Description' }, + { name: 'twitter:site', content: '@testsite' }, + { name: 'twitter:creator', content: '@testcreator' }, + { name: 'twitter:card', content: 'summary' }, + ]); + }); }); diff --git a/packages/explorer/src/content/metadata/twitter.ts b/packages/explorer/src/content/metadata/twitter.ts index 25717395..18dd0b6c 100644 --- a/packages/explorer/src/content/metadata/twitter.ts +++ b/packages/explorer/src/content/metadata/twitter.ts @@ -1,13 +1,23 @@ +import { getConfig } from '@galactiks/config'; import type { Content, MetadataHeaders } from '../../types/index.js'; export const getTwitterCard = ( document: Content ): MetadataHeaders['twitterCard'] => { + const { twitter } = getConfig(); + const headers = [ { name: 'twitter:title', content: document.name }, { name: 'twitter:description', content: document.description }, ]; + if (twitter?.site) { + headers.push({ name: 'twitter:site', content: twitter.site }); + } + if (twitter?.creator) { + headers.push({ name: 'twitter:creator', content: twitter.creator }); + } + if (document.image) { headers.push( { name: 'twitter:card', content: 'summary_large_image' }, diff --git a/packages/explorer/src/types/_schemas.ts b/packages/explorer/src/types/_schemas.ts index 3b7b7671..65f05df6 100644 --- a/packages/explorer/src/types/_schemas.ts +++ b/packages/explorer/src/types/_schemas.ts @@ -24,6 +24,14 @@ const metadataHeaders = z }) ) .optional(), + facebook: z + .array( + z.object({ + property: z.string(), + content: z.string(), + }) + ) + .optional(), twitterCard: z .array( z.object({ diff --git a/turbo.json b/turbo.json index 6067656b..95070323 100644 --- a/turbo.json +++ b/turbo.json @@ -29,6 +29,7 @@ "cache": false }, "test": { + "dependsOn": ["^build"], "outputLogs": "new-only" } }