diff --git a/docs/app/app.config.ts b/docs/app/app.config.ts index 7b08102..9439c1d 100644 --- a/docs/app/app.config.ts +++ b/docs/app/app.config.ts @@ -1,4 +1,15 @@ export default defineAppConfig({ + ogCovers: { + mark: 'froQ', + colors: { + title: '#1a1917', + paper: '#f4f2ec', + border: 'rgba(26, 25, 23, 0.12)', + mark: 'rgba(26, 25, 23, 0.11)', + meta: '#85837c', + evergreen: 'rgba(22, 163, 74, 0.8)', + }, + }, site: { nav: [ { label: 'nav.docs', to: '/docs', external: false }, diff --git a/docs/app/composables/usePaperOgImage.ts b/docs/app/composables/usePaperOgImage.ts new file mode 100644 index 0000000..52b03db --- /dev/null +++ b/docs/app/composables/usePaperOgImage.ts @@ -0,0 +1,97 @@ +import type { MaybeRefOrGetter } from 'vue' +import type { ShareMetaInput } from './useShareMeta' +import { shareTitle, SITE_NAME } from '~/utils/site' + +function cardTitle(value: string | undefined): string { + const titled = shareTitle(value) + return titled || SITE_NAME +} + +/** Generated paper card for routes that are not Nuxt Content entries. */ +export function usePaperOgImage( + share: ShareMetaInput, + paper?: { title?: MaybeRefOrGetter }, +): void { + const title = computed(() => cardTitle( + paper?.title != null ? toValue(paper.title) : toValue(share.title), + )) + + defineOgImage('Paper', { + title: () => title.value, + words: 0, + }, { + alt: () => title.value, + }) + + useShareMeta({ + ...share, + image: false, + }) +} + +interface ContentPage { + path: string + title?: string | null + description?: string | null + cover?: string | null + ogImage?: boolean | { url?: string, component?: string, props?: Record } | null + evergreen?: boolean | null + kind?: string | null + created?: string | null + date?: string | null + updated?: string | null + last_modified?: string | null + words?: number | null +} + +/** Cover precedence + generated paper card for a Nuxt Content entry. */ +export function useContentOgImage( + page: MaybeRefOrGetter, + collection: string, + share: Omit, +): void { + const files = useRuntimeConfig().public.publicFiles + const override = computed(() => contentOgOverride(toValue(page), collection, files)) + const current = computed(() => toValue(page)) + + if (override.value === undefined) { + defineOgImage(ogComponentName(current.value), { + title: () => paperFields(current.value).title, + evergreen: () => paperFields(current.value).evergreen, + created: () => paperFields(current.value).created, + updated: () => paperFields(current.value).updated, + words: () => paperFields(current.value).words, + ...ogExtraProps(current.value), + }, { + alt: () => paperFields(current.value).title, + }) + } + + useShareMeta({ + ...share, + image: () => override.value === undefined ? false : override.value, + }) +} + +/** Index/list routes under a collection (e.g. changelog) without a content file. */ +export function useCollectionIndexOgImage( + source: { collection: string, path: string }, + share: ShareMetaInput, +): void { + const files = useRuntimeConfig().public.publicFiles + const override = computed(() => explicitContentCover(source, path => hasShareFile(files, path))) + + if (!override.value) { + defineOgImage('Paper', { + title: () => cardTitle(toValue(share.title)), + words: 0, + }, { + alt: () => cardTitle(toValue(share.title)), + }) + } + + useShareMeta({ + ...share, + image: () => override.value ?? false, + }) +} diff --git a/docs/app/composables/useShareMeta.ts b/docs/app/composables/useShareMeta.ts index 2bbd5e8..883b2bf 100644 --- a/docs/app/composables/useShareMeta.ts +++ b/docs/app/composables/useShareMeta.ts @@ -1,17 +1,40 @@ import type { MaybeRefOrGetter } from 'vue' -import { canonicalUrl, shareTitle } from '../../shared/site' +import { absoluteImageUrl, canonicalUrl, OG_IMAGE_HEIGHT, OG_IMAGE_PATH, OG_IMAGE_WIDTH, shareTitle, SITE_NAME } from '~/utils/site' -/** Document title plus Open Graph / Twitter tags for the current route. */ -export function useShareMeta(input: { +export interface ShareMetaInput { title?: MaybeRefOrGetter description?: MaybeRefOrGetter type?: MaybeRefOrGetter<'website' | 'article' | undefined> -} = {}): void { + /** + * Share image path or https URL. + * `false` when defineOgImage owns `og:image`. + * Omitted routes use `/og.png`. + */ + image?: MaybeRefOrGetter +} + +function filled(value: string | undefined, fallback: string): string { + const text = value?.trim() + return text || fallback +} + +/** Document title plus Open Graph / Twitter tags for the current route. */ +export function useShareMeta(input: ShareMetaInput = {}): void { const route = useRoute() - const title = computed(() => shareTitle(toValue(input.title))) + + const title = computed(() => shareTitle(input.title == null ? undefined : toValue(input.title))) const description = computed(() => { const value = input.description == null ? undefined : toValue(input.description) - return value || undefined + return filled(value, SITE_DESCRIPTION) + }) + const type = computed(() => toValue(input.type) ?? 'website') + const url = computed(() => canonicalUrl(route.path)) + const skipImage = toValue(input.image) === false + const image = computed(() => { + const value = toValue(input.image) + if (value === false || !value) + return absoluteImageUrl(OG_IMAGE_PATH) + return absoluteImageUrl(value) }) useHead({ @@ -19,12 +42,31 @@ export function useShareMeta(input: { }) useSeoMeta({ - description, - ogTitle: title, - twitterTitle: title, - ogDescription: description, - twitterDescription: description, - ogUrl: () => canonicalUrl(route.path), - ogType: () => toValue(input.type) ?? 'website', + description: () => description.value, + ogTitle: () => title.value, + ogDescription: () => description.value, + ogUrl: () => url.value, + ogType: () => type.value, + ogSiteName: SITE_NAME, + twitterCard: 'summary_large_image', + twitterTitle: () => title.value, + twitterDescription: () => description.value, + }) + + if (!skipImage) { + useSeoMeta({ + ogImage: () => image.value, + ogImageSecureUrl: () => image.value, + ogImageType: 'image/png', + ogImageWidth: OG_IMAGE_WIDTH, + ogImageHeight: OG_IMAGE_HEIGHT, + ogImageAlt: () => title.value, + twitterImage: () => image.value, + twitterImageAlt: () => title.value, + }) + } + + useHead({ + link: [{ rel: 'canonical', href: () => url.value }], }) } diff --git a/docs/app/error.vue b/docs/app/error.vue index ac8476d..cb4e7f4 100644 --- a/docs/app/error.vue +++ b/docs/app/error.vue @@ -6,7 +6,7 @@ const props = defineProps<{ error: NuxtError }>() const { t } = useI18n() const link = useSiteLink() -useShareMeta({ +usePaperOgImage({ title: () => t('notFound.title'), description: () => props.error.statusMessage || props.error.message, }) diff --git a/docs/app/pages/[...slug].vue b/docs/app/pages/[...slug].vue index 46ec489..4dbb44f 100644 --- a/docs/app/pages/[...slug].vue +++ b/docs/app/pages/[...slug].vue @@ -16,7 +16,7 @@ const { data: page } = await useAsyncData( if (!page.value) throw createError({ statusCode: 404, statusMessage: 'Not found', fatal: true }) -useShareMeta({ +useContentOgImage(page, 'pages', { title: () => page.value?.title, description: () => page.value?.description, type: 'article', diff --git a/docs/app/pages/changelog.vue b/docs/app/pages/changelog.vue index f6d16ae..e23235b 100644 --- a/docs/app/pages/changelog.vue +++ b/docs/app/pages/changelog.vue @@ -7,7 +7,10 @@ const { data: releases } = await useAsyncData( () => queryCollection('changelog').where('path', 'LIKE', `${contentPath('/changelog')}/%`).order('date', 'DESC').all(), ) -useShareMeta({ +useCollectionIndexOgImage({ + collection: 'changelog', + path: contentPath('/changelog'), +}, { title: () => t('changelog.title'), description: () => t('changelog.lede'), }) diff --git a/docs/app/pages/components/[concept]/[name].vue b/docs/app/pages/components/[concept]/[name].vue index 5e7839c..d72609a 100644 --- a/docs/app/pages/components/[concept]/[name].vue +++ b/docs/app/pages/components/[concept]/[name].vue @@ -10,7 +10,7 @@ if (!concept.value || !item.value) const hasApi = computed(() => Boolean(doc.value?.model || doc.value?.props?.length || doc.value?.slots?.length || doc.value?.events?.length)) -useShareMeta({ +usePaperOgImage({ title: () => item.value?.name, description: () => doc.value?.description, }) diff --git a/docs/app/pages/components/[concept]/index.vue b/docs/app/pages/components/[concept]/index.vue index 5bbbc28..a50b1fa 100644 --- a/docs/app/pages/components/[concept]/index.vue +++ b/docs/app/pages/components/[concept]/index.vue @@ -7,7 +7,7 @@ const { docFor } = await useComponentDocs() if (!concept.value) throw createError({ statusCode: 404, statusMessage: 'Not found', fatal: true }) -useShareMeta({ +usePaperOgImage({ title: () => concept.value?.id, description: () => concept.value?.items.map(item => item.name).join(', '), }) diff --git a/docs/app/pages/components/index.vue b/docs/app/pages/components/index.vue index 0b6a4cd..3052867 100644 --- a/docs/app/pages/components/index.vue +++ b/docs/app/pages/components/index.vue @@ -3,7 +3,7 @@ const { t } = useI18n() const link = useSiteLink() const sections = componentSections() -useShareMeta({ +usePaperOgImage({ title: () => t('components.title'), description: () => t('components.lede'), }) diff --git a/docs/app/pages/docs/[...slug].vue b/docs/app/pages/docs/[...slug].vue index 0ac7110..0cd31a3 100644 --- a/docs/app/pages/docs/[...slug].vue +++ b/docs/app/pages/docs/[...slug].vue @@ -73,7 +73,7 @@ const index = computed(() => pages.value.findIndex(item => item.to === herePath. const prev = computed(() => pages.value[index.value - 1]) const next = computed(() => pages.value[index.value + 1]) -useShareMeta({ +useContentOgImage(doc, 'docs', { title: () => doc.value?.title, description: () => doc.value?.description, type: 'article', diff --git a/docs/app/pages/index.vue b/docs/app/pages/index.vue index 976b79d..7f05a80 100644 --- a/docs/app/pages/index.vue +++ b/docs/app/pages/index.vue @@ -9,10 +9,10 @@ const catalog = componentCatalog() const count = catalog.reduce((total, concept) => total + concept.items.length, 0) const options = computed(() => ['quiet', 'clear', 'loud'].map(value => ({ value, label: t(`hero.${value}`) }))) -useShareMeta({ +usePaperOgImage({ title: 'froq/ui', description: () => t('hero.lede'), -}) +}, { title: 'froq/ui' })