Skip to main content

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.

content.config.ts
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.md

Render 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.

app/pages/docs/[...slug].vue
<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.

app/components/DocsSidebar.vue
<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".