Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion docs/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export default defineNuxtConfig({
extends: ['docus'],
modules: ['@nuxtjs/i18n', 'nuxt-studio'],
modules: ['@nuxtjs/i18n'],
site: {
name: 'Docus',
},
Expand Down
1 change: 1 addition & 0 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"docus": "workspace:*",
"nuxt": "4.6.0",
"nuxt-studio": "https://pkg.pr.new/nuxt-studio@46e0af4",
"shiki": "^4.5.0",
"tailwindcss": "^4.3.3"
},
"devDependencies": {
Expand Down
19 changes: 19 additions & 0 deletions layer/app/components/docs/DocsContentLink.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script setup lang="ts">
// Content v4: `ContentRenderer` maps links to a bare `NuxtLink` and drops v3's `rel="nofollow"` on external ones.
const props = defineProps<{
href?: string
target?: '_blank' | '_parent' | '_self' | '_top' | null
}>()

const rel = computed(() => props.href && /^https?:\/\//.test(props.href) ? 'nofollow' : undefined)
</script>

<template>
<ProseA
:href="href"
:target="target"
:rel="rel"
>
<slot />
</ProseA>
</template>
13 changes: 12 additions & 1 deletion layer/app/pages/[[lang]]/[...slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import { kebabCase } from 'scule'
import type { ContentNavigationItem, Collections } from '@nuxt/content'
import { findPageHeadline } from '@nuxt/content/utils'
import { collectionDirs } from '#build/docus/content-v4.mjs'
import { DocsContentLink } from '#components'
import { getLocaleKey } from '../../../utils/locale'
import type { DocsPage } from '../../types'

Expand Down Expand Up @@ -54,6 +56,14 @@ defineOgImage('Docs', {

const github = computed(() => appConfig.github ? appConfig.github : null)

// Content v4: `stem` is relative to the collection folder, and `extension` keeps its dot.
const contentFile = computed(() => {
const dir = collectionDirs[collectionName.value]
const stem = page.value?.stem || ''
const extension = String(page.value?.extension || '').replace(/^\./, '')
return `${dir && !stem.startsWith(`${dir}/`) ? `${dir}/` : ''}${stem}.${extension}`
})

const editLink = computed(() => {
if (!github.value) {
return
Expand All @@ -65,7 +75,7 @@ const editLink = computed(() => {
github.value.branch,
github.value.rootDir,
'content',
`${page.value?.stem}.${page.value?.extension}`,
contentFile.value,
].filter(Boolean).join('/')
})
</script>
Expand Down Expand Up @@ -99,6 +109,7 @@ const editLink = computed(() => {
<ContentRenderer
v-if="page"
:value="page"
:components="{ a: DocsContentLink }"
/>

<USeparator v-if="github">
Expand Down
2 changes: 2 additions & 0 deletions layer/app/templates/landing.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Collections } from '@nuxt/content'
import { DocsContentLink } from '#components'
import { getLocaleKey } from '../../utils/locale'

const route = useRoute()
Expand Down Expand Up @@ -35,5 +36,6 @@ if (!page.value?.seo?.ogImage) {
<ContentRenderer
v-if="page"
:value="page"
:components="{ a: DocsContentLink }"
/>
</template>
16 changes: 16 additions & 0 deletions layer/app/utils/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
import type { ContentNavigationItem } from '@nuxt/content'
import { navigationConfigs } from '#build/docus/content-v4.mjs'

/**
* Content v4 drops `.navigation.yml` files, so merge them back into their folder node.
* Empty on v3, which already does it.
*/
function applyNavigationConfigs(items: ContentNavigationItem[]): ContentNavigationItem[] {
return items.flatMap((item) => {
const { navigation, ...config } = navigationConfigs[item.path] || {}
if (navigation === false) return []

const children = item.children && applyNavigationConfigs(item.children)
return [{ ...item, ...config, ...(children ? { children } : {}) }]
})
}

export const flattenNavigation = (items?: ContentNavigationItem[]): ContentNavigationItem[] => items?.flatMap(
item => item.children
Expand All @@ -14,6 +29,7 @@ export function transformNavigation(
isI18nEnabled: boolean,
locale?: string,
): ContentNavigationItem[] {
data = applyNavigationConfigs(data)
if (isI18nEnabled && locale) {
// i18n: first strip locale level, then check for docs level
const localeResult = data.find(item => item.path === `/${locale}`)?.children || data
Expand Down
128 changes: 128 additions & 0 deletions layer/modules/content-v4.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { addTemplate, addTypeTemplate, defineNuxtModule } from '@nuxt/kit'
import { parseFrontmatter } from '@comark/vue/parse'
import { defu } from 'defu'
import { resolveModulePath } from 'exsolve'
import { join } from 'pathe'
import { readPackageJSON } from 'pkg-types'
import { getLocaleKey, normalizeLocale } from '../utils/locale'
import { docsFolderExists, findLocaleFolder, landingPageExists } from '../utils/pages'

type I18nLocale = string | { code: string }

// Workarounds for Nuxt Content v4 regressions, each one a no-op on v3.
export default defineNuxtModule({
meta: {
name: 'content-v4',
},
async setup(_options, nuxt) {
const contentPackage = await readPackageJSON(resolveModulePath('@nuxt/content', { from: import.meta.url }))
const isContentV4 = contentPackage.version?.startsWith('4.') ?? false
const rootDir = nuxt.options.rootDir

// Always generated, so the runtime fallbacks resolve on v3 too.
addTemplate({
filename: 'docus/content-v4.mjs',
getContents: () => [
`export const navigationConfigs = ${JSON.stringify(isContentV4 ? readNavigationConfigs(rootDir, getLocales(nuxt.options)) : {})}`,
`export const collectionDirs = ${JSON.stringify(isContentV4 ? getCollectionDirs(rootDir, getLocales(nuxt.options)) : {})}`,
].join('\n'),
})
addTypeTemplate({
filename: 'types/docus-content-v4.d.ts',
getContents: () => `declare module '#build/docus/content-v4.mjs' {
export const navigationConfigs: Record<string, Record<string, unknown>>
export const collectionDirs: Record<string, string>
}
`,
})

if (!isContentV4) return

// Nuxt UI prose headings read the anchor config `@nuxtjs/mdc` used to set.
const publicConfig = nuxt.options.runtimeConfig.public as Record<string, unknown>
publicConfig.mdc = defu(publicConfig.mdc as Record<string, unknown>, {
headings: { anchorLinks: { h1: false, h2: true, h3: true, h4: true, h5: false, h6: false } },
})

// Nuxt UI sets its highlight themes through `@nuxtjs/mdc` defaults, which v4 doesn't read.
const content = nuxt.options as typeof nuxt.options & { content?: { build?: { markdown?: { highlight?: false | Record<string, unknown> } } } }
const highlight = content.content?.build?.markdown?.highlight
if (highlight && !highlight.theme) {
highlight.theme = { light: 'material-theme-lighter', default: 'material-theme', dark: 'material-theme-palenight' }
}

// `#build/content/components.mjs` skips components from `node_modules`, so Nuxt UI's (`::u-page-hero`…) render as raw tags.
nuxt.hook('modules:done', () => {
const template = nuxt.options.build.templates.find(template => template.filename === 'content/components.mjs')
const getContents = template?.getContents
if (!template || !getContents) return

template.getContents = async (data) => {
const contents = await getContents(data)
const names = new Set([...contents.matchAll(/^ {2}"(\w+)":/gm)].map(match => match[1]))
const loaders = (nuxt.apps.default?.components ?? [])
.filter((component) => {
if (component.global || component.island || !component.filePath.endsWith('.vue') || names.has(component.pascalName)) return false
names.add(component.pascalName)
return true
})
.map((component) => {
const pick = component.export === 'default' ? '' : `.then(m => m[${JSON.stringify(component.export)}])`
return ` ${JSON.stringify(component.pascalName)}: async () => import(${JSON.stringify(component.filePath)})${pick},\n`
})
return contents.replace('export const localComponents = {\n', match => match + loaders.join(''))
}
})
},
})

function getLocales(options: object): string[] {
const locales = (options as { i18n?: { locales?: I18nLocale[] } }).i18n?.locales
return Array.isArray(locales) ? locales.map(locale => normalizeLocale(typeof locale === 'string' ? locale : locale.code)) : []
}

/**
* The content folder each collection reads, mirroring `content.config.ts`.
* v4's `stem` is relative to it, v3's was relative to `content/`.
*/
function getCollectionDirs(rootDir: string, locales: string[]): Record<string, string> {
if (!locales.length) {
return { docs: docsFolderExists(rootDir) ? 'docs' : '', landing: '' }
}

const dirs: Record<string, string> = {}
for (const code of locales) {
const dir = findLocaleFolder(rootDir, code) || code
const key = getLocaleKey(code)
dirs[`docs_${key}`] = docsFolderExists(rootDir, dir) ? `${dir}/docs` : dir
if (!landingPageExists(rootDir)) dirs[`landing_${key}`] = dir
}
return dirs
}

/**
* `.navigation.yml` files keyed by the navigation path of their folder.
* v4 doesn't parse YAML in v3 mode, so folder titles and icons are lost otherwise.
*/
function readNavigationConfigs(rootDir: string, locales: string[]): Record<string, Record<string, unknown>> {
const contentDir = join(rootDir, 'content')
if (!existsSync(contentDir)) return {}

const configs: Record<string, Record<string, unknown>> = {}
for (const file of readdirSync(contentDir, { recursive: true, encoding: 'utf8' })) {
if (!/(?:^|\/)\.navigation\.ya?ml$/.test(file)) continue

const segments = file.split('/').slice(0, -1)
const path = '/' + segments
.map((segment, index) => index === 0 && locales.includes(normalizeLocale(segment)) ? normalizeLocale(segment) : toPathSegment(segment))
.join('/')
configs[path] = parseFrontmatter(`---\n${readFileSync(join(contentDir, file), 'utf8')}\n---`).data as Record<string, unknown>
}
return configs
}

/** v3 `path-meta`: drop the `1.` ordering prefix, but keep versions like `1.x`. */
function toPathSegment(segment: string): string {
return (/^\d+(?:\.\d+)*(?:\.x)?$/.test(segment) ? segment : segment.replace(/^\d+\./, '')).toLowerCase()
}
4 changes: 4 additions & 0 deletions layer/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default defineNuxtConfig({
})
},
'@nuxt/ui',
resolve('./modules/content-v4'),
'@nuxt/content',
'nuxt-agent-discovery',
'@nuxt/image',
Expand Down Expand Up @@ -119,6 +120,9 @@ export default defineNuxtConfig({
nitroConfig.prerender.routes.push('/openapi.json')
},
},
agentDiscovery: {
source: resolve('./server/agent-source'),
},
icon: {
customCollections: [
{
Expand Down
2 changes: 1 addition & 1 deletion layer/nuxt.schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { field, group } from '@nuxt/content/preview'
import { field, group } from './utils/preview'

export default defineNuxtSchema({
appConfig: {
Expand Down
2 changes: 2 additions & 0 deletions layer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@shikijs/langs": "^4.5.0",
"@shikijs/stream": "^4.5.0",
"@shikijs/themes": "^4.5.0",
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
"@takumi-rs/core": "^2.14.0",
"@vueuse/core": "^15.0.0",
"ai": "^7.0.127",
Expand All @@ -63,6 +64,7 @@
"pathe": "^2.0.3",
"pkg-types": "^2.3.3",
"scule": "^1.3.0",
"shiki": "^4.5.0",
"tailwindcss": "^4.3.3",
"ufo": "^1.6.4",
"zod": "^4.6.5",
Expand Down
4 changes: 4 additions & 0 deletions layer/server/agent-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createComarkSource } from '#agent-discovery/comark'

// Content v4: the v3 source reads `#content/manifest` and minimark bodies, which v4 dropped.
export default createComarkSource(() => content)
9 changes: 6 additions & 3 deletions layer/server/api/__sitemap__/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,14 @@ export default defineSitemapEventHandler(async (event) => {
const urls: Array<{ loc: string, lastmod?: string }> = []

for (const collection of collections) {
// Collection might not exist, skip it
const pages = await (queryCollection as unknown as (event: unknown, collection: string) => { all: () => Promise<Page[]> })(event, collection).all().catch(() => [] as Page[])
// Collection might not exist, skip it (Content v4 throws instead of rejecting)
const pages = await Promise.resolve()
.then(() => (queryCollection as unknown as (event: unknown, collection: string) => { all: () => Promise<Page[]> })(event, collection).all())
.catch(() => [] as Page[])

for (const page of pages) {
const meta = page.meta || {}
// Content v4 keeps frontmatter outside the schema at the top level, v3 under `meta`
const meta = { ...page, ...page.meta }
const path = page.path || '/'

if (meta.sitemap === false || isNavigationPath(path)) {
Expand Down
74 changes: 74 additions & 0 deletions layer/utils/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { defu } from 'defu'

// Copy of `@nuxt/content/preview` from Content v3, which Content v4 doesn't ship.

type InputValue = Record<string, unknown>

type ConfigInputsTypes = 'default' | 'string' | 'number' | 'boolean' | 'array' | 'object' | 'file' | 'media' | 'component' | 'icon' | 'textarea'

type PreviewFieldData = {
title?: string
description?: string
default?: unknown
required?: unknown
type?: ConfigInputsTypes
icon?: string
fields?: { [key: string]: InputValue }
[key: string]: unknown
}

const inputTypes: Record<ConfigInputsTypes, string> = {
default: 'string',
string: 'string',
number: 'number',
boolean: 'boolean',
array: 'array',
object: 'object',
file: 'string',
media: 'string',
component: 'string',
icon: 'string',
textarea: 'string',
}

/**
* Helper to build preview compatible configuration schema.
*/
export function field(schema: PreviewFieldData): InputValue {
if (!schema.type) {
throw new Error(`Missing type in schema ${JSON.stringify(schema)}`)
}

const base = { type: inputTypes[schema.type], tags: [`@previewInput ${schema.type === 'default' ? 'string' : schema.type}`] }
const result = defu(base, schema) as PreviewFieldData & { tags: string[] }

if (result.icon) {
result.tags.push(`@previewIcon ${result.icon}`)
delete result.icon
}
return {
$schema: result,
}
}

export function group(schema: PreviewFieldData): InputValue {
const result = { ...schema }

if (result.icon) {
result.tags = [`@previewIcon ${result.icon}`]
delete result.icon
}

const fields: Record<string, InputValue> = {}
if (result.fields) {
for (const key of Object.keys(result.fields)) {
fields[key] = result.fields[key]!
}
delete result.fields
}

return {
$schema: result,
...fields,
}
}
Loading
Loading