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) {