docus 5.8.1 → 5.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/app/app.config.ts +1 -0
- package/app/app.vue +5 -1
- package/app/components/OgImage/Docs.takumi.vue +43 -0
- package/app/components/OgImage/Landing.takumi.vue +67 -0
- package/app/components/app/AppFooterRight.vue +4 -1
- package/app/components/app/AppHeader.vue +3 -1
- package/app/composables/useDocusColorMode.ts +7 -0
- package/app/error.vue +3 -0
- package/app/middleware/colorMode.global.ts +8 -0
- package/app/pages/[[lang]]/[...slug].vue +16 -12
- package/app/templates/landing.vue +3 -3
- package/app/utils/ogImage.ts +23 -0
- package/modules/assistant/README.md +10 -3
- package/modules/assistant/index.ts +7 -5
- package/modules/assistant/runtime/server/api/search.ts +5 -0
- package/modules/config.ts +7 -2
- package/modules/skills/index.ts +146 -0
- package/modules/skills/runtime/server/routes/skills-files.ts +49 -0
- package/modules/skills/runtime/server/routes/skills-index.ts +8 -0
- package/nuxt.config.ts +8 -2
- package/nuxt.schema.ts +22 -0
- package/package.json +23 -21
- package/server/mcp/tools/get-page.ts +16 -5
- package/server/mcp/tools/list-pages.ts +13 -3
- package/app/components/OgImage/OgImageDocs.vue +0 -76
- package/app/components/OgImage/OgImageLanding.vue +0 -98
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
[](https://docus.dev)
|
|
2
2
|
|
|
3
3
|
# Docus
|
|
4
4
|
|
package/app/app.config.ts
CHANGED
package/app/app.vue
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
import type { ContentNavigationItem, PageCollections } from '@nuxt/content'
|
|
3
3
|
import * as nuxtUiLocales from '@nuxt/ui/locale'
|
|
4
4
|
import { transformNavigation } from './utils/navigation'
|
|
5
|
+
import { useDocusColorMode } from './composables/useDocusColorMode'
|
|
5
6
|
import { useSubNavigation } from './composables/useSubNavigation'
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
+
const appConfig = useAppConfig()
|
|
9
|
+
const { seo } = appConfig
|
|
10
|
+
const { forced: forcedColorMode } = useDocusColorMode()
|
|
8
11
|
const site = useSiteConfig()
|
|
9
12
|
const { locale, locales, isEnabled, switchLocalePath } = useDocusI18n()
|
|
10
13
|
const { isEnabled: isAssistantEnabled, panelWidth: assistantPanelWidth, shouldPushContent } = useAssistant()
|
|
@@ -79,6 +82,7 @@ const { subNavigationMode } = useSubNavigation(navigation)
|
|
|
79
82
|
<LazyUContentSearch
|
|
80
83
|
:files="files"
|
|
81
84
|
:navigation="navigation"
|
|
85
|
+
:color-mode="!forcedColorMode"
|
|
82
86
|
/>
|
|
83
87
|
<template v-if="isAssistantEnabled">
|
|
84
88
|
<LazyAssistantPanel />
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
const { title, description, headline } = defineProps<{ title?: string, description?: string, headline?: string }>()
|
|
3
|
+
|
|
4
|
+
const appConfig = useAppConfig()
|
|
5
|
+
const { name: siteName } = useSiteConfig()
|
|
6
|
+
const primaryColor = appConfig.ui?.colors?.primary ?? 'emerald'
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<div class="w-full h-full flex flex-col justify-between bg-neutral-950 px-[80px] py-[60px]">
|
|
11
|
+
<!-- Radial glow top-right: wide soft layer -->
|
|
12
|
+
<div class="absolute top-0 right-0 w-[700px] h-[700px] bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.10)_0%,rgba(255,255,255,0.04)_40%,transparent_70%)]" />
|
|
13
|
+
<!-- Radial glow top-right: tight bright core -->
|
|
14
|
+
<div class="absolute top-0 right-0 w-[350px] h-[350px] bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.22)_0%,rgba(255,255,255,0.08)_35%,transparent_65%)]" />
|
|
15
|
+
|
|
16
|
+
<div class="flex-1 flex flex-col justify-center">
|
|
17
|
+
<p
|
|
18
|
+
v-if="headline"
|
|
19
|
+
:class="`uppercase text-[22px] font-bold m-0 mb-5 tracking-[0.05em] text-${primaryColor}-500`"
|
|
20
|
+
>
|
|
21
|
+
{{ headline }}
|
|
22
|
+
</p>
|
|
23
|
+
<h1
|
|
24
|
+
v-if="title"
|
|
25
|
+
class="m-0 mb-6 text-[50px] font-bold text-white leading-[1.1] w-full max-w-[900px] wrap-break-word"
|
|
26
|
+
>
|
|
27
|
+
{{ title?.slice(0, 60) }}
|
|
28
|
+
</h1>
|
|
29
|
+
<p
|
|
30
|
+
v-if="description"
|
|
31
|
+
class="m-0 text-[28px] text-neutral-400 leading-[1.4] w-full max-w-[900px] wrap-break-word"
|
|
32
|
+
>
|
|
33
|
+
{{ description?.slice(0, 200) }}
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div class="flex">
|
|
38
|
+
<div class="text-white text-[18px] font-normal rounded-lg px-5 py-2">
|
|
39
|
+
{{ siteName }}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
const { title, description } = defineProps<{ title?: string, description?: string }>()
|
|
3
|
+
|
|
4
|
+
const appConfig = useAppConfig()
|
|
5
|
+
const { name: siteName } = useSiteConfig()
|
|
6
|
+
const primaryColor = appConfig.ui?.colors?.primary ?? 'emerald'
|
|
7
|
+
const logoPath = appConfig.header?.logo?.dark || appConfig.header?.logo?.light
|
|
8
|
+
|
|
9
|
+
const logoSvg = await fetchLogoSvg(logoPath)
|
|
10
|
+
|
|
11
|
+
async function fetchLogoSvg(path?: string): Promise<string> {
|
|
12
|
+
if (!path) return ''
|
|
13
|
+
try {
|
|
14
|
+
const { url: siteUrl } = useSiteConfig()
|
|
15
|
+
const url = path.startsWith('http') ? path : `${siteUrl}${path}`
|
|
16
|
+
const svg = await $fetch<string>(url, { responseType: 'text' })
|
|
17
|
+
return svg.replace('<svg', '<svg width="48" height="48"')
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return ''
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<div class="w-full h-full flex flex-col justify-between bg-neutral-950 px-[80px] py-[60px]">
|
|
27
|
+
<!-- Radial glow top-right: wide soft layer -->
|
|
28
|
+
<div class="absolute top-0 right-0 w-[700px] h-[700px] bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.10)_0%,rgba(255,255,255,0.04)_40%,transparent_70%)]" />
|
|
29
|
+
<!-- Radial glow top-right: tight bright core -->
|
|
30
|
+
<div class="absolute top-0 right-0 w-[350px] h-[350px] bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.22)_0%,rgba(255,255,255,0.08)_35%,transparent_65%)]" />
|
|
31
|
+
|
|
32
|
+
<div class="flex-1 flex flex-col justify-center w-full">
|
|
33
|
+
<div
|
|
34
|
+
v-if="logoSvg"
|
|
35
|
+
class="flex justify-center mb-8"
|
|
36
|
+
>
|
|
37
|
+
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
38
|
+
<div
|
|
39
|
+
class="w-[48px] h-[48px]"
|
|
40
|
+
v-html="logoSvg"
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
<div
|
|
44
|
+
v-if="title"
|
|
45
|
+
class="flex justify-center mb-6"
|
|
46
|
+
>
|
|
47
|
+
<h1 class="m-0 text-[50px] font-bold text-white leading-[1.1] text-center wrap-break-word">
|
|
48
|
+
{{ title?.slice(0, 60) }}
|
|
49
|
+
</h1>
|
|
50
|
+
</div>
|
|
51
|
+
<div
|
|
52
|
+
v-if="description"
|
|
53
|
+
class="flex justify-center"
|
|
54
|
+
>
|
|
55
|
+
<p class="m-0 text-[28px] text-neutral-400 leading-[1.4] text-center wrap-break-word">
|
|
56
|
+
{{ description?.slice(0, 200) }}
|
|
57
|
+
</p>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div class="flex">
|
|
62
|
+
<div :class="`text-[18px] font-normal rounded-lg px-5 py-2 text-${primaryColor}-500`">
|
|
63
|
+
{{ siteName }}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</template>
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { useDocusColorMode } from '../../composables/useDocusColorMode'
|
|
3
|
+
|
|
2
4
|
const appConfig = useAppConfig()
|
|
5
|
+
const { forced: forcedColorMode } = useDocusColorMode()
|
|
3
6
|
|
|
4
7
|
interface FooterLink {
|
|
5
8
|
'icon': string
|
|
@@ -44,5 +47,5 @@ const links = computed<FooterLink[]>(() => {
|
|
|
44
47
|
v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
|
|
45
48
|
/>
|
|
46
49
|
</template>
|
|
47
|
-
<UColorModeButton />
|
|
50
|
+
<UColorModeButton v-if="!forcedColorMode" />
|
|
48
51
|
</template>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { useDocusColorMode } from '../../composables/useDocusColorMode'
|
|
2
3
|
import { useDocusI18n } from '../../composables/useDocusI18n'
|
|
3
4
|
import { useSubNavigation } from '../../composables/useSubNavigation'
|
|
4
5
|
|
|
5
6
|
const appConfig = useAppConfig()
|
|
7
|
+
const { forced: forcedColorMode } = useDocusColorMode()
|
|
6
8
|
const site = useSiteConfig()
|
|
7
9
|
|
|
8
10
|
const { isEnabled: isAssistantEnabled } = useAssistant()
|
|
@@ -58,7 +60,7 @@ const links = computed(() => appConfig.github && appConfig.github.url
|
|
|
58
60
|
|
|
59
61
|
<UContentSearchButton class="lg:hidden" />
|
|
60
62
|
|
|
61
|
-
<ClientOnly>
|
|
63
|
+
<ClientOnly v-if="!forcedColorMode">
|
|
62
64
|
<UColorModeButton />
|
|
63
65
|
|
|
64
66
|
<template #fallback>
|
package/app/error.vue
CHANGED
|
@@ -3,11 +3,13 @@ import type { NuxtError } from '#app'
|
|
|
3
3
|
import type { ContentNavigationItem, PageCollections } from '@nuxt/content'
|
|
4
4
|
import * as nuxtUiLocales from '@nuxt/ui/locale'
|
|
5
5
|
import { transformNavigation } from './utils/navigation'
|
|
6
|
+
import { useDocusColorMode } from './composables/useDocusColorMode'
|
|
6
7
|
|
|
7
8
|
const props = defineProps<{
|
|
8
9
|
error: NuxtError
|
|
9
10
|
}>()
|
|
10
11
|
|
|
12
|
+
const { forced: forcedColorMode } = useDocusColorMode()
|
|
11
13
|
const { locale, locales, isEnabled, t, switchLocalePath } = useDocusI18n()
|
|
12
14
|
|
|
13
15
|
const nuxtUiLocale = computed(() => nuxtUiLocales[locale.value as keyof typeof nuxtUiLocales] || nuxtUiLocales.en)
|
|
@@ -70,6 +72,7 @@ provide('navigation', navigation)
|
|
|
70
72
|
<LazyUContentSearch
|
|
71
73
|
:files="files"
|
|
72
74
|
:navigation="navigation"
|
|
75
|
+
:color-mode="!forcedColorMode"
|
|
73
76
|
/>
|
|
74
77
|
</ClientOnly>
|
|
75
78
|
</UApp>
|
|
@@ -45,8 +45,10 @@ watch(() => navigation?.value, () => {
|
|
|
45
45
|
headline.value = findPageHeadline(navigation?.value, page.value?.path) || headline.value
|
|
46
46
|
})
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
defineOgImage('Docs', {
|
|
49
49
|
headline: headline.value,
|
|
50
|
+
title: title?.slice(0, 60),
|
|
51
|
+
description: formatOgDescription(title, description),
|
|
50
52
|
})
|
|
51
53
|
|
|
52
54
|
const github = computed(() => appConfig.github ? appConfig.github : null)
|
|
@@ -115,17 +117,19 @@ addPrerenderPath(`/raw${route.path}.md`)
|
|
|
115
117
|
>
|
|
116
118
|
{{ t('docs.edit') }}
|
|
117
119
|
</UButton>
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
120
|
+
<template v-if="github?.url">
|
|
121
|
+
<span>{{ t('common.or') }}</span>
|
|
122
|
+
<UButton
|
|
123
|
+
variant="link"
|
|
124
|
+
color="neutral"
|
|
125
|
+
:to="`${github.url}/issues/new/choose`"
|
|
126
|
+
target="_blank"
|
|
127
|
+
icon="i-lucide-alert-circle"
|
|
128
|
+
:ui="{ leadingIcon: 'size-4' }"
|
|
129
|
+
>
|
|
130
|
+
{{ t('docs.report') }}
|
|
131
|
+
</UButton>
|
|
132
|
+
</template>
|
|
129
133
|
</div>
|
|
130
134
|
</USeparator>
|
|
131
135
|
<UContentSurround :surround="surround" />
|
|
@@ -23,9 +23,9 @@ useSeo({
|
|
|
23
23
|
})
|
|
24
24
|
|
|
25
25
|
if (!page.value?.seo?.ogImage) {
|
|
26
|
-
|
|
27
|
-
title,
|
|
28
|
-
description,
|
|
26
|
+
defineOgImage('Landing', {
|
|
27
|
+
title: title?.slice(0, 60),
|
|
28
|
+
description: formatOgDescription(title, description),
|
|
29
29
|
})
|
|
30
30
|
}
|
|
31
31
|
</script>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// nuxt-og-image caps the encoded URL segment at 200 chars.
|
|
2
|
+
// Fixed overhead (component name, param keys, path encoding) is ~50 chars,
|
|
3
|
+
// leaving a ~150-char budget for title + description combined.
|
|
4
|
+
const OG_BUDGET = 150
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Trims description to fit within the nuxt-og-image 200-char URL segment limit,
|
|
8
|
+
* accounting for the title length and trying to cut at the last sentence boundary.
|
|
9
|
+
*/
|
|
10
|
+
export function formatOgDescription(title: string | undefined, description: string | undefined): string | undefined {
|
|
11
|
+
if (!description) return undefined
|
|
12
|
+
|
|
13
|
+
const titleLen = Math.min(title?.length ?? 0, 60)
|
|
14
|
+
const maxLen = OG_BUDGET - titleLen
|
|
15
|
+
if (maxLen <= 0) return undefined
|
|
16
|
+
|
|
17
|
+
const cleaned = description.replace(/,/g, '')
|
|
18
|
+
if (cleaned.length <= maxLen) return cleaned
|
|
19
|
+
|
|
20
|
+
const truncated = cleaned.slice(0, maxLen)
|
|
21
|
+
const lastDot = truncated.lastIndexOf('.')
|
|
22
|
+
return lastDot > 0 ? truncated.slice(0, lastDot + 1) : truncated
|
|
23
|
+
}
|
|
@@ -35,13 +35,20 @@ export default defineNuxtConfig({
|
|
|
35
35
|
})
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
4.
|
|
38
|
+
4. Authenticate to AI Gateway in one of two ways:
|
|
39
|
+
|
|
40
|
+
- **`AI_GATEWAY_API_KEY`** — Set it in the Vercel project env UI (and locally in `.env` if you want).
|
|
41
|
+
- **OIDC** — On Vercel, `VERCEL_OIDC_TOKEN` is injected automatically; you do **not** add it (or an API key) in the dashboard. For local builds, run `vercel env pull` on a linked project so `.env` contains the token:
|
|
39
42
|
|
|
40
43
|
```bash
|
|
44
|
+
# Option A — API key (dashboard + optional local .env)
|
|
41
45
|
AI_GATEWAY_API_KEY=your-gateway-key
|
|
46
|
+
|
|
47
|
+
# Option B — local only, after vercel env pull (not set manually on Vercel)
|
|
48
|
+
VERCEL_OIDC_TOKEN=...
|
|
42
49
|
```
|
|
43
50
|
|
|
44
|
-
> **Note:** The module
|
|
51
|
+
> **Note:** The module enables when `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` is present at build time. On Vercel, OIDC covers that without you creating env vars in the UI. If neither is available at build, the module stays disabled and a warning is logged.
|
|
45
52
|
|
|
46
53
|
## Usage
|
|
47
54
|
|
|
@@ -199,7 +206,7 @@ Composable for syntax highlighting code blocks with Shiki.
|
|
|
199
206
|
- Nuxt 4.x
|
|
200
207
|
- Nuxt UI 3.x (for `USlideover`, `UButton`, `UTextarea`, `UChatMessages`, etc.)
|
|
201
208
|
- An MCP server running (path configurable via `mcpServer`)
|
|
202
|
-
- `AI_GATEWAY_API_KEY`
|
|
209
|
+
- `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` at build time
|
|
203
210
|
|
|
204
211
|
## Customization
|
|
205
212
|
|
|
@@ -33,12 +33,14 @@ export default defineNuxtModule<AssistantModuleOptions>({
|
|
|
33
33
|
model: 'google/gemini-3-flash',
|
|
34
34
|
},
|
|
35
35
|
setup(options, nuxt) {
|
|
36
|
-
const
|
|
36
|
+
const hasAiGatewayAuth = !!(
|
|
37
|
+
process.env.AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN
|
|
38
|
+
)
|
|
37
39
|
|
|
38
40
|
const { resolve } = createResolver(import.meta.url)
|
|
39
41
|
|
|
40
42
|
nuxt.options.runtimeConfig.public.assistant = {
|
|
41
|
-
enabled:
|
|
43
|
+
enabled: hasAiGatewayAuth,
|
|
42
44
|
apiPath: options.apiPath!,
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -60,14 +62,14 @@ export default defineNuxtModule<AssistantModuleOptions>({
|
|
|
60
62
|
components.forEach(name =>
|
|
61
63
|
addComponent({
|
|
62
64
|
name,
|
|
63
|
-
filePath:
|
|
65
|
+
filePath: hasAiGatewayAuth
|
|
64
66
|
? resolve(`./runtime/components/${name}.vue`)
|
|
65
67
|
: resolve('./runtime/components/AssistantChatDisabled.vue'),
|
|
66
68
|
}),
|
|
67
69
|
)
|
|
68
70
|
|
|
69
|
-
if (!
|
|
70
|
-
log.warn('AI assistant disabled: AI_GATEWAY_API_KEY
|
|
71
|
+
if (!hasAiGatewayAuth) {
|
|
72
|
+
log.warn('AI assistant disabled: neither AI_GATEWAY_API_KEY nor VERCEL_OIDC_TOKEN found')
|
|
71
73
|
return
|
|
72
74
|
}
|
|
73
75
|
|
|
@@ -37,6 +37,11 @@ function getSystemPrompt(siteName: string) {
|
|
|
37
37
|
- Be concise, helpful, and direct
|
|
38
38
|
- Guide users like a friendly expert would
|
|
39
39
|
|
|
40
|
+
**Links and exploration:**
|
|
41
|
+
- Tool results include a \`url\` for each page — prefer markdown links \`[label](url)\` so users can open the doc in one click
|
|
42
|
+
- When it helps, add extra links (related pages, “read more”, side topics) — make the answer easy to dig into, not a wall of text
|
|
43
|
+
- Stick to URLs from tool results (\`url\` / \`path\`) so links stay valid
|
|
44
|
+
|
|
40
45
|
**FORMATTING RULES (CRITICAL):**
|
|
41
46
|
- NEVER use markdown headings (#, ##, ###, etc.)
|
|
42
47
|
- Use **bold text** for emphasis and section labels
|
package/modules/config.ts
CHANGED
|
@@ -58,13 +58,18 @@ export default defineNuxtModule({
|
|
|
58
58
|
branch: getGitBranch(),
|
|
59
59
|
})
|
|
60
60
|
|
|
61
|
+
const forcedColorMode = (nuxt.options.appConfig.docus as Record<string, unknown>)?.colorMode as string | undefined
|
|
62
|
+
if (forcedColorMode === 'light' || forcedColorMode === 'dark') {
|
|
63
|
+
nuxt.options.colorMode = defu({ preference: forcedColorMode, fallback: forcedColorMode }, nuxt.options.colorMode || {}) as typeof nuxt.options.colorMode
|
|
64
|
+
}
|
|
65
|
+
|
|
61
66
|
/*
|
|
62
67
|
** I18N
|
|
63
68
|
*/
|
|
64
|
-
const typedNuxtOptions = nuxt.options as typeof nuxt.options & { i18n?: DocusI18nOptions }
|
|
69
|
+
const typedNuxtOptions = nuxt.options as typeof nuxt.options & { i18n?: false | DocusI18nOptions }
|
|
65
70
|
const i18nOptions = typedNuxtOptions.i18n
|
|
66
71
|
|
|
67
|
-
if (i18nOptions
|
|
72
|
+
if (i18nOptions && typeof i18nOptions === 'object' && i18nOptions.locales) {
|
|
68
73
|
const { resolve } = createResolver(import.meta.url)
|
|
69
74
|
|
|
70
75
|
// Filter locales to only include existing ones
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { addServerHandler, createResolver, defineNuxtModule, logger } from '@nuxt/kit'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { parse as parseYaml } from 'yaml'
|
|
6
|
+
|
|
7
|
+
interface SkillEntry {
|
|
8
|
+
name: string
|
|
9
|
+
description: string
|
|
10
|
+
files: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SKILL_NAME_REGEX = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
|
|
14
|
+
const MAX_NAME_LENGTH = 64
|
|
15
|
+
|
|
16
|
+
const log = logger.withTag('Docus')
|
|
17
|
+
|
|
18
|
+
export default defineNuxtModule({
|
|
19
|
+
meta: {
|
|
20
|
+
name: 'skills',
|
|
21
|
+
},
|
|
22
|
+
async setup(_options, nuxt) {
|
|
23
|
+
const skillsDir = join(nuxt.options.rootDir, 'skills')
|
|
24
|
+
if (!existsSync(skillsDir)) return
|
|
25
|
+
|
|
26
|
+
const catalog = await scanSkills(skillsDir)
|
|
27
|
+
if (!catalog.length) return
|
|
28
|
+
|
|
29
|
+
log.info(`Found ${catalog.length} agent skill${catalog.length > 1 ? 's' : ''}: ${catalog.map(s => s.name).join(', ')}`)
|
|
30
|
+
|
|
31
|
+
nuxt.options.runtimeConfig.skills = { catalog }
|
|
32
|
+
|
|
33
|
+
const { resolve } = createResolver(import.meta.url)
|
|
34
|
+
|
|
35
|
+
nuxt.hook('nitro:config', (nitroConfig) => {
|
|
36
|
+
nitroConfig.serverAssets ||= []
|
|
37
|
+
nitroConfig.serverAssets.push({ baseName: 'skills', dir: skillsDir })
|
|
38
|
+
|
|
39
|
+
nitroConfig.prerender ||= {}
|
|
40
|
+
nitroConfig.prerender.routes ||= []
|
|
41
|
+
nitroConfig.prerender.routes.push('/.well-known/skills/index.json')
|
|
42
|
+
for (const skill of catalog) {
|
|
43
|
+
for (const file of skill.files) {
|
|
44
|
+
nitroConfig.prerender.routes.push(`/.well-known/skills/${skill.name}/${file}`)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
addServerHandler({
|
|
50
|
+
route: '/.well-known/skills/index.json',
|
|
51
|
+
handler: resolve('./runtime/server/routes/skills-index'),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
addServerHandler({
|
|
55
|
+
route: '/.well-known/skills/**',
|
|
56
|
+
handler: resolve('./runtime/server/routes/skills-files'),
|
|
57
|
+
})
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
function parseFrontmatter(content: string): { name?: string, description?: string } | null {
|
|
62
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
63
|
+
if (!match?.[1]) return null
|
|
64
|
+
try {
|
|
65
|
+
return parseYaml(match[1])
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validateSkillName(name: string, dirName: string): boolean {
|
|
73
|
+
if (name.length > MAX_NAME_LENGTH) {
|
|
74
|
+
log.warn(`Skill "${name}" exceeds ${MAX_NAME_LENGTH} character limit`)
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
if (!SKILL_NAME_REGEX.test(name) || name.includes('--')) {
|
|
78
|
+
log.warn(`Skill name "${name}" does not match the Agent Skills naming spec`)
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
if (name !== dirName) {
|
|
82
|
+
log.warn(`Skill name "${name}" does not match directory name "${dirName}"`)
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function listFilesRecursively(dir: string, base: string = ''): Promise<string[]> {
|
|
89
|
+
const files: string[] = []
|
|
90
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
const relPath = base ? `${base}/${entry.name}` : entry.name
|
|
93
|
+
if (entry.isDirectory()) {
|
|
94
|
+
files.push(...await listFilesRecursively(join(dir, entry.name), relPath))
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
files.push(relPath)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return files
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function scanSkills(skillsDir: string): Promise<SkillEntry[]> {
|
|
104
|
+
const catalog: SkillEntry[] = []
|
|
105
|
+
const entries = await readdir(skillsDir, { withFileTypes: true })
|
|
106
|
+
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (!entry.isDirectory()) continue
|
|
109
|
+
|
|
110
|
+
const skillDir = join(skillsDir, entry.name)
|
|
111
|
+
const skillMdPath = join(skillDir, 'SKILL.md')
|
|
112
|
+
|
|
113
|
+
if (!existsSync(skillMdPath)) continue
|
|
114
|
+
|
|
115
|
+
const content = await readFile(skillMdPath, 'utf-8')
|
|
116
|
+
const frontmatter = parseFrontmatter(content)
|
|
117
|
+
|
|
118
|
+
if (!frontmatter?.description) {
|
|
119
|
+
log.warn(`Skipping skill "${entry.name}": missing description in SKILL.md frontmatter`)
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const name = frontmatter.name || entry.name
|
|
124
|
+
if (!validateSkillName(name, entry.name)) continue
|
|
125
|
+
|
|
126
|
+
const allFiles = await listFilesRecursively(skillDir)
|
|
127
|
+
const files = allFiles.filter(f => !f.split('/').some(s => s.startsWith('.')))
|
|
128
|
+
const sortedFiles = ['SKILL.md', ...files.filter(f => f !== 'SKILL.md')]
|
|
129
|
+
|
|
130
|
+
catalog.push({
|
|
131
|
+
name,
|
|
132
|
+
description: frontmatter.description,
|
|
133
|
+
files: sortedFiles,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return catalog
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
declare module 'nuxt/schema' {
|
|
141
|
+
interface RuntimeConfig {
|
|
142
|
+
skills: {
|
|
143
|
+
catalog: SkillEntry[]
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
2
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
3
|
+
'.json': 'application/json; charset=utf-8',
|
|
4
|
+
'.yaml': 'text/yaml; charset=utf-8',
|
|
5
|
+
'.yml': 'text/yaml; charset=utf-8',
|
|
6
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
7
|
+
'.py': 'text/plain; charset=utf-8',
|
|
8
|
+
'.sh': 'text/plain; charset=utf-8',
|
|
9
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
10
|
+
'.ts': 'text/plain; charset=utf-8',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getContentType(path: string): string {
|
|
14
|
+
const ext = path.slice(path.lastIndexOf('.'))
|
|
15
|
+
return CONTENT_TYPES[ext] || 'application/octet-stream'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default defineEventHandler(async (event) => {
|
|
19
|
+
const url = getRequestURL(event)
|
|
20
|
+
const prefix = '/.well-known/skills/'
|
|
21
|
+
const idx = url.pathname.indexOf(prefix)
|
|
22
|
+
if (idx === -1) {
|
|
23
|
+
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const filePath = decodeURIComponent(url.pathname.slice(idx + prefix.length))
|
|
27
|
+
|
|
28
|
+
if (!filePath || filePath.includes('..')) {
|
|
29
|
+
throw createError({ statusCode: 400, statusMessage: 'Bad Request' })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { skills } = useRuntimeConfig(event)
|
|
33
|
+
const skillName = filePath.split('/')[0]
|
|
34
|
+
if (!skills.catalog.some((s: { name: string }) => s.name === skillName)) {
|
|
35
|
+
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const storage = useStorage('assets:skills')
|
|
39
|
+
const content = await storage.getItemRaw<string>(filePath)
|
|
40
|
+
|
|
41
|
+
if (!content) {
|
|
42
|
+
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setResponseHeader(event, 'content-type', getContentType(filePath))
|
|
46
|
+
setResponseHeader(event, 'cache-control', 'public, max-age=3600')
|
|
47
|
+
|
|
48
|
+
return content
|
|
49
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export default defineEventHandler((event) => {
|
|
2
|
+
const { skills } = useRuntimeConfig(event)
|
|
3
|
+
|
|
4
|
+
setResponseHeader(event, 'content-type', 'application/json')
|
|
5
|
+
setResponseHeader(event, 'cache-control', 'public, max-age=3600')
|
|
6
|
+
|
|
7
|
+
return { skills: skills.catalog }
|
|
8
|
+
})
|
package/nuxt.config.ts
CHANGED
|
@@ -10,6 +10,7 @@ export default defineNuxtConfig({
|
|
|
10
10
|
resolve('./modules/config'),
|
|
11
11
|
resolve('./modules/routing'),
|
|
12
12
|
resolve('./modules/markdown-rewrite'),
|
|
13
|
+
resolve('./modules/skills'),
|
|
13
14
|
resolve('./modules/css'),
|
|
14
15
|
() => {
|
|
15
16
|
const nuxt = useNuxt()
|
|
@@ -38,9 +39,11 @@ export default defineNuxtConfig({
|
|
|
38
39
|
|
|
39
40
|
// Fix @vercel/oidc ESM export issue (transitive dep of @ai-sdk/gateway)
|
|
40
41
|
// Only needed when AI assistant is enabled.
|
|
41
|
-
if (process.env.AI_GATEWAY_API_KEY) {
|
|
42
|
+
if (process.env.AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN) {
|
|
42
43
|
config.optimizeDeps.include.push('@vercel/oidc')
|
|
43
|
-
config.optimizeDeps.include.map(id =>
|
|
44
|
+
config.optimizeDeps.include = config.optimizeDeps.include.map(id =>
|
|
45
|
+
id.replace(/^@vercel\/oidc$/, 'docus > @vercel/oidc'),
|
|
46
|
+
)
|
|
44
47
|
}
|
|
45
48
|
})
|
|
46
49
|
},
|
|
@@ -118,6 +121,9 @@ export default defineNuxtConfig({
|
|
|
118
121
|
},
|
|
119
122
|
provider: 'iconify',
|
|
120
123
|
},
|
|
124
|
+
ogImage: {
|
|
125
|
+
zeroRuntime: true,
|
|
126
|
+
},
|
|
121
127
|
robots: {
|
|
122
128
|
groups: [
|
|
123
129
|
{
|
package/nuxt.schema.ts
CHANGED
|
@@ -277,6 +277,28 @@ export default defineNuxtSchema({
|
|
|
277
277
|
}),
|
|
278
278
|
},
|
|
279
279
|
}),
|
|
280
|
+
docus: group({
|
|
281
|
+
title: 'Docus',
|
|
282
|
+
description: 'Docus configuration.',
|
|
283
|
+
icon: 'i-lucide-settings',
|
|
284
|
+
fields: {
|
|
285
|
+
locale: field({
|
|
286
|
+
type: 'string',
|
|
287
|
+
title: 'Locale',
|
|
288
|
+
description: 'Default locale for single-language documentation.',
|
|
289
|
+
icon: 'i-lucide-languages',
|
|
290
|
+
default: 'en',
|
|
291
|
+
}),
|
|
292
|
+
colorMode: field({
|
|
293
|
+
type: 'string',
|
|
294
|
+
title: 'Color Mode',
|
|
295
|
+
description: 'Force a specific color mode. Leave empty for system preference with toggle.',
|
|
296
|
+
icon: 'i-lucide-monitor',
|
|
297
|
+
default: '',
|
|
298
|
+
required: ['', 'light', 'dark'],
|
|
299
|
+
}),
|
|
300
|
+
},
|
|
301
|
+
}),
|
|
280
302
|
assistant: group({
|
|
281
303
|
title: 'Assistant',
|
|
282
304
|
description: 'Assistant configuration.',
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "docus",
|
|
3
3
|
"description": "Nuxt layer for Docus documentation theme",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.9.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./nuxt.config.ts",
|
|
7
7
|
"repository": {
|
|
@@ -22,39 +22,41 @@
|
|
|
22
22
|
"README.md"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@ai-sdk/gateway": "^3.0.
|
|
26
|
-
"@ai-sdk/mcp": "^1.0.
|
|
27
|
-
"@ai-sdk/vue": "3.0.
|
|
28
|
-
"@iconify-json/lucide": "^1.2.
|
|
29
|
-
"@iconify-json/simple-icons": "^1.2.
|
|
25
|
+
"@ai-sdk/gateway": "^3.0.83",
|
|
26
|
+
"@ai-sdk/mcp": "^1.0.30",
|
|
27
|
+
"@ai-sdk/vue": "3.0.141",
|
|
28
|
+
"@iconify-json/lucide": "^1.2.100",
|
|
29
|
+
"@iconify-json/simple-icons": "^1.2.75",
|
|
30
30
|
"@iconify-json/vscode-icons": "^1.2.45",
|
|
31
31
|
"@nuxt/content": "^3.12.0",
|
|
32
32
|
"@nuxt/image": "^2.0.0",
|
|
33
|
-
"@nuxt/kit": "^4.
|
|
34
|
-
"@nuxt/ui": "^4.
|
|
35
|
-
"@nuxtjs/i18n": "^10.2.
|
|
36
|
-
"@nuxtjs/mcp-toolkit": "^0.
|
|
37
|
-
"@nuxtjs/mdc": "^0.
|
|
38
|
-
"@nuxtjs/robots": "^
|
|
33
|
+
"@nuxt/kit": "^4.4.2",
|
|
34
|
+
"@nuxt/ui": "^4.6.0",
|
|
35
|
+
"@nuxtjs/i18n": "^10.2.4",
|
|
36
|
+
"@nuxtjs/mcp-toolkit": "^0.13.2",
|
|
37
|
+
"@nuxtjs/mdc": "^0.21.0",
|
|
38
|
+
"@nuxtjs/robots": "^6.0.6",
|
|
39
|
+
"@shikijs/core": "^4.0.2",
|
|
40
|
+
"@shikijs/engine-javascript": "^4.0.2",
|
|
41
|
+
"@shikijs/langs": "^4.0.2",
|
|
42
|
+
"@shikijs/themes": "^4.0.2",
|
|
43
|
+
"@takumi-rs/core": "^0.73.1",
|
|
39
44
|
"@vueuse/core": "^14.2.1",
|
|
40
|
-
"ai": "6.0.
|
|
45
|
+
"ai": "6.0.141",
|
|
41
46
|
"defu": "^6.1.4",
|
|
42
47
|
"exsolve": "^1.0.8",
|
|
43
48
|
"git-url-parse": "^16.1.0",
|
|
44
|
-
"motion-v": "^
|
|
49
|
+
"motion-v": "^2.2.0",
|
|
45
50
|
"nuxt-llms": "^0.2.0",
|
|
46
|
-
"nuxt-og-image": "^
|
|
51
|
+
"nuxt-og-image": "^6.3.1",
|
|
47
52
|
"pkg-types": "^2.3.0",
|
|
48
53
|
"scule": "^1.3.0",
|
|
49
|
-
"@shikijs/core": "^3.22.0",
|
|
50
|
-
"@shikijs/engine-javascript": "^3.22.0",
|
|
51
|
-
"@shikijs/langs": "^3.22.0",
|
|
52
|
-
"@shikijs/themes": "^3.22.0",
|
|
53
54
|
"shiki-stream": "^0.1.4",
|
|
54
|
-
"tailwindcss": "^4.2.
|
|
55
|
+
"tailwindcss": "^4.2.2",
|
|
55
56
|
"ufo": "^1.6.3",
|
|
57
|
+
"yaml": "^2.7.1",
|
|
56
58
|
"zod": "^4.3.6",
|
|
57
|
-
"zod-to-json-schema": "^3.25.
|
|
59
|
+
"zod-to-json-schema": "^3.25.2"
|
|
58
60
|
},
|
|
59
61
|
"peerDependencies": {
|
|
60
62
|
"better-sqlite3": "12.x",
|
|
@@ -17,9 +17,19 @@ WHEN NOT TO USE: If you don't know the exact path and need to search/explore, us
|
|
|
17
17
|
|
|
18
18
|
WORKFLOW: This tool returns the complete page content including title, description, and full markdown. Use this when you need to provide detailed answers or code examples from specific documentation pages.
|
|
19
19
|
`,
|
|
20
|
+
annotations: {
|
|
21
|
+
readOnlyHint: true,
|
|
22
|
+
destructiveHint: false,
|
|
23
|
+
idempotentHint: true,
|
|
24
|
+
openWorldHint: false,
|
|
25
|
+
},
|
|
20
26
|
inputSchema: {
|
|
21
27
|
path: z.string().describe('The page path from list-pages or provided by the user (e.g., /en/getting-started/installation)'),
|
|
22
28
|
},
|
|
29
|
+
inputExamples: [
|
|
30
|
+
{ path: '/en/getting-started/installation' },
|
|
31
|
+
{ path: '/getting-started/introduction' },
|
|
32
|
+
],
|
|
23
33
|
cache: '1h',
|
|
24
34
|
handler: async ({ path }) => {
|
|
25
35
|
const event = useEvent()
|
|
@@ -38,21 +48,22 @@ WORKFLOW: This tool returns the complete page content including title, descripti
|
|
|
38
48
|
.first()
|
|
39
49
|
|
|
40
50
|
if (!page) {
|
|
41
|
-
|
|
51
|
+
throw createError({ statusCode: 404, message: 'Page not found' })
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
const content = await event.$fetch<string>(`/raw${path}.md`)
|
|
45
55
|
|
|
46
|
-
return
|
|
56
|
+
return {
|
|
47
57
|
title: page.title,
|
|
48
58
|
path: page.path,
|
|
49
59
|
description: page.description,
|
|
50
60
|
content,
|
|
51
61
|
url: `${siteUrl}${page.path}`,
|
|
52
|
-
}
|
|
62
|
+
}
|
|
53
63
|
}
|
|
54
|
-
catch {
|
|
55
|
-
|
|
64
|
+
catch (error) {
|
|
65
|
+
if ((error as { statusCode?: number }).statusCode === 404) throw error
|
|
66
|
+
throw createError({ statusCode: 500, message: 'Failed to get page' })
|
|
56
67
|
}
|
|
57
68
|
},
|
|
58
69
|
})
|
|
@@ -23,9 +23,19 @@ OUTPUT: Returns a structured list with:
|
|
|
23
23
|
- path: Exact path for use with get-page
|
|
24
24
|
- description: Brief summary of page content
|
|
25
25
|
- url: Full URL for reference`,
|
|
26
|
+
annotations: {
|
|
27
|
+
readOnlyHint: true,
|
|
28
|
+
destructiveHint: false,
|
|
29
|
+
idempotentHint: true,
|
|
30
|
+
openWorldHint: false,
|
|
31
|
+
},
|
|
26
32
|
inputSchema: {
|
|
27
|
-
locale: z.string().optional().describe('The locale to filter pages by'),
|
|
33
|
+
locale: z.string().optional().describe('The locale to filter pages by (e.g., "en", "fr")'),
|
|
28
34
|
},
|
|
35
|
+
inputExamples: [
|
|
36
|
+
{ locale: 'en' },
|
|
37
|
+
{},
|
|
38
|
+
],
|
|
29
39
|
cache: '1h',
|
|
30
40
|
handler: async ({ locale }) => {
|
|
31
41
|
const event = useEvent()
|
|
@@ -52,10 +62,10 @@ OUTPUT: Returns a structured list with:
|
|
|
52
62
|
}),
|
|
53
63
|
)
|
|
54
64
|
|
|
55
|
-
return
|
|
65
|
+
return allPages.flat()
|
|
56
66
|
}
|
|
57
67
|
catch {
|
|
58
|
-
|
|
68
|
+
throw createError({ statusCode: 500, message: 'Failed to list pages' })
|
|
59
69
|
}
|
|
60
70
|
},
|
|
61
71
|
})
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
<script lang="ts" setup>
|
|
2
|
-
const props = withDefaults(defineProps<{ title?: string, description?: string, headline?: string }>(), {
|
|
3
|
-
title: 'title',
|
|
4
|
-
description: 'description',
|
|
5
|
-
})
|
|
6
|
-
|
|
7
|
-
const title = (props.title || '').slice(0, 60)
|
|
8
|
-
const description = (props.description || '').slice(0, 200)
|
|
9
|
-
</script>
|
|
10
|
-
|
|
11
|
-
<template>
|
|
12
|
-
<div class="w-full h-full flex flex-col justify-center bg-neutral-900">
|
|
13
|
-
<svg
|
|
14
|
-
class="absolute right-0 top-0 opacity-50"
|
|
15
|
-
width="629"
|
|
16
|
-
height="593"
|
|
17
|
-
viewBox="0 0 629 593"
|
|
18
|
-
fill="none"
|
|
19
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
20
|
-
>
|
|
21
|
-
<g filter="url(#filter0_f_199_94966)">
|
|
22
|
-
<path
|
|
23
|
-
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"
|
|
24
|
-
fill="white"
|
|
25
|
-
/>
|
|
26
|
-
</g>
|
|
27
|
-
<defs>
|
|
28
|
-
<filter
|
|
29
|
-
id="filter0_f_199_94966"
|
|
30
|
-
x="0.873535"
|
|
31
|
-
y="-659"
|
|
32
|
-
width="1255.25"
|
|
33
|
-
height="1251.52"
|
|
34
|
-
filterUnits="userSpaceOnUse"
|
|
35
|
-
color-interpolation-filters="sRGB"
|
|
36
|
-
>
|
|
37
|
-
<feFlood
|
|
38
|
-
flood-opacity="0"
|
|
39
|
-
result="BackgroundImageFix"
|
|
40
|
-
/>
|
|
41
|
-
<feBlend
|
|
42
|
-
mode="normal"
|
|
43
|
-
in="SourceGraphic"
|
|
44
|
-
in2="BackgroundImageFix"
|
|
45
|
-
result="shape"
|
|
46
|
-
/>
|
|
47
|
-
<feGaussianBlur
|
|
48
|
-
stdDeviation="40.5"
|
|
49
|
-
result="effect1_foregroundBlur_199_94966"
|
|
50
|
-
/>
|
|
51
|
-
</filter>
|
|
52
|
-
</defs>
|
|
53
|
-
</svg>
|
|
54
|
-
|
|
55
|
-
<div class="pl-[100px]">
|
|
56
|
-
<p
|
|
57
|
-
v-if="headline"
|
|
58
|
-
class="uppercase text-[24px] text-emerald-500 mb-4 font-semibold"
|
|
59
|
-
>
|
|
60
|
-
{{ headline }}
|
|
61
|
-
</p>
|
|
62
|
-
<h1
|
|
63
|
-
v-if="title"
|
|
64
|
-
class="m-0 text-[75px] font-semibold mb-4 text-white flex items-center"
|
|
65
|
-
>
|
|
66
|
-
<span>{{ title }}</span>
|
|
67
|
-
</h1>
|
|
68
|
-
<p
|
|
69
|
-
v-if="description"
|
|
70
|
-
class="text-[32px] text-neutral-300 leading-tight w-[700px]"
|
|
71
|
-
>
|
|
72
|
-
{{ description }}
|
|
73
|
-
</p>
|
|
74
|
-
</div>
|
|
75
|
-
</div>
|
|
76
|
-
</template>
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
<script lang="ts" setup>
|
|
2
|
-
const props = withDefaults(defineProps<{ title?: string, description?: string }>(), {
|
|
3
|
-
title: 'title',
|
|
4
|
-
description: 'description',
|
|
5
|
-
})
|
|
6
|
-
|
|
7
|
-
const appConfig = useAppConfig()
|
|
8
|
-
const logoPath = appConfig.header?.logo?.dark || appConfig.header?.logo?.light
|
|
9
|
-
|
|
10
|
-
const logoSvg = await fetchLogoSvg(logoPath)
|
|
11
|
-
|
|
12
|
-
const title = (props.title || '').slice(0, 60)
|
|
13
|
-
const description = (props.description || '').slice(0, 200)
|
|
14
|
-
|
|
15
|
-
async function fetchLogoSvg(path?: string): Promise<string> {
|
|
16
|
-
if (!path) return ''
|
|
17
|
-
try {
|
|
18
|
-
// eslint-disable-next-line
|
|
19
|
-
// @ts-ignore
|
|
20
|
-
const { url: siteUrl } = useSiteConfig()
|
|
21
|
-
const url = path.startsWith('http') ? path : `${siteUrl}${path}`
|
|
22
|
-
const svg = await $fetch<string>(url, { responseType: 'text' })
|
|
23
|
-
return svg.replace('<svg', '<svg width="100%" height="100%"')
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
return ''
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
</script>
|
|
30
|
-
|
|
31
|
-
<template>
|
|
32
|
-
<div class="w-full h-full flex items-center justify-center bg-neutral-900">
|
|
33
|
-
<svg
|
|
34
|
-
class="absolute right-0 top-0 opacity-50"
|
|
35
|
-
width="629"
|
|
36
|
-
height="593"
|
|
37
|
-
viewBox="0 0 629 593"
|
|
38
|
-
fill="none"
|
|
39
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
40
|
-
>
|
|
41
|
-
<g filter="url(#filter0_f_199_94966)">
|
|
42
|
-
<path
|
|
43
|
-
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"
|
|
44
|
-
fill="white"
|
|
45
|
-
/>
|
|
46
|
-
</g>
|
|
47
|
-
<defs>
|
|
48
|
-
<filter
|
|
49
|
-
id="filter0_f_199_94966"
|
|
50
|
-
x="0.873535"
|
|
51
|
-
y="-659"
|
|
52
|
-
width="1255.25"
|
|
53
|
-
height="1251.52"
|
|
54
|
-
filterUnits="userSpaceOnUse"
|
|
55
|
-
color-interpolation-filters="sRGB"
|
|
56
|
-
>
|
|
57
|
-
<feFlood
|
|
58
|
-
flood-opacity="0"
|
|
59
|
-
result="BackgroundImageFix"
|
|
60
|
-
/>
|
|
61
|
-
<feBlend
|
|
62
|
-
mode="normal"
|
|
63
|
-
in="SourceGraphic"
|
|
64
|
-
in2="BackgroundImageFix"
|
|
65
|
-
result="shape"
|
|
66
|
-
/>
|
|
67
|
-
<feGaussianBlur
|
|
68
|
-
stdDeviation="40.5"
|
|
69
|
-
result="effect1_foregroundBlur_199_94966"
|
|
70
|
-
/>
|
|
71
|
-
</filter>
|
|
72
|
-
</defs>
|
|
73
|
-
</svg>
|
|
74
|
-
|
|
75
|
-
<div
|
|
76
|
-
class="flex flex-col items-center justify-center p-8"
|
|
77
|
-
>
|
|
78
|
-
<div
|
|
79
|
-
v-if="logoSvg"
|
|
80
|
-
class="flex items-center justify-center mb-10 max-w-[900px]"
|
|
81
|
-
style="width: 72px; height: 72px;"
|
|
82
|
-
v-html="logoSvg"
|
|
83
|
-
/>
|
|
84
|
-
<h1
|
|
85
|
-
v-if="title"
|
|
86
|
-
class="m-0 text-5xl font-semibold mb-4 text-white text-center"
|
|
87
|
-
>
|
|
88
|
-
{{ title }}
|
|
89
|
-
</h1>
|
|
90
|
-
<p
|
|
91
|
-
v-if="description"
|
|
92
|
-
class="text-center text-2xl text-neutral-300 leading-tight max-w-[800px]"
|
|
93
|
-
>
|
|
94
|
-
{{ description }}
|
|
95
|
-
</p>
|
|
96
|
-
</div>
|
|
97
|
-
</div>
|
|
98
|
-
</template>
|