diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c1e6a93..064966c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,4 +40,4 @@ jobs: run: pnpm test - name: Publish - run: pnpx pkg-pr-new publish --no-template --pnpm './packages/nuxt' './packages/meta' + run: pnpx pkg-pr-new publish --no-template --pnpm './packages/*' diff --git a/.npmrc b/.npmrc index cf040424..5342d728 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,3 @@ +filter=@compodium/* shamefully-hoist=true strict-peer-dependencies=false diff --git a/build.config.ts b/build.config.ts new file mode 100644 index 00000000..0f23b5ab --- /dev/null +++ b/build.config.ts @@ -0,0 +1,13 @@ +import type { BuildConfig } from 'unbuild' + +export default { + clean: true, + rollup: { + esbuild: { + target: 'esnext' + }, + emitCJS: true, + cjsBridge: true + }, + declaration: true +} satisfies BuildConfig diff --git a/package.json b/package.json index fa657cb0..f2ca87df 100644 --- a/package.json +++ b/package.json @@ -6,23 +6,32 @@ "license": "MIT", "type": "module", "scripts": { - "dev:prepare": "pnpm -r dev:prepare", - "dev": "COMPODIUM_LOCAL=true nuxi dev playground", + "dev:prepare": "pnpm -r --color dev:prepare", + "dev": "COMPODIUM_DEVTOOLS_URL=http://localhost:4242 pnpm --color --parallel -r dev", + "dev:nuxt": "COMPODIUM_DEVTOOLS_URL=http://localhost:4242 pnpm --color --parallel --filter '!@compodium/playground-vue' dev", + "dev:vue": "COMPODIUM_DEVTOOLS_URL=http://localhost:4242 pnpm --color --parallel --filter '!@compodium/playground-nuxt' dev", "lint": "eslint .", - "typecheck": "pnpm -r typecheck", - "test": "pnpm -r test", + "typecheck": "pnpm -r --color typecheck", + "test": "pnpm -r --color test", "bump": "jiti ./scripts/bump.ts" }, "devDependencies": { "@nuxt/eslint-config": "^1.2.0", + "@nuxt/test-utils": "^3.17.2", + "@types/node": "^22.13.1", "changelogen": "^0.6.1", "eslint": "^9.22.0", "typescript": "5.6.3", + "unbuild": "^3.5.0", + "vite": "^6.1.0", "vitest": "^3.0.8" }, "resolutions": { + "rollup": "4.35.0", "typescript": "5.6.3", - "vue-tsc": "2.2.0" + "vue-tsc": "2.2.0", + "nuxt": "3.15.4", + "@nuxt/ui": "3.0.0-beta.3" }, "packageManager": "pnpm@10.6.2" } diff --git a/packages/core/.gitignore b/packages/core/.gitignore new file mode 100644 index 00000000..29669c1b --- /dev/null +++ b/packages/core/.gitignore @@ -0,0 +1,81 @@ +# Created by .ignore support plugin (hsz.mobi) +### Node template +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# Nuxt generate +dist + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless + +# IDE +.idea diff --git a/packages/core/.vscode/settings.json b/packages/core/.vscode/settings.json new file mode 100644 index 00000000..eb8204b6 --- /dev/null +++ b/packages/core/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "eslint.experimental.useFlatConfig": true +} diff --git a/packages/core/build.config.ts b/packages/core/build.config.ts new file mode 100644 index 00000000..ef6fe41e --- /dev/null +++ b/packages/core/build.config.ts @@ -0,0 +1,11 @@ +import { defineBuildConfig } from 'unbuild' +import config from '../../build.config' + +export default defineBuildConfig({ + ...config, + entries: [ + 'src/index', + { builder: 'copy', input: './src/runtime', outDir: './dist/runtime' }, + { builder: 'copy', input: '../../', pattern: 'LICENSE.md|README.md' } + ] +}) diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..c636cd8f --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,60 @@ +{ + "name": "@compodium/core", + "type": "module", + "version": "0.1.0-beta.5", + "description": "A plug and play component playground for Vue and Nuxt.", + "license": "MIT", + "repository": { + "url": "romhml/compodium", + "directory": "packages/core" + }, + "keywords": [ + "nuxt", + "components", + "documentation" + ], + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "./*": "./dist/*" + }, + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "dev:prepare": "unbuild", + "dev": "unbuild --watch", + "prepack": "unbuild && pnpm --filter devtools generate", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "@nuxt/schema": ">=3", + "vite": ">=3", + "vue": ">=3" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + }, + "dependencies": { + "@compodium/examples": "workspace:^", + "@compodium/meta": "workspace:^", + "@vueuse/core": "^12.8.2", + "chokidar": "^4.0.3", + "hookable": "^5.5.3", + "pathe": "^2.0.3", + "scule": "^1.3.0", + "sirv": "^3.0.1", + "tinyglobby": "^0.2.12", + "ufo": "^1.5.4", + "unplugin": "^2.2.0", + "unplugin-ast": "^0.14.3", + "zod": "^3.24.2" + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 00000000..7539c71b --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,79 @@ +import { joinURL } from 'ufo' +import AST from 'unplugin-ast/vite' +import { RemoveWrapperFunction } from 'unplugin-ast/transformers' + +import { libraryCollections as libraryCollectionsConfig } from '@compodium/examples' +import { collectionsPlugin } from './plugins/collections' +import { metaPlugin } from './plugins/meta' +import { examplePlugin } from './plugins/examples' +import { devtoolsPlugin } from './plugins/devtools' +import { colorsPlugin } from './plugins/colors' +import { iconifyPlugin } from './plugins/iconify' + +import type { Collection, PluginConfig, PluginOptions } from './types' + +export * from './types' + +export const compodium = /* #__PURE__ */ (options: PluginOptions) => { + const exampleDir = { + path: joinURL(options.rootDir, options.dir, 'examples'), + pattern: '**/*.{vue,tsx}' + } + + const componentDirs = options?.componentDirs.map((dir) => { + const componentDir = typeof dir === 'string' ? { path: dir } : dir + return { + pattern: '**/*.{vue,tsx}', + ...componentDir, + ignore: (componentDir.ignore ?? []).concat(options.ignore ?? []) + } + }).filter(collection => !collection.path?.includes('node_modules/')) + + const componentCollection: Collection = { + name: 'Components', + exampleDir, + dirs: componentDirs + } + + const libraryCollections = options.includeLibraryCollections + ? options.componentDirs.map((dir) => { + const path = typeof dir === 'string' ? dir : dir.path + const collection = libraryCollectionsConfig.find((c: any) => path.includes(`node_modules/${c.package}`)) + if (collection) { + return { + ...collection, + exampleDir: { + ...typeof dir === 'string' ? {} : dir, + path: collection.exampleDir, + pattern: '**/*.{vue,tsx}' + }, + dirs: [{ + ...typeof dir === 'string' ? {} : dir, + path, + pattern: '**/*.{vue,tsx}', + ignore: collection.ignore + }] + } + } + }).filter(c => !!c) + : [] + + const config: PluginConfig = { + ...options, + libraryCollections, + componentCollection + } + + return [ + collectionsPlugin(config), + metaPlugin(config), + devtoolsPlugin(config), + examplePlugin(config), + iconifyPlugin(config), + colorsPlugin(config), + AST({ + include: [/\.[jt]sx?$/, /\.vue$/], + transformer: [RemoveWrapperFunction(['extendCompodiumMeta'])] + }) + ] +} diff --git a/packages/core/src/plugins/collections.ts b/packages/core/src/plugins/collections.ts new file mode 100644 index 00000000..e00ac2cf --- /dev/null +++ b/packages/core/src/plugins/collections.ts @@ -0,0 +1,94 @@ +import type { PluginConfig } from '../types' +import { scanComponents } from './utils' +import { watch } from 'chokidar' +import type { VitePlugin } from 'unplugin' +import { resolve } from 'pathe' + +export function collectionsPlugin(config: PluginConfig): VitePlugin { + return { + name: 'compodium:collections', + configureServer(server) { + server.middlewares.use('/__compodium__/api/collections', async (_, res) => { + try { + const collections = await Promise.all([config.componentCollection, ...config.libraryCollections].map(async (col) => { + const components = await scanComponents(col.dirs, config.rootDir) + const examples = await scanComponents([col.exampleDir], config.rootDir) + + const collectionComponents = components.map((c) => { + const componentExamples = examples?.filter(e => e.pascalName.startsWith(`${c.pascalName}Example`)).map(e => ({ + ...e, + isExample: true, + componentPath: resolve(config.rootDir, c.filePath) + })) + + const mainExample = componentExamples.find(e => e.pascalName === `${c.pascalName}Example`) + const component = mainExample ?? c + + return { + ...component, + docUrl: col.getDocUrl?.(c.pascalName), + examples: componentExamples.filter(e => e.pascalName !== mainExample?.pascalName) + } + }) + + return { + ...col, + components: collectionComponents + } + })) + + res.setHeader('Content-Type', 'application/json') + res.write(JSON.stringify(collections)) + res.end() + } catch { + res.statusCode = 500 + res.end(JSON.stringify({ error: 'Failed to fetch collections' })) + } + }) + + const watchedPaths = [ + ...config.componentCollection.dirs, + config.componentCollection.exampleDir + ].map(d => resolve(config.rootDir, d.path)) + + // Watch for changes in example directory + const watcher = watch(watchedPaths, { + persistent: true, + awaitWriteFinish: { + stabilityThreshold: 200, + pollInterval: 100 + } + }) + + watcher.on('add', async (filePath: string) => { + if (watchedPaths.find(p => filePath.startsWith(p))) { + server.ws.send({ + type: 'custom', + event: 'compodium:hmr', + data: { path: filePath, event: 'component:added' } + }) + } + }) + + watcher.on('addDir', async (filePath: string) => { + if (watchedPaths.find(p => filePath.startsWith(p))) { + server.ws.send({ + type: 'custom', + event: 'compodium:hmr', + data: { path: filePath, event: 'component:added' } + }) + } + }) + + watcher.on('unlink', async (filePath: string) => { + if (watchedPaths.find(p => filePath.startsWith(p))) { + server.ws.send({ + type: 'custom', + event: 'compodium:hmr', + data: { path: filePath, event: 'component:removed' } + }) + } + }) + } + } +} diff --git a/packages/core/src/plugins/colors.ts b/packages/core/src/plugins/colors.ts new file mode 100644 index 00000000..a17282d5 --- /dev/null +++ b/packages/core/src/plugins/colors.ts @@ -0,0 +1,24 @@ +import type { VitePlugin } from 'unplugin' +import type { PluginConfig } from '../types' + +/** + * This plugin is responsible for getting the generated virtual templates and + * making them available to the Vue build. + */ +export function colorsPlugin(config: PluginConfig): VitePlugin { + return { + name: 'compodium:colors', + configureServer(server) { + server.middlewares.use('/__compodium__/api/colors', async (req, res) => { + try { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(config.extras?.colors)) + } catch (err) { + res.statusCode = 500 + res.end(JSON.stringify({ error: `Failed to fetch iconify API: ${err}` })) + } + }) + } + + } +} diff --git a/packages/core/src/plugins/devtools.ts b/packages/core/src/plugins/devtools.ts new file mode 100644 index 00000000..383fa07d --- /dev/null +++ b/packages/core/src/plugins/devtools.ts @@ -0,0 +1,55 @@ +import type { VitePlugin } from 'unplugin' +import sirv from 'sirv' +import type { PluginConfig } from '../types' +import { resolve, dirname } from 'pathe' +import { fileURLToPath } from 'node:url' +import { existsSync, readFileSync } from 'node:fs' +import { joinURL } from 'ufo' + +export function devtoolsPlugin(config: PluginConfig): VitePlugin { + const userPreview = resolve(joinURL(config.rootDir, config.dir, 'preview.vue')) + + return { + name: 'compodium:devtools', + config(config) { + if (process.env.COMPODIUM_DEVTOOLS_URL) { + config.server ||= {} + config.server.proxy ||= {} + config.server.proxy['/__compodium__/devtools'] = { + target: process.env.COMPODIUM_DEVTOOLS_URL, + ws: true, + rewriteWsOrigin: true, + changeOrigin: true, + followRedirects: true + } + } + }, + + configureServer(server) { + if (process.env.COMPODIUM_DEVTOOLS_URL) return + server.middlewares.use('/__compodium__/devtools', + sirv(resolve(dirname(fileURLToPath(import.meta.url)), './client/devtools'), + { single: true, setHeaders: res => res.setHeader('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400') } + ) + ) + }, + + resolveId(id) { + if (id === 'virtual:compodium:preview') { + if (existsSync(userPreview)) { + return userPreview + } + return '\0virtual:compodium:preview' + } + }, + + load(id) { + if (id === '\0virtual:compodium:preview') { + if (existsSync(userPreview)) { + return readFileSync(userPreview, 'utf-8') + } + return `export { default } from '${import.meta.resolve('@compodium/core/runtime/preview.vue')}'` + } + } + } +} diff --git a/packages/core/src/plugins/examples.ts b/packages/core/src/plugins/examples.ts new file mode 100644 index 00000000..96c1dd15 --- /dev/null +++ b/packages/core/src/plugins/examples.ts @@ -0,0 +1,54 @@ +import fs from 'node:fs/promises' +import type { VitePlugin } from 'unplugin' +import type { PluginConfig } from '../types' +import { resolve } from 'node:path' + +export function examplePlugin(config: PluginConfig): VitePlugin { + return { + name: 'compodium:examples', + + configureServer(server) { + const paths = [ + config.componentCollection.exampleDir, + ...config.libraryCollections.map(c => c.exampleDir) + ].map(d => resolve(config.rootDir, d.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') + + if (!path) { + res.statusCode = 400 + res.end(JSON.stringify({ error: 'Example path is required' })) + return + } + + if (!paths.find(p => path.startsWith(p))) { + res.statusCode = 403 + res.end(JSON.stringify({ error: 'Forbidden' })) + return + } + + const exampleCode = await fs.readFile(path) + + let result = exampleCode.toString() + .replace(/extendCompodiumMeta\s*\([\s\S]*?\)\s*;?/g, '') + + if (config._nuxt) { + result = result + .replace(/import .* from 'vue'/, '') + } + + result = result.replace(/]*>\s*<\/script>/g, '') + + res.setHeader('Content-Type', 'text/plain') + res.end(result) + } catch { + res.statusCode = 500 + res.end(JSON.stringify({ error: 'Failed to fetch example code' })) + } + }) + } + } +} diff --git a/packages/core/src/plugins/iconify.ts b/packages/core/src/plugins/iconify.ts new file mode 100644 index 00000000..497b00cf --- /dev/null +++ b/packages/core/src/plugins/iconify.ts @@ -0,0 +1,34 @@ +import type { VitePlugin } from 'unplugin' +import type { PluginConfig } from '../types' +import { joinURL } from 'ufo' + +export function iconifyPlugin(_config: PluginConfig): VitePlugin { + return { + name: 'compodium:iconify', + + configureServer(server) { + server.middlewares.use('/__compodium__/api/iconify', async (req, res) => { + try { + const url = new URL(req.url!, `http://${req.headers.host}`) + const q = decodeURIComponent(url.searchParams.get('q') ?? '') + + const resp = await fetch(joinURL('https://api.iconify.design', q as string)) + + if (!resp.ok) { + res.statusCode = resp.status + res.end(await resp.text()) + return + } + + const result = await resp.json() + res.setHeader('Content-Type', 'application/json') + res.setHeader('Cache-Control', 'public, max-age=604800') // 604800 seconds = 1 week + res.end(JSON.stringify(result)) + } catch (err) { + res.statusCode = 500 + res.end(JSON.stringify({ error: `Failed to fetch iconify API: ${err}` })) + } + }) + } + } +} diff --git a/packages/nuxt/src/runtime/server/services/checker.ts b/packages/core/src/plugins/meta/checker.ts similarity index 85% rename from packages/nuxt/src/runtime/server/services/checker.ts rename to packages/core/src/plugins/meta/checker.ts index a6736e38..6c8408ea 100644 --- a/packages/nuxt/src/runtime/server/services/checker.ts +++ b/packages/core/src/plugins/meta/checker.ts @@ -1,12 +1,8 @@ import { createCheckerByJson } from '@compodium/meta' -import type { ComponentMeta } from '../../../types' +import type { CompodiumMeta, ComponentsDir } from '../../types' +import { inferPropTypes } from './infer' -// @ts-expect-error virtual file -import dirs from '#compodium/nitro/dirs' - -let checker - -export function createChecker() { +export function createChecker(dirs: ComponentsDir[]) { const rootDir = process.cwd() const metaChecker = createCheckerByJson( rootDir, @@ -40,10 +36,14 @@ export function createChecker() { const checker = { ...metaChecker, - getComponentMeta: (componentPath: string): ComponentMeta => { + getComponentMeta: (componentPath: string): CompodiumMeta => { const meta = metaChecker.getComponentMeta(componentPath) return { - props: meta.props.filter((sch: any) => !sch.global).map((sch: any) => stripeTypeScriptInternalTypesSchema(sch, true)), + props: meta.props + .filter((sch: any) => !sch.global) + .map((sch: any) => stripeTypeScriptInternalTypesSchema(sch, true)) + .map(inferPropTypes), + compodium: meta.compodium // events: meta.events.map(sch => stripeTypeScriptInternalTypesSchema(sch, true)), // exposed: meta.exposed.map(sch => stripeTypeScriptInternalTypesSchema(sch, true)), @@ -54,10 +54,6 @@ export function createChecker() { return checker } -export function getChecker(): ReturnType { - return checker ??= createChecker() -} - export function stripeTypeScriptInternalTypesSchema(type: any, topLevel: boolean = true): any { if (!type) { return type diff --git a/packages/core/src/plugins/meta/index.ts b/packages/core/src/plugins/meta/index.ts new file mode 100644 index 00000000..e4e57bc9 --- /dev/null +++ b/packages/core/src/plugins/meta/index.ts @@ -0,0 +1,81 @@ +import { readFile } from 'node:fs/promises' +import type { PluginConfig } from '../../types' +import { createChecker } from './checker' +import { watch } from 'chokidar' +import type { VitePlugin } from 'unplugin' +import { resolve } from 'pathe' + +export function metaPlugin(config: PluginConfig): VitePlugin { + const checkerDirs = [ + ...config.componentCollection.dirs, + config.componentCollection.exampleDir, + ...config.libraryCollections.flatMap(c => c.dirs), + ...config.libraryCollections.map(c => c.exampleDir) + ] + + const checker = createChecker(checkerDirs) + + return { + name: 'compodium:meta', + + configureServer(server) { + server.middlewares.use('/__compodium__/api/meta', async (req, res) => { + try { + const url = new URL(req.url!, `http://${req.headers.host}`) + const componentPath = url.searchParams.get('component') + + if (!componentPath) { + res.statusCode = 400 + res.end(JSON.stringify({ error: 'Component path is required' })) + return + } + + const meta = checker.getComponentMeta(componentPath) + + if (!meta) { + res.statusCode = 404 + res.end(JSON.stringify({ error: 'Component not found' })) + return + } + + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(meta)) + } catch { + res.statusCode = 500 + res.end(JSON.stringify({ error: 'Failed to fetch metadata' })) + } + }) + + const watchedPaths = [ + ...config.componentCollection.dirs, + config.componentCollection.exampleDir + ].map(d => resolve(config.rootDir, d.path)) + + // Watch for changes in example directory + const watcher = watch(watchedPaths, { + persistent: true, + awaitWriteFinish: { + stabilityThreshold: 200, + pollInterval: 100 + } + }) + + watcher.on('add', async () => { + checker.reload() + }) + + watcher.on('change', async (filePath: string) => { + if (watchedPaths.find(p => filePath.startsWith(p))) { + const code = await readFile(filePath, 'utf-8') + checker.updateFile(filePath, code) + + server.ws.send({ + type: 'custom', + event: 'compodium:hmr', + data: { path: filePath, event: 'component:changed' } + }) + } + }) + } + } +} diff --git a/packages/nuxt/src/runtime/server/services/infer.ts b/packages/core/src/plugins/meta/infer.ts similarity index 90% rename from packages/nuxt/src/runtime/server/services/infer.ts rename to packages/core/src/plugins/meta/infer.ts index 0a0c934a..f11d93e1 100644 --- a/packages/nuxt/src/runtime/server/services/infer.ts +++ b/packages/core/src/plugins/meta/infer.ts @@ -1,6 +1,6 @@ -import type { PropertyMeta } from '@compodium/meta' +import type { PropertyMeta as VuePropertyMeta } from '@compodium/meta' import { type ZodSchema, z } from 'zod' -import type { PropSchema, PropInputType, PropertyType } from '../../../types' +import type { PropSchema, PropInputType, PropertyMeta } from '../../types' // Define a type for a resolver that includes an ID and a Zod schema. export type PropSchemaResolver = { @@ -112,7 +112,7 @@ const propResolvers: PropSchemaResolver[] = [ { inputType: 'array', schema: arrayInputSchema } ] -export function inferPropTypes(prop: PropertyMeta): PropertyType { +export function inferPropTypes(prop: VuePropertyMeta): PropertyMeta { const defaultValue = prop?.tags.find(tag => tag.name === 'defaultValue')?.text?.trim()?.replace(/^`|`$/g, '') if (prop.tags?.find(tag => tag.name === 'IconifyIcon')) return { ...prop, @@ -127,7 +127,7 @@ export function inferPropTypes(prop: PropertyMeta): PropertyType { } } -export function inferSchemaType(schema: string | PropertyMeta['schema'] | PropertyMeta['schema'][]): PropSchema[] { +export function inferSchemaType(schema: string | VuePropertyMeta['schema'] | VuePropertyMeta['schema'][]): PropSchema[] { const schemas = Array.isArray(schema) ? schema : [schema] return schemas.flatMap((schema) => { for (const resolver of propResolvers) { @@ -136,15 +136,15 @@ export function inferSchemaType(schema: string | PropertyMeta['schema'] | Proper const propType = { schema: result.data, inputType: resolver.inputType, type: result.data?.type ?? result.data } if (propType.inputType === 'object') { - const nestedSchema: Record = propType.schema.schema + const nestedSchema: Record = propType.schema.schema propType.schema.schema = Object.entries(nestedSchema).reduce((acc, [key, sch]) => { acc[key] = inferPropTypes(sch) return acc - }, {} as Record) + }, {} as Record) } if (propType.inputType === 'array') { - const nestedSchema: PropertyMeta['schema'][] = propType.schema.schema + const nestedSchema: VuePropertyMeta['schema'][] = propType.schema.schema propType.schema.schema = nestedSchema.flatMap(sch => inferSchemaType(sch), {} as any) // Ignore the array if the item schema cannot be resolved if (!propType.schema.schema?.length) return [] diff --git a/packages/nuxt/src/nuxt.ts b/packages/core/src/plugins/utils.ts similarity index 72% rename from packages/nuxt/src/nuxt.ts rename to packages/core/src/plugins/utils.ts index cde74064..e9fe9331 100644 --- a/packages/nuxt/src/nuxt.ts +++ b/packages/core/src/plugins/utils.ts @@ -1,15 +1,13 @@ -import { readdir } from 'node:fs/promises' import { basename, dirname, extname, join, relative } from 'pathe' import { glob } from 'tinyglobby' import { kebabCase, pascalCase, splitByCase } from 'scule' -import { isIgnored, useNuxt } from '@nuxt/kit' import { withTrailingSlash } from 'ufo' -import type { Component, ComponentsDir } from 'nuxt/schema' +import type { Component, ComponentsDir } from '../types' +import { realpath } from 'node:fs/promises' /* Nuxt internal functions used to scan example components without adding them to the application */ const ISLAND_RE = /\.island(?:\.global)?$/ -const GLOBAL_RE = /\.global(?:\.island)?$/ const COMPONENT_MODE_RE = /(?<=\.)(client|server)(\.global|\.island)*$/ const MODE_REPLACEMENT_RE = /(\.(client|server))?(\.global|\.island)*$/ export const QUOTE_RE = /["']/g @@ -59,36 +57,15 @@ export async function scanComponents(dirs: ComponentsDir[], srcDir: string): Pro const scannedPaths: string[] = [] for (const dir of dirs) { - if (dir.enabled === false) { - continue - } // A map from resolved path to component name (used for making duplicate warning message) const resolvedNames = new Map() const files = (await glob(dir.pattern!, { cwd: dir.path, ignore: dir.ignore })).sort() - // Check if the directory exists (globby will otherwise read it case insensitively on MacOS) - if (files.length) { - const siblings = await readdir(dirname(dir.path)).catch(() => [] as string[]) - - const directory = basename(dir.path) - if (!siblings.includes(directory)) { - const directoryLowerCase = directory.toLowerCase() - const caseCorrected = siblings.find(sibling => sibling.toLowerCase() === directoryLowerCase) - if (caseCorrected) { - const nuxt = useNuxt() - const original = relative(nuxt.options.srcDir, dir.path) - const corrected = relative(nuxt.options.srcDir, join(dirname(dir.path), caseCorrected)) - console.warn(`[Compodium] Components not scanned from \`~/${corrected}\`. Did you mean to name the directory \`~/${original}\` instead?`) - continue - } - } - } - for (const _file of files) { const filePath = join(dir.path, _file) - if (scannedPaths.find(d => filePath.startsWith(withTrailingSlash(d))) || isIgnored(filePath)) { + if (scannedPaths.find(d => filePath.startsWith(withTrailingSlash(d)))) { continue } @@ -116,8 +93,7 @@ export async function scanComponents(dirs: ComponentsDir[], srcDir: string): Pro */ let fileName = basename(filePath, extname(filePath)) - const island = ISLAND_RE.test(fileName) || dir.island - const global = GLOBAL_RE.test(fileName) || dir.global + const island = ISLAND_RE.test(fileName) const mode = island ? 'server' : (fileName.match(COMPONENT_MODE_RE)?.[1] || 'all') as 'client' | 'server' | 'all' fileName = fileName.replace(MODE_REPLACEMENT_RE, '') @@ -129,10 +105,6 @@ export async function scanComponents(dirs: ComponentsDir[], srcDir: string): Pro const componentNameSegments = resolveComponentNameSegments(fileName.replace(QUOTE_RE, ''), prefixParts) const pascalName = pascalCase(componentNameSegments) - if (LAZY_COMPONENT_NAME_REGEX.test(pascalName)) { - console.warn(`[Compodium] The component \`${pascalName}\` (in \`${filePath}\`) is using the reserved "Lazy" prefix used for dynamic imports, which may cause it to break at runtime.`) - } - if (resolvedNames.has(pascalName + suffix) || resolvedNames.has(pascalName)) { warnAboutDuplicateComponent(pascalName, filePath, resolvedNames.get(pascalName) || resolvedNames.get(pascalName + suffix)!) continue @@ -140,31 +112,13 @@ export async function scanComponents(dirs: ComponentsDir[], srcDir: string): Pro resolvedNames.set(pascalName + suffix, filePath) const kebabName = kebabCase(componentNameSegments) - const shortPath = relative(srcDir, filePath) - const chunkName = 'components/' + kebabName + suffix - let component: Component = { - // inheritable from directory configuration + const component: Component = { mode, - global, - island, - prefetch: Boolean(dir.prefetch), - preload: Boolean(dir.preload), - // specific to the file filePath, + realPath: await realpath(filePath), pascalName, - kebabName, - chunkName, - shortPath, - export: 'default', - // by default, give priority to scanned components - priority: dir.priority ?? 1, - // @ts-expect-error untyped property - _scanned: true - } - - if (typeof dir.extendComponent === 'function') { - component = (await dir.extendComponent(component)) || component + kebabName } // Ignore files like `~/components/index.vue` which end up not having a name at all @@ -205,5 +159,3 @@ function warnAboutDuplicateComponent(componentName: string, filePath: string, du + `\n - ${duplicatePath}` ) } - -const LAZY_COMPONENT_NAME_REGEX = /^Lazy(?=[A-Z])/ diff --git a/packages/nuxt/src/runtime/composables/extendCompodiumMeta.ts b/packages/core/src/runtime/composables/extendCompodiumMeta.ts similarity index 99% rename from packages/nuxt/src/runtime/composables/extendCompodiumMeta.ts rename to packages/core/src/runtime/composables/extendCompodiumMeta.ts index 55dfb2ab..7d8e98d8 100644 --- a/packages/nuxt/src/runtime/composables/extendCompodiumMeta.ts +++ b/packages/core/src/runtime/composables/extendCompodiumMeta.ts @@ -1,4 +1,5 @@ import type { CompodiumMeta } from '../../types' + /** * Runtime placeholder for the for the extendCompodiumMeta macro. */ diff --git a/packages/nuxt/src/runtime/preview.vue b/packages/core/src/runtime/preview.vue similarity index 84% rename from packages/nuxt/src/runtime/preview.vue rename to packages/core/src/runtime/preview.vue index c194c44f..e77f000f 100644 --- a/packages/nuxt/src/runtime/preview.vue +++ b/packages/core/src/runtime/preview.vue @@ -23,8 +23,6 @@ body { justify-content: center; align-items: center; min-height: 100vh; - min-width: 100vw; - background-color: transparent; - padding: 64px; + padding: 24px } diff --git a/packages/core/src/runtime/root.vue b/packages/core/src/runtime/root.vue new file mode 100644 index 00000000..ae93ead3 --- /dev/null +++ b/packages/core/src/runtime/root.vue @@ -0,0 +1,117 @@ + + + diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 00000000..ae2cee02 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,158 @@ +import type { ScanDir, Component as NuxtComponent } from '@nuxt/schema' +import type { PropertyMeta as VuePropertyMeta } from '@compodium/meta' +import type { Hookable } from 'hookable' +import type { InputSchema } from './plugins/meta/infer' + +export type PluginOptions = { + rootDir: string + + componentDirs: (ComponentsDir | string)[] + + /* Whether to include default collections for third-party libraries. */ + includeLibraryCollections?: boolean + + /* Customize compodium's base directory. Defaults to 'compodium/' */ + dir: string + + /* List of glob patterns to ignore components */ + ignore?: string[] + + extras?: { + /* Customize Compodium's UI Colors. */ + colors?: { + primary?: string + neutral?: string + } + } + + /* Internal */ + _nuxt?: boolean +} + +export type PluginConfig = PluginOptions & { + libraryCollections: Collection[] + componentCollection: Collection +} + +export type IconifyIcon = string & {} + +export type * from './plugins/meta/infer' + +export type PropInputType = 'array' | 'object' | 'stringEnum' | 'primitiveArray' | 'array' | 'string' | 'number' | 'boolean' | 'date' | 'icon' + +type StringLiteral = T extends string ? (string extends T ? never : T) : never +type FilterStringLiteral = { + [K in keyof T]: T[K] extends StringLiteral ? K : never; +}[keyof T] + +type ComboItem = keyof FilterStringLiteral | undefined +type Combo = [ComboItem, ComboItem] | [ComboItem] + +export type ComponentsDir = Pick + +export type CompodiumMeta> = { + compodium?: { + combo?: Combo + defaultProps?: Partial + } + props: PropertyMeta[] +} + +export type PropSchema = { + inputType: PropInputType + description?: string + default?: string + type: string + schema: InputSchema +} + +export type PropertyMeta = Omit & { + schema: PropSchema[] +} + +export type Component = Pick & { + realPath: string + docUrl?: string + examples?: ComponentExample[] +} + +export type ComponentExample = Component & { + isExample: true + componentPath?: string +} + +export type Collection = { + name: string + package?: string + icon?: string + version?: string + prefix?: string + ignore?: string[] + dirs: ComponentsDir[] + exampleDir: ComponentsDir + getDocUrl?: (componentName: string) => string +} + +export type ComponentCollection = Collection & { + components: (Component & Partial)[] +} + +declare module 'nuxt/schema' { + interface AppConfigInput { + compodium?: { + defaultProps?: Record + } + } + + interface AppConfig { + compodium: { + componentsPath: string + collections: Collection[] + defaultProps?: Record + matchUIColors?: boolean + } + } +} + +export interface CompodiumHooks { + // Triggered when the components code has been updated + 'component:changed': (path: string) => void + + // Triggered when a new component has been added + 'component:added': (path: string) => void + + // Triggered when a component has been deleted + 'component:removed': (path: string) => void + + // Called after the renderer has mounted + 'renderer:mounted': () => void + + // Update the renderer component + 'renderer:update-component': (payload: { path: string, props: Record }) => void + + // Update the renderer props + 'renderer:update-props': (payload: { props: Record }) => void + + // Update the renderer combo (displaying multiple variants) + 'renderer:update-combo': (payload: { props: { value: string, options: string[] }[] }) => void + + // Update the renderer colorMode + 'renderer:set-color': (color: 'light' | 'dark') => void + + // Toggle the renderer grid + 'renderer:grid': (payload: { enabled: boolean, gap: number }) => void +} + +declare global { + interface Window { + /** + * Compodium Hooks for the renderer and devtools + */ + __COMPODIUM_HOOKS__?: Hookable + + /** + * Macro to configure components and examples. + */ + extendCompodiumMeta: >(_options: CompodiumMeta['compodium']) => void + } +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 00000000..99920f4b --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "esnext", + "jsx": "preserve", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "strict": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "exclude": [ + "dist" + ] +} diff --git a/packages/devtools/app/app.vue b/packages/devtools/app/app.vue index ad7cac5e..8067937e 100644 --- a/packages/devtools/app/app.vue +++ b/packages/devtools/app/app.vue @@ -13,7 +13,9 @@ useAsyncData('__compodium-fetch-colors', async () => { diff --git a/packages/devtools/app/components/CollapseContainer.vue b/packages/devtools/app/components/CollapseContainer.vue index 031c88b1..2747344a 100644 --- a/packages/devtools/app/components/CollapseContainer.vue +++ b/packages/devtools/app/components/CollapseContainer.vue @@ -1,6 +1,4 @@ + + diff --git a/packages/devtools/app/components/ComponentPreview.vue b/packages/devtools/app/components/ComponentPreview.vue deleted file mode 100644 index 7fa8abbe..00000000 --- a/packages/devtools/app/components/ComponentPreview.vue +++ /dev/null @@ -1,15 +0,0 @@ - - -