Skip to content
Open
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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Nuxt LLMs automatically generates [`llms.txt` markdown documentation](https://ll
- Generates & prerenders `/llms.txt` automatically
- Generate & prerenders `/llms-full.txt` when enabled
- Customizable sections directly from your `nuxt.config.ts`
- `useLlmsAlternate` composable to advertise per-page markdown alternates (RFC 8288)
- Integrates with Nuxt modules and your application via the runtime hooks system

## Quick Setup
Expand Down Expand Up @@ -120,6 +121,32 @@ export default defineNuxtConfig({
})
```

## Per-page markdown discovery

`/llms.txt` advertises your site at the global level, but agents crawling individual pages have no standard way to know that a markdown counterpart exists. The `useLlmsAlternate` composable solves this by emitting two discovery hints per [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288):

- `<link rel="alternate" type="text/markdown" href="...">` in the document `<head>`
- `Link: <...>; rel="alternate"; type="text/markdown"` HTTP response header

Agents can read the `Link` header from a `HEAD` request — without parsing HTML — and switch to the markdown URL for ingestion.

```vue
<!-- app/pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
useLlmsAlternate(`/raw/blog/${route.params.slug}.md`)
</script>
```

The composable accepts a string, a `ref`, or a getter. Falsy values are ignored, so it is safe to call before async data resolves:

```ts
const { data: post } = await useAsyncData(/* ... */)
useLlmsAlternate(() => post.value?.markdownUrl)
```

This pairs naturally with `@nuxt/content`'s `/raw/<path>.md` endpoint, but works with any markdown URL — including endpoints you serve yourself.

## Extending the documentation using hooks

The module provides a hooks system that allows you to dynamically extend both documentation formats. There are two main hooks:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"@nuxt/kit": "^4.2.2"
},
"devDependencies": {
"@nuxt/content": "^3.10.0",
"@nuxt/content": "^3.13.0",
"@nuxt/devtools": "^3.1.1",
"@nuxt/eslint-config": "^1.12.1",
"@nuxt/module-builder": "^1.0.2",
Expand Down
5 changes: 5 additions & 0 deletions playground/pages/[...slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ const route = useRoute()
const { data } = await useAsyncData(() => 'posts' + route.path, async () => {
return await queryCollection('content').path(route.path).first()
})

useLlmsAlternate(() => {
const path = data.value?.path
return path && path !== '/' ? `/raw${path}.md` : null
})
</script>

<template>
Expand Down
567 changes: 399 additions & 168 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defineNuxtModule, createResolver, addServerHandler, addPrerenderRoutes, addServerImports, useLogger } from '@nuxt/kit'
import { defineNuxtModule, createResolver, addServerHandler, addPrerenderRoutes, addServerImports, addImports, useLogger } from '@nuxt/kit'
import { version } from '../package.json'
import type { ModuleOptions } from './runtime/types'

Expand All @@ -16,6 +16,11 @@ export default defineNuxtModule<ModuleOptions>({
const logger = useLogger('nuxt-llms')
const { resolve } = createResolver(import.meta.url)

addImports({
name: 'useLlmsAlternate',
from: resolve('./runtime/composables/useLlmsAlternate'),
})

const llmsConfig = nuxt.options.runtimeConfig.llms = {
domain: options.domain,
title: options.title,
Expand Down
41 changes: 41 additions & 0 deletions src/runtime/composables/useLlmsAlternate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
import { setResponseHeader } from 'h3'
import { useHead, useRequestEvent } from '#imports'

/**
* Advertise a markdown alternate of the current page so AI agents can
* discover and fetch the markdown version instead of parsing HTML.
*
* Emits two discovery hints (RFC 8288):
* - `<link rel="alternate" type="text/markdown" href="...">` in the document head
* - `Link: <...>; rel="alternate"; type="text/markdown"` HTTP response header (server only)
*
* @example
* ```ts
* // app/pages/blog/[slug].vue
* useLlmsAlternate(`/raw/blog/${route.params.slug}.md`)
* ```
*
* Accepts a string, ref, or getter. Falsy values are ignored, so it is safe to
* call before async data has resolved.
*/
export function useLlmsAlternate(
href: MaybeRefOrGetter<string | null | undefined>,
): void {
const resolved = computed(() => toValue(href) || null)

useHead({
link: () => {
const value = resolved.value
return value ? [{ rel: 'alternate', type: 'text/markdown', href: value }] : []
},
})

if (import.meta.server) {
const value = resolved.value
if (!value) return
const event = useRequestEvent()
if (!event) return
setResponseHeader(event, 'Link', `<${value}>; rel="alternate"; type="text/markdown"`)
}
}
61 changes: 61 additions & 0 deletions test/alternate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { fileURLToPath } from 'node:url'
import { describe, it, expect } from 'vitest'
import { setup, $fetch, fetch } from '@nuxt/test-utils/e2e'

describe('useLlmsAlternate', async () => {
await setup({
rootDir: fileURLToPath(new URL('./fixtures/alternate', import.meta.url)),
})

describe('with a literal href', () => {
it('emits a <link rel="alternate" type="text/markdown"> in the document head', async () => {
const html = await $fetch<string>('/')
expect(html).toContain('rel="alternate"')
expect(html).toContain('type="text/markdown"')
expect(html).toContain('href="/raw/index.md"')
})

it('emits a Link HTTP response header', async () => {
const response = await fetch('/')
expect(response.headers.get('link')).toBe(
'</raw/index.md>; rel="alternate"; type="text/markdown"',
)
})
})

describe('with a getter', () => {
it('resolves the getter and emits both hints', async () => {
const html = await $fetch<string>('/getter')
expect(html).toContain('href="/raw/post-from-getter.md"')

const response = await fetch('/getter')
expect(response.headers.get('link')).toBe(
'</raw/post-from-getter.md>; rel="alternate"; type="text/markdown"',
)
})
})

describe('with falsy values (null, empty string, getter returning undefined)', () => {
it('does not emit a <link rel="alternate"> tag', async () => {
const html = await $fetch<string>('/empty')
expect(html).not.toContain('rel="alternate"')
})

it('does not emit a Link header', async () => {
const response = await fetch('/empty')
expect(response.headers.get('link')).toBeNull()
})
})

describe('on a page that does not call the composable', () => {
it('does not emit a <link rel="alternate"> tag', async () => {
const html = await $fetch<string>('/none')
expect(html).not.toContain('rel="alternate"')
})

it('does not emit a Link header', async () => {
const response = await fetch('/none')
expect(response.headers.get('link')).toBeNull()
})
})
})
3 changes: 3 additions & 0 deletions test/fixtures/alternate/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>
8 changes: 8 additions & 0 deletions test/fixtures/alternate/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default defineNuxtConfig({
modules: ['../../../src/module'],
compatibilityDate: '2025-06-06',
llms: {
domain: 'https://llms.nuxt.com',
title: 'Nuxt LLMs module',
},
})
5 changes: 5 additions & 0 deletions test/fixtures/alternate/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"private": true,
"name": "alternate",
"type": "module"
}
10 changes: 10 additions & 0 deletions test/fixtures/alternate/pages/empty.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<script setup lang="ts">
defineOptions({ name: 'AlternateEmpty' })
useLlmsAlternate(null)
useLlmsAlternate('')
useLlmsAlternate(() => undefined)
</script>

<template>
<div>empty</div>
</template>
12 changes: 12 additions & 0 deletions test/fixtures/alternate/pages/getter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script setup lang="ts">
import { ref } from 'vue'

defineOptions({ name: 'AlternateGetter' })

const slug = ref('post-from-getter')
useLlmsAlternate(() => `/raw/${slug.value}.md`)
</script>

<template>
<div>getter</div>
</template>
8 changes: 8 additions & 0 deletions test/fixtures/alternate/pages/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script setup lang="ts">
defineOptions({ name: 'AlternateIndex' })
useLlmsAlternate('/raw/index.md')
</script>

<template>
<div>literal</div>
</template>
7 changes: 7 additions & 0 deletions test/fixtures/alternate/pages/none.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script setup lang="ts">
defineOptions({ name: 'AlternateNone' })
</script>

<template>
<div>none</div>
</template>