first commit

This commit is contained in:
2025-07-19 13:59:44 +00:00
commit 2540bc968f
113 changed files with 21736 additions and 0 deletions

36
app/app/app.config.ts Normal file
View File

@ -0,0 +1,36 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'emerald',
neutral: 'zinc',
},
},
uiPro: {
contentNavigation: {
slots: {
linkLeadingIcon: 'size-4 mr-1',
listWithChildren: 'border-(--ui-bg-elevated)',
linkTrailing: 'hidden',
},
variants: {
active: {
false: {
link: 'text-toned hover:after:bg-accented',
},
},
},
defaultVariants: {
variant: 'link',
},
},
pageLinks: {
slots: {
linkLeadingIcon: 'size-4',
linkLabelExternalIcon: 'size-2.5',
},
},
},
toc: {
title: 'On this page',
},
})

56
app/app/app.vue Normal file
View File

@ -0,0 +1,56 @@
<script setup lang="ts">
const { seo } = useAppConfig()
const site = useSiteConfig()
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs'), {
transform: data => data.find(item => item.path === '/docs')?.children || data || [],
})
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), {
server: false,
})
useHead({
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
],
link: [
{ rel: 'icon', href: '/favicon.ico' },
],
htmlAttrs: {
lang: 'en',
},
})
useSeoMeta({
titleTemplate: seo.titleTemplate,
title: seo.title,
description: seo.description,
ogSiteName: site.name,
twitterCard: 'summary_large_image',
})
provide('navigation', navigation)
</script>
<template>
<UApp>
<NuxtLoadingIndicator color="var(--ui-primary)" />
<AppHeader />
<UMain>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UMain>
<AppFooter />
<ClientOnly>
<LazyUContentSearch
:files="files"
:navigation="navigation"
/>
</ClientOnly>
</UApp>
</template>

View File

@ -0,0 +1,5 @@
@import "tailwindcss";
@import "@nuxt/ui-pro";
@source "../../../content/**/*";
@source "../../app.config.ts";

View File

@ -0,0 +1,83 @@
<script setup lang="ts">
import { motion } from 'motion-v'
import type { VariantType } from 'motion-v'
const props = defineProps<{
open: boolean
}>()
const variants: { [k: string]: VariantType | ((custom: unknown) => VariantType) } = {
normal: {
rotate: 0,
y: 0,
opacity: 1,
},
close: (custom: unknown) => {
const c = custom as number
return {
rotate: c === 1 ? 45 : c === 3 ? -45 : 0,
y: c === 1 ? 6 : c === 3 ? -6 : 0,
opacity: c === 2 ? 0 : 1,
transition: {
type: 'spring',
stiffness: 260,
damping: 20,
},
}
},
}
const state = computed(() => props.open ? 'close' : 'normal')
</script>
<template>
<UButton
size="sm"
variant="ghost"
color="neutral"
class="-me-1.5"
square
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="size-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<motion.line
x1="4"
y1="6"
x2="20"
y2="6"
:variants="variants"
:animate="state"
:custom="1"
class="outline-none"
/>
<motion.line
x1="4"
y1="12"
x2="20"
y2="12"
:variants="variants"
:animate="state"
:custom="2"
class="outline-none"
/>
<motion.line
x1="4"
y1="18"
x2="20"
y2="18"
:variants="variants"
:animate="state"
:custom="3"
class="outline-none"
/>
</svg>
</UButton>
</template>

View File

@ -0,0 +1,76 @@
<script lang="ts" setup>
const props = withDefaults(defineProps<{ title?: string, description?: string, headline?: string }>(), {
title: 'title',
description: 'description',
})
const title = computed(() => (props.title || '').slice(0, 60))
const description = computed(() => (props.description || '').slice(0, 200))
</script>
<template>
<div class="w-full h-full flex flex-col justify-center bg-neutral-900">
<svg
class="absolute right-0 top-0 opacity-50"
width="629"
height="593"
viewBox="0 0 629 593"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g filter="url(#filter0_f_199_94966)">
<path
d="M628.5 -578L639.334 -94.4223L806.598 -548.281L659.827 -87.387L965.396 -462.344L676.925 -74.0787L1087.69 -329.501L688.776 -55.9396L1160.22 -164.149L694.095 -34.9354L1175.13 15.7948L692.306 -13.3422L1130.8 190.83L683.602 6.50012L1032.04 341.989L668.927 22.4412L889.557 452.891L649.872 32.7537L718.78 511.519L628.5 36.32L538.22 511.519L607.128 32.7537L367.443 452.891L588.073 22.4412L224.955 341.989L573.398 6.50012L126.198 190.83L564.694 -13.3422L81.8734 15.7948L562.905 -34.9354L96.7839 -164.149L568.224 -55.9396L169.314 -329.501L580.075 -74.0787L291.604 -462.344L597.173 -87.387L450.402 -548.281L617.666 -94.4223L628.5 -578Z"
fill="white"
/>
</g>
<defs>
<filter
id="filter0_f_199_94966"
x="0.873535"
y="-659"
width="1255.25"
height="1251.52"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood
flood-opacity="0"
result="BackgroundImageFix"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="40.5"
result="effect1_foregroundBlur_199_94966"
/>
</filter>
</defs>
</svg>
<div class="pl-[100px]">
<p
v-if="headline"
class="uppercase text-[24px] text-emerald-500 mb-4 font-semibold"
>
{{ headline }}
</p>
<h1
v-if="title"
class="m-0 text-[75px] font-semibold mb-4 text-white flex items-center"
>
<span>{{ title }}</span>
</h1>
<p
v-if="description"
class="text-[32px] text-neutral-300 leading-tight w-[700px]"
>
{{ description }}
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,73 @@
<script lang="ts" setup>
const props = withDefaults(defineProps<{ title?: string, description?: string, headline?: string }>(), {
title: 'title',
description: 'description',
})
const title = computed(() => (props.title || '').slice(0, 60))
const description = computed(() => (props.description || '').slice(0, 200))
</script>
<template>
<div class="w-full h-full flex items-center justify-center bg-neutral-900">
<svg
class="absolute right-0 top-0 opacity-50 "
width="629"
height="593"
viewBox="0 0 629 593"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g filter="url(#filter0_f_199_94966)">
<path
d="M628.5 -578L639.334 -94.4223L806.598 -548.281L659.827 -87.387L965.396 -462.344L676.925 -74.0787L1087.69 -329.501L688.776 -55.9396L1160.22 -164.149L694.095 -34.9354L1175.13 15.7948L692.306 -13.3422L1130.8 190.83L683.602 6.50012L1032.04 341.989L668.927 22.4412L889.557 452.891L649.872 32.7537L718.78 511.519L628.5 36.32L538.22 511.519L607.128 32.7537L367.443 452.891L588.073 22.4412L224.955 341.989L573.398 6.50012L126.198 190.83L564.694 -13.3422L81.8734 15.7948L562.905 -34.9354L96.7839 -164.149L568.224 -55.9396L169.314 -329.501L580.075 -74.0787L291.604 -462.344L597.173 -87.387L450.402 -548.281L617.666 -94.4223L628.5 -578Z"
fill="white"
/>
</g>
<defs>
<filter
id="filter0_f_199_94966"
x="0.873535"
y="-659"
width="1255.25"
height="1251.52"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood
flood-opacity="0"
result="BackgroundImageFix"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feGaussianBlur
stdDeviation="40.5"
result="effect1_foregroundBlur_199_94966"
/>
</filter>
</defs>
</svg>
<div class="flex flex-col justify-center p-8">
<div class="flex justify-center mb-8">
<AppHeaderLogo white />
</div>
<h1
v-if="title"
class="flex justify-center m-0 text-5xl font-semibold mb-4 text-white"
>
<span>{{ title }}</span>
</h1>
<p
v-if="description"
class="text-center text-2xl text-neutral-300 leading-tight"
>
{{ description }}
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,40 @@
<script setup lang="ts">
const appConfig = useAppConfig()
const links = computed(() => [
...Object.entries(appConfig.socials || {}).map(([key, url]) => ({
'icon': `i-simple-icons-${key}`,
'to': url,
'target': '_blank',
'aria-label': `${key} social link`,
})),
appConfig.github?.url && {
'icon': 'i-simple-icons-github',
'to': appConfig.github.url,
'target': '_blank',
'aria-label': 'GitHub repository',
},
].filter(Boolean))
</script>
<template>
<UFooter>
<template #left>
<div class="text-sm text-muted">
Copyright © {{ new Date().getFullYear() }}
</div>
</template>
<template #right>
<template v-if="links.length">
<UButton
v-for="(link, index) of links"
:key="index"
size="sm"
v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
/>
</template>
<UColorModeButton />
</template>
</UFooter>
</template>

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
const appConfig = useAppConfig()
const site = useSiteConfig()
const links = computed(() => appConfig.github?.url
? [
{
'icon': 'i-simple-icons-github',
'to': appConfig.github.url,
'target': '_blank',
'aria-label': 'GitHub',
},
]
: [])
</script>
<template>
<UHeader
:ui="{ center: 'flex-1' }"
to="/"
:title="appConfig.header?.title || site.name"
>
<AppHeaderCenter />
<template #title>
<AppHeaderLogo class="h-6 w-auto shrink-0" />
</template>
<template #right>
<AppHeaderCTA />
<UContentSearchButton class="lg:hidden" />
<UColorModeButton />
<template v-if="links?.length">
<UButton
v-for="(link, index) of links"
:key="index"
v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
/>
</template>
</template>
<template #toggle="{ open, toggle }">
<IconMenuToggle
:open="open"
class="lg:hidden"
@click="toggle"
/>
</template>
<template #body>
<AppHeaderBody />
</template>
</UHeader>
</template>

View File

@ -0,0 +1,13 @@
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content'
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
</script>
<template>
<UContentNavigation
highlight
variant="link"
:navigation="navigation"
/>
</template>

View File

@ -0,0 +1,3 @@
<template>
<div />
</template>

View File

@ -0,0 +1,6 @@
<template>
<UContentSearchButton
:collapsed="false"
class="w-full"
/>
</template>

View File

@ -0,0 +1,16 @@
<script setup lang="ts">
const appConfig = useAppConfig()
</script>
<template>
<UColorModeImage
v-if="appConfig.header?.logo?.dark || appConfig.header?.logo?.light"
:light="appConfig.header?.logo?.light || appConfig.header?.logo?.dark"
:dark="appConfig.header?.logo?.dark || appConfig.header?.logo?.light"
:alt="appConfig.header?.logo?.alt || appConfig.header?.title"
class="h-6 w-auto shrink-0"
/>
<span v-else>
{{ appConfig.header?.title || '{appConfig.header.title}' }}
</span>
</template>

View File

@ -0,0 +1,3 @@
<template>
<div />
</template>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
const appConfig = useAppConfig()
</script>
<template>
<div
v-if="appConfig.toc?.bottom?.links?.length"
class="hidden lg:block space-y-6"
>
<USeparator type="dashed" />
<UPageLinks
:title="appConfig.toc?.bottom?.title || 'Links'"
:links="appConfig.toc?.bottom?.links"
/>
</div>
</template>

View File

@ -0,0 +1,77 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
const route = useRoute()
const toast = useToast()
const { copy, copied } = useClipboard()
const markdownLink = computed(() => `${window?.location?.origin}/raw${route.path}.md`)
const items = [
{
label: 'Copy Markdown link',
icon: 'i-lucide-link',
onSelect() {
copy(markdownLink.value)
toast.add({
title: 'Markdown link copied to clipboard',
icon: 'i-lucide-check-circle',
color: 'success',
})
},
},
{
label: 'View as Markdown',
icon: 'i-simple-icons:markdown',
target: '_blank',
to: markdownLink.value,
},
{
label: 'Open in ChatGPT',
icon: 'i-simple-icons:openai',
target: '_blank',
to: `https://chatgpt.com/?hints=search&q=${encodeURIComponent(`Read ${markdownLink.value} so I can ask questions about it.`)}`,
},
{
label: 'Open in Claude',
icon: 'i-simple-icons:anthropic',
target: '_blank',
to: `https://claude.ai/new?q=${encodeURIComponent(`Read ${markdownLink.value} so I can ask questions about it.`)}`,
},
]
</script>
<template>
<UButtonGroup size="sm">
<UButton
label="Copy page"
:icon="copied ? 'i-lucide-copy-check' : 'i-lucide-copy'"
color="neutral"
variant="outline"
:ui="{
leadingIcon: [copied ? 'text-primary' : 'text-neutral', 'size-3.5'],
}"
@click="copy(markdownLink)"
/>
<UDropdownMenu
size="sm"
:items="items"
:content="{
align: 'end',
side: 'bottom',
sideOffset: 8,
}"
:ui="{
content: 'w-48',
}"
>
<UButton
icon="i-lucide-chevron-down"
color="neutral"
variant="outline"
/>
</UDropdownMenu>
</UButtonGroup>
</template>

42
app/app/error.vue Normal file
View File

@ -0,0 +1,42 @@
<script setup lang="ts">
import type { NuxtError } from '#app'
defineProps<{
error: NuxtError
}>()
useHead({
htmlAttrs: {
lang: 'en',
},
})
useSeoMeta({
title: 'Page not found',
description: 'We are sorry but this page could not be found.',
})
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs'))
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), {
server: false,
})
provide('navigation', navigation)
</script>
<template>
<UApp>
<AppHeader />
<UError :error="error" />
<AppFooter />
<ClientOnly>
<LazyUContentSearch
:files="files"
:navigation="navigation"
/>
</ClientOnly>
</UApp>
</template>

24
app/app/layouts/docs.vue Normal file
View File

@ -0,0 +1,24 @@
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content'
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
</script>
<template>
<UContainer>
<UPage>
<template #left>
<UPageAside>
<DocsAsideLeftTop />
<UContentNavigation
highlight
:navigation="navigation"
/>
</UPageAside>
</template>
<slot />
</UPage>
</UContainer>
</template>

136
app/app/pages/[...slug].vue Normal file
View File

@ -0,0 +1,136 @@
<script setup lang="ts">
import { kebabCase } from 'scule'
import type { ContentNavigationItem } from '@nuxt/content'
import { findPageHeadline } from '@nuxt/content/utils'
import { addPrerenderPath } from '../utils/prerender'
definePageMeta({
layout: 'docs',
})
const route = useRoute()
const appConfig = useAppConfig()
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
const [{ data: page }, { data: surround }] = await Promise.all([
useAsyncData(kebabCase(route.path), () => queryCollection('docs').path(route.path).first()),
useAsyncData(`${kebabCase(route.path)}-surround`, () => {
return queryCollectionItemSurroundings('docs', route.path, {
fields: ['description'],
})
}),
])
if (!page.value) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
// Add the page path to the prerender list
addPrerenderPath(`/raw${route.path}.md`)
const title = page.value.seo?.title || page.value.title
const description = page.value.seo?.description || page.value.description
useSeoMeta({
title,
ogTitle: title,
description,
ogDescription: description,
})
const headline = computed(() => findPageHeadline(navigation?.value, page.value?.path))
defineOgImageComponent('Docs', {
headline: headline.value,
})
const editLink = computed(() => {
if (!appConfig.github) {
return
}
return [
appConfig.github.url,
'edit',
appConfig.github.branch,
appConfig.github.rootDir,
'content',
`${page.value?.stem}.${page.value?.extension}`,
].filter(Boolean).join('/')
})
</script>
<template>
<UPage v-if="page">
<UPageHeader
:title="page.title"
:description="page.description"
:headline="headline"
:ui="{
wrapper: 'flex-row items-center flex-wrap justify-between',
}"
>
<template #links>
<UButton
v-for="(link, index) in page.links"
:key="index"
size="sm"
v-bind="link"
/>
<DocsPageHeaderLinks />
</template>
</UPageHeader>
<UPageBody>
<ContentRenderer
v-if="page"
:value="page"
/>
<USeparator>
<div
v-if="editLink"
class="flex items-center gap-2 text-sm text-muted"
>
<UButton
variant="link"
color="neutral"
:to="editLink"
target="_blank"
icon="i-lucide-pen"
:ui="{ leadingIcon: 'size-4' }"
>
Edit this page
</UButton>
or
<UButton
variant="link"
color="neutral"
:to="`${appConfig.github.url}/issues/new/choose`"
target="_blank"
icon="i-lucide-alert-circle"
:ui="{ leadingIcon: 'size-4' }"
>
Report an issue
</UButton>
</div>
</USeparator>
<UContentSurround :surround="surround" />
</UPageBody>
<template
v-if="page?.body?.toc?.links?.length"
#right
>
<UContentToc
highlight
:title="appConfig.toc?.title || 'Table of Contents'"
:links="page.body?.toc?.links"
>
<template #bottom>
<DocsAsideRightBottom />
</template>
</UContentToc>
</template>
</UPage>
</template>

39
app/app/pages/index.vue Normal file
View File

@ -0,0 +1,39 @@
<script setup lang="ts">
const { data: page } = await useAsyncData('index', () => queryCollection('landing').path('/').first())
if (!page.value) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
// Reconsider it once this is implemented: https://github.com/nuxt/content/issues/3419
const prose = page.value.meta.prose as boolean
const title = page.value.seo?.title || page.value.title
const description = page.value.seo?.description || page.value.description
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
})
if (page.value?.seo?.ogImage) {
useSeoMeta({
ogImage: page.value.seo.ogImage,
twitterImage: page.value.seo.ogImage,
})
}
else {
defineOgImageComponent('Landing', {
title,
description,
})
}
</script>
<template>
<ContentRenderer
v-if="page"
:value="page"
:prose="prose || false"
/>
</template>

39
app/app/types/index.d.ts vendored Normal file
View File

@ -0,0 +1,39 @@
declare module 'nuxt/schema' {
interface AppConfig {
seo: {
titleTemplate: string
title: string
description: string
}
header: {
title: string
logo: {
light: string
dark: string
alt: string
}
}
socials: Record<string, string>
toc: {
title: string
bottom: {
title: string
links: {
icon: string
label: string
to: string
target: string
}[]
}
}
github: {
owner: string
name: string
url: string
branch: string
rootDir?: string
}
}
}
export {}

110
app/app/utils/git.ts Normal file
View File

@ -0,0 +1,110 @@
import { execSync } from 'node:child_process'
import { readGitConfig } from 'pkg-types'
import gitUrlParse from 'git-url-parse'
export interface GitInfo {
// Repository name
name: string
// Repository owner/organization
owner: string
// Repository URL
url: string
}
export function getGitBranch() {
const envName
= process.env.CF_PAGES_BRANCH
|| process.env.CI_COMMIT_BRANCH
|| process.env.VERCEL_GIT_COMMIT_REF
|| process.env.BRANCH
|| process.env.GITHUB_REF_NAME
if (envName && envName !== 'HEAD') {
return envName
}
try {
const branch = execSync('git rev-parse --abbrev-ref HEAD').toString().trim()
if (branch && branch !== 'HEAD') {
return branch
}
}
catch {
// Ignore error
}
return 'main'
}
export async function getLocalGitInfo(rootDir: string): Promise<GitInfo | undefined> {
const remote = await getLocalGitRemote(rootDir)
if (!remote) {
return
}
// https://www.npmjs.com/package/git-url-parse#clipboard-example
const { name, owner, source } = gitUrlParse(remote)
const url = `https://${source}/${owner}/${name}`
return {
name,
owner,
url,
}
}
async function getLocalGitRemote(dir: string): Promise<string | undefined> {
try {
const parsed = await readGitConfig(dir)
if (!parsed) {
return
}
return parsed.remote?.['origin']?.url
}
catch {
// Ignore error
}
}
export function getGitEnv(): GitInfo {
// https://github.com/unjs/std-env/issues/59
const envInfo = {
// Provider
provider: process.env.VERCEL_GIT_PROVIDER // vercel
|| (process.env.GITHUB_SERVER_URL ? 'github' : undefined) // github
|| '',
// Owner
owner: process.env.VERCEL_GIT_REPO_OWNER // vercel
|| process.env.GITHUB_REPOSITORY_OWNER // github
|| process.env.CI_PROJECT_PATH?.split('/').shift() // gitlab
|| '',
// Name
name: process.env.VERCEL_GIT_REPO_SLUG
|| process.env.GITHUB_REPOSITORY?.split('/').pop() // github
|| process.env.CI_PROJECT_PATH?.split('/').splice(1).join('/') // gitlab
|| '',
// Url
url: process.env.REPOSITORY_URL || '', // netlify
}
if (!envInfo.url && envInfo.provider && envInfo.owner && envInfo.name) {
envInfo.url = `https://${envInfo.provider}.com/${envInfo.owner}/${envInfo.name}`
}
// If only url available (ex: Netlify)
if (!envInfo.name && !envInfo.owner && envInfo.url) {
try {
const { name, owner } = gitUrlParse(envInfo.url)
envInfo.name = name
envInfo.owner = owner
}
catch {
// Ignore error
}
}
return {
name: envInfo.name,
owner: envInfo.owner,
url: envInfo.url,
}
}

29
app/app/utils/meta.ts Normal file
View File

@ -0,0 +1,29 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
export function inferSiteURL() {
// https://github.com/unjs/std-env/issues/59
return (
process.env.NUXT_SITE_URL
|| (process.env.NEXT_PUBLIC_VERCEL_URL && `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`) // Vercel
|| process.env.URL // Netlify
|| process.env.CI_PAGES_URL // Gitlab Pages
|| process.env.CF_PAGES_URL // Cloudflare Pages
)
}
export async function getPackageJsonMetadata(dir: string) {
try {
const packageJson = await readFile(resolve(dir, 'package.json'), 'utf-8')
const parsed = JSON.parse(packageJson)
return {
name: parsed.name,
description: parsed.description,
}
}
catch {
return {
name: 'docs',
}
}
}

View File

@ -0,0 +1,12 @@
export const addPrerenderPath = (path: string) => {
const event = useRequestEvent()
if (event) {
event.node.res.setHeader(
'x-nitro-prerender',
[
event.node.res.getHeader('x-nitro-prerender'),
path,
].filter(Boolean).join(','),
)
}
}

31
app/content.config.ts Normal file
View File

@ -0,0 +1,31 @@
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
export default defineContentConfig({
collections: {
landing: defineCollection({
type: 'page',
source: {
// @ts-expect-error __DOCS_DIR__ is not defined
cwd: globalThis.__DOCS_DIR__,
include: 'index.md',
},
}),
docs: defineCollection({
type: 'page',
source: {
// @ts-expect-error __DOCS_DIR__ is not defined
cwd: globalThis.__DOCS_DIR__,
include: '**',
exclude: ['index.md'],
},
schema: z.object({
links: z.array(z.object({
label: z.string(),
icon: z.string(),
to: z.string(),
target: z.string().optional(),
})).optional(),
}),
}),
},
})

View File

@ -0,0 +1,54 @@
import { defineNuxtModule } from '@nuxt/kit'
import { defu } from 'defu'
import { inferSiteURL, getPackageJsonMetadata } from '../app/utils/meta'
import { getGitBranch, getGitEnv, getLocalGitInfo } from '../app/utils/git'
export default defineNuxtModule({
meta: {
name: 'default-configs',
},
async setup(_options, nuxt) {
const dir = nuxt.options.rootDir
const url = inferSiteURL()
const meta = await getPackageJsonMetadata(dir)
const gitInfo = await getLocalGitInfo(dir) || getGitEnv()
const siteName = nuxt.options?.site?.name || meta.name || gitInfo?.name || ''
nuxt.options.llms = defu(nuxt.options.llms, {
domain: url,
title: siteName,
description: meta.description || '',
full: {
title: siteName,
description: meta.description || '',
},
})
nuxt.options.site = defu(nuxt.options.site, {
url,
name: siteName,
debug: false,
})
nuxt.options.appConfig.header = defu(nuxt.options.appConfig.header, {
title: siteName,
})
nuxt.options.appConfig.seo = defu(nuxt.options.appConfig.seo, {
titleTemplate: `%s - ${siteName}`,
title: siteName,
description: meta.description || '',
})
nuxt.options.appConfig.github = defu(nuxt.options.appConfig.github, {
owner: gitInfo?.owner,
name: gitInfo?.name,
url: gitInfo?.url,
branch: getGitBranch(),
})
nuxt.options.appConfig.toc = defu(nuxt.options.appConfig.toc, {
title: 'On this page',
})
},
})

49
app/nuxt.config.ts Normal file
View File

@ -0,0 +1,49 @@
import { extendViteConfig } from '@nuxt/kit'
// Flag enabled when developing docs theme
const dev = !!process.env.NUXT_DOCS_DEV
export default defineNuxtConfig({
modules: [
'@nuxt/ui-pro',
'@nuxt/content',
'@nuxt/image',
'@nuxtjs/robots',
'nuxt-og-image',
'nuxt-llms',
() => {
// Update @nuxt/content optimizeDeps options
extendViteConfig((config) => {
config.optimizeDeps ||= {}
config.optimizeDeps.include ||= []
config.optimizeDeps.include.push('@nuxt/content > slugify')
config.optimizeDeps.include = config.optimizeDeps.include
.map(id => id.replace(/^@nuxt\/content > /, 'docus > @nuxt/content > '))
})
},
],
devtools: {
enabled: dev,
},
css: ['../app/assets/css/main.css'],
content: {
build: {
markdown: {
highlight: {
langs: ['bash', 'diff', 'json', 'js', 'ts', 'html', 'css', 'vue', 'shell', 'mdc', 'md', 'yaml'],
},
},
},
},
nitro: {
prerender: {
routes: ['/'],
crawlLinks: true,
failOnError: false,
autoSubfolderIndex: false,
},
},
icon: {
provider: 'iconify',
},
})

218
app/nuxt.schema.ts Normal file
View File

@ -0,0 +1,218 @@
import { field, group } from '@nuxt/content/preview'
export default defineNuxtSchema({
appConfig: {
ui: group({
title: 'UI',
description: 'UI Customization.',
icon: 'i-lucide-palette',
fields: {
colors: group({
title: 'Colors',
description: 'Manage main colors of your application',
icon: 'i-lucide-palette',
fields: {
primary: field({
type: 'string',
title: 'Primary',
description: 'Primary color of your UI.',
icon: 'i-lucide-palette',
default: 'green',
required: ['red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose'],
}),
neutral: field({
type: 'string',
title: 'Neutral',
description: 'Neutral color of your UI.',
icon: 'i-lucide-palette',
default: 'slate',
required: ['slate', 'gray', 'zinc', 'neutral', 'stone'],
}),
},
}),
icons: group({
title: 'Icons',
description: 'Manage icons used in the application.',
icon: 'i-lucide-settings',
fields: {
search: field({
type: 'icon',
title: 'Search Bar',
description: 'Icon to display in the search bar.',
icon: 'i-lucide-search',
default: 'i-lucide-search',
}),
dark: field({
type: 'icon',
title: 'Dark mode',
description: 'Icon of color mode button for dark mode.',
icon: 'i-lucide-moon',
default: 'i-lucide-moon',
}),
light: field({
type: 'icon',
title: 'Light mode',
description: 'Icon of color mode button for light mode.',
icon: 'i-lucide-sun',
default: 'i-lucide-sun',
}),
external: field({
type: 'icon',
title: 'External Link',
description: 'Icon for external link.',
icon: 'i-lucide-external-link',
default: 'i-lucide-external-link',
}),
chevron: field({
type: 'icon',
title: 'Chevron',
description: 'Icon for chevron.',
icon: 'i-lucide-chevron-down',
default: 'i-lucide-chevron-down',
}),
hash: field({
type: 'icon',
title: 'Hash',
description: 'Icon for hash anchors.',
icon: 'i-lucide-hash',
default: 'i-lucide-hash',
}),
},
}),
},
}),
seo: group({
title: 'SEO',
description: 'SEO configuration.',
icon: 'i-lucide-search',
fields: {
title: field({
type: 'string',
title: 'Title',
description: 'Title to display in the header.',
icon: 'i-lucide-type',
default: '',
}),
description: field({
type: 'string',
title: 'Description',
description: 'Description to display in the header.',
icon: 'i-lucide-type',
default: '',
}),
},
}),
header: group({
title: 'Header',
description: 'Header configuration.',
icon: 'i-lucide-layout',
fields: {
title: field({
type: 'string',
title: 'Title',
description: 'Title to display in the header.',
icon: 'i-lucide-type',
default: '',
}),
logo: group({
title: 'Logo',
description: 'Header logo configuration.',
icon: 'i-lucide-image',
fields: {
light: field({
type: 'media',
title: 'Light Mode Logo',
description: 'Pick an image from your gallery.',
icon: 'i-lucide-sun',
default: '',
}),
dark: field({
type: 'media',
title: 'Dark Mode Logo',
description: 'Pick an image from your gallery.',
icon: 'i-lucide-moon',
default: '',
}),
alt: field({
type: 'string',
title: 'Alt',
description: 'Alt to display for accessibility.',
icon: 'i-lucide-text',
default: '',
}),
},
}),
},
}),
socials: field({
type: 'object',
title: 'Social Networks',
description: 'Social links configuration.',
icon: 'i-lucide-network',
default: {},
}),
toc: group({
title: 'Table of contents',
description: 'TOC configuration.',
icon: 'i-lucide-list',
fields: {
title: field({
type: 'string',
title: 'Title',
description: 'Title of the table of contents.',
icon: 'i-lucide-heading',
default: 'On this page',
}),
bottom: group({
title: 'Bottom',
description: 'Bottom section of the table of contents.',
icon: 'i-lucide-list',
fields: {
title: field({
type: 'string',
title: 'Title',
description: 'Title of the bottom section.',
icon: 'i-lucide-heading',
default: 'Community',
}),
links: field({
type: 'array',
title: 'Links',
description: 'Links to display in the bottom section.',
icon: 'i-lucide-link',
default: [],
}),
},
}),
},
}),
github: group({
title: 'GitHub',
description: 'GitHub configuration.',
icon: 'i-simple-icons-github',
fields: {
url: field({
type: 'string',
title: 'URL',
description: 'GitHub URL.',
icon: 'i-simple-icons-github',
default: '',
}),
branch: field({
type: 'string',
title: 'Branch',
description: 'GitHub branch.',
icon: 'i-lucide-git-branch',
default: 'main',
}),
rootDir: field({
type: 'string',
title: 'Root Directory',
description: 'Root directory of the GitHub repository.',
icon: 'i-lucide-folder',
default: '',
}),
},
}),
},
})

View File

@ -0,0 +1,25 @@
import { withLeadingSlash } from 'ufo'
import { stringify } from 'minimark/stringify'
import { queryCollection } from '@nuxt/content/nitro'
export default eventHandler(async (event) => {
const slug = getRouterParams(event)['slug.md']
if (!slug?.endsWith('.md')) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
const path = withLeadingSlash(slug.replace('.md', ''))
const page = await queryCollection(event, 'docs').path(path).first()
if (!page) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
// Add title and description to the top of the page if missing
if (page.body.value[0]?.[0] !== 'h1') {
page.body.value.unshift(['blockquote', {}, page.description])
page.body.value.unshift(['h1', {}, page.title])
}
setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8')
return stringify({ ...page.body, type: 'minimark' }, { format: 'markdown/html' })
})

3
app/tsconfig.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}