Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -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 },
Expand Down
97 changes: 97 additions & 0 deletions docs/app/composables/usePaperOgImage.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> },
): 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<string, unknown> } | 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<ContentPage | null | undefined>,
collection: string,
share: Omit<ShareMetaInput, 'image'>,
): 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,
})
}
68 changes: 55 additions & 13 deletions docs/app/composables/useShareMeta.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,72 @@
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<string | undefined>
description?: MaybeRefOrGetter<string | undefined>
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<string | false | undefined>
}

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({
title: () => input.title == null ? undefined : toValue(input.title) || undefined,
})

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 }],
})
}
2 changes: 1 addition & 1 deletion docs/app/error.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
2 changes: 1 addition & 1 deletion docs/app/pages/[...slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 4 additions & 1 deletion docs/app/pages/changelog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
})
Expand Down
2 changes: 1 addition & 1 deletion docs/app/pages/components/[concept]/[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
2 changes: 1 addition & 1 deletion docs/app/pages/components/[concept]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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(', '),
})
Expand Down
2 changes: 1 addition & 1 deletion docs/app/pages/components/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const { t } = useI18n()
const link = useSiteLink()
const sections = componentSections()

useShareMeta({
usePaperOgImage({
title: () => t('components.title'),
description: () => t('components.lede'),
})
Expand Down
2 changes: 1 addition & 1 deletion docs/app/pages/docs/[...slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions docs/app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
</script>

<template>
Expand Down
13 changes: 13 additions & 0 deletions docs/app/utils/site.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export {
absoluteImageUrl,
canonicalUrl,
IMAGE_ORIGIN,
OG_IMAGE_HEIGHT,
OG_IMAGE_PATH,
OG_IMAGE_URL,
OG_IMAGE_WIDTH,
shareTitle,
SITE_DESCRIPTION,
SITE_NAME,
SITE_ORIGIN,
} from '../../shared/site'
19 changes: 19 additions & 0 deletions docs/content.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { fileURLToPath } from 'node:url'
import { defineCollection, defineContentConfig, z } from '@nuxt/content'
import { defineOgImageSchema } from 'nuxt-og-image/content'

const field = z.object({
name: z.string(),
Expand All @@ -9,6 +10,18 @@ const field = z.object({
description: z.string(),
})

// Optional frontmatter `cover`: a `public/` path or an absolute https URL. It wins over `ogImage.url`.
const card = {
cover: z.string().optional(),
ogImage: defineOgImageSchema({ z }),
words: z.number().optional(),
evergreen: z.boolean().optional(),
kind: z.string().optional(),
created: z.string().optional(),
updated: z.string().optional(),
last_modified: z.string().optional(),
}

// Every file lives under a locale folder (`zh/`, `en/`), so paths start with `/zh`, `/en`.
export default defineContentConfig({
collections: {
Expand All @@ -21,6 +34,8 @@ export default defineContentConfig({
schema: z.object({
kicker: z.string().optional(),
head: z.boolean().default(true),
date: z.string().optional(),
...card,
}),
}),
changelog: defineCollection({
Expand All @@ -29,6 +44,7 @@ export default defineContentConfig({
schema: z.object({
version: z.string(),
date: z.string(),
...card,
}),
}),
// Numeric prefixes (`1.install.md`) order the sidebar and are dropped from the path.
Expand All @@ -37,6 +53,8 @@ export default defineContentConfig({
source: '*/docs/**/*.md',
schema: z.object({
kicker: z.string().optional(),
date: z.string().optional(),
...card,
}),
}),
// `Name.md` is English. `Name.zh.md` is Chinese. Both sit next to the component.
Expand All @@ -56,6 +74,7 @@ export default defineContentConfig({
payload: z.string(),
description: z.string(),
})).default([]),
...card,
}),
}),
},
Expand Down
Loading
Loading