diff --git a/README.md b/README.md index 6187d273..a9e66c85 100644 --- a/README.md +++ b/README.md @@ -45,19 +45,14 @@ Configure Compodium in your Nuxt project by customizing the settings in your `nu ```ts export default defineNuxtConfig({ compodium: { - /* Customize your component collections */ - collections: [ - { name: 'Components', path: 'components/' } - ], + /* Customize compodium's base directory */ + dir: 'compodium/', - /* Whether to include default collections for third-party libraries. */ - includeDefaultCollections: true, - - /* Customize the directory for preview examples */ - examples: 'compodium/', + /* List of glob patterns to exclude components */ + exclude: [], - /* Customize the preview component path. */ - previewComponent: 'compodium/preview.vue', + /* Whether to include default collections for third-party libraries. */ + includeLibraryCollections: true, extras: { ui: { @@ -110,37 +105,13 @@ body { > [!NOTE] > Compodium renders outside of your `app.vue` component. This is useful if you need to inject CSS or provide parent components. -### Collections - -You can control which components are added to the preview using the `collections` configuration. All components inside the specified `path` of your collection will be scanned and added to it. - -```ts -{ - name: 'Components', - path: 'components', - /* Add a prefix to all components, similar to Nuxt's components.prefix option */ - prefix: '', - /* Glob patterns for components to ignore */ - ignore: [], - /* If true, the path will be resolved from your node_modules/ folder */ - external: false, - /* Specify a function to generate the components' documentation URL. This will display a button to go to the component's documentation in the preview if specified */ - getDocUrl?: (componentName: string) => string | null -} -``` - -By default, Compodium will detect the UI libraries you have installed and automatically add a collection with examples for all components. You can disable this by setting `includeDefaultCollections` to `false`. - -> [!NOTE] -> Currently, this feature supports Nuxt UI. More libraries will be added soon! - ### Component Examples -You can provide examples for your components in the `compodium/examples` folder. Examples will be matched to components based on the filename. Each example must be named after its corresponding component, followed by the `Example` keyword and an optional label. +You can provide examples for your components in the `compodium/examples/` folder. Examples will be matched to components based on the filename. Each example must be named after its corresponding component, followed by the `Example` keyword and an optional label. ```bash compodium -└── components # The collection's name +└── examples ├── BaseInputExampleDisabled.vue # Will be added to the BaseInput component. ├── BaseButtonExample.vue # Will be the main example for the BaseButton component. └── BaseButtonExampleWithLabel.vue # Will be added to the BaseButton component. diff --git a/playground/compodium/components/CompodiumWelcomeExample.vue b/playground/compodium/examples/CompodiumWelcomeExample.vue similarity index 100% rename from playground/compodium/components/CompodiumWelcomeExample.vue rename to playground/compodium/examples/CompodiumWelcomeExample.vue diff --git a/playground/compodium/components/CompodiumWelcomeExampleWithShake.vue b/playground/compodium/examples/CompodiumWelcomeExampleWithShake.vue similarity index 100% rename from playground/compodium/components/CompodiumWelcomeExampleWithShake.vue rename to playground/compodium/examples/CompodiumWelcomeExampleWithShake.vue diff --git a/playground/compodium/components/CompodiumWelcomeExampleWithSpin.vue b/playground/compodium/examples/CompodiumWelcomeExampleWithSpin.vue similarity index 100% rename from playground/compodium/components/CompodiumWelcomeExampleWithSpin.vue rename to playground/compodium/examples/CompodiumWelcomeExampleWithSpin.vue diff --git a/playground/components/InputProps.vue b/playground/components/InputProps.vue deleted file mode 100644 index 4eb0ed7d..00000000 --- a/playground/components/InputProps.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - diff --git a/playground/components/SupportedProps.vue b/playground/components/SupportedProps.vue new file mode 100644 index 00000000..be110040 --- /dev/null +++ b/playground/components/SupportedProps.vue @@ -0,0 +1,52 @@ + + + diff --git a/src/module.ts b/src/module.ts index a888f74f..97aeedba 100644 --- a/src/module.ts +++ b/src/module.ts @@ -14,21 +14,19 @@ import { watch } from 'chokidar' import { compodiumVite } from './vite' import { colors } from 'consola/utils' import { version } from '../package.json' -import { joinURL } from 'ufo' +import { joinURL, withTrailingSlash } from 'ufo' import { getLibraryCollections } from './runtime/libs' +import micromatch from 'micromatch' export interface ModuleOptions { - /* Customize your component collections */ - collections?: CollectionConfig[] - /* Whether to include default collections for third-party libraries. */ - includeDefaultCollections: boolean + includeLibraryCollections: boolean - /* Customize the directory for preview examples. Defaults to 'compodium/' */ - examples: string + /* Customize compodium's base directory. Defaults to 'compodium/' */ + dir: string - /* Customize the preview component path. Defaults to compodium/preview.vue */ - previewComponent: string + /* List of glob patterns to exclude components */ + exclude: string[] extras: { ui: { @@ -44,9 +42,8 @@ export default defineNuxtModule({ configKey: 'compodium' }, defaults: { - previewComponent: 'compodium/preview.vue', - examples: 'compodium/', - includeDefaultCollections: true, + dir: 'compodium/', + includeLibraryCollections: true, extras: { ui: { matchColors: true @@ -63,17 +60,13 @@ export default defineNuxtModule({ if (!nuxt.options.dev) return - addComponentsDir({ path: resolve('./runtime/components') }) + const compodiumComponentsDir = resolve('./runtime/components') + addComponentsDir({ path: compodiumComponentsDir, priority: -1 }) const appResolver = createResolver(nuxt.options.rootDir) + const libraryCollections = options.includeLibraryCollections ? await getLibraryCollections(nuxt.options, appResolver) : [] - options.collections ??= [ - { name: 'Components', path: nuxt.options.future?.compatibilityVersion === 4 ? 'app/components/' : 'components/' } - ] - - const libraryCollections = options.includeDefaultCollections ? await getLibraryCollections(nuxt.options, appResolver) : [] - - let previewComponent = appResolver.resolve(options.previewComponent) + let previewComponent = appResolver.resolve(joinURL(options.dir, 'preview.vue')) if (!existsSync(previewComponent)) { previewComponent = resolve('./runtime/preview.vue') } @@ -83,13 +76,13 @@ export default defineNuxtModule({ getContents: () => `export { default } from '${previewComponent}'` }) - nuxt.options.appConfig.compodium = { - collections: options.collections.map(c => ({ ...c, id: c.id ?? camelCase(c.name), path: appResolver.resolve(c.path) })).concat(libraryCollections), - matchUIColors: options.extras?.ui?.matchColors - } - const appConfig = nuxt.options.appConfig - appConfig.compodium = defu(nuxt.options.appConfig.compodium as any, { defaultProps }) + + appConfig.compodium = defu(appConfig.compodium as any, { + matchUIColors: options.extras?.ui?.matchColors, + defaultProps, + componentsPath: join(nuxt.options.buildDir, '/compodium/components.json') + }) nuxt.hooks.hookOnce('app:resolve', (app) => { const rootComponent = app.rootComponent @@ -106,84 +99,134 @@ export default defineNuxtModule({ if (pages.length) pages.push({ path: '/__compodium__/renderer', file: resolve('./runtime/renderer-placeholder.vue') }) }) - const examplesDirs = [ - appResolver.resolve(options.examples), - ...options.collections.filter(c => c.examplesPath).map(c => appResolver.resolve(c.examplesPath as string)) - ] - - const libraryExampleDirs = libraryCollections.map(c => ({ path: resolve(c.examplePath), pattern: '**/*.{vue,ts,tsx}', prefix: c.prefix })) - - const exampleComponents = options.examples - ? (await scanComponents([...examplesDirs.map(examplesDir => ({ - path: examplesDir, - pattern: '**/*.{vue,ts,tsx}' - })), ...libraryExampleDirs], nuxt.options.rootDir)).map(c => ({ ...c, isExample: true })) - : [] - - // Watch for changes in example directory - const examplesWatcher = watch([...examplesDirs, ...libraryExampleDirs.map(e => e.path)], { - persistent: true, - awaitWriteFinish: { - stabilityThreshold: 200, - pollInterval: 100 + // This file will be read directly server side. This is a hack after realising that virtual module didn't work with HMR server side. + nuxt.options.nitro.virtual = nuxt.options.nitro.virtual || {} + nuxt.options.nitro.virtual['#compodium/nitro/dirs'] = () => { + return readFileSync(join(nuxt.options.buildDir, '/compodium/dirs.mjs'), 'utf-8') + } + nuxt.options.nitro.virtual['#compodium/nitro/collections'] = () => { + return readFileSync(join(nuxt.options.buildDir, '/compodium/collections.mjs'), 'utf-8') + } + + nuxt.hooks.hookOnce('components:dirs', async (dirs) => { + const collections = dirs.map((dir) => { + const path = typeof dir === 'string' ? dir : dir.path + return { + ...typeof dir === 'string' ? {} : dir, + path, + name: 'Components', + id: 'components' + } + }).filter(collection => !micromatch.isMatch( + withTrailingSlash(collection.path), + [compodiumComponentsDir, 'node_modules/**'], + { contains: true }) + ) + + const examplesDir = { + path: appResolver.resolve(joinURL(options.dir, 'examples/')), + pattern: '**/*.{vue,ts,tsx}' } - }) - // FIXME: This might cause a race condition with the vite plugin. - examplesWatcher.on('add', async (path) => { - const comps = await scanComponents(examplesDirs.map(examplesDir => ({ path: examplesDir, pattern: '**/*.{vue,ts,tsx}' })), nuxt.options.rootDir) - const newExample = comps.find(c => c.filePath === path) - if (newExample) { - exampleComponents.push({ ...newExample, isExample: true }) + const libraryExampleDirs = libraryCollections.map(c => ({ + path: resolve(c.examplePath), + pattern: '**/*.{vue,ts,tsx}', + prefix: c.prefix + })) + + // @ts-expect-error type not resolved + appConfig.compodium.collections = collections.concat(libraryCollections) + + const exampleComponents = (await scanComponents([ + examplesDir, + ...libraryExampleDirs + ], nuxt.options.rootDir) ?? []).map(c => ({ ...c, isExample: true })) + + // Watch for changes in example directory + const examplesWatcher = watch([examplesDir, ...libraryExampleDirs].map(e => e.path), { + persistent: true, + awaitWriteFinish: { + stabilityThreshold: 200, + pollInterval: 100 + } + }) + + examplesWatcher.on('add', async (path) => { + const comps = await scanComponents([examplesDir], nuxt.options.rootDir) + const newExample = comps.find(c => c.filePath === path) + if (newExample) { + exampleComponents.push({ ...newExample, isExample: true }) + await updateTemplates({ + filter: template => template.filename === 'compodium/components.json' + }) + } + }) + + examplesWatcher.on('unlink', async (path) => { + const index = exampleComponents.findIndex(c => c.filePath === path) + if (index !== -1) exampleComponents.splice(index, 1) await updateTemplates({ filter: template => template.filename === 'compodium/components.json' }) - } - }) - - examplesWatcher.on('unlink', async (path) => { - const index = exampleComponents.findIndex(c => c.filePath === path) - if (index !== -1) exampleComponents.splice(index, 1) - await updateTemplates({ - filter: template => template.filename === 'compodium/components.json' }) - }) - addTemplate({ - filename: 'compodium/components.json', - write: true, - getContents: ({ nuxt, app }) => { - const collections = (nuxt.options.appConfig.compodium as any).collections - const components = [...app.components, ...exampleComponents] - return JSON.stringify(components.reduce((acc, component) => { - const collection = getComponentCollection(component, collections) - - const componentId = camelCase(component.kebabName) - const baseName = collection?.prefix - ? component.pascalName.replace(new RegExp(`^${pascalCase(collection?.prefix)}`), '') - : component.pascalName - - acc[componentId] = { - ...component, - baseName, - componentId, - collectionId: collection?.id, - docUrl: collection?.getDocUrl?.(component.pascalName) - } - return acc - }, {} as Record), null, 2) - } - }) + addTemplate({ + filename: 'compodium/components.json', + write: true, + getContents: ({ app }) => { + const collections = (appConfig.compodium as any).collections + const components = [...app.components, ...exampleComponents].reduce((acc, component) => { + if (options?.exclude?.length && micromatch.isMatch(component.filePath, options.exclude, { contains: true })) { + return acc + } + + const collection = getComponentCollection(component, collections) + const componentId = camelCase(component.kebabName) + const baseName = collection?.prefix + ? component.pascalName.replace(new RegExp(`^${pascalCase(collection?.prefix)}`), '') + : component.pascalName + + acc[componentId] = { + ...component, + baseName, + componentId, + collectionId: collection?.id, + docUrl: collection?.getDocUrl?.(component.pascalName) + } + return acc + }, {} as Record) + + return JSON.stringify(components, null, 2) + } + }) - nuxt.hooks.hookOnce('components:dirs', (dirs) => { addTemplate({ filename: 'compodium/dirs.mjs', write: true, getContents: () => { - return `export default ${JSON.stringify([...dirs, ...examplesDirs, ...libraryExampleDirs])}` + return `export default ${JSON.stringify([...dirs, examplesDir, ...libraryExampleDirs])}` } }) - addVitePlugin(compodiumVite({ dirs: [...dirs, ...examplesDirs, ...libraryExampleDirs] })) + + addTemplate({ + filename: 'compodium/collections.mjs', + write: true, + getContents: () => { + return `export default ${JSON.stringify(collections.concat(libraryCollections))}` + } + }) + + // This file will be read directly server side. This is a hack after realising that virtual module didn't work with HMR server side. + nuxt.options.nitro.virtual!['#compodium/nitro/dirs'] = () => { + return readFileSync(join(nuxt.options.buildDir, '/compodium/dirs.mjs'), 'utf-8') + } + nuxt.options.nitro.virtual!['#compodium/nitro/collections'] = () => { + return readFileSync(join(nuxt.options.buildDir, '/compodium/collections.mjs'), 'utf-8') + } + + addVitePlugin(compodiumVite({ + dirs: [...dirs, examplesDir, ...libraryExampleDirs] + })) }) if (process.env.COMPODIUM_LOCAL === 'true') { @@ -225,14 +268,6 @@ export default defineNuxtModule({ }) } - // This file will be read directly server side. This is a hack after realising that virtual module didn't work with HMR server side. - nuxt.options.nitro.virtual = nuxt.options.nitro.virtual || {} - nuxt.options.nitro.virtual['#compodium/nitro/dirs'] = () => { - return readFileSync(join(nuxt.options.buildDir, '/compodium/dirs.mjs'), 'utf-8') - } - - (appConfig.compodium as any).componentsPath = join(nuxt.options.buildDir, '/compodium/components.json') - addServerHandler({ method: 'get', route: '/__compodium__/api/collections', diff --git a/src/runtime/server/api/collections.get.ts b/src/runtime/server/api/collections.get.ts index f7c8ba02..1563463b 100644 --- a/src/runtime/server/api/collections.get.ts +++ b/src/runtime/server/api/collections.get.ts @@ -2,12 +2,12 @@ import fs from 'node:fs/promises' import { defineEventHandler } from 'h3' import type { Component, ComponentCollection, Collection, ComponentExample } from '../../../types' import { useAppConfig } from '#imports' -import { pascalCase } from 'scule' import { getComponentCollection } from '../../utils' export default defineEventHandler(async () => { - const config = useAppConfig().compodium as any - const collections = config.collections as Collection[] + const config = (useAppConfig().compodium) as any + // @ts-expect-error virtual file + const collections: Collection[] = await import('#compodium/nitro/collections').then(i => i.default) const componentsRaw = await fs.readFile(config.componentsPath, 'utf-8') const components = Object.values(JSON.parse(componentsRaw)) as (Component | ComponentExample)[] @@ -17,10 +17,8 @@ export default defineEventHandler(async () => { const collection = getComponentCollection(component, collections) if (!collection || component.isExample) return acc - const collectionPrefix = collection.external ? '' : pascalCase(collection.name) - - const componentExamples = examples?.filter(e => e.pascalName.match(`${collectionPrefix}${component.pascalName}Example`)) - const mainExample = componentExamples.find(e => e.pascalName === `${collectionPrefix}${component.pascalName}Example`) + const componentExamples = examples?.filter(e => e.pascalName.startsWith(`${component.pascalName}Example`)) + const mainExample = componentExamples.find(e => e.pascalName === `${component.pascalName}Example`) acc[collection.id] ??= { ...collection, components: {} } acc[collection.id].components[component.componentId] = { @@ -30,7 +28,7 @@ export default defineEventHandler(async () => { componentId: component.componentId, collectionId: collection.id, examples: componentExamples.filter(e => e.pascalName !== mainExample?.pascalName).map(e => ({ - name: e.pascalName.replace(`${collectionPrefix}${component.pascalName}Example`, ''), + name: e.pascalName.replace(`${component.pascalName}Example`, ''), ...e, collectionId: collection.id, componentId: component.componentId diff --git a/src/types.d.ts b/src/types.d.ts index cffdba46..73d7cc93 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -67,7 +67,6 @@ export type Collection = { icon?: string prefix?: string ignore?: string[] - examplesPath?: string } export type ComponentCollection = Collection & { diff --git a/src/utils.ts b/src/utils.ts index c96c29ac..7568d440 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -12,7 +12,7 @@ import micromatch from 'micromatch' export function getComponentCollection(component: Component, collections: T[]) { return collections.find((c: any) => { if (!c.external && component.filePath?.match('node_modules/')) return false - return micromatch.isMatch(component.filePath, [c.path], { ignore: c.ignore, contains: true }) + return micromatch.isMatch(component.filePath, [c.path], { contains: true }) }) } diff --git a/test/basic.test.ts b/test/basic.test.ts index cb50f248..25ac4859 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -41,13 +41,13 @@ describe('basic', async () => { const collections = await $fetch>('/__compodium__/api/collections') expect(collections.components.components.basicComponent.examples).toEqual([ expect.objectContaining({ - baseName: 'ComponentsBasicComponentExampleWithSuffix', + baseName: 'BasicComponentExampleWithSuffix', collectionId: 'components', componentId: 'basicComponent', - shortPath: 'compodium/components/BasicComponentExampleWithSuffix.vue', + shortPath: 'compodium/examples/BasicComponentExampleWithSuffix.vue', isExample: true, name: 'WithSuffix', - pascalName: 'ComponentsBasicComponentExampleWithSuffix' + pascalName: 'BasicComponentExampleWithSuffix' }) ]) }) @@ -55,17 +55,22 @@ describe('basic', async () => { it('overrides component with main example', async () => { const collections = await $fetch>('/__compodium__/api/collections') expect(collections.components.components.basicComponent).toEqual(expect.objectContaining({ - pascalName: 'ComponentsBasicComponentExample', - shortPath: 'compodium/components/BasicComponentExample.vue', + pascalName: 'BasicComponentExample', + shortPath: 'compodium/examples/BasicComponentExample.vue', collectionId: 'components', componentId: 'basicComponent' })) }) + + it('ignores excluded components', async () => { + const collections = await $fetch>('/__compodium__/api/collections') + expect(collections.components.components.excludedComponent).toBeUndefined() + }) }) describe('examples api', () => { it('works', async () => { - const example = await $fetch('/__compodium__/api/example/componentsBasicComponentExample') + const example = await $fetch('/__compodium__/api/example/basicComponentExample') expect(example).toMatchInlineSnapshot(` "