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
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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`).
- 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/`).

## Usage

Expand Down Expand Up @@ -58,7 +58,7 @@ export default defineAppConfig({

> Nuxt merges `app.config.ts` across layers with [defu](https://github.com/unjs/defu), which **concatenates arrays**. A consumer's list is *appended to* the layer's, not substituted for it — which is why every array default in the layer is empty. Keep it that way.

`comarkDocs` in `nuxt.config.ts` covers `isr` (`false` disables the generated ISR route rules), `codeExplorer.allowRepos`, and `contentDir`. The GitHub repo, branch and content directory are inferred from the local git checkout and `VERCEL_GIT_*`; override them at runtime with `NUXT_DOCS_*` env vars (`NUXT_DOCS_GITHUB_OWNER`, `NUXT_DOCS_GITHUB_REPO`, `NUXT_DOCS_GITHUB_BRANCH`, …).
`comarkDocs` in `nuxt.config.ts` covers `isr` (`false` disables the generated ISR route rules), `codeExplorer.allowRepos`, `contentDir`, and `skills.dir`. The GitHub repo, branch and content directory are inferred from the local git checkout and `VERCEL_GIT_*`; override them at runtime with `NUXT_DOCS_*` env vars (`NUXT_DOCS_GITHUB_OWNER`, `NUXT_DOCS_GITHUB_REPO`, `NUXT_DOCS_GITHUB_BRANCH`, …).

> **Builds without `.git`.** The content directory is stored relative to the *repository* root, since that's what the GitHub source, the edit links and the push webhook all need — an app in `docs/` becomes `docs/content`. That's derived by relativising against the git root, so a build that can't see `.git` (a shallow or context-limited Docker build, an exported tarball) can only assume the app *is* the repository root. For a single-app repo that's correct; for an app in a subdirectory it silently points every production content read at a path that doesn't exist, and dev won't show it because dev reads the absolute path. The build warns when it has to assume. Set `comarkDocs.contentDir` (or `NUXT_DOCS_CONTENT_DIR`) to silence it authoritatively.

Expand All @@ -81,6 +81,33 @@ The wordmarks (`LogoComark`, `LogoComarkContent`) live in the layer because each

Components can still be replaced by shipping a same-named one (`AppHeader`, `AppFooter`, `AppHeaderBrand`, `OgImage/OgImageDocs.satori.vue`), but neither site needs to.

### Agent Skills

Drop skills into a `skills/` directory at the app root and the layer serves them at `/.well-known/skills/`, following the [Cloudflare 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
```

```
my-docs/
└─ skills/
└─ my-product/
├─ SKILL.md
└─ references/
└─ api.md
```

Each skill needs a `SKILL.md` whose frontmatter includes a `description`. `name` defaults to the directory name and must match the [Agent Skills naming spec](https://agentskills.io/specification#name-field) (lowercase letters, numbers and hyphens). Discovery:

```
GET /.well-known/skills/index.json
GET /.well-known/skills/{skill-name}/SKILL.md
GET /.well-known/skills/{skill-name}/references/api.md
```

Override the directory with `comarkDocs.skills.dir` if it isn't `skills/`. 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.

### Keyboard shortcuts

| Keys | Action |
Expand Down
8 changes: 8 additions & 0 deletions modules/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ export interface ComarkDocsOptions {
/** GitHub repos (`owner/name`) `/api/code-explorer` may read. Defaults to the content repo only. */
allowRepos?: string[]
}
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
}
}

export default defineNuxtModule<ComarkDocsOptions>({
Expand Down
49 changes: 49 additions & 0 deletions modules/skills/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { addPrerenderRoutes, addServerHandler, createResolver, defineNuxtModule, useLogger } from '@nuxt/kit'
import { defu } from 'defu'
import { join } from 'pathe'
import { scanSkills } from './utils'
import type { ComarkDocsOptions } from '../config'

const logger = useLogger('comark-docs')

export default defineNuxtModule({
meta: {
name: 'comark-docs/skills',
},
async setup(_options, nuxt) {
const comarkDocs = (nuxt.options as typeof nuxt.options & { comarkDocs?: ComarkDocsOptions }).comarkDocs
const skillsDir = join(nuxt.options.rootDir, comarkDocs?.skills?.dir || 'skills')

const { catalog, warnings } = await scanSkills(skillsDir)
for (const warning of warnings) logger.warn(warning)
if (!catalog.length) return

logger.info(`Found ${catalog.length} agent skill${catalog.length > 1 ? 's' : ''}: ${catalog.map((s) => s.name).join(', ')}`)
nuxt.options.runtimeConfig.skills = { catalog }

const { resolve } = createResolver(import.meta.url)
const handler = resolve('./runtime/server/routes/skills-files')

nuxt.hook('nitro:config', (nitroConfig) => {
nitroConfig.serverAssets ||= []
nitroConfig.serverAssets.push({ baseName: 'skills', dir: skillsDir })
})

const prerenderRoutes = ['/.well-known/skills', '/.well-known/skills/', '/.well-known/skills/index.json']
for (const skill of catalog) {
for (const file of skill.files) {
prerenderRoutes.push(`/.well-known/skills/${skill.name}/${file}`)
}
}
addPrerenderRoutes(prerenderRoutes)

if (!nuxt.options.dev && comarkDocs?.isr !== false) {
nuxt.options.routeRules = defu(nuxt.options.routeRules, {
'/.well-known/skills/**': { isr: true },
}) as typeof nuxt.options.routeRules
}

addServerHandler({ route: '/.well-known/skills', handler })
addServerHandler({ route: '/.well-known/skills/**', handler })
},
})
53 changes: 53 additions & 0 deletions modules/skills/runtime/server/routes/skills-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { resolveSkillFilePath, type SkillEntry } from '../../../utils'

const PREFIX = '/.well-known/skills/'
const CONTENT_TYPES: Record<string, string> = {
'.md': 'text/markdown; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.yaml': 'text/yaml; charset=utf-8',
'.yml': 'text/yaml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.py': 'text/plain; charset=utf-8',
'.sh': 'text/plain; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.ts': 'text/plain; charset=utf-8',
}

function contentType(path: string): string {
const dot = path.lastIndexOf('.')
return (dot === -1 ? undefined : CONTENT_TYPES[path.slice(dot)]) || 'application/octet-stream'
}

export default defineEventHandler(async (event) => {
const url = getRequestURL(event)
const idx = url.pathname.indexOf(PREFIX)
const filePath = idx === -1 ? '' : decodeURIComponent(url.pathname.slice(idx + PREFIX.length))
const { skills } = useRuntimeConfig(event)

if (!filePath || filePath === 'index.json') {
setHeader(event, 'content-type', 'application/json')
setHeader(event, 'cache-control', 'public, max-age=3600')
return { skills: skills.catalog }
}

const resolved = resolveSkillFilePath(filePath)
if (!resolved) {
throw createError({ statusCode: 400, statusMessage: 'Bad Request' })
}

const catalog = skills.catalog as SkillEntry[]
const skill = catalog.find((entry) => entry.name === resolved.skillName)
if (!skill || !skill.files.includes(resolved.relativeFile)) {
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}

const storagePath = `${resolved.skillName}/${resolved.relativeFile}`
const content = await useStorage('assets:skills').getItemRaw(storagePath)
if (!content) {
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}

setHeader(event, 'content-type', contentType(storagePath))
setHeader(event, 'cache-control', 'public, max-age=3600')
return content
})
124 changes: 124 additions & 0 deletions modules/skills/test/skills.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { mkdir, mkdtemp, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'pathe'
import { describe, expect, it } from 'vitest'
import { resolveSkillFilePath, scanSkills } from '../utils'

async function skillsRoot(): Promise<string> {
return mkdtemp(join(tmpdir(), 'comark-skills-'))
}

async function writeSkill(root: string, name: string, skillMd: string, extra: Record<string, string> = {}) {
const dir = join(root, name)
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'SKILL.md'), skillMd)
for (const [rel, body] of Object.entries(extra)) {
const path = join(dir, rel)
await mkdir(join(path, '..'), { recursive: true })
await writeFile(path, body)
}
}

describe('scanSkills', () => {
it('catalogues a valid skill with supporting files, SKILL.md first', async () => {
const root = await skillsRoot()
await writeSkill(
root,
'my-product',
'---\nname: my-product\ndescription: >\n Build apps with My Product.\n---\n',
{
'references/api.md': '# API\n',
'scripts/setup.sh': '#!/bin/sh\n',
}
)

const { catalog, warnings } = await scanSkills(root)
expect(warnings).toEqual([])
expect(catalog).toEqual([
{
name: 'my-product',
description: 'Build apps with My Product.\n',
files: ['SKILL.md', 'references/api.md', 'scripts/setup.sh'],
},
])
})

it('defaults name to the directory when frontmatter omits it', async () => {
const root = await skillsRoot()
await writeSkill(root, 'create-project', '---\ndescription: Scaffold a project.\n---\n')
expect((await scanSkills(root)).catalog[0]?.name).toBe('create-project')
})

it('skips skills without a description, with an invalid name, or with a name/dir mismatch', async () => {
const root = await skillsRoot()
await writeSkill(root, 'no-desc', '---\nname: no-desc\n---\n')
await writeSkill(root, 'BadName', '---\nname: BadName\ndescription: Nope.\n---\n')
await writeSkill(root, 'mismatch', '---\nname: other\ndescription: Nope.\n---\n')
await writeSkill(root, 'ok-skill', '---\ndescription: Fine.\n---\n')

const { catalog, warnings } = await scanSkills(root)
expect(catalog.map((s) => s.name)).toEqual(['ok-skill'])
expect(warnings).toHaveLength(3)
})

it('omits hidden files from the catalog', async () => {
const root = await skillsRoot()
await writeSkill(root, 'my-skill', '---\ndescription: Hidden files stay private.\n---\n', {
'.secret': 'nope',
'refs/.cache': 'nope',
})
expect((await scanSkills(root)).catalog[0]?.files).toEqual(['SKILL.md'])
})

it('returns an empty catalog when the directory is missing', async () => {
expect(await scanSkills(join(tmpdir(), 'comark-skills-missing'))).toEqual({
catalog: [],
warnings: [],
})
})

it('skips a skill whose SKILL.md is a directory, without aborting the scan', async () => {
const root = await skillsRoot()
await mkdir(join(root, 'broken', 'SKILL.md'), { recursive: true })
await writeSkill(root, 'ok-skill', '---\ndescription: Fine.\n---\n')

const { catalog, warnings } = await scanSkills(root)
expect(catalog.map((s) => s.name)).toEqual(['ok-skill'])
expect(warnings.some((w) => w.includes('broken') && w.includes('not a file'))).toBe(true)
})

it('does not list a symlink that points outside the skill directory', async () => {
const root = await skillsRoot()
const outside = await mkdtemp(join(tmpdir(), 'comark-skills-outside-'))
await writeFile(join(outside, 'secret.md'), 'leaked')
await writeSkill(root, 'my-skill', '---\ndescription: Fine.\n---\n', {
'references/api.md': '# API\n',
})
await symlink(join(outside, 'secret.md'), join(root, 'my-skill', 'leaked.md'))
await symlink(outside, join(root, 'my-skill', 'escape'))

expect((await scanSkills(root)).catalog[0]?.files).toEqual(['SKILL.md', 'references/api.md'])
})
})

describe('resolveSkillFilePath', () => {
it('normalises in-skill `.` / `..` segments', () => {
expect(resolveSkillFilePath('my-skill/refs/../SKILL.md')).toEqual({
skillName: 'my-skill',
relativeFile: 'SKILL.md',
})
expect(resolveSkillFilePath('my-skill/./references/api.md')).toEqual({
skillName: 'my-skill',
relativeFile: 'references/api.md',
})
})

it('rejects paths that escape or have no file', () => {
expect(resolveSkillFilePath('../etc/passwd')).toBeNull()
expect(resolveSkillFilePath('my-skill/../../etc/passwd')).toBeNull()
expect(resolveSkillFilePath('/etc/passwd')).toBeNull()
expect(resolveSkillFilePath('my-skill/SKILL.md\0.png')).toBeNull()
expect(resolveSkillFilePath('my-skill')).toBeNull()
expect(resolveSkillFilePath('')).toBeNull()
})
})
Loading
Loading