diff --git a/packages/layer/app/components/content/ProsePre.vue b/packages/layer/app/components/content/ProsePre.vue
index c40f8b09..637bed3f 100644
--- a/packages/layer/app/components/content/ProsePre.vue
+++ b/packages/layer/app/components/content/ProsePre.vue
@@ -74,9 +74,9 @@ const codeAttributes = computed(() => isShowingLineNumber.value
diff --git a/packages/layer/app/components/docs/DocsSidebar.vue b/packages/layer/app/components/docs/DocsSidebar.vue
index e013bf6d..b9fe01d4 100644
--- a/packages/layer/app/components/docs/DocsSidebar.vue
+++ b/packages/layer/app/components/docs/DocsSidebar.vue
@@ -1,12 +1,11 @@
diff --git a/packages/layer/app/components/docs/DocsTableOfContents.vue b/packages/layer/app/components/docs/DocsTableOfContents.vue
index 1f01ff6c..b57532e9 100644
--- a/packages/layer/app/components/docs/DocsTableOfContents.vue
+++ b/packages/layer/app/components/docs/DocsTableOfContents.vue
@@ -27,7 +27,8 @@ onMounted(() => {
props.toc?.forEach((item) => {
const el = document.getElementById(item.id)
- if (el) observer.observe(el)
+ if (el)
+ observer.observe(el)
})
onUnmounted(() => observer.disconnect())
diff --git a/packages/layer/app/components/ui/tooltip/Tooltip.vue b/packages/layer/app/components/ui/tooltip/Tooltip.vue
new file mode 100644
index 00000000..2a393d6d
--- /dev/null
+++ b/packages/layer/app/components/ui/tooltip/Tooltip.vue
@@ -0,0 +1,19 @@
+
+
+
diff --git a/packages/layer/app/components/ui/tooltip/TooltipContent.vue b/packages/layer/app/components/ui/tooltip/TooltipContent.vue
new file mode 100644
index 00000000..c5d2df9c
--- /dev/null
+++ b/packages/layer/app/components/ui/tooltip/TooltipContent.vue
@@ -0,0 +1,34 @@
+
+
+
diff --git a/packages/layer/app/components/ui/tooltip/TooltipProvider.vue b/packages/layer/app/components/ui/tooltip/TooltipProvider.vue
new file mode 100644
index 00000000..395927d5
--- /dev/null
+++ b/packages/layer/app/components/ui/tooltip/TooltipProvider.vue
@@ -0,0 +1,14 @@
+
+
+
diff --git a/packages/layer/app/components/ui/tooltip/TooltipTrigger.vue b/packages/layer/app/components/ui/tooltip/TooltipTrigger.vue
new file mode 100644
index 00000000..3332950e
--- /dev/null
+++ b/packages/layer/app/components/ui/tooltip/TooltipTrigger.vue
@@ -0,0 +1,15 @@
+
+
+
diff --git a/packages/layer/app/components/ui/tooltip/index.ts b/packages/layer/app/components/ui/tooltip/index.ts
new file mode 100644
index 00000000..8f8d514d
--- /dev/null
+++ b/packages/layer/app/components/ui/tooltip/index.ts
@@ -0,0 +1,4 @@
+export { default as Tooltip } from "./Tooltip.vue"
+export { default as TooltipContent } from "./TooltipContent.vue"
+export { default as TooltipProvider } from "./TooltipProvider.vue"
+export { default as TooltipTrigger } from "./TooltipTrigger.vue"
diff --git a/packages/layer/app/composables/useNavigation.ts b/packages/layer/app/composables/useNavigation.ts
index 1eb2fb7f..62bff040 100644
--- a/packages/layer/app/composables/useNavigation.ts
+++ b/packages/layer/app/composables/useNavigation.ts
@@ -27,7 +27,7 @@ function mapWithType(item: ContentNavigationItem): NavigationItem {
}
}
-export async function useNavigation() {
+export async function useNavigation(): Promise<{ data: Ref
}> {
const { data } = useAsyncData('navigation', () => {
return queryCollectionNavigation('docs')
}, {
diff --git a/packages/layer/app/pages/[...slug].vue b/packages/layer/app/pages/[...slug].vue
index 79768c32..3db186a3 100644
--- a/packages/layer/app/pages/[...slug].vue
+++ b/packages/layer/app/pages/[...slug].vue
@@ -8,16 +8,15 @@ definePageMeta({
const route = useRoute()
const { data: page } = await useAsyncData(`docs-${route.path}`, () =>
- queryCollection('docs').path(route.path).first(),
-)
+ queryCollection('docs').path(route.path).first())
const { data: surround } = await useAsyncData(`surround-${route.path}`, () =>
- queryCollectionItemSurroundings('docs', route.path),
-)
+ queryCollectionItemSurroundings('docs', route.path))
// Extract TOC from page body
-const toc = computed(() => {
- if (!page.value?.body) return []
+const _toc = computed(() => {
+ if (!page.value?.body)
+ return []
const headings: { id: string, text: string, depth: number }[] = []
@@ -25,7 +24,7 @@ const toc = computed(() => {
if (node.tag && node.tag.match(/^h[2-4]$/)) {
const id = node.props?.id
const text = extractText(node.children)
- const depth = parseInt(node.tag.charAt(1))
+ const depth = Number.parseInt(node.tag.charAt(1))
if (id && text) {
headings.push({ id, text, depth })
}
@@ -38,11 +37,14 @@ const toc = computed(() => {
}
function extractText(children: any[]): string {
- if (!children) return ''
+ if (!children)
+ return ''
return children
.map((child) => {
- if (typeof child === 'string') return child
- if (child.children) return extractText(child.children)
+ if (typeof child === 'string')
+ return child
+ if (child.children)
+ return extractText(child.children)
return ''
})
.join('')
@@ -70,7 +72,7 @@ useSeoMeta({
:description="page.description"
/>
-
+
diff --git a/packages/layer/app/pages/index.vue b/packages/layer/app/pages/index.vue
index 3dda9b81..26c4c8b8 100644
--- a/packages/layer/app/pages/index.vue
+++ b/packages/layer/app/pages/index.vue
@@ -4,8 +4,7 @@ definePageMeta({
})
const { data: page } = await useAsyncData('landing', () =>
- queryCollection('landing').path('/').first(),
-)
+ queryCollection('landing').path('/').first())
diff --git a/packages/layer/app/plugins/ssr-width.ts b/packages/layer/app/plugins/ssr-width.ts
new file mode 100644
index 00000000..da7bf414
--- /dev/null
+++ b/packages/layer/app/plugins/ssr-width.ts
@@ -0,0 +1,5 @@
+import { provideSSRWidth } from '@vueuse/core'
+
+export default defineNuxtPlugin((nuxtApp) => {
+ provideSSRWidth(1024, nuxtApp.vueApp)
+})
diff --git a/packages/layer/app/utils/cn.ts b/packages/layer/app/utils/cn.ts
deleted file mode 100644
index abba253f..00000000
--- a/packages/layer/app/utils/cn.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { ClassValue } from 'clsx'
-import { clsx } from 'clsx'
-import { twMerge } from 'tailwind-merge'
-
-export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs))
-}
diff --git a/packages/layer/eslint.config.mjs b/packages/layer/eslint.config.mjs
new file mode 100644
index 00000000..6a85a89c
--- /dev/null
+++ b/packages/layer/eslint.config.mjs
@@ -0,0 +1,11 @@
+// @ts-check
+import antfu from '@antfu/eslint-config'
+import withNuxt from './.nuxt/eslint.config.mjs'
+
+export default withNuxt(
+ antfu({
+ type: 'lib',
+ }, {
+ ignores: ['**/dist/**', '**/.nuxt/**', '**/node_modules/**', 'app/components/ui/**' ],
+ }),
+)
diff --git a/packages/layer/modules/config.ts b/packages/layer/modules/config.ts
index b6d8eaa0..b63431aa 100644
--- a/packages/layer/modules/config.ts
+++ b/packages/layer/modules/config.ts
@@ -1,7 +1,7 @@
import { defineNuxtModule } from '@nuxt/kit'
import { defu } from 'defu'
-import { inferSiteURL, getPackageJsonMetadata } from '../utils/meta'
import { getGitBranch, getGitEnv } from '../utils/git'
+import { getPackageJsonMetadata, inferSiteURL } from '../utils/meta'
export default defineNuxtModule({
meta: {
diff --git a/packages/layer/modules/css.ts b/packages/layer/modules/css.ts
index cb66ff15..cc82840b 100644
--- a/packages/layer/modules/css.ts
+++ b/packages/layer/modules/css.ts
@@ -1,5 +1,6 @@
-import { defineNuxtModule, addTemplate, createResolver } from '@nuxt/kit'
+import { addTemplate, createResolver, defineNuxtModule } from '@nuxt/kit'
import { joinURL } from 'ufo'
+import { resolveModulePath } from 'exsolve'
export default defineNuxtModule({
meta: {
@@ -11,19 +12,27 @@ export default defineNuxtModule({
const contentDir = joinURL(dir, 'content')
const layerDir = resolver.resolve('../app')
+ const mainCssPath = resolver.resolve('../app/assets/css/main.css')
+ const tailwindPath = resolveModulePath('tailwindcss', { from: import.meta.url, conditions: ['style'] })
// Create a CSS template that includes source directives for Tailwind
const cssTemplate = addTemplate({
filename: 'docs-layer.css',
getContents: () => {
- return `/* Auto-generated Tailwind source directives */
+ return `@import ${JSON.stringify(tailwindPath)};
+
@source "${contentDir.replace(/\\/g, '/')}/**/*";
@source "${layerDir.replace(/\\/g, '/')}/**/*";
-@source "../../app.config.ts";`
+@source "../../app.config.ts";
+
+@import ${JSON.stringify(mainCssPath)};`
},
})
- // Add the generated CSS file to Nuxt
- nuxt.options.css.push(cssTemplate.dst)
+ // Remove main.css from nuxt.options.css if present (we import it in docs-layer.css)
+ nuxt.options.css = nuxt.options.css.filter(css => !css.includes('main.css'))
+
+ // Add the generated CSS file to Nuxt - unshift to load first
+ nuxt.options.css.unshift(cssTemplate.dst)
},
})
diff --git a/packages/layer/modules/shadcn.ts b/packages/layer/modules/shadcn.ts
new file mode 100644
index 00000000..07598923
--- /dev/null
+++ b/packages/layer/modules/shadcn.ts
@@ -0,0 +1,116 @@
+import { readdirSync, readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import {
+ addComponent,
+ addComponentsDir,
+ createResolver,
+ defineNuxtModule,
+ findPath,
+ useLogger,
+} from '@nuxt/kit'
+import { parseSync } from 'oxc-parser'
+
+// Module options TypeScript interface definition
+export interface ModuleOptions {
+ /**
+ * Prefix for all the imported component.
+ * @default "Ui"
+ */
+ prefix?: string
+ /**
+ * Directory that the component lives in.
+ * Will respect the Nuxt aliases.
+ * @link https://nuxt.com/docs/api/nuxt-config#alias
+ * @default "@/components/ui"
+ */
+ componentDir?: string
+}
+
+export default defineNuxtModule({
+ meta: {
+ name: 'shadcn',
+ configKey: 'shadcn',
+ },
+ defaults: {
+ prefix: 'Ui',
+ componentDir: '@/components/ui',
+ },
+ async setup({ prefix, componentDir }, nuxt) {
+ const COMPONENT_DIR_PATH = componentDir!
+ const ROOT_DIR_PATH = nuxt.options.rootDir
+
+ const logger = useLogger('shadcn-nuxt')
+ logger.start('Setting up shadcn-nuxt module', { COMPONENT_DIR_PATH, ROOT_DIR_PATH })
+
+ // Build list of potential component directory paths from all layers
+ // _layers[0] is the app, subsequent entries are extended layers
+ // We check all layers and find the first existing component directory
+ const potentialPaths: string[] = []
+
+ for (const layer of nuxt.options._layers) {
+ const layerRoot = layer.cwd
+ // Resolve component directory relative to layer root
+ const componentPath = COMPONENT_DIR_PATH.replace(/^@\//, '')
+
+ // Try app/ subdirectory first (Nuxt 4 layer structure)
+ potentialPaths.push(join(layerRoot, 'app', componentPath))
+ // Also try direct path (traditional structure)
+ potentialPaths.push(join(layerRoot, componentPath))
+ }
+
+ logger.info('Checking', { potentialPaths })
+ // Use findPath to find the first existing component directory
+ const componentsPath = (await findPath(potentialPaths, {}, 'dir')) || ROOT_DIR_PATH
+
+ logger.info('Decided on', { componentsPath })
+
+ // Create resolver relative to the found components path
+ const { resolve, resolvePath } = createResolver(componentsPath)
+
+ // Tell Nuxt to not scan `componentsDir` for auto imports as we will do it manually
+ // See https://github.com/unovue/shadcn-vue/pull/528#discussion_r1590206268
+ addComponentsDir({
+ path: componentsPath,
+ extensions: [],
+ ignore: ['**/*'],
+ }, {
+ prepend: true,
+ })
+
+ // Manually scan `componentsDir` for components and register them for auto imports
+ try {
+ await Promise.all(readdirSync(componentsPath).map(async (dir) => {
+ try {
+ const filePath = await resolvePath(join(componentsPath, dir, 'index'), { extensions: ['.ts', '.js'] })
+ const content = readFileSync(filePath, { encoding: 'utf8' })
+ const ast = parseSync(filePath, content, {
+ sourceType: 'module',
+ })
+
+ const exportedKeys: string[] = ast.program.body
+ .filter(node => node.type === 'ExportNamedDeclaration')
+ // @ts-expect-error parse return any
+ .flatMap(node => node.specifiers?.map(specifier => specifier.exported?.name) || [])
+ .filter((key: string) => /^[A-Z]/.test(key))
+
+ exportedKeys.forEach((key) => {
+ addComponent({
+ name: `${prefix}${key}`, // name of the component to be used in vue templates
+ export: key, // (optional) if the component is a named (rather than default) export
+ filePath: resolve(filePath),
+ priority: 1,
+ })
+ })
+ }
+ catch (err) {
+ if (err instanceof Error)
+ console.warn('Module error: ', err.message)
+ }
+ }))
+ }
+ catch (err) {
+ if (err instanceof Error)
+ console.warn(err.message)
+ }
+ },
+})
\ No newline at end of file
diff --git a/packages/layer/nuxt.config.ts b/packages/layer/nuxt.config.ts
index 009467ab..fb4fd49e 100644
--- a/packages/layer/nuxt.config.ts
+++ b/packages/layer/nuxt.config.ts
@@ -1,29 +1,44 @@
+import { addComponentsDir, createResolver } from '@nuxt/kit'
import tailwindcss from '@tailwindcss/vite'
-import { createResolver } from '@nuxt/kit'
const { resolve } = createResolver(import.meta.url)
export default defineNuxtConfig({
+ hooks: {
+ 'components:dirs': (dirs) => {
+ // Register app components from the layer directory
+ dirs.push({
+ path: resolve('./app/components/app'),
+ pathPrefix: false,
+ global: true,
+ })
+ // Register docs components from the layer directory
+ dirs.push({
+ path: resolve('./app/components/docs'),
+ pathPrefix: false,
+ global: true,
+ })
+ // Register content components from the layer directory
+ dirs.push({
+ path: resolve('./app/components/content'),
+ pathPrefix: false,
+ global: true,
+ })
+ },
+ },
modules: [
resolve('./modules/config'),
resolve('./modules/css'),
+ resolve('./modules/shadcn'),
'@nuxtjs/color-mode',
'@nuxt/content',
'@nuxt/image',
'nuxt-shiki',
'nuxt-og-image',
+ '@nuxt/eslint',
],
-
devtools: { enabled: true },
-
- components: [
- { path: resolve('./app/components'), ignore: ['ui/**/*', 'content/**/*'] },
- // UI components are NOT globally registered - import explicitly from ~/components/ui/*
- { path: resolve('./app/components/content'), global: true, pathPrefix: false },
- ],
-
css: [resolve('./app/assets/css/main.css')],
-
content: {
build: {
markdown: {
@@ -31,7 +46,6 @@ export default defineNuxtConfig({
},
},
},
-
shiki: {
defaultTheme: {
light: 'github-light-default',
@@ -53,17 +67,14 @@ export default defineNuxtConfig({
'mdc',
],
},
-
colorMode: {
classSuffix: '',
preference: 'system',
fallback: 'light',
},
-
vite: {
plugins: [tailwindcss()],
},
-
nitro: {
prerender: {
crawlLinks: true,
@@ -71,12 +82,17 @@ export default defineNuxtConfig({
autoSubfolderIndex: false,
},
},
- compatibilityDate: '2025-01-01',
- ogImage: {
- fonts: [
- 'Geist:400',
- 'Geist:500',
- 'Geist:600',
- ],
+ eslint: {
+ config: {
+ standalone: false,
},
+ },
+ compatibilityDate: '2025-01-01',
+ ogImage: {
+ fonts: [
+ 'Geist:400',
+ 'Geist:500',
+ 'Geist:600',
+ ],
+ },
})
diff --git a/packages/layer/package.json b/packages/layer/package.json
index 764fba55..5c6180d0 100644
--- a/packages/layer/package.json
+++ b/packages/layer/package.json
@@ -1,24 +1,24 @@
{
"name": "@pleaseai/docs",
+ "type": "module",
"version": "0.1.0",
"description": "Nuxt layer for documentation sites using shadcn-vue",
- "type": "module",
- "main": "./nuxt.config.ts",
+ "license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/pleaseai/docs.git"
},
- "license": "MIT",
+ "main": "./nuxt.config.ts",
"files": [
+ "README.md",
"app",
+ "content.config.ts",
"i18n",
"lib",
"modules",
- "server",
- "content.config.ts",
"nuxt.config.ts",
"nuxt.schema.ts",
- "README.md"
+ "server"
],
"scripts": {
"dev": "nuxt dev",
@@ -26,30 +26,41 @@
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
- "typecheck": "vue-tsc -b"
+ "typecheck": "vue-tsc -b",
+ "lint": "eslint .",
+ "lint:fix": "eslint . --fix"
+ },
+ "peerDependencies": {
+ "nuxt": "^4.0.0"
},
"dependencies": {
"@iconify-json/lucide": "^1.2.75",
"@nuxt/content": "^3.8.2",
+ "@nuxt/eslint": "^1.11.0",
"@nuxt/image": "^2.0.0",
"@nuxt/kit": "^4.2.1",
"@nuxtjs/color-mode": "^4.0.0",
"@nuxtjs/mdc": "^0.18.4",
+ "@tabler/icons-vue": "^3.35.0",
"@vueuse/core": "^14.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"debug": "^4.4.1",
"defu": "^6.1.4",
- "lucide-vue-next": "^0.553.0",
+ "eslint": "^9.39.1",
+ "exsolve": "^1.0.8",
+ "lucide-vue-next": "^0.555.0",
"nuxt-og-image": "^5.1.12",
"nuxt-shiki": "^0.3.1",
+ "oxc-parser": "^0.99.0",
"parse5": "^7.3.0",
"rehype-raw": "^7.0.0",
- "reka-ui": "^2.6.0",
+ "reka-ui": "^2.6.1",
"remark-emoji": "^5.0.1",
"remark-gfm": "^4.0.1",
"remark-mdc": "^3.5.1",
"remark-rehype": "^11.1.2",
+ "shadcn-nuxt": "2.3.3",
"slugify": "^1.6.6",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
@@ -58,12 +69,11 @@
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0"
},
- "peerDependencies": {
- "nuxt": "^4.0.0"
- },
"devDependencies": {
+ "@antfu/eslint-config": "^6.2.0",
"@tailwindcss/vite": "^4.1.17",
"better-sqlite3": "^11.9.1",
+ "typescript": "^5.9.3",
"vue-tsc": "^2.0.0"
}
}
diff --git a/packages/layer/utils/git.ts b/packages/layer/utils/git.ts
index 4ba41831..f04f4c31 100644
--- a/packages/layer/utils/git.ts
+++ b/packages/layer/utils/git.ts
@@ -1,4 +1,5 @@
import { execSync } from 'node:child_process'
+import process from 'node:process'
export interface GitInfo {
name: string
@@ -6,7 +7,7 @@ export interface GitInfo {
url: string
}
-export function getGitBranch() {
+export function getGitBranch(): string {
const envName
= process.env.CF_PAGES_BRANCH
|| process.env.CI_COMMIT_BRANCH
diff --git a/packages/layer/utils/meta.ts b/packages/layer/utils/meta.ts
index d46a8550..e0f772e3 100644
--- a/packages/layer/utils/meta.ts
+++ b/packages/layer/utils/meta.ts
@@ -1,7 +1,8 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
+import process from 'node:process'
-export function inferSiteURL() {
+export function inferSiteURL(): string | undefined {
return (
process.env.NUXT_SITE_URL
|| (process.env.NEXT_PUBLIC_VERCEL_URL && `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`)
@@ -11,7 +12,7 @@ export function inferSiteURL() {
)
}
-export async function getPackageJsonMetadata(dir: string) {
+export async function getPackageJsonMetadata(dir: string): Promise<{ name: string, description?: string }> {
try {
const packageJson = await readFile(resolve(dir, 'package.json'), 'utf-8')
const parsed = JSON.parse(packageJson)