From 371a9273421c738a551cc8109a16b4b9f781229a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ya=C3=ABl=20Guilloux?= Date: Mon, 27 Jun 2022 15:43:21 +0200 Subject: [PATCH 1/9] feat(use-content-head): add helper for binding in both ContentDoc and documentDriven --- docs/package.json | 3 +++ docs/yarn.lock | 6 ++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/package.json b/docs/package.json index dd161a8ea..112fed361 100755 --- a/docs/package.json +++ b/docs/package.json @@ -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", diff --git a/docs/yarn.lock b/docs/yarn.lock index 7b6e5ee88..036ca1b44 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -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-27601186.8c7a185" - resolved "https://registry.yarnpkg.com/@nuxt/content-edge/-/content-edge-2.0.1-27601186.8c7a185.tgz#7e9ee06fd93043860bf9b7e6f7686606994f084c" - integrity sha512-5CuHDupLFIBs0BlvPtHrkE99SiJVjYDX514DgaJizSs4gmz+iekyyw9f0GBbI+Os+IB+zZ64VebbxsB1n/aoXw== +"@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" From 28300e1b2d292d023409096c8c85569998af73a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ya=C3=ABl=20Guilloux?= Date: Mon, 27 Jun 2022 15:48:14 +0200 Subject: [PATCH 2/9] feat(impl): feature implem --- .../4.api/1.components/1.content-doc.md | 4 +- .../4.api/2.composables/6.use-content-head.md | 38 +++++++++ src/module.ts | 1 + src/runtime/components/ContentDoc.ts | 46 ++++------- src/runtime/composables/head.ts | 79 +++++++++++++++++++ src/runtime/pages/document-driven.vue | 55 +------------ src/runtime/plugins/documentDriven.ts | 4 +- 7 files changed, 142 insertions(+), 85 deletions(-) create mode 100644 docs/content/4.api/2.composables/6.use-content-head.md create mode 100644 src/runtime/composables/head.ts diff --git a/docs/content/4.api/1.components/1.content-doc.md b/docs/content/4.api/1.components/1.content-doc.md index 5a37f9ec8..aa0c0dc25 100644 --- a/docs/content/4.api/1.components/1.content-doc.md +++ b/docs/content/4.api/1.components/1.content-doc.md @@ -23,7 +23,9 @@ It uses ``{lang=html} and ``{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 diff --git a/docs/content/4.api/2.composables/6.use-content-head.md b/docs/content/4.api/2.composables/6.use-content-head.md new file mode 100644 index 000000000..fc1eec699 --- /dev/null +++ b/docs/content/4.api/2.composables/6.use-content-head.md @@ -0,0 +1,38 @@ +--- +title: 'useContentHead()' +description: 'Configuring your 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 [``](/api/components/content-doc) component and the default [`documentDriven`](https://content.nuxtjs.org/guide/writing/document-driven) catch-all page. + +## 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] + + ``` + + ```vue [with queryContent] + + ``` + +:: diff --git a/src/module.ts b/src/module.ts index 8ffc553a8..cde19e219 100644 --- a/src/module.ts +++ b/src/module.ts @@ -319,6 +319,7 @@ export default defineNuxtModule({ 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') } ]) diff --git a/src/runtime/components/ContentDoc.ts b/src/runtime/components/ContentDoc.ts index 5ee670203..0ba4fbe25 100644 --- a/src/runtime/components/ContentDoc.ts +++ b/src/runtime/components/ContentDoc.ts @@ -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: { @@ -51,41 +51,27 @@ export default defineComponent({ type: Object as PropType, 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 ', 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, @@ -93,11 +79,13 @@ export default defineComponent({ // 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 }, diff --git a/src/runtime/composables/head.ts b/src/runtime/composables/head.ts new file mode 100644 index 000000000..da86b1609 --- /dev/null +++ b/src/runtime/composables/head.ts @@ -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) } +} diff --git a/src/runtime/pages/document-driven.vue b/src/runtime/pages/document-driven.vue index 6d5da2d80..7f5d2dc13 100644 --- a/src/runtime/pages/document-driven.vue +++ b/src/runtime/pages/document-driven.vue @@ -1,60 +1,9 @@