diff --git a/PLUGINS.md b/PLUGINS.md new file mode 100644 index 0000000..9e64c90 --- /dev/null +++ b/PLUGINS.md @@ -0,0 +1,18 @@ +# Trakli UI plugins + +Set `TRAKLI_UI_LAYERS` to a comma-separated list of installed Nuxt layer paths before development or build. Paths are relative to the web UI checkout, or absolute. + +```bash +TRAKLI_UI_LAYERS=/path/to/plugin/ui npm run dev +TRAKLI_UI_LAYERS=/path/to/plugin/ui npm run build +TRAKLI_UI_LAYERS=/path/to/plugin/ui npm run test:run +TRAKLI_UI_LAYERS=/path/to/plugin/ui npm run check:plugins +``` + +Without this setting, the app loads no optional plugin layers. A layer supplies its pages, translations and optional components. Keep the matching backend plugin enabled. Installing or removing a layer requires restarting development or rebuilding the app. + +Backend integration descriptors insert links into the existing sidebar and settings slots. Links to local routes are hidden when the route is absent. Custom controls can still register through `useExtensionSlots().registerComponent()`. + +Layer pages use the host's authentication middleware, layouts, components and API client. Keep provider return URLs on the same browser hostname as the UI to preserve login cookies. + +Plugin tests live under the layer's `tests` directory. The host test runner includes tests from the configured layers and supplies its aliases and dependencies. diff --git a/composables/useExtensionSlots.ts b/composables/useExtensionSlots.ts index 45e9d13..94a8616 100644 --- a/composables/useExtensionSlots.ts +++ b/composables/useExtensionSlots.ts @@ -16,6 +16,7 @@ const componentRegistry = ref>({}); */ export const useExtensionSlots = () => { const { integrations } = useIntegrations(); + const router = useRouter(); const contributionsFor = (slot: ExtensionSlot) => computed(() => { @@ -39,6 +40,11 @@ export const useExtensionSlots = () => { continue; } + const href = ui.onboarding?.href || ui.card?.href; + if (href?.startsWith('/') && !router.resolve(href).matched.length) { + continue; + } + items.push({ key: integration.key, slot, diff --git a/nuxt.config.ts b/nuxt.config.ts index ca54dc5..7c5949f 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,6 +1,11 @@ import { defineNuxtConfig } from 'nuxt/config'; import Icons from 'unplugin-icons/vite'; +const pluginLayers = (process.env.TRAKLI_UI_LAYERS || '') + .split(',') + .map((layer) => layer.trim()) + .filter(Boolean); + // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ vite: { @@ -12,7 +17,7 @@ export default defineNuxtConfig({ // A layer registers a component against a slot key with // useExtensionSlots().registerComponent(), and DescriptorRenderer resolves it when a // descriptor sets `ui.component`. That is how plugin UI reaches the app. - extends: ['@whilesmart/eloquent-admin-ui'], + extends: ['@whilesmart/eloquent-admin-ui', ...pluginLayers], routeRules: { '/ai-insights': { redirect: '/assistant' } }, diff --git a/package.json b/package.json index b6b5d9b..9187f6a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "format:check": "prettier --check ./", "test": "vitest", "test:run": "vitest run", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "check:plugins": "node scripts/check-plugin-layers.mjs" }, "dependencies": { "@whilesmart/eloquent-admin-ui": "^0.1.0", diff --git a/scripts/check-plugin-layers.mjs b/scripts/check-plugin-layers.mjs new file mode 100644 index 0000000..1fbe2b9 --- /dev/null +++ b/scripts/check-plugin-layers.mjs @@ -0,0 +1,49 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { dirname, extname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ESLint } from 'eslint'; +import * as prettier from 'prettier'; + +const host = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const layers = (process.env.TRAKLI_UI_LAYERS || '') + .split(',') + .map((layer) => layer.trim()) + .filter(Boolean) + .map((layer) => resolve(host, layer)); +const extensions = new Set(['.ts', '.js', '.mjs', '.vue', '.json', '.md']); +const excluded = new Set(['node_modules', '.nuxt', '.output', '.git']); + +async function sources(directory) { + const files = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (excluded.has(entry.name)) continue; + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...(await sources(path))); + else if (extensions.has(extname(path))) files.push(path); + } + return files; +} + +if (layers.length) { + let common = host; + for (const layer of layers) { + while (relative(common, layer).startsWith('..')) common = dirname(common); + } + const eslint = new ESLint({ cwd: common, overrideConfigFile: join(host, 'eslint.config.mjs') }); + const results = await eslint.lintFiles(layers.map((layer) => `${layer}/**/*.{ts,js,mjs,vue}`)); + console.log(await (await eslint.loadFormatter('stylish')).format(results)); + if (results.some((result) => result.errorCount)) process.exitCode = 1; + + const options = await prettier.resolveConfig(join(host, 'nuxt.config.ts')); + for (const layer of layers) { + for (const file of await sources(layer)) { + if (!(await prettier.check(await readFile(file, 'utf8'), { ...options, filepath: file }))) { + console.error(`Formatting failed: ${relative(host, file)}`); + process.exitCode = 1; + } + } + } + if (!process.exitCode) console.log('Plugin layer lint and formatting passed.'); +} else { + console.log('No plugin layers configured.'); +} diff --git a/tests/composables/useExtensionSlots.test.ts b/tests/composables/useExtensionSlots.test.ts index ecdca0f..0b8ab5b 100644 --- a/tests/composables/useExtensionSlots.test.ts +++ b/tests/composables/useExtensionSlots.test.ts @@ -27,6 +27,17 @@ function makeIntegration(overrides: Partial): Integration { describe('useExtensionSlots', () => { beforeEach(() => { mockIntegrations.value = []; + vi.stubGlobal('useRouter', () => ({ resolve: () => ({ matched: [{}] }) })); + }); + + it('hides local links when their plugin route is not installed', () => { + vi.stubGlobal('useRouter', () => ({ resolve: () => ({ matched: [] }) })); + mockIntegrations.value = [ + makeIntegration({ + ui: { slots: ['sidebar.nav'], card: { title: 'Optional plugin', href: '/optional' } } + }) + ]; + expect(useExtensionSlots().contributionsFor('sidebar.nav').value).toEqual([]); }); it('collects only contributions that declare the slot', () => { diff --git a/vitest.config.ts b/vitest.config.ts index b7ddf25..7887f42 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,13 +3,24 @@ import vue from '@vitejs/plugin-vue'; import Icons from 'unplugin-icons/vite'; import { resolve } from 'path'; +const pluginLayers = (process.env.TRAKLI_UI_LAYERS || '') + .split(',') + .map((layer) => layer.trim()) + .filter(Boolean); + export default defineConfig({ plugins: [Icons({ compiler: 'vue3', autoInstall: false }), vue()], + server: { + fs: { allow: [resolve(__dirname), ...pluginLayers.map((layer) => resolve(__dirname, layer))] } + }, test: { globals: true, environment: 'happy-dom', setupFiles: ['./tests/setup.ts'], - include: ['tests/**/*.{test,spec}.{js,ts}'], + include: [ + 'tests/**/*.{test,spec}.{js,ts}', + ...pluginLayers.map((layer) => `${layer}/tests/**/*.{test,spec}.{js,ts}`) + ], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], @@ -17,6 +28,7 @@ export default defineConfig({ } }, resolve: { + dedupe: ['vue', '@vue/test-utils', 'vitest'], alias: { '@': resolve(__dirname, './'), '~': resolve(__dirname, './')