Skip to content

feat: add useLlmsAlternate composable for per-page markdown discovery#40

Open
sergioazoc wants to merge 3 commits intonuxt-content:mainfrom
sergioazoc:feat/alternate-link-discovery
Open

feat: add useLlmsAlternate composable for per-page markdown discovery#40
sergioazoc wants to merge 3 commits intonuxt-content:mainfrom
sergioazoc:feat/alternate-link-discovery

Conversation

@sergioazoc
Copy link
Copy Markdown

What

Adds a useLlmsAlternate(href) composable that advertises a markdown counterpart of the current page through two RFC 8288 discovery hints:

  • <link rel="alternate" type="text/markdown" href="..."> in the document <head>
  • Link: <...>; rel="alternate"; type="text/markdown" HTTP response header (server only)
// app/pages/blog/[slug].vue
const route = useRoute()
useLlmsAlternate(`/raw/blog/${route.params.slug}.md`)

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

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

Why

/llms.txt advertises the site at the global level, but agents crawling individual pages have no standard way to know that a markdown counterpart exists at /raw/<path>.md (the endpoint @nuxt/content already exposes when this module is installed). They have to either parse the HTML, guess URLs, or send Accept: text/markdown and hope for content negotiation.

The web has had the standard mechanism for this since 1998: Link headers + <link rel="alternate">. It is how RSS auto-discovery, hreflang, and rel="canonical" work. Applying the same pattern to markdown alternates means an agent can read the Link header from a single HEAD request — without parsing HTML — and switch to the markdown URL for ingestion. The token savings of HTML→Markdown for LLM consumption are well documented; that math only kicks in if the agent knows the markdown URL exists.

Demo (end-to-end in the playground)

$ curl -I http://localhost:3000/second-page
link: </raw/second-page.md>; rel="alternate"; type="text/markdown"

$ curl http://localhost:3000/raw/second-page.md
# This is the second page of the website

Hello from second page

The playground's [...slug].vue calls useLlmsAlternate with a getter derived from the loaded content document; the alternate URL is served by @nuxt/content's native /raw/**:slug.md endpoint.

Working in production at https://sergioazocar.com/blog/screaming-architecture-la-clave-para-un-frontend-escalable/ — check the response headers and <head>.

Scope

This PR ships the discovery primitive only. It deliberately does not include:

  • Auto-injection on @nuxt/content pages — would require integration between the two modules, better as a follow-up once this lands.
  • Content negotiation on canonical URLs — the bigger ask in Feature: automatic HTML to markdown conversion for agents #35; has cache and Vary tradeoffs that warrant their own discussion.

Both are natural follow-ups that build on this primitive.

Tests

7 new tests in `test/alternate.test.ts` (fixture: `test/fixtures/alternate`) covering:

Scenario Path Asserts
Literal href `/` head tag + `Link` header with exact value
Reactive getter `/getter` getter resolves, both hints emitted
Falsy values (`null`, `''`, `() => undefined`) `/empty` no head tag, no `Link` header
Composable not called `/none` no head tag, no `Link` header

`pnpm verify` passes: lint, 9/9 tests, typecheck, prepack.

Related

  • Addresses the discoverability piece of Feature: automatic HTML to markdown conversion for agents #35. Content negotiation can land in a separate PR once the direction here is agreed.
  • Bumps `@nuxt/content` devDep from `^3.10.0` to `^3.13.0` so the playground can demonstrate the full loop (3.13 introduced the `features/llms` integration that auto-registers `/raw/**:slug.md` when `nuxt-llms` is present).

Notes for review

  • The composable is the module's first client-side surface; everything else has been server-side. The `addImports` call in `module.ts` is unconditional (does not depend on `domain`) since the composable is useful even without `/llms.txt` generation.
  • The composable internally uses `useHead` for the `` tag and `setResponseHeader` (server only) for the HTTP `Link` header.

sergioazoc added 3 commits May 5, 2026 14:31
Emits two RFC 8288 hints so AI agents can discover the markdown
counterpart of an HTML page without parsing the body:

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

Composable accepts a string, ref, or getter; falsy values are ignored
so it is safe to call before async data resolves.

Pairs naturally with @nuxt/content's /raw/<path>.md endpoint but works
with any markdown URL the consumer chooses to advertise.
Restructures the fixture into a multi-page app so each scenario has
its own route and assertion:

  - /          literal href (head + Link header)
  - /getter    reactive getter resolves correctly
  - /empty     null, '' and getter returning undefined are no-ops
  - /none      pages that never call the composable get no Link header
@nuxt/content 3.13.0 introduced the `features/llms` integration that
auto-registers the `/raw/**:slug.md` server handler when nuxt-llms is
present. With this bump, the playground demonstrates the full agent
discovery loop end-to-end:

  GET /second-page
    → Link: </raw/second-page.md>; rel="alternate"; type="text/markdown"
  GET /raw/second-page.md
    → 200 text/markdown; charset=utf-8 + raw markdown body
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant