From 5e013857434527c3a4dec456bdecf05be01baa05 Mon Sep 17 00:00:00 2001 From: Romain Hamel Date: Wed, 15 Jul 2026 22:26:59 +0200 Subject: [PATCH] feat(nuxt): add layer support --- packages/core/src/plugins/collections.ts | 20 +++--- packages/core/src/plugins/devtools.ts | 8 ++- packages/core/src/plugins/examples.ts | 18 +++-- packages/core/src/plugins/meta/index.ts | 4 +- packages/core/src/plugins/utils.ts | 11 +++ packages/core/src/types.ts | 5 +- packages/examples/src/index.ts | 4 +- packages/nuxt/src/module.ts | 7 +- .../nuxt/test/fixtures/layers/app/app.vue | 3 + .../layers/app/components/RootComponent.vue | 3 + .../examples/DuplicateComponentExample.vue | 3 + .../examples/RootComponentExample.vue | 3 + .../app/components/DuplicateComponent.vue | 3 + .../layer/app/components/LayerComponent.vue | 3 + .../examples/DuplicateComponentExample.vue | 3 + .../examples/LayerComponentExample.vue | 3 + .../layers/layer/compodium/preview.vue | 5 ++ .../test/fixtures/layers/layer/nuxt.config.ts | 1 + .../nuxt/test/fixtures/layers/nuxt.config.ts | 4 ++ .../nuxt/test/fixtures/layers/package.json | 5 ++ packages/nuxt/test/layers.nuxt.test.ts | 68 +++++++++++++++++++ packages/vue/src/index.ts | 2 +- 22 files changed, 157 insertions(+), 29 deletions(-) create mode 100644 packages/nuxt/test/fixtures/layers/app/app.vue create mode 100644 packages/nuxt/test/fixtures/layers/app/components/RootComponent.vue create mode 100644 packages/nuxt/test/fixtures/layers/compodium/examples/DuplicateComponentExample.vue create mode 100644 packages/nuxt/test/fixtures/layers/compodium/examples/RootComponentExample.vue create mode 100644 packages/nuxt/test/fixtures/layers/layer/app/components/DuplicateComponent.vue create mode 100644 packages/nuxt/test/fixtures/layers/layer/app/components/LayerComponent.vue create mode 100644 packages/nuxt/test/fixtures/layers/layer/compodium/examples/DuplicateComponentExample.vue create mode 100644 packages/nuxt/test/fixtures/layers/layer/compodium/examples/LayerComponentExample.vue create mode 100644 packages/nuxt/test/fixtures/layers/layer/compodium/preview.vue create mode 100644 packages/nuxt/test/fixtures/layers/layer/nuxt.config.ts create mode 100644 packages/nuxt/test/fixtures/layers/nuxt.config.ts create mode 100644 packages/nuxt/test/fixtures/layers/package.json create mode 100644 packages/nuxt/test/layers.nuxt.test.ts diff --git a/packages/core/src/plugins/collections.ts b/packages/core/src/plugins/collections.ts index 51d0cc18..014ee304 100644 --- a/packages/core/src/plugins/collections.ts +++ b/packages/core/src/plugins/collections.ts @@ -11,11 +11,11 @@ import { fileURLToPath } from 'node:url' export function resolveCollections(options: PluginOptions, viteConfig: any): Collection[] { const rootDir = options.rootDir ?? viteConfig.root - - const exampleDir = { - path: joinURL(rootDir, options.dir, 'examples'), + const rootDirs = options._rootDirs ?? [rootDir] + const exampleDirs = rootDirs.map(root => ({ + path: joinURL(root, options.dir, 'examples'), pattern: '**/*.{vue,tsx}' - } + })) const componentDirs = options?.componentDirs.map((dir) => { const componentDir = typeof dir === 'string' ? { path: dir } : dir @@ -29,7 +29,7 @@ export function resolveCollections(options: PluginOptions, viteConfig: any): Col const componentCollection: Collection = { name: 'Components', - exampleDir, + exampleDirs, dirs: componentDirs } @@ -44,11 +44,11 @@ export function resolveCollections(options: PluginOptions, viteConfig: any): Col return [{ ...collection, - exampleDir: { - path: resolve(collection.exampleDir), + exampleDirs: collection.exampleDirs.map(exampleDir => ({ + path: resolve(exampleDir), pattern: '**/*.{vue,tsx}', prefix: collection.prefix - }, + })), dirs: [{ path: resolve(pkgPath, collection.path), pattern: '**/*.{vue,tsx}', @@ -82,7 +82,7 @@ export function collectionsPlugin(options: PluginOptions): VitePlugin { try { const result = await Promise.all(collections.map(async (col) => { const components = await scanComponents(col.dirs) - const examples = await scanComponents([col.exampleDir]) + const examples = await scanComponents(col.exampleDirs) const collectionComponents: Component[] = [] @@ -132,7 +132,7 @@ export function collectionsPlugin(options: PluginOptions): VitePlugin { const watchedPaths = [ ...componentCollection.dirs, - componentCollection.exampleDir + ...componentCollection.exampleDirs ].map(d => d.path) // Watch for changes in example directory diff --git a/packages/core/src/plugins/devtools.ts b/packages/core/src/plugins/devtools.ts index a600b486..ad51a2b3 100644 --- a/packages/core/src/plugins/devtools.ts +++ b/packages/core/src/plugins/devtools.ts @@ -8,14 +8,15 @@ import { joinURL } from 'ufo' import { resolvePathSync } from 'mlly' export function devtoolsPlugin(options: PluginOptions): VitePlugin { - let userPreview: string + let userPreviews: string[] return { name: 'compodium:devtools', apply: 'serve', configResolved(viteConfig) { - userPreview = resolve(joinURL(options.rootDir ?? viteConfig.root, options.dir, 'preview.vue')) + const rootDirs = options._rootDirs ?? [options.rootDir ?? viteConfig.root] + userPreviews = rootDirs.map(rootDir => resolve(joinURL(rootDir, options.dir, 'preview.vue'))) }, config(config) { if (process.env.COMPODIUM_DEVTOOLS_URL) { @@ -42,7 +43,8 @@ export function devtoolsPlugin(options: PluginOptions): VitePlugin { resolveId(id) { if (id === 'virtual:compodium:preview') { - if (existsSync(userPreview)) { + const userPreview = userPreviews.find(preview => existsSync(preview)) + if (userPreview) { return userPreview } return resolvePathSync('../runtime/preview.vue', { extensions: ['.vue'], url: import.meta.url }) diff --git a/packages/core/src/plugins/examples.ts b/packages/core/src/plugins/examples.ts index 4eac1e1a..f0a86768 100644 --- a/packages/core/src/plugins/examples.ts +++ b/packages/core/src/plugins/examples.ts @@ -2,6 +2,7 @@ import fs from 'node:fs/promises' import type { VitePlugin } from 'unplugin' import type { Collection, PluginOptions } from '../types' import { resolveCollections } from './collections' +import { getRealPath, isPathInside } from './utils' export function examplePlugin(options: PluginOptions): VitePlugin { let collections: Collection[] @@ -14,26 +15,29 @@ export function examplePlugin(options: PluginOptions): VitePlugin { collections = resolveCollections(options, viteConfig) }, - configureServer(server) { - const allowedPaths = collections.map(c => c.exampleDir.path) + async configureServer(server) { + const allowedRoots = await Promise.all( + collections.flatMap(c => c.exampleDirs.map(dir => getRealPath(dir.path))) + ) server.middlewares.use('/__compodium__/api/example', async (req, res) => { try { const url = new URL(req.url!, `http://${req.headers.host}`) - const path = url.searchParams.get('path') + const requestedPath = url.searchParams.get('path') - if (!path) { + if (!requestedPath) { res.statusCode = 400 res.end(JSON.stringify({ error: 'Example path is required' })) return } - if (!allowedPaths.find(p => path.startsWith(p))) { + const canonicalPath = await getRealPath(requestedPath) + if (!allowedRoots.some(root => isPathInside(canonicalPath, root))) { res.statusCode = 403 - res.end(JSON.stringify({ error: 'Forbidden', message: `${allowedPaths}\n ${path}` })) + res.end(JSON.stringify({ error: 'Forbidden' })) return } - const exampleCode = await fs.readFile(path) + const exampleCode = await fs.readFile(canonicalPath) let result = exampleCode.toString() .replace(/extendCompodiumMeta\s*\([\s\S]*?\)\s*;?/g, '') diff --git a/packages/core/src/plugins/meta/index.ts b/packages/core/src/plugins/meta/index.ts index 7f0e7168..e6e71969 100644 --- a/packages/core/src/plugins/meta/index.ts +++ b/packages/core/src/plugins/meta/index.ts @@ -41,7 +41,7 @@ export function metaPlugin(options: PluginOptions): VitePlugin { configureServer(server) { const checkerDirs = collections.flatMap(c => [ ...c.dirs, - c.exampleDir + ...c.exampleDirs ]) const checker = createChecker(checkerDirs, rootDir, options.tsconfigPath) @@ -76,7 +76,7 @@ export function metaPlugin(options: PluginOptions): VitePlugin { const watchedPaths = [ ...componentCollection.dirs, - componentCollection.exampleDir + ...componentCollection.exampleDirs ].map(d => d.path) // Watch for changes in example directory diff --git a/packages/core/src/plugins/utils.ts b/packages/core/src/plugins/utils.ts index 568c547d..7bb23d3a 100644 --- a/packages/core/src/plugins/utils.ts +++ b/packages/core/src/plugins/utils.ts @@ -1,3 +1,4 @@ +import { isAbsolute, resolve, sep } from 'node:path' import { basename, dirname, extname, join, relative } from 'pathe' import { glob } from 'tinyglobby' import { kebabCase, pascalCase, splitByCase } from 'scule' @@ -157,3 +158,13 @@ function warnAboutDuplicateComponent(componentName: string, filePath: string, du + `\n - ${duplicatePath}` ) } + +export async function getRealPath(path: string) { + const normalizedPath = resolve(path) + return realpath(normalizedPath).catch(() => normalizedPath) +} + +export function isPathInside(path: string, root: string) { + const relativePath = relative(root, path) + return relativePath === '' || (relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath)) +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index eb9b79f5..ffa9efb7 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -56,6 +56,9 @@ export type PluginOptions = { /* Internal */ _nuxt?: boolean + /* Internal: ordered application and inherited layer roots */ + _rootDirs?: string[] + /* Internal */ tsconfigPath?: string } @@ -147,7 +150,7 @@ export type Collection = { prefix?: string ignore?: string[] dirs: ComponentsDir[] - exampleDir: ComponentsDir + exampleDirs: ComponentsDir[] wrapperComponent?: string getDocUrl?: (componentName: string) => string } diff --git a/packages/examples/src/index.ts b/packages/examples/src/index.ts index 77b409e8..ad2ef7c2 100644 --- a/packages/examples/src/index.ts +++ b/packages/examples/src/index.ts @@ -8,7 +8,7 @@ export type LibraryCollection = { icon: string prefix?: string ignore?: string[] - exampleDir: string + exampleDirs: string[] wrapperComponent?: string path: string getDocUrl?: (componentName: string) => string @@ -23,7 +23,7 @@ export const libraryCollections = [ name: 'Nuxt UI', package: '@nuxt/ui', icon: 'lineicons:nuxt', - exampleDir: resolve('./examples/ui'), + exampleDirs: [resolve('./examples/ui')], path: './runtime/components', ignore: ['App.vue', 'Toast.vue', '*Provider.vue', '*Base.vue', '*Content.vue'], prefix: 'U', diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 4e24c7c7..cf36fd0c 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -8,7 +8,7 @@ import { defu } from 'defu' import { compodium } from '@compodium/core' import type { PluginOptions } from '@compodium/core' -export type ModuleOptions = Omit +export type ModuleOptions = Omit export default defineNuxtModule({ meta: { @@ -53,12 +53,13 @@ export default defineNuxtModule({ nuxt.hooks.hookOnce('components:dirs', async (dirs) => { addVitePlugin(compodium({ + ...options, componentDirs: dirs, rootDir: nuxt.options.rootDir, + _rootDirs: nuxt.options._layers.map(layer => layer.config.rootDir), tsconfigPath: resolvePath(nuxt.options.rootDir, nuxt.options.buildDir, 'tsconfig.app.json'), baseUrl: nuxt.options.app.baseURL, - _nuxt: true, - ...options + _nuxt: true }) as Parameters[0]) }) diff --git a/packages/nuxt/test/fixtures/layers/app/app.vue b/packages/nuxt/test/fixtures/layers/app/app.vue new file mode 100644 index 00000000..59d0167e --- /dev/null +++ b/packages/nuxt/test/fixtures/layers/app/app.vue @@ -0,0 +1,3 @@ + diff --git a/packages/nuxt/test/fixtures/layers/app/components/RootComponent.vue b/packages/nuxt/test/fixtures/layers/app/components/RootComponent.vue new file mode 100644 index 00000000..6d4e0514 --- /dev/null +++ b/packages/nuxt/test/fixtures/layers/app/components/RootComponent.vue @@ -0,0 +1,3 @@ + diff --git a/packages/nuxt/test/fixtures/layers/compodium/examples/DuplicateComponentExample.vue b/packages/nuxt/test/fixtures/layers/compodium/examples/DuplicateComponentExample.vue new file mode 100644 index 00000000..6b93f420 --- /dev/null +++ b/packages/nuxt/test/fixtures/layers/compodium/examples/DuplicateComponentExample.vue @@ -0,0 +1,3 @@ + diff --git a/packages/nuxt/test/fixtures/layers/compodium/examples/RootComponentExample.vue b/packages/nuxt/test/fixtures/layers/compodium/examples/RootComponentExample.vue new file mode 100644 index 00000000..9525a4b0 --- /dev/null +++ b/packages/nuxt/test/fixtures/layers/compodium/examples/RootComponentExample.vue @@ -0,0 +1,3 @@ + diff --git a/packages/nuxt/test/fixtures/layers/layer/app/components/DuplicateComponent.vue b/packages/nuxt/test/fixtures/layers/layer/app/components/DuplicateComponent.vue new file mode 100644 index 00000000..963709ef --- /dev/null +++ b/packages/nuxt/test/fixtures/layers/layer/app/components/DuplicateComponent.vue @@ -0,0 +1,3 @@ + diff --git a/packages/nuxt/test/fixtures/layers/layer/app/components/LayerComponent.vue b/packages/nuxt/test/fixtures/layers/layer/app/components/LayerComponent.vue new file mode 100644 index 00000000..6445d149 --- /dev/null +++ b/packages/nuxt/test/fixtures/layers/layer/app/components/LayerComponent.vue @@ -0,0 +1,3 @@ + diff --git a/packages/nuxt/test/fixtures/layers/layer/compodium/examples/DuplicateComponentExample.vue b/packages/nuxt/test/fixtures/layers/layer/compodium/examples/DuplicateComponentExample.vue new file mode 100644 index 00000000..5bdac4ad --- /dev/null +++ b/packages/nuxt/test/fixtures/layers/layer/compodium/examples/DuplicateComponentExample.vue @@ -0,0 +1,3 @@ + diff --git a/packages/nuxt/test/fixtures/layers/layer/compodium/examples/LayerComponentExample.vue b/packages/nuxt/test/fixtures/layers/layer/compodium/examples/LayerComponentExample.vue new file mode 100644 index 00000000..4ed0bf49 --- /dev/null +++ b/packages/nuxt/test/fixtures/layers/layer/compodium/examples/LayerComponentExample.vue @@ -0,0 +1,3 @@ + diff --git a/packages/nuxt/test/fixtures/layers/layer/compodium/preview.vue b/packages/nuxt/test/fixtures/layers/layer/compodium/preview.vue new file mode 100644 index 00000000..18ea25d9 --- /dev/null +++ b/packages/nuxt/test/fixtures/layers/layer/compodium/preview.vue @@ -0,0 +1,5 @@ + diff --git a/packages/nuxt/test/fixtures/layers/layer/nuxt.config.ts b/packages/nuxt/test/fixtures/layers/layer/nuxt.config.ts new file mode 100644 index 00000000..268da7f8 --- /dev/null +++ b/packages/nuxt/test/fixtures/layers/layer/nuxt.config.ts @@ -0,0 +1 @@ +export default defineNuxtConfig({}) diff --git a/packages/nuxt/test/fixtures/layers/nuxt.config.ts b/packages/nuxt/test/fixtures/layers/nuxt.config.ts new file mode 100644 index 00000000..9e8883b7 --- /dev/null +++ b/packages/nuxt/test/fixtures/layers/nuxt.config.ts @@ -0,0 +1,4 @@ +export default defineNuxtConfig({ + extends: ['./layer'], + modules: ['../../../src/module'] +}) diff --git a/packages/nuxt/test/fixtures/layers/package.json b/packages/nuxt/test/fixtures/layers/package.json new file mode 100644 index 00000000..a5b8f18f --- /dev/null +++ b/packages/nuxt/test/fixtures/layers/package.json @@ -0,0 +1,5 @@ +{ + "private": true, + "name": "layers", + "type": "module" +} diff --git a/packages/nuxt/test/layers.nuxt.test.ts b/packages/nuxt/test/layers.nuxt.test.ts new file mode 100644 index 00000000..4df02a46 --- /dev/null +++ b/packages/nuxt/test/layers.nuxt.test.ts @@ -0,0 +1,68 @@ +import { dirname, join } from 'pathe' +import { describe, expect, it } from 'vitest' +import { setup, $fetch } from '@nuxt/test-utils/e2e' +import type { ComponentCollection } from '@compodium/core' +import { fileURLToPath } from 'node:url' +import { joinURL } from 'ufo' + +describe('Nuxt layers', async () => { + const rootDir = fileURLToPath(joinURL(dirname(import.meta.url), './fixtures/layers')) + const layerRootDir = join(rootDir, 'layer') + + await setup({ + rootDir, + dev: true, + setupTimeout: 30000 + }) + + it('discovers root and inherited components and examples', async () => { + const collections = await $fetch('/__compodium__/api/collections') + + expect(collections).toContainComponent({ pascalName: 'RootComponentExample' }) + expect(collections).toContainComponent({ pascalName: 'LayerComponentExample' }) + }) + + it('returns example directories in root-first layer order', async () => { + const collections = await $fetch('/__compodium__/api/collections') + const applicationCollection = collections.find(collection => collection.name === 'Components') + + expect(applicationCollection?.exampleDirs).toEqual([ + { path: join(rootDir, 'compodium/examples'), pattern: '**/*.{vue,tsx}' }, + { path: join(layerRootDir, 'compodium/examples'), pattern: '**/*.{vue,tsx}' } + ]) + }) + + it('gives root examples precedence over inherited duplicates', async () => { + const collections = await $fetch('/__compodium__/api/collections') + const applicationCollection = collections.find(collection => collection.name === 'Components') + const duplicate = applicationCollection?.components.find(component => component.pascalName === 'DuplicateComponentExample') + + expect(duplicate?.filePath).toBe(join(rootDir, 'compodium/examples/DuplicateComponentExample.vue')) + }) + + it('serves inherited examples', async () => { + const example = await $fetch('/__compodium__/api/example', { + query: { + path: join(layerRootDir, 'compodium/examples/LayerComponentExample.vue') + } + }) + + expect(example).toContain('') + }) + + it.each([ + ['traversal', join(rootDir, 'compodium/examples/../../package.json')], + ['sibling-prefix', join(rootDir, 'compodium/examples-private/Example.vue')] + ])('rejects %s paths outside example directories', async (_, path) => { + await expect($fetch('/__compodium__/api/example', { query: { path } })).rejects.toMatchObject({ + statusCode: 403, + data: { error: 'Forbidden' } + }) + }) + + it('uses an inherited preview when the root has none', async () => { + const html = await $fetch('/__compodium__/renderer') + + expect(html).toContain('
>) => { +export const compodium = /* #__PURE__ */ (opts?: Partial>) => { const options = defu(opts, { dir: './compodium', includeLibraryCollections: true