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
25 changes: 9 additions & 16 deletions docs/content/3.guide/1.writing/2.markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,22 +61,15 @@ description: 'meta description of the page'

### Native parameters

| Key | Type | Default | Description |
|---------|--------| -----|-----|
| `title` | `string` | First `<h1>`{lang="html"} of the page | Title of the page (will also be injected in metas) |
| `description` | `string` | First `<p>`{lang="html"} of the page | Description of the page, will be added below the title and injected into the metas |
| `draft` | `Boolean` | `false` | Mark the page as draft (and only display it in development mode). You can also use the filename suffix `.draft`, example: `3.my-draft-page.draft.md` |
| `navigation` | `Boolean` | `true` | Define if the page is included in [`fetchContentNavigation`](/guide/displaying/navigation) return value. |

When used together with [`<ContentDoc>`](/guide/displaying/rendering#contentdoc-) to display the current page, you can use the following parameters:

| Key | Type | Default | Description |
|---------|--------| -----|-----|
| `head.title` | `String` | Generated `title` | Overrides the `<title>` |
| `head.description` | `String` | Generated `description` | Overrides the `<meta name="description">` |
| `head.image` | `String` | | Overrides the `<meta property="og:image>` |

You can set any head parameters inside the front-matter, read more in the [Head Management](https://v3.nuxtjs.org/guide/features/head-management) section of Nuxt 3.
| Key | Type | Default | Description |
| ------------------------------------------- | --------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `title` | `string` | First `<h1>`{lang="html"} of the page | Title of the page, will also be injected in metas |
| `description` | `string` | First `<p>`{lang="html"} of the page | Description of the page, will be shown below the title and injected into the metas |
| `draft` | `Boolean` | `false` | Mark the page as draft (and only display it in development mode). |
| `navigation` | `Boolean` | `true` | Define if the page is included in [`fetchContentNavigation`](/guide/displaying/navigation) return value. |
| [`head`](/api/composables/use-content-head) | `Object` | `true` | Easy access to [`useContentHead`](/api/composables/use-content-head) |

When used together with [`<ContentDoc>`](/guide/displaying/rendering#contentdoc-) or the [document-driven mode](/guide/writing/document-driven) to display the current page, the [`useContentHead() composable`](/api/composables/use-content-head) will be used to set the page's metadata.

## Code Highlighting

Expand Down
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
82 changes: 82 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,82 @@
---
title: 'useContentHead()'
description: 'Configuring your <head> tag from your content has never been easier!'
---

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

It is already implemented for you in both [`<ContentDoc />`](/api/components/content-doc) component and the default [`documentDriven`](https://content.nuxtjs.org/guide/writing/document-driven) page.

## Parameters

These parameters can be used from the [Front-Matter](/guide/writing/markdown#front-matter) section of your pages.

| Key | Type | Default | Description |
| ------------------ | ------------------ | -------------------- | ----------------------------------------------------------------------------------- |
| `head` | `Object` | | A [useHead](https://v3.nuxtjs.org/guide/features/head-management) compatible object |
| `title` | `String` | | Will be used as the default value for `head.title` |
| `head.title` | `String` | Parsed `title` | Sets the `<title>` tag |
| `description` | `String` | | Will be used as the default value for `head.description` |
| `head.description` | `String` | Parsed `description` | Sets the `<meta name="description">` tag |
| `image` | `String \| Object` | | Will be used as the default value for `head.image` |
| `image.src` | `String` | | The source of the image |
| `image.alt` | `String` | | The alt description of the image |
| `image.xxx` | `String` | | Any [`og:image:xxx` compatible](https://ogp.me/#structured) attribute |
| `head.image` | `String \| Object` | | Overrides the `<meta property="og:image">` |

At the exception of `title`, `description` and `image`, the `head` object behaves exactly the same in [Front-Matter](/guide/writing/markdown#front-matter) as it would in [`useHead({ ... })`](https://v3.nuxtjs.org/guide/features/head-management) composable.

You can specify any value that is writeable in `yaml` format.

```md [example-usage.md]
---
title: 'My Page Title'
description: 'What a lovely page.'
image:
src: '/assets/image.jpg'
alt: 'An image showcasing My Page.'
width: 400
height: 300
head:
meta:
- name: 'keywords'
content: 'nuxt, vue, content'
- name: 'robots'
content: 'index, follow'
- name: 'author'
content: 'NuxtLabs'
- name: 'copyright'
content: 'Β© 2022 NuxtLabs'
---
```

## 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>
```

::
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
6 changes: 6 additions & 0 deletions playground/document-driven/content/0.index.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
---
head.titleTemplate: '%s | TEST'
title: Hello World
description: Hello, here is a description
---

# Home

Hello World!
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
86 changes: 86 additions & 0 deletions src/runtime/composables/head.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { RouteLocationNormalized, RouteLocationNormalizedLoaded } from 'vue-router'
import type { HeadObjectPlain } from '@vueuse/head'
import type { Ref } from 'vue'
import { ParsedContent } from '../types'
import { useRoute, nextTick, useHead, unref, nextTick, watch } from '#imports'

export const useContentHead = (
_content: ParsedContent | Ref<ParsedContent>,
to: RouteLocationNormalized | RouteLocationNormalizedLoaded = useRoute()
) => {
const content = unref(_content)

const refreshHead = (data: ParsedContent = content) => {
// Don't call this function if no route is yet available
if (!to.path || !data) { return }

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

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

// Grab description from `head.description` or fallback to `data.description`
// @ts-ignore - We expect `head.description` from Nuxt configurations...
const description = head?.description || data?.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 `data.description`
// @ts-ignore - We expect `head.image` from Nuxt configurations...
const image = head?.image || data?.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) }
}

watch(() => unref(_content), refreshHead, { immediate: true })
}