Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(use-content-head): add helper for <head> binding #1295

Merged
merged 10 commits into from
Jun 27, 2022
4 changes: 3 additions & 1 deletion docs/content/4.api/1.components/1.content-doc.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ It uses `<ContentRenderer>`{lang=html} and `<ContentQuery>`{lang=html} under the
- `query`{lang=ts}: A query to be passed to `queryContent()`.
- Type: `QueryBuilderParams`{lang=ts}
- Default: `undefined`{lang=ts}

- `head`{lang=ts}: Toggles the usage of [`useContentHead`](/api/composables/use-content-head).
- Type: `Boolean`{lang=ts}
- Default: `true`{lang=ts}

## Slots

Expand Down
38 changes: 38 additions & 0 deletions docs/content/4.api/2.composables/6.use-content-head.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
title: 'useContentHead()'
description: 'Configuring your <head> tag from your content has never been easier!'
---

`useContentHead()`{lang="ts"} is a small helper providing easy binding between your content data and [`useHead`](https://v3.nuxtjs.org/guide/features/head-management) composable from Nuxt 3.

It is already implement for you in both [`<ContentDoc />`](/api/components/content-doc) component and the default [`documentDriven`](https://content.nuxtjs.org/guide/writing/document-driven) catch-all page.
Tahul marked this conversation as resolved.
Show resolved Hide resolved

## Usage

`useContentHead()`{lang="ts"} is available everywhere in your app where `useHead` would be.

It takes two arguments:

- `document`: A document data (of any type)
- `to`: A route path
- Default: `useRoute()`{lang=ts}

::code-group

```vue [with documentDriven]
<script setup lang="ts">
const { page } = useContent()

useContentHead(page)
</script>
```

```vue [with queryContent]
<script setup lang="ts">
const { data: page } = await useAsyncData('my-page', queryContent('/').findOne)

useContentHead(page)
</script>
```

::
3 changes: 3 additions & 0 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
"preview": "nuxi preview",
"generate": "nuxi generate"
},
"resolutions": {
"@nuxt/content": "file:../"
},
"devDependencies": {
"@docus/docs-theme": "npm:@docus/docs-theme-edge@latest",
"@docus/github": "npm:@docus/github-edge@latest",
Expand Down
12 changes: 5 additions & 7 deletions docs/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -451,10 +451,8 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"

"@nuxt/content@npm:@nuxt/content-edge@latest":
version "2.0.1-27605578.cfd8782"
resolved "https://registry.yarnpkg.com/@nuxt/content-edge/-/content-edge-2.0.1-27605578.cfd8782.tgz#08212f1cc261754035c1afea4c01bd873c34affd"
integrity sha512-knX0/J5Peic/ggAMO1k/b4BEb5JDk99malCEne8ulE6onlgv22Y3nBmAHkEu5fAwY3QmeadseSvosK3zLxDS8A==
"@nuxt/content@file:../", "@nuxt/content@npm:@nuxt/content-edge@latest":
version "2.0.1"
dependencies:
"@nuxt/kit" "^3.0.0-rc.4"
csvtojson "^2.0.10"
Expand Down Expand Up @@ -5784,9 +5782,9 @@ socket.io-client@^4.5.1:
socket.io-parser "~4.2.0"

socket.io-parser@~4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.0.tgz#3f01e5bc525d94aa52a97ed5cbc12e229bbc4d6b"
integrity sha512-tLfmEwcEwnlQTxFB7jibL/q2+q8dlVQzj4JdRLJ/W/G1+Fu9VSxCx1Lo+n1HvXxKnM//dUuD0xgiA7tQf57Vng==
version "4.2.1"
resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.1.tgz#01c96efa11ded938dcb21cbe590c26af5eff65e5"
integrity sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g==
dependencies:
"@socket.io/component-emitter" "~3.1.0"
debug "~4.3.1"
Expand Down
1 change: 1 addition & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ export default defineNuxtModule<ModuleOptions>({
addAutoImport([
{ name: 'queryContent', as: 'queryContent', from: resolveRuntimeModule('./composables/query') },
{ name: 'useContentHelpers', as: 'useContentHelpers', from: resolveRuntimeModule('./composables/helpers') },
{ name: 'useContentHead', as: 'useContentHead', from: resolveRuntimeModule('./composables/head') },
{ name: 'withContentBase', as: 'withContentBase', from: resolveRuntimeModule('./composables/utils') },
{ name: 'useUnwrap', as: 'useUnwrap', from: resolveRuntimeModule('./composables/utils') }
])
Expand Down
46 changes: 17 additions & 29 deletions src/runtime/components/ContentDoc.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@

import { PropType, defineComponent, h, useSlots, nextTick } from 'vue'
import { PropType, defineComponent, h, useSlots } from 'vue'
import type { QueryBuilderParams } from '../types'
import ContentRenderer from './ContentRenderer'
import ContentQuery from './ContentQuery'
import { useRoute, useHead } from '#imports'
import { useRoute, useContentHead } from '#imports'

export default defineComponent({
props: {
Expand Down Expand Up @@ -51,53 +51,41 @@ export default defineComponent({
type: Object as PropType<QueryBuilderParams>,
required: false,
default: undefined
},

/**
* Whether or not to map the document data to the `head` property.
*/
head: {
type: Boolean,
required: false,
default: true
}
},
render (ctx) {
const slots = useSlots()

const { tag, excerpt, path, query } = ctx
const { tag, excerpt, path, query, head } = ctx

// Merge local `path` props and apply `findOne` query default.
const contentQueryProps = Object.assign(query || {}, { path, find: 'one' })

const emptyNode = (slot: string, data: any) => h('pre', null, JSON.stringify({ message: 'You should use slots with <ContentDoc>', slot, data }, null, 2))

const addHead = (doc: any) => {
if (path !== useRoute().path) { return }
const head = Object.assign({}, doc.head)
head.title = head.title || doc.title
head.meta = head.meta || []
const description = head.description || doc.description
// Shortcut for head.description
if (description && head.meta.filter(m => m.name === 'description').length === 0) {
head.meta.push({
name: 'description',
content: description
})
}
// Shortcut for head.image to og:image in meta
if (head.image && head.meta.filter(m => m.property === 'og:image').length === 0) {
head.meta.push({
property: 'og:image',
content: head.image
})
}
if (process.client) { nextTick(() => useHead(head)) } else { useHead(head) }
}

return h(
ContentQuery,
contentQueryProps,
{
// Default slot
default: slots?.default
? ({ data, refresh, isPartial }) => {
addHead(data)
return slots.default({ doc: data, refresh, isPartial, excerpt, ...this.$attrs })
if (head) { useContentHead(data) }

return slots.default?.({ doc: data, refresh, isPartial, excerpt, ...this.$attrs })
}
: ({ data }) => {
addHead(data)
if (head) { useContentHead(data) }

return h(
ContentRenderer,
{ value: data, excerpt, tag, ...this.$attrs },
Expand Down
79 changes: 79 additions & 0 deletions src/runtime/composables/head.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { RouteLocationNormalized, RouteLocationNormalizedLoaded } from 'vue-router'
import type { HeadObjectPlain } from '@vueuse/head'
import { ParsedContent } from '../types'
import { useRoute, nextTick, useHead } from '#imports'

export const useContentHead = (
document: ParsedContent,
to: RouteLocationNormalized | RouteLocationNormalizedLoaded = useRoute()
) => {
// Don't call this function if no route is yet available
if (!to.path) { return }

// Default head to `document?.head`
const head: HeadObjectPlain = Object.assign({}, document?.head || {})

// Great basic informations from the document
head.title = head.title || document.title
head.meta = head.meta || []

// Grab description from `head.description` or fallback to `document.description`
// @ts-ignore - We expect `head.description` from Nuxt configurations...
const description = head?.description || document.description

// Shortcut for head.description
if (description && head.meta.filter(m => m.name === 'description').length === 0) {
head.meta.push({
name: 'description',
content: description
})
}

// Grab description from `head` or fallback to `document.description`
// @ts-ignore - We expect `head.image` from Nuxt configurations...
const image = head?.image || document.image

// Shortcut for head.image to og:image in meta
if (image && head.meta.filter(m => m.property === 'og:image').length === 0) {
// Handles `image: '/image/src.jpg'`
if (typeof image === 'string') {
head.meta.push({
property: 'og:image',
// @ts-ignore - We expect `head.image` from Nuxt configurations...
content: head.image
})
}

// Handles: `image.src: '/image/src.jpg'` & `image.alt: 200`...
if (typeof image === 'object') {
// https://ogp.me/#structured
const imageKeys = [
'src',
'secure_url',
'type',
'width',
'height',
'alt'
]

// Look on available keys
for (const key of imageKeys) {
// `src` is a shorthand for the URL.
if (key === 'src' && image.src) {
head.meta.push({
property: 'og:image',
content: image[key]
})
} else if (image[key]) {
head.meta.push({
property: `og:${key}`,
content: image[key]
})
}
}
}
}

// @ts-ignore
if (process.client) { nextTick(() => useHead(head)) } else { useHead(head) }
}
55 changes: 2 additions & 53 deletions src/runtime/pages/document-driven.vue
Original file line number Diff line number Diff line change
@@ -1,60 +1,9 @@
<script setup lang="ts">
import { computed, useContent, useHead } from '#imports'
import { useContent, useContentHead } from '#imports'

const { page } = useContent()

const cover = computed(() => {
const cover = page.value?.cover /* || theme.value?.cover */

if (typeof cover === 'string') {
return { src: cover, alt: page.value?.title /* || theme.value.title */ }
}

return cover || {}
})

useHead({
bodyAttrs: {
class: []
},
title: page.value?.title,
titleTemplate: /* theme.value?.title ? '%s | Playground' : */ '%s',
meta: [
{ hid: 'description', name: 'description', content: page.value?.description /* || theme.value?.description */ },
{ hid: 'og:site_name', property: 'og:site_name', content: 'Nuxt' },
{ hid: 'og:type', property: 'og:type', content: 'website' },
{
hid: 'twitter:site',
name: 'twitter:site',
content: '' /* theme.value?.url || theme.value?.socials?.twitter || '' */
},
{
hid: 'twitter:card',
name: 'twitter:card',
content: 'summary_large_image'
},
{
hid: 'og:image',
property: 'og:image',
content: cover.value.src || ''
},
{
hid: 'og:image:secure_url',
property: 'og:image:secure_url',
content: cover.value.src || ''
},
{
hid: 'og:image:alt',
property: 'og:image:alt',
content: cover.value.alt || ''
},
{
hid: 'twitter:image',
name: 'twitter:image',
content: cover.value.src || ''
}
]
})
useContentHead(page)
</script>

<template>
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/plugins/documentDriven.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useRuntimeConfig, addRouteMiddleware } from '#app'
import { withoutTrailingSlash } from 'ufo'
import { NavItem, ParsedContent } from '../types'
// @ts-ignore
import { defineNuxtPlugin, queryContent, useContentHelpers, useContentState, fetchContentNavigation, useRoute } from '#imports'
import { defineNuxtPlugin, queryContent, useContentHelpers, useContentHead, useContentState, fetchContentNavigation, useRoute } from '#imports'
// @ts-ignore
import layouts from '#build/layouts'

Expand Down Expand Up @@ -43,7 +43,7 @@ export default defineNuxtPlugin((nuxt) => {
const refresh = async (to: RouteLocationNormalized | RouteLocationNormalizedLoaded, force: boolean = false) => {
const { navigation, page, globals, surround } = useContentState()

const promises: (() => Promise<any> | undefined)[] = []
const promises: (() => Promise<any> | any)[] = []

/**
* `navigation`
Expand Down