diff --git a/packages/karbon/src/runtime/composables/__tests__/__snapshots__/seo-preset.spec.ts.snap b/packages/karbon/src/runtime/composables/__tests__/__snapshots__/seo-preset.spec.ts.snap
new file mode 100644
index 00000000..6468f0c2
--- /dev/null
+++ b/packages/karbon/src/runtime/composables/__tests__/__snapshots__/seo-preset.spec.ts.snap
@@ -0,0 +1,49 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`can handle article 1`] = `
+{
+ "description": "blurb",
+ "ogDescription": "blurb",
+ "ogImage": "cover_url",
+ "ogTitle": "title",
+ "ogType": "website",
+ "ogUrl": "/article_url",
+ "title": "title",
+ "twitterCard": "summary_large_image",
+ "twitterDescription": "blurb",
+ "twitterImage": "cover_url",
+ "twitterTitle": "title",
+}
+`;
+
+exports[`can handle author page 1`] = `
+{
+ "description": "
Hello world
",
+ "ogDescription": "Hello world
",
+ "ogImage": "https://example.com/profile.png",
+ "ogTitle": "User Name",
+ "ogType": "website",
+ "ogUrl": "/author_url",
+ "title": "User Name",
+ "twitterCard": "summary_large_image",
+ "twitterDescription": "Hello world
",
+ "twitterImage": "https://example.com/profile.png",
+ "twitterTitle": "User Name",
+}
+`;
+
+exports[`can handle real article 1`] = `
+{
+ "description": "meta_description",
+ "ogDescription": "og_description",
+ "ogImage": "https://example.com/cover.png",
+ "ogTitle": "og_title",
+ "ogType": "article",
+ "ogUrl": "/article_url",
+ "title": "meta_title",
+ "twitterCard": "summary_large_image",
+ "twitterDescription": "og_description",
+ "twitterImage": "https://example.com/cover.png",
+ "twitterTitle": "og_title",
+}
+`;
diff --git a/packages/karbon/src/runtime/composables/__tests__/seo-preset.spec.ts b/packages/karbon/src/runtime/composables/__tests__/seo-preset.spec.ts
new file mode 100644
index 00000000..2f82dd67
--- /dev/null
+++ b/packages/karbon/src/runtime/composables/__tests__/seo-preset.spec.ts
@@ -0,0 +1,272 @@
+import { expect, it } from 'vitest'
+import { identity, reduce } from 'remeda'
+import type { SEOContext } from '../seo-preset'
+import { resolveSEOPresets } from '../seo-preset'
+import type { BaseMeta, ResourcePage } from '../../types'
+
+it('can handle article', () => {
+ const handlers = resolveSEOPresets([{ preset: 'basic' }])
+ const results: unknown[] = []
+ const resourceURL: ResourcePage = {
+ enable: true,
+ isValid: () => true,
+ getIdentity: () => ({ type: 'article', id: '1' }),
+ toURL: () => '',
+ route: '',
+ }
+ const context: SEOContext = {
+ metaType: 'article',
+ runtimeConfig: {} as any,
+ site: {
+ name: 'site_name',
+ },
+ articleFilter: identity,
+ useHead: (input) => void results.push(input),
+ useSeoMeta: (input) => void results.push(input),
+ resourceUrls: {
+ article: {
+ ...resourceURL,
+ toURL: () => 'article_url',
+ },
+ desk: {
+ ...resourceURL,
+ toURL: () => 'desk_url',
+ },
+ author: {
+ ...resourceURL,
+ toURL: () => 'author_url',
+ },
+ tag: {
+ ...resourceURL,
+ toURL: () => 'tag_url',
+ },
+ },
+ }
+
+ const article = {
+ title: 'title',
+ blurb: 'blurb',
+ bio: 'author_bio',
+ cover: {
+ url: 'cover_url',
+ },
+ author: {
+ name: 'author_name',
+ },
+ }
+
+ for (const handler of handlers) {
+ handler(article, context)
+ }
+
+ expect(reduce(results, (acc, cur) => Object.assign(acc, cur), {})).toMatchSnapshot()
+})
+
+const ARTICLE_FIXTURE = {
+ __typename: 'Article',
+ slug: 'article_slug',
+ featured: false,
+ layout: null,
+ shadow_authors: null,
+ metafields: [],
+ relevances: [
+ {
+ __typename: 'Article',
+ id: '2',
+ title: 'Another Article
',
+ },
+ ],
+ content_blocks: [],
+ bio: 'My bio',
+ bioHTML: 'My bio
',
+ published_at: '2024-01-01T00:00:00+00:00',
+ updated_at: '2024-01-01T00:00:00+00:00',
+ title: 'title',
+ blurb: 'blurb',
+ seo: {
+ og: {
+ title: 'og_title',
+ description: 'og_description',
+ },
+ meta: {
+ title: 'meta_title',
+ description: 'meta_description',
+ },
+ hasSlug: true,
+ ogImage: '',
+ },
+ plaintext: 'Hello world',
+ cover: {
+ alt: 'cover alt',
+ url: 'https://example.com/cover.png',
+ crop: {
+ key: '123',
+ top: 50,
+ left: 50,
+ zoom: 1,
+ width: 0,
+ height: 0,
+ realWidth: 1600,
+ realHeight: 915,
+ },
+ caption: '',
+ },
+ authors: [
+ {
+ __typename: 'User',
+ bio: 'My bio
',
+ slug: 'author_slug',
+ avatar: 'https://example.com/profile.png',
+ email: 'author@example.com',
+ location: 'Earth',
+ first_name: 'Author',
+ last_name: 'Name',
+ full_name: 'Author Name',
+ id: '1',
+ socials: {
+ LinkedIn: 'www.example.com',
+ },
+ name: 'Author Name',
+ },
+ ],
+ desk: {
+ __typename: 'Desk',
+ id: '5',
+ name: 'Desk',
+ slug: 'desk',
+ layout: null,
+ desk: null,
+ },
+ tags: [],
+ id: '1',
+ plan: 'free',
+ html: 'Hello world
',
+ segments: [
+ {
+ id: 'normal',
+ type: 'p',
+ html: 'Hello world
',
+ },
+ ],
+ __sp_cf: {},
+ __sp_cf_editor_block: {},
+}
+
+it('can handle real article', () => {
+ const handlers = resolveSEOPresets([{ preset: 'basic' }])
+ const results: unknown[] = []
+ const resourceURL: ResourcePage = {
+ enable: true,
+ isValid: () => true,
+ getIdentity: () => ({ type: 'article', id: '1' }),
+ toURL: () => '',
+ route: '',
+ }
+ const context: SEOContext = {
+ metaType: 'article',
+ runtimeConfig: {} as any,
+ site: {
+ name: 'site_name',
+ },
+ articleFilter: identity,
+ useHead: (input) => void results.push(input),
+ useSeoMeta: (input) => void results.push(input),
+ resourceUrls: {
+ article: {
+ ...resourceURL,
+ toURL: () => 'article_url',
+ },
+ desk: {
+ ...resourceURL,
+ toURL: () => 'desk_url',
+ },
+ author: {
+ ...resourceURL,
+ toURL: () => 'author_url',
+ },
+ tag: {
+ ...resourceURL,
+ toURL: () => 'tag_url',
+ },
+ },
+ }
+
+ for (const handler of handlers) {
+ handler(ARTICLE_FIXTURE, context)
+ }
+
+ expect(reduce(results, (acc, cur) => Object.assign(acc, cur), {})).toMatchSnapshot()
+})
+
+const USER_FIXTURE = {
+ __typename: 'User',
+ id: '16',
+ slug: 'user_slug',
+ email: 'user@example.com',
+ first_name: 'User',
+ last_name: 'Name',
+ full_name: 'User Name',
+ avatar: 'https://example.com/profile.png',
+ location: 'Earth',
+ bio: 'Hello world
',
+ website: 'example.com',
+ socials: {
+ Twitter: 'twitter.example.com',
+ Facebook: 'fb.example.com',
+ LinkedIn: 'linkedin.example.com',
+ YouTube: 'www.youtube.com/example',
+ Pinterest: 'www.example.com/a',
+ },
+ created_at: '2024-01-01T00:00:00+00:00',
+ updated_at: '2024-01-01T00:00:00+00:00',
+ desks: [],
+ name: 'User Name',
+ __sp_cf: {},
+ __sp_cf_editor_block: {},
+}
+
+it('can handle author page', () => {
+ const handlers = resolveSEOPresets([{ preset: 'basic' }])
+ const results: unknown[] = []
+ const resourceURL: ResourcePage = {
+ enable: true,
+ isValid: () => true,
+ getIdentity: () => ({ type: 'article', id: '1' }),
+ toURL: () => '',
+ route: '',
+ }
+ const context: SEOContext = {
+ metaType: 'author',
+ runtimeConfig: {} as any,
+ site: {
+ name: 'site_name',
+ },
+ articleFilter: identity,
+ useHead: (input) => void results.push(input),
+ useSeoMeta: (input) => void results.push(input),
+ resourceUrls: {
+ article: {
+ ...resourceURL,
+ toURL: () => 'article_url',
+ },
+ desk: {
+ ...resourceURL,
+ toURL: () => 'desk_url',
+ },
+ author: {
+ ...resourceURL,
+ toURL: () => 'author_url',
+ },
+ tag: {
+ ...resourceURL,
+ toURL: () => 'tag_url',
+ },
+ },
+ }
+
+ for (const handler of handlers) {
+ handler(USER_FIXTURE, context)
+ }
+
+ expect(reduce(results, (acc, cur) => Object.assign(acc, cur), {})).toMatchSnapshot()
+})
diff --git a/packages/karbon/src/runtime/composables/seo-preset.ts b/packages/karbon/src/runtime/composables/seo-preset.ts
new file mode 100644
index 00000000..8a0664bc
--- /dev/null
+++ b/packages/karbon/src/runtime/composables/seo-preset.ts
@@ -0,0 +1,262 @@
+import { filter, first, identity, isTruthy, map, pathOr, pipe } from 'remeda'
+import type { PartialDeep } from 'type-fest'
+import type { MetaFlatInput } from '@zhead/schema'
+import { isDefined } from '@vueuse/core'
+import truncate from 'lodash.truncate'
+import { parseURL, resolveURL, withHttps, withoutTrailingSlash } from 'ufo'
+import type { ArticleMeta, AuthorMeta, DeskMeta, ResourcePage, Resources, TagMeta } from '../types'
+import { invalidContext } from '../utils/invalid-context'
+import type { getSite, useArticleFilter, useHead, useRuntimeConfig, useSeoMeta } from '#imports'
+
+interface SEOItem {
+ title: string
+ description: string
+}
+
+interface SEOConfig {
+ meta: SEOItem
+ og: SEOItem
+ ogImage: string
+}
+
+export interface SEOInput extends SEOItem {
+ plaintext: string
+ headline: string
+ cover: { url: string }
+ seo: SEOConfig
+}
+
+export type RawSEOInput = PartialDeep & Record
+
+function path(object: RawSEOInput, p: string[]): string | undefined {
+ return pathOr(object as Record, p as any, undefined)
+}
+
+function createFirstFound(paths: string[][]) {
+ return (input: RawSEOInput): string | undefined => {
+ return pipe(
+ paths,
+ map((p) => path(input, p)),
+ filter(isTruthy),
+ filter(Boolean),
+ first(),
+ )
+ }
+}
+
+const TITLE = [['seo', 'meta', 'title'], ['title'], ['name']]
+const DESK_DESCRIPTION = [['deskSEO', 'meta', 'description']]
+const DESCRIPTION = [['seo', 'meta', 'description'], ['blurb'], ['plaintext'], ...DESK_DESCRIPTION]
+const OG_TITLE = [['seo', 'og', 'title'], ...TITLE]
+const OG_DESK_DESCRIPTION = [['deskSEO', 'og', 'description']]
+const OG_DESCRIPTION = [['seo', 'og', 'description'], ...OG_DESK_DESCRIPTION, ...DESCRIPTION]
+const OG_IMAGE = [['seo', 'ogImage'], ['headline'], ['cover', 'url'], ['avatar']]
+const AUTHOR_BIO = [['bio']]
+const TYPE_NAME = [['__typename']]
+
+export interface SEOContext {
+ metaType?: Resources
+ site: Awaited>
+ runtimeConfig: ReturnType
+ articleFilter: ReturnType
+ useHead: typeof useHead
+ useSeoMeta: typeof useSeoMeta
+ resourceUrls: {
+ // @ts-expect-error unknown
+ article: ResourcePage
+ desk: ResourcePage
+ author: ResourcePage
+ tag: ResourcePage
+ [key: string]: ResourcePage
+ }
+}
+
+type MetaInput = MetaFlatInput & { title?: string }
+
+type SEOHandler = (input: RawSEOInput, context: SEOContext) => T | undefined | false
+type SEOPreset = SEOHandler | SEOHandler[]
+type SEOMapResult = MetaInput | undefined | false
+type SEOMapFn = (input: T, context: SEOContext) => SEOMapResult
+type SEOFilterFn = (input: T, ctx: SEOContext) => T
+
+function createSEO(
+ pick: (input: RawSEOInput, context: SEOContext) => T,
+ map: SEOMapFn,
+ filter: SEOFilterFn = identity,
+): SEOHandler {
+ return (input: RawSEOInput, context: SEOContext) => {
+ return map(filter(pick(input, context), context), context)
+ }
+}
+
+function simpleSEO(
+ paths: string[][],
+ map: SEOMapFn,
+ filter: SEOFilterFn = identity,
+): SEOHandler {
+ return createSEO(createFirstFound(paths), map, filter)
+}
+
+function seoHtmlFilter(input: string | undefined, ctx: SEOContext) {
+ if (!input) {
+ return input
+ }
+
+ return ctx.articleFilter(input)
+}
+
+interface MetaDefineSEOInput {
+ type?: 'meta'
+ setup: (options: Record) => SEOPreset
+}
+
+type DefineSEOInput = MetaDefineSEOInput
+
+type NormalizedSEOHandler = (input: RawSEOInput, context: SEOContext) => void
+type NormalizedSEOPreset = (options: Record) => NormalizedSEOHandler
+
+function useMeta(seo: MetaInput, ctx: SEOContext) {
+ const { title, ...meta } = seo
+
+ if (title) {
+ ctx.useHead({ title }, { mode: 'server' })
+ }
+
+ ctx.useSeoMeta(meta)
+}
+
+interface MetaDefineSEOHandlerInput {
+ type?: 'meta'
+ handler: SEOHandler
+}
+
+type DefineSEOHandlerInput = MetaDefineSEOHandlerInput
+
+export function defineSEOHandler(inputOrHandler: DefineSEOHandlerInput | SEOHandler): NormalizedSEOHandler {
+ const { handler } = typeof inputOrHandler === 'function' ? { handler: inputOrHandler } : inputOrHandler
+
+ return (input: RawSEOInput, ctx: SEOContext) => {
+ const seo = handler(input, ctx)
+
+ if (seo) {
+ useMeta(seo, ctx)
+ }
+ }
+}
+
+export function defineSEOPreset(
+ inputOrSetup: DefineSEOInput | ((options: Record) => SEOPreset),
+): NormalizedSEOPreset {
+ const { setup } = typeof inputOrSetup === 'function' ? { setup: inputOrSetup } : inputOrSetup
+
+ return (options: Record) => {
+ const maybeHandlers = setup(options)
+ const handlers = Array.isArray(maybeHandlers) ? maybeHandlers : [maybeHandlers]
+ return (input: RawSEOInput, context: SEOContext) => {
+ for (const handle of handlers) {
+ const seo = handle(input, context)
+
+ if (seo) {
+ useMeta(seo, context)
+ }
+ }
+ }
+ }
+}
+
+type ResourceType = 'Article' | 'Desk' | 'Tag' | 'User'
+const typeMap: Record = {
+ Article: 'article',
+ Desk: 'desk',
+ User: 'author',
+ Tag: 'tag',
+}
+function getResourceURL(input: RawSEOInput, context: SEOContext): string | undefined {
+ // skipcq: JS-W1043
+ const typeName: ResourceType = input.__typename || '_'
+ const resourceType = context.metaType || typeMap[typeName]
+ const resourceUrls = context.resourceUrls[resourceType]
+ if (!resourceUrls?.enable) return undefined
+
+ // skipcq: JS-W1043
+ const siteUrl = (context.runtimeConfig?.public?.siteUrl as string) || '/'
+ const url = resourceUrls.toURL(input as any, resourceUrls._context ?? invalidContext)
+ return withoutTrailingSlash(resolveURL(siteUrl, url))
+}
+
+function getTwitterSite(_input: RawSEOInput, context: SEOContext) {
+ const twitterLink = context.site?.socials?.Twitter
+ if (!twitterLink) return undefined
+
+ const { pathname } = parseURL(withHttps(twitterLink))
+ const accountPath = pathname.split('/')[1]
+ return accountPath ? `@${accountPath}` : undefined
+}
+
+export const basic = defineSEOPreset(({ twitterCard = 'summary_large_image' }) => [
+ // Author
+ simpleSEO(AUTHOR_BIO, (authorBio) => {
+ const bio = truncate(authorBio ?? '', {
+ length: 150,
+ separator: /,? +/,
+ })
+ return isDefined(authorBio) && { description: bio, ogDescription: bio, twitterDescription: bio }
+ }),
+
+ // Resource
+ simpleSEO(TITLE, (title: string | undefined) => isDefined(title) && { title }, seoHtmlFilter),
+ simpleSEO(OG_TITLE, (ogTitle) => isDefined(ogTitle) && { ogTitle, twitterTitle: ogTitle }, seoHtmlFilter),
+ simpleSEO(DESCRIPTION, (description) => isDefined(description) && { description }, seoHtmlFilter),
+ simpleSEO(
+ OG_DESCRIPTION,
+ (ogDescription) => isDefined(ogDescription) && { ogDescription, twitterDescription: ogDescription },
+ seoHtmlFilter,
+ ),
+ simpleSEO(OG_IMAGE, (ogImage) => isDefined(ogImage) && { ogImage, twitterImage: ogImage }),
+
+ // Common
+ simpleSEO(TYPE_NAME, (typeName) => {
+ const type = typeName as ResourceType
+ if (type === 'Article') return { ogType: 'article' }
+ return { ogType: 'website' }
+ }),
+ createSEO(getResourceURL, (ogUrl) => isDefined(ogUrl) && { ogUrl }),
+ createSEO(getTwitterSite, (twitterSite) => isDefined(twitterSite) && { twitterSite }),
+ () => ({ twitterCard }),
+])
+
+const emptyPreset = defineSEOPreset(() => [])
+
+const presets: Record = {
+ basic,
+ __empty: emptyPreset,
+}
+
+export const builtinPresets = new Set(Object.keys(presets))
+
+export interface PresetConfig {
+ preset?: string
+ presetFactory?: NormalizedSEOPreset
+ options?: Record
+}
+
+type InlineSEOPreset = [preset: NormalizedSEOPreset, options?: Record]
+
+export type PresetConfigInput = PresetConfig | InlineSEOPreset | NormalizedSEOHandler
+
+export function resolveSEOPresets(configs: PresetConfigInput[]): NormalizedSEOHandler[] {
+ return configs.map((config: PresetConfigInput) => {
+ if (typeof config === 'function') {
+ return config
+ } else if (Array.isArray(config)) {
+ const [presetFactory, options = {}] = config
+ return presetFactory(options)
+ }
+
+ if (config.presetFactory) {
+ return config.presetFactory(config.options || {})
+ }
+
+ return (presets[config.preset || '__empty'] || emptyPreset)(config.options || {})
+ })
+}
diff --git a/packages/karbon/src/runtime/composables/seo.ts b/packages/karbon/src/runtime/composables/seo.ts
index 57e248b7..ba97943c 100644
--- a/packages/karbon/src/runtime/composables/seo.ts
+++ b/packages/karbon/src/runtime/composables/seo.ts
@@ -1,263 +1,15 @@
-import { compact, filter, first, identity, map, pathOr, pipe } from 'remeda'
-import type { PartialDeep } from 'type-fest'
-import type { MetaFlatInput } from '@zhead/schema'
import type { MaybeRefOrGetter } from '@vueuse/core'
-import { isDefined, toRef } from '@vueuse/core'
+import { toRef } from '@vueuse/core'
import { watchSyncEffect } from 'vue'
-import truncate from 'lodash.truncate'
-import { parseURL, resolveURL, withHttps, withoutTrailingSlash } from 'ufo'
-import type { BaseMeta, Resources } from '../types'
-import { invalidContext } from '../utils/invalid-context'
+import type { Resources } from '../types'
+import type { PresetConfigInput, RawSEOInput, SEOContext } from './seo-preset'
import { getSite, useArticleFilter, useHead, useNuxtApp, useRuntimeConfig, useSeoMeta } from '#imports'
import urls from '#build/storipress-urls.mjs'
-interface SEOItem {
- title: string
- description: string
-}
-
-interface SEOConfig {
- meta: SEOItem
- og: SEOItem
- ogImage: string
-}
-
-export interface SEOInput extends SEOItem {
- plaintext: string
- headline: string
- cover: { url: string }
- seo: SEOConfig
-}
-
-export type RawSEOInput = PartialDeep & Record
-
-function path(object: RawSEOInput, p: string[]): string | undefined {
- return pathOr(object as Record, p as any, undefined)
-}
-
-function createFirstFound(paths: string[][]) {
- return (input: RawSEOInput): string | undefined => {
- return pipe(
- paths,
- map((p) => path(input, p)),
- compact,
- filter(Boolean),
- first(),
- )
- }
-}
-
-const TITLE = [['seo', 'meta', 'title'], ['title'], ['name']]
-const DESK_DESCRIPTION = [['deskSEO', 'meta', 'description']]
-const DESCRIPTION = [['seo', 'meta', 'description'], ['blurb'], ['plaintext'], ...DESK_DESCRIPTION]
-const OG_TITLE = [['seo', 'og', 'title'], ...TITLE]
-const OG_DESK_DESCRIPTION = [['deskSEO', 'og', 'description']]
-const OG_DESCRIPTION = [['seo', 'og', 'description'], ...OG_DESK_DESCRIPTION, ...DESCRIPTION]
-const OG_IMAGE = [['seo', 'ogImage'], ['headline'], ['cover', 'url'], ['avatar']]
-const AUTHOR_BIO = [['bio']]
-const TYPE_NAME = [['__typename']]
-
-interface SEOContext {
- metaType?: Resources
- site: Awaited>
- runtimeConfig: ReturnType
- articleFilter: ReturnType
-}
-
-type MetaInput = MetaFlatInput & { title?: string }
-
-type SEOHandler = (input: RawSEOInput, context: SEOContext) => T | undefined | false
-type SEOPreset = SEOHandler | SEOHandler[]
-type SEOMapResult = MetaInput | undefined | false
-type SEOMapFn = (input: T, context: SEOContext) => SEOMapResult
-type SEOFilterFn = (input: T, ctx: SEOContext) => T
-
-function createSEO(
- pick: (input: RawSEOInput, context: SEOContext) => T,
- map: SEOMapFn,
- filter: SEOFilterFn = identity,
-): SEOHandler {
- return (input: RawSEOInput, context: SEOContext) => {
- return map(filter(pick(input, context), context), context)
- }
-}
-
-function simpleSEO(
- paths: string[][],
- map: SEOMapFn,
- filter: SEOFilterFn = identity,
-): SEOHandler {
- return createSEO(createFirstFound(paths), map, filter)
-}
-
-function seoHtmlFilter(input: string | undefined, ctx: SEOContext) {
- if (!input) {
- return input
- }
-
- return ctx.articleFilter(input)
-}
-
-interface MetaDefineSEOInput {
- type?: 'meta'
- setup: (options: Record) => SEOPreset
-}
-
-type DefineSEOInput = MetaDefineSEOInput
-
-type NormalizedSEOHandler = (input: RawSEOInput, context: SEOContext) => void
-type NormalizedSEOPreset = (options: Record) => NormalizedSEOHandler
-
-function useMeta(seo: MetaInput) {
- const { title, ...meta } = seo
-
- if (title) {
- useHead({ title }, { mode: 'server' })
- }
-
- useSeoMeta(meta)
-}
-
-interface MetaDefineSEOHandlerInput {
- type?: 'meta'
- handler: SEOHandler
-}
-
-type DefineSEOHandlerInput = MetaDefineSEOHandlerInput
-
-export function defineSEOHandler(inputOrHandler: DefineSEOHandlerInput | SEOHandler): NormalizedSEOHandler {
- const { handler } = typeof inputOrHandler === 'function' ? { handler: inputOrHandler } : inputOrHandler
-
- return (input: RawSEOInput, ctx: SEOContext) => {
- const seo = handler(input, ctx)
-
- if (seo) {
- useMeta(seo)
- }
- }
-}
-
-export function defineSEOPreset(
- inputOrSetup: DefineSEOInput | ((options: Record) => SEOPreset),
-): NormalizedSEOPreset {
- const { setup } = typeof inputOrSetup === 'function' ? { setup: inputOrSetup } : inputOrSetup
-
- return (options: Record) => {
- const maybeHandlers = setup(options)
- const handlers = Array.isArray(maybeHandlers) ? maybeHandlers : [maybeHandlers]
- return (input: RawSEOInput, context: SEOContext) => {
- for (const handle of handlers) {
- const seo = handle(input, context)
-
- if (seo) {
- useMeta(seo)
- }
- }
- }
- }
-}
-
-type ResourceType = 'Article' | 'Desk' | 'Tag' | 'User'
-const typeMap: Record = {
- Article: 'article',
- Desk: 'desk',
- User: 'author',
- Tag: 'tag',
-}
-function getResourceURL(input: RawSEOInput, context: SEOContext): string | undefined {
- // skipcq: JS-W1043
- const typeName: ResourceType = input.__typename || '_'
- const resourceType = context.metaType || typeMap[typeName]
- const resourceUrls = urls[resourceType]
- if (!resourceUrls?.enable) return undefined
-
- // skipcq: JS-W1043
- const siteUrl = (context.runtimeConfig?.public?.siteUrl as string) || '/'
- const url = resourceUrls.toURL(input as BaseMeta, resourceUrls._context ?? invalidContext)
- return withoutTrailingSlash(resolveURL(siteUrl, url))
-}
-
-function getTwitterSite(_input: RawSEOInput, context: SEOContext) {
- const twitterLink = context.site?.socials?.Twitter
- if (!twitterLink) return undefined
-
- const { pathname } = parseURL(withHttps(twitterLink))
- const accountPath = pathname.split('/')[1]
- return accountPath ? `@${accountPath}` : undefined
-}
-
-export const basic = defineSEOPreset(({ twitterCard = 'summary_large_image' }) => [
- // Resource
- simpleSEO(TITLE, (title: string | undefined) => isDefined(title) && { title }, seoHtmlFilter),
- simpleSEO(OG_TITLE, (ogTitle) => isDefined(ogTitle) && { ogTitle, twitterTitle: ogTitle }, seoHtmlFilter),
- simpleSEO(DESCRIPTION, (description) => isDefined(description) && { description }, seoHtmlFilter),
- simpleSEO(
- OG_DESCRIPTION,
- (ogDescription) => isDefined(ogDescription) && { ogDescription, twitterDescription: ogDescription },
- seoHtmlFilter,
- ),
- simpleSEO(OG_IMAGE, (ogImage) => isDefined(ogImage) && { ogImage, twitterImage: ogImage }),
-
- // Author
- simpleSEO(AUTHOR_BIO, (authorBio) => {
- const bio = truncate(authorBio ?? '', {
- length: 150,
- separator: /,? +/,
- })
- return isDefined(authorBio) && { description: bio, ogDescription: bio, twitterDescription: bio }
- }),
-
- // Common
- simpleSEO(TYPE_NAME, (typeName) => {
- const type = typeName as ResourceType
- if (type === 'Article') return { ogType: 'article' }
- return { ogType: 'website' }
- }),
- createSEO(getResourceURL, (ogUrl) => isDefined(ogUrl) && { ogUrl }),
- createSEO(getTwitterSite, (twitterSite) => isDefined(twitterSite) && { twitterSite }),
- () => ({ twitterCard }),
-])
-
-const emptyPreset = defineSEOPreset(() => [])
-
-const presets: Record = {
- basic,
- __empty: emptyPreset,
-}
-
-export const builtinPresets = new Set(Object.keys(presets))
-
-export interface PresetConfig {
- preset?: string
- presetFactory?: NormalizedSEOPreset
- options?: Record
-}
-
-type InlineSEOPreset = [preset: NormalizedSEOPreset, options?: Record]
-
-type PresetConfigInput = PresetConfig | InlineSEOPreset | NormalizedSEOHandler
-
function loadSEOConfig(): PresetConfigInput[] {
return useNuxtApp().$storipressInternal.seoConfig
}
-export function resolveSEOPresets(configs: PresetConfigInput[]): NormalizedSEOHandler[] {
- return configs.map((config: PresetConfigInput) => {
- if (typeof config === 'function') {
- return config
- } else if (Array.isArray(config)) {
- const [presetFactory, options = {}] = config
- return presetFactory(options)
- }
-
- if (config.presetFactory) {
- return config.presetFactory(config.options || {})
- }
-
- return (presets[config.preset || '__empty'] || emptyPreset)(config.options || {})
- })
-}
-
export function useSEO(
maybeRefInput: MaybeRefOrGetter,
presets: PresetConfigInput[] = loadSEOConfig(),
@@ -276,6 +28,9 @@ export function useSEO(
runtimeConfig,
site,
articleFilter,
+ useHead,
+ useSeoMeta,
+ resourceUrls: urls,
}
const input = refInput.value
for (const handle of handlers) {