Skip to content

Commit

Permalink
feat(use-content-head): add helper for <head> binding (#1295)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tahul committed Jun 27, 2022
1 parent 3dc1f25 commit 105f690
Show file tree
Hide file tree
Showing 11 changed files with 220 additions and 114 deletions.
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 })
}

0 comments on commit 105f690

Please sign in to comment.