Build a documentation site
Create a docs collection at /docs with an ordered sidebar and previous/next links.
Build the site from three pieces: a collection mounted at /docs, a catch-all page, and a sidebar. Start with the collection.
import { defineCollection, defineContentConfig } from '@lupinum/ginko-content/config'
import { z } from 'zod'
export const docs = defineCollection({
type: 'page',
source: 'docs/**/*.md',
route: '/docs',
schema: z.object({
title: z.string(),
description: z.string(),
icon: z.string().optional()
})
})
export default defineContentConfig({ collections: { docs } })Order the sidebar with numbered folders
Number your folders. The numeric prefix sets the order and is stripped from the URL, so 2.guides/1.writing.md resolves to /docs/guides/writing.
content/docs/
1.get-started/
1.index.md
2.installation.md
2.guides/
1.index.md
3.reference/
1.index.mdRender every docs route
One catch-all page handles the whole collection. Pass surround to load the neighboring pages, and check page.value yourself: useContentPage never throws its own 404.
<script setup lang="ts">
import { createError, useSeoMeta } from '#imports'
import { docs } from '~~/content.config'
definePageMeta({ key: route => route.path })
const { page, previous, next } = await useContentPage(docs, {
surround: { select: ['description'] }
})
if (!page.value) {
throw createError({ statusCode: 404, statusMessage: 'Document not found', fatal: true })
}
useSeoMeta({
title: () => page.value?.title,
description: () => page.value?.description
})
</script>
<template>
<article v-if="page">
<ContentRenderer :value="page" />
<nav aria-label="Adjacent documentation" class="grid sm:grid-cols-2">
<NuxtLink v-if="previous" :to="previous.path">
<span>Previous</span>
<strong>{{ previous.title }}</strong>
</NuxtLink>
<NuxtLink v-if="next" :to="next.path">
<span>Next</span>
<strong>{{ next.title }}</strong>
</NuxtLink>
</nav>
</article>
</template>The v-if guards matter: previous is null on the first page and next is null on the last.
Build the sidebar
navigation(docs) returns the same ordered tree, ready for useAsyncData.
<script setup lang="ts">
import { navigation } from '@lupinum/ginko-content/client'
import { docs } from '~~/content.config'
const { data: items } = await useAsyncData('docs-navigation', () =>
navigation(docs, { select: ['description'] })
)
</script>
<template>
<nav aria-label="Documentation">
<ul>
<li v-for="(item, index) in items" :key="item.path ?? `group:${index}`">
<NuxtLink v-if="item.path" :to="item.path">{{ item.title }}</NuxtLink>
<span v-else>{{ item.title }}</span>
<ul v-if="item.children?.length">
<li v-for="(child, childIndex) in item.children" :key="child.path ?? `group:${index}:${childIndex}`">
<NuxtLink v-if="child.path" :to="child.path">{{ child.title }}</NuxtLink>
<span v-else>{{ child.title }}</span>
</li>
</ul>
</li>
</ul>
</nav>
</template>A folder configured through .navigation.yml can be a structural group with children but no page of its own. Such an item has children but no path, so render its label as plain text and keep walking the children. That is why every link sits behind a v-if="item.path".