Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/content/1.guides/4.content.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,25 @@ content: |
}
```

### `lastmod` from frontmatter dates

The module sets `lastmod` on a content page from its frontmatter dates:

1. A `sitemap.lastmod` in frontmatter always wins.
2. Otherwise the modification date: `seo.articleModifiedTime`, `updatedAt`, `modifiedAt`, `dateModified` or `lastModified`.
3. Otherwise the publication date: `seo.articlePublishedTime`, `publishedAt`, `datePublished` or `date`.

A value that is not a date is skipped. The collection schema does not need to declare these keys.

```md
---
publishedAt: 2025-03-01
updatedAt: 2025-06-12
---
```

This page gets `<lastmod>2025-06-12</lastmod>`. To read no frontmatter dates, set `contentLastmod: false`.

### Exclude from Sitemap

If you'd like to exclude a page from the sitemap, you can set `sitemap: false` in the frontmatter or `robots: false`
Expand Down
6 changes: 6 additions & 0 deletions docs/content/3.api/0.config.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ This will only do anything when using multiple sitemaps.

Sets the current date as the default `lastmod` for all entries that don't already have one.

## `contentLastmod: boolean`{lang="ts"}

- Default: `true`{lang="ts"}

Sets `lastmod` on content pages from their frontmatter dates. See [Nuxt Content](/docs/sitemap/guides/content#lastmod-from-frontmatter-dates). Set `false` to read no frontmatter dates.

## `sitemaps: boolean | Record<string, SitemapConfig> & { index?: (string | SitemapIndexEntry)[] }`

- Default: `false`
Expand Down
1 change: 1 addition & 0 deletions skills/nuxtjs-sitemap/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ If `@nuxtjs/robots` is installed, the module adds the sitemap to `robots.txt`. I
- Prerendered routes are added, including routes the prerender crawler finds.
- A route with the `robots: false` route rule is dropped. So is a `robots` string with `noindex` or `none`.
- When a page is prerendered, `<img>` and `<video>` inside `<main>` become image and video entries. `article:modified_time` becomes `lastmod`.
- A content page gets `lastmod` from frontmatter: `sitemap.lastmod`, then a modification date (`updatedAt`, `modifiedAt`), then a publication date (`publishedAt`, `date`). `contentLastmod: false` turns this off.
- Entries are sorted by path depth, then alphabetically. Set `sortEntries: false` to keep source order.
- An invalid `lastmod` is dropped from the entry. Dev prints an XML comment in its place.
- In production, the resolved sitemap is cached for `cacheMaxAgeSeconds` (600 by default). Dev and prerender do not cache.
Expand Down
5 changes: 3 additions & 2 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { robotsBlocksIndexing } from './runtime/utils-pure'
import { registerTypeTemplates } from './templates'
import { createAppPagesReader } from './utils-internal/appPages'
import { serializeContentCallback } from './utils-internal/contentCallbacks'
import { resolveContentLastmod } from './utils-internal/contentLastmod'
import {
generatePathForI18nPages,
normalizeLocales,
Expand Down Expand Up @@ -147,6 +148,7 @@ export default defineNuxtModule<ModuleOptions>({
experimentalStreaming: false,
defaultSitemapsChunkSize: 1000,
autoLastmod: false,
contentLastmod: true,
discoverImages: true,
discoverVideos: true,
urls: [],
Expand Down Expand Up @@ -539,7 +541,6 @@ export default defineNuxtModule<ModuleOptions>({
body: { value: [string, Record<string, any>][] }
sitemap?: Partial<SitemapUrl> | false
path: string
updatedAt?: string
} & Record<string, any>
nuxtV3Collections.add(ctx.collection.name)
// ignore .dot files and paths
Expand Down Expand Up @@ -568,7 +569,7 @@ export default defineNuxtModule<ModuleOptions>({
images.push(...discoverContentImages(content.body))
// Note: videos only supported through prerendering for simpler logic

const lastmod = content.seo?.articleModifiedTime || content.updatedAt
const lastmod = config.contentLastmod === false ? undefined : resolveContentLastmod(content)
const defaults: Partial<SitemapUrl> = {
loc: content.path,
}
Expand Down
11 changes: 11 additions & 0 deletions src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ export interface ModuleOptions extends SitemapDefinition {
* @default false
*/
autoLastmod: boolean
/**
* Set `lastmod` on content pages from their frontmatter dates.
*
* A modification date (`updatedAt`, `modifiedAt`, `dateModified`, `lastModified` or
* `seo.articleModifiedTime`) wins. Otherwise the publication date (`publishedAt`,
* `datePublished`, `date` or `seo.articlePublishedTime`) is used. A `sitemap.lastmod` in
* frontmatter always wins. Set `false` to read no frontmatter dates.
*
* @default true
*/
contentLastmod: boolean
/**
* Sources to exclude from the sitemap.
*/
Expand Down
39 changes: 39 additions & 0 deletions src/utils-internal/contentLastmod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Frontmatter keys that hold when a page last changed, read first.
const MODIFIED_DATE_KEYS = ['updatedAt', 'modifiedAt', 'dateModified', 'lastModified'] as const
// Keys that hold when a page was published. For a page not edited since, that is its last change.
const PUBLISHED_DATE_KEYS = ['publishedAt', 'datePublished', 'date'] as const

type ContentDate = string | Date

function asContentDate(value: unknown): ContentDate | undefined {
if (value instanceof Date)
return Number.isNaN(value.getTime()) ? undefined : value
if (typeof value === 'string' && value.trim() && !Number.isNaN(Date.parse(value)))
return value
return undefined
}

/**
* The `lastmod` of a content page, read from its frontmatter.
*
* A modification date wins over a publication date. `seo.articleModifiedTime` and
* `seo.articlePublishedTime` come first in each group. A key the collection schema does not
* declare is read from `meta`, where Nuxt Content keeps undeclared frontmatter.
*/
export function resolveContentLastmod(content: Record<string, unknown>): ContentDate | undefined {
const seo = (content.seo || {}) as Record<string, unknown>
const meta = (content.meta || {}) as Record<string, unknown>
const read = (key: string) => content[key] ?? meta[key]
const candidates = [
seo.articleModifiedTime,
...MODIFIED_DATE_KEYS.map(read),
seo.articlePublishedTime,
...PUBLISHED_DATE_KEYS.map(read),
]
for (const candidate of candidates) {
const date = asContentDate(candidate)
if (date)
return date
}
return undefined
}
22 changes: 22 additions & 0 deletions test/e2e/comark-content/content-lastmod-off.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'

const { resolve } = createResolver(import.meta.url)

await setup({
rootDir: resolve('../../fixtures/comark-content'),
nuxtConfig: {
sitemap: {
contentLastmod: false,
},
},
})

describe('comark-content with contentLastmod off', () => {
it('reads no frontmatter dates and keeps an explicit sitemap.lastmod', async () => {
const urls = await $fetch<{ loc: string, lastmod?: string }[]>('/__sitemap__/comark-content-urls.json')
expect(urls.find(url => url.loc === '/posts/published')).toEqual({ loc: '/posts/published' })
expect(urls.find(url => url.loc === '/bar')?.lastmod).toBe('2021-10-20')
}, 60000)
})
8 changes: 8 additions & 0 deletions test/e2e/comark-content/default.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ describe('comark-content default', () => {
{
"loc": "/posts/foo",
},
{
"lastmod": "2024-05-01",
"loc": "/posts/published",
},
]
`)
}, 60000)
Expand Down Expand Up @@ -75,6 +79,10 @@ describe('comark-content default', () => {
<url>
<loc>https://nuxtseo.com/posts/foo</loc>
</url>
<url>
<loc>https://nuxtseo.com/posts/published</loc>
<lastmod>2024-05-01</lastmod>
</url>
</urlset>"
`)
}, 60000)
Expand Down
16 changes: 16 additions & 0 deletions test/e2e/content-v3/default.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,21 @@ describe('nuxt/content v3 default', () => {
"lastmod": "2021-10-20",
"loc": "/posts/bar",
},
{
"lastmod": "2023-03-04",
"loc": "/posts/dated",
},
{
"lastmod": "2021-10-20",
"loc": "/posts/fallback",
},
{
"loc": "/posts/foo",
},
{
"lastmod": "2024-05-01",
"loc": "/posts/published",
},
{
"changefreq": "weekly",
"lastmod": "2025-05-14",
Expand Down Expand Up @@ -89,13 +97,21 @@ describe('nuxt/content v3 default', () => {
<loc>https://nuxtseo.com/posts/bar</loc>
<lastmod>2021-10-20</lastmod>
</url>
<url>
<loc>https://nuxtseo.com/posts/dated</loc>
<lastmod>2023-03-04</lastmod>
</url>
<url>
<loc>https://nuxtseo.com/posts/fallback</loc>
<lastmod>2021-10-20</lastmod>
</url>
<url>
<loc>https://nuxtseo.com/posts/foo</loc>
</url>
<url>
<loc>https://nuxtseo.com/posts/published</loc>
<lastmod>2024-05-01</lastmod>
</url>
</urlset>"
`)
}, 60000)
Expand Down
8 changes: 8 additions & 0 deletions test/fixtures/comark-content/content/posts/published.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
publishedAt: '2024-05-01'
updatedAt: not a date
---

# Published

Only a publication date.
7 changes: 7 additions & 0 deletions test/fixtures/content-v3/content/posts/dated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
date: '2023-03-04'
---

# Dated

A date the schema declares.
8 changes: 8 additions & 0 deletions test/fixtures/content-v3/content/posts/published.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
publishedAt: '2024-05-01'
updatedAt: not a date
---

# Published

Only a publication date.
32 changes: 32 additions & 0 deletions test/unit/content-lastmod.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { resolveContentLastmod } from '../../src/utils-internal/contentLastmod'

describe('resolveContentLastmod', () => {
it('prefers a modification date over a publication date', () => {
expect(resolveContentLastmod({ publishedAt: '2024-01-01', updatedAt: '2024-06-01' })).toBe('2024-06-01')
})

it('reads seo.articleModifiedTime first', () => {
expect(resolveContentLastmod({ seo: { articleModifiedTime: '2024-07-01' }, updatedAt: '2024-06-01' })).toBe('2024-07-01')
})

it('falls back to the publication date', () => {
expect(resolveContentLastmod({ publishedAt: '2024-01-01' })).toBe('2024-01-01')
expect(resolveContentLastmod({ date: '2023-03-04' })).toBe('2023-03-04')
})

it('reads undeclared frontmatter from meta', () => {
expect(resolveContentLastmod({ meta: { date: '2022-02-02' } })).toBe('2022-02-02')
})

it('accepts a Date that YAML parsed', () => {
const date = new Date('2021-10-20')
expect(resolveContentLastmod({ date })).toBe(date)
})

it('skips a value that is not a date', () => {
expect(resolveContentLastmod({ updatedAt: 'soon', date: '2023-03-04' })).toBe('2023-03-04')
expect(resolveContentLastmod({ date: true })).toBeUndefined()
expect(resolveContentLastmod({ title: 'No dates' })).toBeUndefined()
})
})
Loading