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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ Content lives as Markdown in your repo and is served **at request time** — par
- **Instant production content** — GitHub-sourced content pinned to a commit SHA, ISR-cached HTML, revalidated on push by a GitHub webhook (`/api/revalidate`).
- **Versioned previews** — any branch (`/tree/:branch`) or commit (`/blob/:sha`) can be previewed through versioned URLs.
- Docs UI built with [Nuxt UI](https://ui.nuxt.com): sidebar navigation, search (`⌘K`), TOC, prev/next links, version history panel.
- SEO & AEO out of the box: sitemap, robots, canonical URLs, OG images (Satori), JSON-LD, `llms.txt` / `llms-full.txt`, raw markdown mirrors (`/raw/**`), RSS, MCP server (`/mcp`), Agent Skills discovery (`/.well-known/skills/`).
- SEO out of the box: sitemap, robots, canonical URLs, OG images (Satori), JSON-LD, RSS.
- Markdown for agents through [nuxt-agent-discovery](https://github.com/benjamincanac/nuxt-agent-discovery): content negotiation on every page URL, raw markdown mirrors (`/raw/**`), `llms.txt` / `llms-full.txt`, `sitemap.md`, `/openapi.json`, `/.well-known/api-catalog`, an MCP server (`/mcp`) with its server card, Agent Skills discovery (`/.well-known/skills/`).

## Quick start

Expand Down Expand Up @@ -44,13 +45,13 @@ From there, the docs cover everything:

## Agent Skills

Drop skills into a `skills/` directory at the app root and the layer serves them at `/.well-known/skills/`, following the [Agent Skills Discovery RFC](https://github.com/cloudflare/agent-skills-discovery-rfc) (v0.1). Users install them with:
Drop skills into a `skills/` directory at the app root and [nuxt-agent-discovery](https://github.com/benjamincanac/nuxt-agent-discovery) serves them at `/.well-known/skills/`, following the [Agent Skills Discovery RFC](https://github.com/cloudflare/agent-skills-discovery-rfc) (v0.1). Users install them with:

```bash
npx skills add https://your-docs-domain.com
```

Each skill is a directory with a `SKILL.md` whose frontmatter includes a `description`; `name` defaults to the directory name. Skills are scanned at build time from the filesystem (they ship with the app, not with GitHub-sourced content), so a skill change needs a redeploy. Override the directory with `comarkDocs.skills.dir`.
Each skill is a directory with a `SKILL.md` whose frontmatter includes a `description`; `name` defaults to the directory name. Skills are scanned at build time from the filesystem (they ship with the app, not with GitHub-sourced content), so a skill change needs a redeploy. Override the directory with `agentDiscovery.skills.dir`.

## Keyboard shortcuts

Expand Down
3 changes: 2 additions & 1 deletion app/components/docs/DocsPageAsideLinks.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ const { copy: copyLink } = useClipboard()
const copying = ref(false)
const site = useSiteConfig()

const mdPath = computed(() => `/raw${route.path}.md`)
const { rawPrefix } = useRuntimeConfig().public.agentDiscovery
const mdPath = computed(() => `${rawPrefix}${route.path}.md`)
const mdUrl = computed(() => `${site.url}${mdPath.value}`)

const { github, docs } = useAppConfig()
Expand Down
1 change: 0 additions & 1 deletion app/utils/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ function walk(items: NavigationItem[], path: string): boolean {
return false
}

// Shared with the server-side `/raw/**` mirror (server/routes/raw/[...slug].md.get.ts).
export { findFirstLeaf } from '../../utils/first-leaf'

export interface BreadcrumbItem {
Expand Down
50 changes: 39 additions & 11 deletions modules/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, readdirSync } from 'node:fs'
import { defineNuxtModule, useLogger } from '@nuxt/kit'
import { addServerPlugin, createResolver, defineNuxtModule, useLogger } from '@nuxt/kit'
import { defu } from 'defu'
import type { ModuleOptions as AgentDiscoveryOptions } from 'nuxt-agent-discovery'
import { resolveContentDir } from '../utils/content-dir'
import { getGitBranch, getGitEnv, getGitRoot, getLocalGitInfo } from '../utils/git'
import { LAYER_ICON_COLLECTIONS } from '../utils/icons'
Expand All @@ -26,12 +27,8 @@ export interface ComarkDocsOptions {
/** GitHub repos (`owner/name`) `/api/code-explorer` may read. Defaults to the content repo only. */
allowRepos?: string[]
}
/** @deprecated Use `agentDiscovery.skills` instead. */
skills?: {
/**
* Directory, relative to the app root, scanned at build time for Agent Skills.
* Each subdirectory with a `SKILL.md` is published at `/.well-known/skills/`.
* @default 'skills'
*/
dir?: string
}
}
Expand All @@ -49,12 +46,13 @@ export default defineNuxtModule<ComarkDocsOptions>({

// Untyped view: `site` (nuxt-site-config) and `appConfig` aren't typed until app.config is generated.
const nuxtOptions = nuxt.options as typeof nuxt.options & {
site?: { url?: string; name?: string }
site?: { url?: string; name?: string; description?: string }
appConfig: Record<string, unknown>
}

// Static module defaults live in the layer's nuxt.config: seeding them here makes module order
// load-bearing. Only build-time discoveries (git, env, the consumer's content dir) belong below.
// This module is listed first in the layer's nuxt.config, so what is seeded below (`site`, `mcp`,
// `agentDiscovery`) is in place before the modules that read it at setup. Static defaults still belong in
// nuxt.config; only build-time discoveries (git, env, the consumer's content dir) are resolved here.

const url = inferSiteURL()
const meta = await getPackageJsonMetadata(rootDir)
Expand Down Expand Up @@ -111,6 +109,8 @@ export default defineNuxtModule<ComarkDocsOptions>({
githubToken: '',
webhookSecret: '',
bypassToken: '',
// Feeds the OpenAPI document.
version: meta.version || '0.0.0',
github: {
owner: gitInfo?.owner || '',
repo: gitInfo?.name || '',
Expand Down Expand Up @@ -144,11 +144,38 @@ export default defineNuxtModule<ComarkDocsOptions>({
}
})

const mcpOptions = (nuxt.options as { mcp?: { name?: string; version?: string } }).mcp
;(nuxt.options as { mcp?: { name?: string; version?: string } }).mcp = defu(mcpOptions, {
const rawMcpOptions = (nuxt.options as { mcp?: false | { name?: string; version?: string; route?: string } }).mcp
const mcp = defu(rawMcpOptions || undefined, {
name: `${siteName} Docs`,
version: '1.0.0',
})
;(nuxt.options as { mcp?: typeof mcp }).mcp = mcp

// What nuxt-agent-discovery cannot know: the MCP server card describing the toolkit's endpoint under the
// same name, and the deprecated `comarkDocs.skills` alias.
if (options.skills) {
logger.warn('`comarkDocs.skills` is deprecated. Move it to `agentDiscovery.skills` in nuxt.config.ts.')
}
const agentDiscovery = (nuxt.options as { agentDiscovery?: AgentDiscoveryOptions }).agentDiscovery
;(nuxt.options as { agentDiscovery?: AgentDiscoveryOptions }).agentDiscovery = defu(agentDiscovery, {
discovery: {
mcpServerCard:
rawMcpOptions === false
? false
: {
endpoint: mcp.route || '/mcp',
name: mcp.name,
version: mcp.version,
...(nuxtOptions.site?.description ? { description: nuxtOptions.site.description } : {}),
},
},
...(options.skills ? { skills: options.skills } : {}),
}) as AgentDiscoveryOptions

// `llms.txt` sections come from the content navigation at request time. Registered here rather than
// scanned from `server/plugins/` so the hook runs ahead of the nuxt-agent-discovery bridge (see the plugin).
const { resolve } = createResolver(import.meta.url)
addServerPlugin(resolve('./runtime/server/plugins/llms'))

// ISR rules here (not `$production`) so they merge cleanly across npm layers; content sections need a redeploy.
if (!nuxt.options.dev && options.isr !== false) {
Expand All @@ -167,6 +194,7 @@ export default defineNuxtModule<ComarkDocsOptions>({
// Global content indexes, purged by the push webhook on content changes.
'/llms.txt': { isr },
'/llms-full.txt': { isr },
'/sitemap.md': { isr },
'/rss.xml': { isr },
// Fetched on every page hydration (see app.vue) and parses every doc body, so cache it.
'/api/content/blob/*/search-sections': { isr: true },
Expand Down
36 changes: 0 additions & 36 deletions modules/markdown-rewrite.ts

This file was deleted.

74 changes: 74 additions & 0 deletions modules/runtime/server/plugins/llms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { NavigationItem } from 'comark-content'
import type { NitroApp } from 'nitropack/types'
import type { LLMsSection } from 'nuxt-llms'
import { useAppConfig } from 'nitropack/runtime'

/**
* Builds `llms.txt` from the content navigation: one section per top-level directory, in sidebar
* order, plus the `docs.llms.links` extras. Registered from modules/config.ts rather than scanned from
* `server/plugins/` so it runs ahead of the nuxt-agent-discovery bridge, which leaves sections
* that carry links alone apart from rewriting every page link to its raw markdown twin, and renders
* `llms-full.txt` from the same content adapter.
*/
export default defineNitroPlugin((nitroApp: NitroApp) => {
nitroApp.hooks.hook('llms:generate', async (event, options) => {
const site = getSiteConfig(event)
const appConfig = useAppConfig(event)
const siteName = appConfig.seo?.siteName || site.name || options.title || ''

options.title ||= siteName
options.description ||= appConfig.docs?.llms?.description || site.description || ''

// A consumer declaring `llms.sections` with `navigation` selectors owns the sections; the bridge
// resolves those. Otherwise the intro goes ahead of the "Documentation Sets" entry nuxt-llms seeds.
if (!options.sections.some((section) => 'navigation' in section)) {
const content = await getProdContent()
const navigation = await content.navigation()
options.sections.unshift(documentationSection(navigation, siteName))
options.sections.push(...navigationSections(navigation))
}

const extraLinks = (appConfig.docs?.llms?.links ?? []) as LLMsSection['links']
if (extraLinks?.length) {
options.sections.push({ title: 'Optional', links: extraLinks })
}
})
})

/** The landing page and the top-level pages that belong to no section. */
function documentationSection(navigation: NavigationItem[], siteName: string): LLMsSection {
return {
title: 'Documentation',
description: 'Every page below is available as raw markdown. Fetch any URL directly.',
links: [
{ title: 'Landing page', description: `Overview of ${siteName}`, href: '/' },
...pageLinks(navigation.filter((item) => !item.children?.length)),
],
}
}

/** One section per top-level directory, carrying its navigation description. */
function navigationSections(navigation: NavigationItem[]): LLMsSection[] {
const sections: LLMsSection[] = []
for (const item of navigation) {
if (!item.children?.length) continue
const links = pageLinks([item])
if (links.length) sections.push({ title: item.title, description: item.description, links })
}
return sections
}

/** Every page in the subtree, depth first, linked on its page URL. */
function pageLinks(items: NavigationItem[]): NonNullable<LLMsSection['links']> {
const links: NonNullable<LLMsSection['links']> = []
const collect = (entries: NavigationItem[]) => {
for (const entry of entries) {
if (entry.page !== false && entry.path && entry.path !== '/') {
links.push({ title: entry.title, description: entry.description, href: entry.path })
}
if (entry.children?.length) collect(entry.children)
}
}
collect(items)
return links
}
49 changes: 0 additions & 49 deletions modules/skills/index.ts

This file was deleted.

53 changes: 0 additions & 53 deletions modules/skills/runtime/server/routes/skills-files.ts

This file was deleted.

Loading
Loading