Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
39 changes: 39 additions & 0 deletions docs/.docs/components/Sandbox.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<script setup lang="ts">
const props = defineProps<{
src?: string
repo?: string
branch?: string
dir?: string
file?: string
}>()

const colorMode = useColorMode()

const url = computed(() => {
if (props.src) {
return props.src
}
const base = `https://stackblitz.com/github/${props.repo}/tree/${props.branch || 'main'}/${props.dir || ''}`
const params = new URLSearchParams({
embed: '1',
file: props.file || 'README.md',
theme: colorMode.value,
})
return `${base}?${params.toString()}`
})
</script>

<template>
<div class="w-full min-h-[500px] mx-auto overflow-hidden rounded-md mt-4 border border-default">
<iframe
v-if="url"
:src="url"
title="StackBlitz Sandbox"
sandbox="allow-modals allow-forms allow-popups allow-scripts allow-same-origin"
class="w-full h-full min-h-[600px] overflow-hidden bg-gray-100 dark:bg-gray-800"
/>
<div v-else class="flex items-center justify-center h-[600px] text-muted">
Loading Sandbox...
</div>
</div>
</template>
19 changes: 19 additions & 0 deletions docs/.docs/content.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
import { resolve } from 'pathe'

export default defineContentConfig({
collections: {
examples: defineCollection({
type: 'page',
source: {
cwd: resolve(__dirname, '../../examples'),
include: '**/README.md',
prefix: '/examples',
exclude: ['**/.**/**', '**/node_modules/**', '**/dist/**', '**/.docs/**'],
},
schema: z.object({
category: z.string().optional(),
}),
}),
},
})
75 changes: 75 additions & 0 deletions docs/.docs/layouts/examples.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content'

// Fetch all examples and group by category
const { data: examples } = await useAsyncData('examples-nav', () =>
queryCollection('examples')
.select('title', 'description', 'category', 'path')
.all(),
)

// Group examples by category
const groupedExamples = computed(() => {
if (!examples.value) return []

const groups: Record<string, ContentNavigationItem[]> = {}

for (const example of examples.value) {
const category = example.category || 'Other'
if (!groups[category]) {
groups[category] = []
}
groups[category].push({
title: example.title,
path: example.path.replace(/\/readme$/i, ''),
})
}

// Convert to navigation items with children
return Object.entries(groups).map(([category, items]) => ({
title: category.charAt(0).toUpperCase() + category.slice(1),
path: '',
children: items,
}))
})

// Flat list for navigation (no groups)
const flatExamples = computed(() => {
if (!examples.value) return []
return examples.value.map((example) => ({
title: example.title,
path: example.path.replace(/\/readme$/i, ''),
}))
})
</script>

<template>
<UContainer>
<UPage :ui="{ left: 'lg:col-span-2 pr-2 border-r border-default' }">
<template #left>
<UPageAside>
<UPageAnchors
:links="[
{ label: 'Docs', icon: 'i-lucide-book-open', to: '/docs' },
{ label: 'Deploy', icon: 'ri:upload-cloud-2-line', to: '/deploy' },
{ label: 'Config', icon: 'ri:settings-3-line', to: '/config' },
{ label: 'Examples', icon: 'i-lucide-folder-code', to: '/examples', active: true },
]"
/>
<USeparator type="dashed" class="py-6" />
<UContentNavigation
v-if="groupedExamples.length"
:navigation="groupedExamples"
:collapsible="false"
/>
<UContentNavigation
v-else-if="flatExamples.length"
:navigation="flatExamples"
:collapsible="false"
/>
</UPageAside>
</template>
<slot />
</UPage>
</UContainer>
</template>
133 changes: 133 additions & 0 deletions docs/.docs/pages/examples/[...slug].vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { joinURL } from 'ufo'
import { kebabCase } from 'scule'

definePageMeta({
layout: 'examples',
})

const appConfig = useAppConfig()
const route = useRoute()

const { data: page } = await useAsyncData(kebabCase(route.path), () =>
queryCollection('examples').path(`${route.path}/readme`).first(),
)
if (!page.value) {
throw createError({
statusCode: 404,
statusMessage: 'Example not found',
message: `${route.path} does not exist`,
fatal: true,
})
}

const { data: surround } = await useAsyncData(`${kebabCase(route.path)}-surround`, () => {
return queryCollectionItemSurroundings('examples', `${route.path}/readme`, {
fields: ['description'],
})
})

// Extract example name from route (e.g., "/examples/vite-ssr-html" -> "vite-ssr-html")
const exampleName = computed(() => {
return route.path.replace(/^\/examples\//, '')
})

// StackBlitz URL for the example
const stackblitzUrl = computed(() => {
return `https://stackblitz.com/fork/github/nitrojs/nitro/tree/main/examples/${exampleName.value}`
})

const breadcrumb = computed(() => [
{ label: 'Examples', icon: 'i-lucide-folder-code', to: '/examples' },
{ label: page.value?.title || exampleName.value },
])

usePageSEO({
title: `${page.value?.title} - ${appConfig.site.name}`,
ogTitle: page.value?.title,
description: page.value?.description,
})

const path = computed(() => route.path.replace(/\/$/, ''))
prerenderRoutes([joinURL('/raw', `${path.value}.md`)])
useHead({
link: [
{
rel: 'alternate',
href: joinURL(appConfig.site.url, 'raw', `${path.value}.md`),
type: 'text/markdown',
Comment on lines 12 to 58
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

🧩 Analysis chain

🏁 Script executed:

fd -t f "\[\.\.\.slug\]\.vue" docs/

Repository: nitrojs/nitro

Length of output: 39


🏁 Script executed:

find . -name "*slug*" -type f | head -20

Repository: nitrojs/nitro

Length of output: 206


🏁 Script executed:

cat -n ./docs/.docs/pages/examples/[...slug].vue

Repository: nitrojs/nitro

Length of output: 4173


🏁 Script executed:

find . -name "nuxt.config.*" -type f | head -5

Repository: nitrojs/nitro

Length of output: 39


🏁 Script executed:

fd -t f "nuxt" docs/ | head -20

Repository: nitrojs/nitro

Length of output: 39


🏁 Script executed:

fd -t f -e config "nuxt" . | head -20

Repository: nitrojs/nitro

Length of output: 39


🏁 Script executed:

ls -la docs/.docs/ | head -30

Repository: nitrojs/nitro

Length of output: 510


🏁 Script executed:

cat docs/.docs/content.config.ts

Repository: nitrojs/nitro

Length of output: 582


🏁 Script executed:

find . -maxdepth 2 -name "nuxt.config.*" -o -name ".nuxtrc*"

Repository: nitrojs/nitro

Length of output: 39


🏁 Script executed:

rg "route\.path" docs/ -A 2 -B 2

Repository: nitrojs/nitro

Length of output: 39


🏁 Script executed:

rg "trailing" docs/ -i

Repository: nitrojs/nitro

Length of output: 715


🏁 Script executed:

rg "useAsyncData" docs/.docs/pages/ -A 3 -B 1

Repository: nitrojs/nitro

Length of output: 1571


🏁 Script executed:

rg "queryCollection.*path.*route\.path" docs/

Repository: nitrojs/nitro

Length of output: 39


🏁 Script executed:

cat docs/.docs/content.config.ts

Repository: nitrojs/nitro

Length of output: 582


Consolidate trailing slash normalization to avoid double slashes in content queries.

Using route.path directly in content queries (lines 12-14, 24-28) can produce //readme if a trailing slash is present. Line 46 already normalizes for the raw markdown URLβ€”apply the same normalization consistently across all queries and path computations by normalizing once at the top.

Consolidate normalizations
+const normalizedPath = computed(() => route.path.replace(/\/$/, ''))
 const { data: page } = await useAsyncData(kebabCase(route.path), () =>
-  queryCollection('examples').path(`${route.path}/readme`).first(),
+  queryCollection('examples').path(`${normalizedPath.value}/readme`).first(),
 )
 if (!page.value) {
   throw createError({
     statusCode: 404,
     statusMessage: 'Example not found',
     message: `${route.path} does not exist`,
     fatal: true,
   })
 }
 
-const { data: surround } = await useAsyncData(`${kebabCase(route.path)}-surround`, () => {
-  return queryCollectionItemSurroundings('examples', `${route.path}/readme`, {
+const { data: surround } = await useAsyncData(`${kebabCase(normalizedPath.value)}-surround`, () => {
+  return queryCollectionItemSurroundings('examples', `${normalizedPath.value}/readme`, {
     fields: ['description'],
   })
 })
 
 // Extract example name from route (e.g., "/examples/vite-ssr-html" -> "vite-ssr-html")
 const exampleName = computed(() => {
-  return route.path.replace(/^\/examples\//, '')
+  return normalizedPath.value.replace(/^\/examples\//, '')
 })
 
 const breadcrumb = computed(() => [
   { label: 'Examples', icon: 'i-lucide-folder-code', to: '/examples' },
   { label: page.value?.title || exampleName.value },
 ])
 
 usePageSEO({
   title: `${page.value?.title} - ${appConfig.site.name}`,
   ogTitle: page.value?.title,
   description: page.value?.description,
 })
 
-const path = computed(() => route.path.replace(/\/$/, ''))
-prerenderRoutes([joinURL('/raw', `${path.value}.md`)])
+prerenderRoutes([joinURL('/raw', `${normalizedPath.value}.md`)])
 useHead({
   link: [
     {
       rel: 'alternate',
-      href: joinURL(appConfig.site.url, 'raw', `${path.value}.md`),
+      href: joinURL(appConfig.site.url, 'raw', `${normalizedPath.value}.md`),
       type: 'text/markdown',
     },
   ],
 })
πŸ€– Prompt for AI Agents
In `@docs/.docs/pages/examples/`[...slug].vue around lines 12 - 53, Normalize
route.path once and reuse that normalized value in all content queries and
derived computations to avoid double slashes: create a single normalizedPath
(derived from route.path with trailing slash removed) and use
normalizedPath.value wherever the code currently uses route.path (including
inside kebabCase for useAsyncData keys,
queryCollection('examples').path(...)/queryCollectionItemSurroundings calls,
exampleName computation, prerenderRoutes/joinURL, etc.), and update any string
concatenations to use `${normalizedPath.value}/readme` so no queries produce
`//readme`.

},
],
})
</script>

<template>
<UPage v-if="page">
<UPageHeader
:title="page.title"
:description="page.description"
:ui="{
wrapper: 'flex-row items-center flex-wrap justify-between',
}"
>
<template #headline>
<UBreadcrumb :items="breadcrumb" />
</template>
<template #links>
<UButton
v-if="stackblitzUrl"
icon="i-simple-icons-stackblitz"
label="Open in StackBlitz"
color="neutral"
variant="outline"
size="sm"
:to="stackblitzUrl"
target="_blank"
/>
<UButton
icon="i-simple-icons-github"
label="View Source"
color="neutral"
variant="outline"
size="sm"
:to="`https://github.com/${appConfig.docs.github}/tree/${appConfig.docs.branch || 'main'}/examples/${exampleName}`"
target="_blank"
/>
</template>
</UPageHeader>

<template v-if="page.body?.toc?.links?.length" #right>
<UContentToc title="On this page" :links="page.body?.toc?.links || []" highlight />
</template>

<UPageBody prose class="break-words">
<Sandbox
repo="nitrojs/nitro"
branch="main"
:dir="`examples/${exampleName}`"
file="vite.config.ts"
class="!mb-6"
/>

<ContentRenderer v-if="page.body" :value="page" />

<div class="space-y-6">
<USeparator type="dashed" />
<div class="mb-4">
<UPageLinks
class="inline-block"
:links="[
{
icon: 'i-lucide-pencil',
label: 'Edit this page',
to: `https://github.com/${appConfig.docs.github}/edit/${appConfig.docs.branch || 'main'}/examples/${exampleName}/README.md`,
target: '_blank',
},
]"
/>
</div>
<UContentSurround v-if="surround?.length" class="mb-4" :surround="surround" />
</div>
</UPageBody>
</UPage>
</template>
94 changes: 94 additions & 0 deletions docs/.docs/pages/examples/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<script setup lang="ts">
definePageMeta({
layout: 'examples',
})

const appConfig = useAppConfig()

// Fetch all examples
const { data: examples } = await useAsyncData('examples-list', () =>
queryCollection('examples')
.select('title', 'description', 'category', 'path')
.all(),
)

// Group examples by category
const groupedExamples = computed(() => {
if (!examples.value) return {}

const groups: Record<string, typeof examples.value> = {}

for (const example of examples.value) {
const category = example.category || 'Other'
if (!groups[category]) {
groups[category] = []
}
groups[category].push(example)
}

return groups
})

const categoryIcons: Record<string, string> = {
vite: 'i-logos-vitejs',
framework: 'i-lucide-puzzle',
features: 'i-lucide-sparkles',
rendering: 'i-lucide-brush',
config: 'i-lucide-settings',
integrations: 'i-lucide-plug',
other: 'i-lucide-folder',
}

usePageSEO({
title: `Examples - ${appConfig.site.name}`,
ogTitle: 'Examples',
description: 'Explore Nitro examples to learn how to build full-stack applications',
})
</script>

<template>
<UPage>
<UPageHeader
title="Examples"
description="Explore Nitro examples to learn how to build full-stack applications with different frameworks and features."
>
<template #headline>
<UBreadcrumb :items="[{ label: 'Examples', icon: 'i-lucide-code' }]" />
</template>
</UPageHeader>

<UPageBody>
<UAlert
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
title="Work in Progress"
description="Nitro v3 Alpha docs and examples are a work in progress β€” expect updates, rough edges, and occasional inaccuracies."
class="mb-8"
/>

<div v-for="(categoryExamples, category) in groupedExamples" :key="category" class="mb-12">
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2">
<UIcon :name="categoryIcons[String(category).toLowerCase()] || categoryIcons.other" class="size-5" />
{{ String(category).charAt(0).toUpperCase() + String(category).slice(1) }}
</h2>

<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<UPageCard
v-for="example in categoryExamples"
:key="example.path"
:to="example.path.replace(/\/readme$/i, '')"
:title="example.title"
:description="example.description"
>
</UPageCard>
</div>
</div>

<div v-if="!examples?.length" class="text-center py-12">
<UIcon name="i-lucide-book-dashed" class="size-12 text-muted mx-auto mb-4" />
<p class="text-muted">No examples</p>
</div>
</UPageBody>
</UPage>
</template>
7 changes: 7 additions & 0 deletions docs/4.examples/0.index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
icon: i-lucide-folder-code
---

# Examples

> Explore Nitro examples to learn how to build full-stack applications
3 changes: 3 additions & 0 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
"dev": "undocs dev",
"build": "undocs build"
},
"dependencies": {
"zod": "^4.3.6"
},
"devDependencies": {
"shaders": "^2.2.43",
"undocs": "^0.4.15"
Expand Down
Loading
Loading