Skip to content

Commit cd51afa

Browse files
authoredJan 19, 2025··
feat: Nuxt Content v3 (#175)
1 parent 56c7d00 commit cd51afa

34 files changed

+2321
-1013
lines changed
 

Diff for: ‎docs/content/2.guides/3.content.md

+56-27
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,77 @@ title: Nuxt Content
33
description: How to use the Nuxt Robots module with Nuxt Content.
44
---
55

6-
Nuxt Robots integrates with Nuxt Content out of the box. Allowing you to configure if
7-
a page should be indexable or not right from your markdown files.
6+
## Introduction
87

9-
## Setup
8+
Nuxt Robots comes with an integration for Nuxt Content that allows you to configure robots straight from your markdown directly.
109

11-
Simply use the `robots: false` frontmatter key to opt out of indexing a page.
10+
## Setup Nuxt Content v3
1211

13-
```md [content/foo.md]
14-
---
15-
robots: false
16-
---
12+
In Nuxt Content v3 we need to use the `asRobotsCollection()`{lang="ts"} function to augment any collections
13+
to be able to use the `robots` frontmatter key.
14+
15+
```ts [content.config.ts]
16+
import { defineCollection, defineContentConfig, z } from '@nuxt/content'
17+
import { asRobotsCollection } from '@nuxtjs/robots'
18+
19+
export default defineContentConfig({
20+
collections: {
21+
content: defineCollection(
22+
// adds the robots frontmatter key to the collection
23+
asRobotsCollection({
24+
type: 'page',
25+
source: '**/*.md',
26+
}),
27+
),
28+
},
29+
})
1730
```
1831

19-
This will require that your markdown files have an associated path. When using [Document Driven Mode](https://content.nuxt.com/document-driven/introduction), all markdown files will automatically
20-
have a path set.
32+
To ensure the tags actually gets rendered you need to ensure you're using the `useSeoMeta()`{lang="ts"} composable with `seo`.
2133

22-
Otherwise, you will need to make sure your markdown files have a `path` key.
34+
```vue [[...slug].vue]
35+
<script setup lang="ts">
36+
import { queryCollection, useRoute } from '#imports'
37+
38+
const route = useRoute()
39+
const { data: page } = await useAsyncData(`page-${route.path}`, () => {
40+
return queryCollection('content').path(route.path).first()
41+
})
42+
useSeoMeta(page.value.seo)
43+
</script>
44+
```
45+
46+
## Setup Nuxt Content v2
47+
48+
In Nuxt Content v2 markdown files require either [Document Driven Mode](https://content.nuxt.com/document-driven/introduction) or a `path` key to be set
49+
in the frontmatter.
2350

2451
```md [content/foo.md]
2552
---
2653
path: /foo
27-
robots: false
2854
---
2955
```
3056

31-
### Requirements
57+
## Usage
3258

33-
The Nuxt Content integration does not currently work with the v3 of Nuxt Content. It also won't work when using deploying your site with Cloudflare.
59+
You can use any boolean or string value as `robots` that will be forwarded as a
60+
[Meta Robots Tag](/learn/controlling-crawlers/meta-tags).
3461

35-
It's recommended to manually disable to integration in these cases:
62+
::code-group
63+
64+
```md [input.md]
65+
robots: false
66+
```
67+
68+
```html [output]
69+
<meta name="robots" content="noindex, nofollow">
70+
```
71+
72+
::
73+
74+
### Disabling Nuxt Content Integration
75+
76+
If you need to disable the Nuxt Content integration, you can do so by setting the `disableNuxtContentIntegration`{lang="ts"} option in the module configuration.
3677

3778
```ts [nuxt.config.ts]
3879
export default defineNuxtConfig({
@@ -41,15 +82,3 @@ export default defineNuxtConfig({
4182
}
4283
})
4384
```
44-
45-
## How it works
46-
47-
This will add an entry to your `robots.txt` file that looks like this:
48-
49-
```robots-txt [robots.txt]
50-
User-agent: *
51-
Disallow: /foo
52-
```
53-
54-
It will add the `X-Robots-Tag` header and `<meta name="robots">` tag to the page with the value of
55-
[`robotsDisabledValue`](/docs/robots/api/config#robotsdisabledvalue).

Diff for: ‎package.json

+5-3
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@
7474
"@iconify-json/logos": "^1.2.4",
7575
"@iconify-json/ri": "^1.2.5",
7676
"@iconify-json/tabler": "^1.2.14",
77-
"@nuxt/content": "^2.13.4",
77+
"@nuxt/content": "^3.0.0",
78+
"@nuxt/content-v2": "npm:@nuxt/content@2.13.4",
7879
"@nuxt/devtools-ui-kit": "^1.7.0",
7980
"@nuxt/module-builder": "^0.8.4",
8081
"@nuxt/test-utils": "^3.15.4",
@@ -93,7 +94,7 @@
9394
"nuxt": "^3.15.1",
9495
"typescript": "5.6.3",
9596
"unocss": "^65.4.0",
96-
"vitest": "^2.1.8",
97+
"vitest": "^3.0.2",
9798
"vue": "3.5.13",
9899
"vue-router": "^4.5.0"
99100
},
@@ -103,7 +104,8 @@
103104
"build": {
104105
"externals": [
105106
"h3",
106-
"consola"
107+
"consola",
108+
"@nuxt/content"
107109
]
108110
}
109111
}

Diff for: ‎pnpm-lock.yaml

+2,033-962
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: ‎pnpm-workspace.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
packages:
22
- client
33
- .playground
4+
- test/fixtures/**

Diff for: ‎src/content.ts

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { z } from '@nuxt/content'
2+
3+
export function asRobotsCollection(collection: any) {
4+
if (collection.type !== 'page') {
5+
return
6+
}
7+
if (!collection.schema) {
8+
collection.schema = z.object({
9+
robots: z.union([z.string(), z.boolean()]).optional(),
10+
})
11+
}
12+
else {
13+
collection.schema = collection.schema.extend({
14+
robots: z.union([z.string(), z.boolean()]).optional(),
15+
})
16+
}
17+
collection._integrations = collection._integrations || []
18+
collection._integrations.push('robots')
19+
return collection
20+
}

Diff for: ‎src/module.ts

+25-13
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ export interface ModulePublicRuntimeConfig {
169169
['nuxt-robots']: ResolvedModuleOptions
170170
}
171171

172+
export * from './content'
173+
172174
export default defineNuxtModule<ModuleOptions>({
173175
meta: {
174176
name: '@nuxtjs/robots',
@@ -331,16 +333,26 @@ export default defineNuxtModule<ModuleOptions>({
331333
}
332334

333335
const nitroPreset = resolveNitroPreset(nuxt.options.nitro)
334-
let usingNuxtContent = hasNuxtModule('@nuxt/content') && config.disableNuxtContentIntegration !== true
335-
if (usingNuxtContent) {
336-
if (await hasNuxtModuleCompatibility('@nuxt/content', '^3')) {
337-
logger.warn('Nuxt Robots does not work with Nuxt Content v3 yet, the integration will be disabled. Learn more at: https://nuxtseo.com/docs/robots/guides/content')
338-
usingNuxtContent = false
339-
}
340-
else if (nitroPreset.startsWith('cloudflare')) {
341-
logger.warn('The Nuxt Robots, Nuxt Content integration does not work with CloudFlare yet, the integration will be disabled. Learn more at: https://nuxtseo.com/docs/robots/guides/content')
342-
usingNuxtContent = false
343-
}
336+
const usingNuxtContent = hasNuxtModule('@nuxt/content')
337+
const isNuxtContentV3 = usingNuxtContent && await hasNuxtModuleCompatibility('@nuxt/content', '^3')
338+
let isNuxtContentV2 = usingNuxtContent && await hasNuxtModuleCompatibility('@nuxt/content', '^2')
339+
if (isNuxtContentV3) {
340+
// @ts-expect-error runtime type
341+
nuxt.hooks.hook('content:file:afterParse', (ctx) => {
342+
if (typeof ctx.content.robots !== 'undefined') {
343+
let rule = ctx.content.robots
344+
if (typeof rule === 'boolean' || !rule) {
345+
rule = rule ? config.robotsEnabledValue : config.robotsDisabledValue
346+
}
347+
// add route rule for the path
348+
ctx.content.seo = ctx.content.seo || {}
349+
ctx.content.seo.robots = rule
350+
}
351+
})
352+
}
353+
else if (isNuxtContentV2 && nitroPreset.startsWith('cloudflare')) {
354+
logger.warn('The Nuxt Robots, Nuxt Content integration does not work with CloudFlare yet, the integration will be disabled. Learn more at: https://nuxtseo.com/docs/robots/guides/content')
355+
isNuxtContentV2 = false
344356
}
345357

346358
nuxt.hook('modules:done', async () => {
@@ -435,7 +447,7 @@ export default defineNuxtModule<ModuleOptions>({
435447

436448
nuxt.options.runtimeConfig['nuxt-robots'] = {
437449
version: version || '',
438-
usingNuxtContent,
450+
isNuxtContentV2,
439451
debug: config.debug,
440452
credits: config.credits,
441453
groups,
@@ -515,10 +527,10 @@ declare module 'h3' {
515527
})
516528
addServerPlugin(resolve('./runtime/server/plugins/initContext'))
517529

518-
if (usingNuxtContent) {
530+
if (isNuxtContentV2) {
519531
addServerHandler({
520532
route: '/__robots__/nuxt-content.json',
521-
handler: resolve('./runtime/server/routes/__robots__/nuxt-content'),
533+
handler: resolve('./runtime/server/routes/__robots__/nuxt-content-v2'),
522534
})
523535
}
524536

Diff for: ‎src/runtime/server/composables/getPathRobotConfig.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { getSiteRobotConfig } from './getSiteRobotConfig'
88

99
export function getPathRobotConfig(e: H3Event, options?: { userAgent?: string, skipSiteIndexable?: boolean, path?: string }): { rule: string, indexable: boolean, debug?: { source: string, line: string } } {
1010
// has already been resolved
11-
const { robotsDisabledValue, robotsEnabledValue, usingNuxtContent } = useRuntimeConfig()['nuxt-robots']
11+
const { robotsDisabledValue, robotsEnabledValue, isNuxtContentV2 } = useRuntimeConfig()['nuxt-robots']
1212
if (!options?.skipSiteIndexable) {
1313
if (!getSiteRobotConfig(e).indexable) {
1414
return {
@@ -69,7 +69,7 @@ export function getPathRobotConfig(e: H3Event, options?: { userAgent?: string, s
6969
}
7070

7171
// 2. nuxt content rules
72-
if (usingNuxtContent && nitroApp._robots?.nuxtContentUrls?.has(withoutTrailingSlash(path))) {
72+
if (isNuxtContentV2 && nitroApp._robots?.nuxtContentUrls?.has(withoutTrailingSlash(path))) {
7373
return {
7474
indexable: false,
7575
rule: robotsDisabledValue,

Diff for: ‎src/runtime/server/plugins/initContext.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { NitroApp } from 'nitropack'
1+
import type { NitroApp } from 'nitropack/types'
22
import { defineNitroPlugin, getRouteRules, useRuntimeConfig } from 'nitropack/runtime'
33
import { withoutTrailingSlash } from 'ufo'
44
import { logger } from '../logger'
@@ -9,11 +9,11 @@ const PRERENDER_NO_SSR_ROUTES = new Set(['/index.html', '/200.html', '/404.html'
99
// we need to init our state using a nitro plugin so the user doesn't throttle the resolve context hook
1010
// important when we integrate with nuxt-simple-sitemap and we're checking thousands of URLs
1111
export default defineNitroPlugin(async (nitroApp: NitroApp) => {
12-
const { usingNuxtContent, robotsDisabledValue } = useRuntimeConfig()['nuxt-robots']
12+
const { isNuxtContentV2, robotsDisabledValue } = useRuntimeConfig()['nuxt-robots']
1313
nitroApp._robots = {} as typeof nitroApp._robots
1414
await resolveRobotsTxtContext(undefined, nitroApp)
1515
const nuxtContentUrls = new Set<string>()
16-
if (usingNuxtContent) {
16+
if (isNuxtContentV2) {
1717
let urls: string[] | undefined
1818
try {
1919
urls = await (await nitroApp.localFetch('/__robots__/nuxt-content.json', {})).json()

Diff for: ‎src/runtime/server/routes/__robots__/nuxt-content.ts renamed to ‎src/runtime/server/routes/__robots__/nuxt-content-v2.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// @ts-expect-error v3 installed
12
import type { ParsedContent } from '@nuxt/content'
23
// @ts-expect-error alias module
34
import { serverQueryContent } from '#content/server'

Diff for: ‎src/runtime/server/routes/robots-txt.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { resolveRobotsTxtContext } from '../util'
1010
export default defineEventHandler(async (e) => {
1111
const nitro = useNitroApp()
1212
const { indexable, hints } = getSiteRobotConfig(e)
13-
const { credits, usingNuxtContent, cacheControl } = useRuntimeConfig(e)['nuxt-robots']
13+
const { credits, isNuxtContentV2, cacheControl } = useRuntimeConfig(e)['nuxt-robots']
1414
// move towards deprecating indexable
1515
let robotsTxtCtx: Omit<HookRobotsConfigContext, 'context' | 'event'> = {
1616
errors: [],
@@ -32,7 +32,7 @@ export default defineEventHandler(async (e) => {
3232
// validate sitemaps are absolute
3333
.map(s => !s.startsWith('http') ? withSiteUrl(e, s, { withBase: true, absolute: true }) : s),
3434
)]
35-
if (usingNuxtContent) {
35+
if (isNuxtContentV2) {
3636
const contentWithRobotRules = await e.$fetch<string[]>('/__robots__/nuxt-content.json', {
3737
headers: {
3838
Accept: 'application/json',
File renamed without changes.
File renamed without changes.
File renamed without changes.

Diff for: ‎test/fixtures/content/nuxt.config.ts renamed to ‎test/fixtures/content-v2/nuxt.config.ts

+4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export default defineNuxtConfig({
1111
url: 'https://nuxtseo.com',
1212
},
1313

14+
alias: {
15+
'@nuxt/content': '@nuxt/content-v2',
16+
},
17+
1418
debug: process.env.NODE_ENV === 'test',
1519
compatibilityDate: '2024-12-18',
1620
})

Diff for: ‎test/fixtures/content-v3/.nuxtrc

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
imports.autoImport=true
2+
typescript.includeWorkspace=true

Diff for: ‎test/fixtures/content-v3/app.vue

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<template>
2+
<NuxtPage />
3+
</template>

Diff for: ‎test/fixtures/content-v3/content.config.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { defineCollection, defineContentConfig, z } from '@nuxt/content'
2+
import { asRobotsCollection } from '../../../src/module'
3+
4+
export default defineContentConfig({
5+
collections: {
6+
content: defineCollection(
7+
asRobotsCollection({
8+
type: 'page',
9+
source: '**/*.md',
10+
schema: z.object({
11+
date: z.string().optional(),
12+
}),
13+
}),
14+
),
15+
},
16+
})

Diff for: ‎test/fixtures/content-v3/content/_partial.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
robots: false
3+
---
4+
5+
# bar

Diff for: ‎test/fixtures/content-v3/content/bar.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
path: /bar
3+
robots: "test"
4+
---
5+
6+
# bar

Diff for: ‎test/fixtures/content-v3/content/foo.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
path: '/foo'
3+
robots: false
4+
date: 2026-10-10
5+
---
6+
7+
# foo

Diff for: ‎test/fixtures/content-v3/content/index.md

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
# index

Diff for: ‎test/fixtures/content-v3/content/posts/bar.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
sitemap:
3+
loc: /blog/posts/bar
4+
lastmod: 2021-10-20
5+
---
6+
# bar

Diff for: ‎test/fixtures/content-v3/content/posts/fallback.md

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
path: /blog/posts/fallback
3+
sitemap:
4+
lastmod: 2021-10-20
5+
---
6+
7+
# foo
8+
9+
no sitemap config

Diff for: ‎test/fixtures/content-v3/content/posts/foo.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# foo
2+
3+
no sitemap config

Diff for: ‎test/fixtures/content-v3/nuxt.config.ts

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { createResolver } from '@nuxt/kit'
2+
import NuxtRobots from '../../../src/module'
3+
4+
const resolver = createResolver(import.meta.url)
5+
6+
const nuxtContent3Resolved = resolver.resolve('node_modules/@nuxt/content/dist/module.mjs')
7+
8+
export default defineNuxtConfig({
9+
modules: [
10+
NuxtRobots,
11+
nuxtContent3Resolved,
12+
'@nuxt/content',
13+
],
14+
15+
content: {
16+
build: {
17+
markdown: {
18+
remarkPlugins: {
19+
// [resolver.resolve('remarkRobots.mjs')]: {},
20+
},
21+
},
22+
},
23+
},
24+
25+
site: {
26+
url: 'https://nuxtseo.com',
27+
},
28+
29+
alias: {
30+
'@nuxt/content': nuxtContent3Resolved,
31+
'remarkRobots': resolver.resolve('remarkRobots.ts'),
32+
},
33+
34+
debug: process.env.NODE_ENV === 'test',
35+
compatibilityDate: '2024-12-06',
36+
})

Diff for: ‎test/fixtures/content-v3/package.json

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"private": "true",
3+
"scripts": {
4+
"dev": "nuxi dev"
5+
},
6+
"dependencies": {
7+
"nuxt": "^3.5.3"
8+
},
9+
"devDependencies": {
10+
"@nuxt/content": "3.0.0-alpha.9"
11+
}
12+
}

Diff for: ‎test/fixtures/content-v3/pages/[...slug].vue

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script setup lang="ts">
2+
import { queryCollection, useRoute } from '#imports'
3+
4+
const route = useRoute()
5+
const { data: page } = await useAsyncData(`page-${route.path}`, () => {
6+
return queryCollection('content').path(route.path).first()
7+
})
8+
useSeoMeta(page.value.seo)
9+
</script>
10+
11+
<template>
12+
<div>
13+
<ContentRenderer v-if="page" :value="page" />
14+
<div v-else>
15+
Page not found
16+
</div>
17+
</div>
18+
</template>

Diff for: ‎test/fixtures/content-v3/remarkRobots.mjs

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { visit } from 'unist-util-visit'
2+
import { parse } from 'yaml'
3+
4+
// TODO experiment with remark plugins
5+
export default function remarkFrontmatterProcessor() {
6+
return (tree) => {
7+
visit(tree, 'yaml', (node) => {
8+
parse(node.value)
9+
// Process the frontmatter data here
10+
})
11+
}
12+
}

Diff for: ‎test/nuxt-content.test.ts renamed to ‎test/nuxt-content-v2.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'
55
const { resolve } = createResolver(import.meta.url)
66

77
await setup({
8-
rootDir: resolve('./fixtures/content'),
8+
rootDir: resolve('./fixtures/content-v2'),
99
})
1010
describe('nuxt/content default', () => {
1111
it('basic', async () => {

Diff for: ‎test/nuxt-content-v3.test.ts

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { createResolver } from '@nuxt/kit'
2+
import { $fetch, setup } from '@nuxt/test-utils'
3+
import { describe, expect, it } from 'vitest'
4+
5+
const { resolve } = createResolver(import.meta.url)
6+
7+
await setup({
8+
rootDir: resolve('./fixtures/content-v3'),
9+
})
10+
describe('nuxt/content default', () => {
11+
it('basic', async () => {
12+
const barHtml = await $fetch('/bar')
13+
// extract robots tag
14+
expect(barHtml.match(/<meta name="robots" content="([^"]+)">/)).toMatchInlineSnapshot(`
15+
[
16+
"<meta name="robots" content="test">",
17+
"test",
18+
]
19+
`)
20+
21+
const fooHtml = await $fetch('/foo')
22+
expect(fooHtml.match(/<meta name="robots" content="([^"]+)">/)).toMatchInlineSnapshot(`
23+
[
24+
"<meta name="robots" content="noindex, nofollow">",
25+
"noindex, nofollow",
26+
]
27+
`)
28+
}, 60000)
29+
})

0 commit comments

Comments
 (0)
Please sign in to comment.