diff --git a/packages/qwik-nx/src/plugins/qwik-nx-vite.plugin.spec.ts b/packages/qwik-nx/src/plugins/qwik-nx-vite.plugin.spec.ts new file mode 100644 index 00000000..267fc721 --- /dev/null +++ b/packages/qwik-nx/src/plugins/qwik-nx-vite.plugin.spec.ts @@ -0,0 +1,303 @@ +import { workspaceRoot } from '@nrwl/devkit'; +import { qwikNxVite, QwikNxVitePluginOptions } from './qwik-nx-vite.plugin'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const fileUtils = require('nx/src/project-graph/file-utils'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const fs = require('fs'); + +const workspaceConfig1 = { + projects: { + 'tmp-test-app-a': { + root: 'apps/test-app-a', + name: 'tmp-test-app-a', + projectType: 'application', + sourceRoot: 'apps/test-app-a/src', + prefix: 'tmp', + tags: ['tag1', 'tag2'], + }, + 'tmp-test-lib-a': { + root: 'libs/test-lib-a', + name: 'tmp-test-lib-a', + projectType: 'library', + sourceRoot: 'libs/test-lib-a/src', + prefix: 'tmp', + tags: ['tag2'], + }, + 'tmp-test-lib-b': { + root: 'libs/test-lib-b', + name: 'tmp-test-lib-b', + projectType: 'library', + sourceRoot: 'libs/test-lib-b/src', + prefix: 'tmp', + tags: ['tag2', 'tag3'], + }, + 'tmp-test-lib-c-nested-1': { + root: 'libs/test-lib-c/nested', + name: 'tmp-test-lib-c-nested-1', + projectType: 'library', + sourceRoot: 'libs/test-lib-c/nested-1/src', + prefix: 'tmp', + tags: ['tag4'], + }, + 'tmp-test-lib-c-nested-2': { + root: 'libs/test-lib-c/nested', + name: 'tmp-test-lib-c-nested-2', + projectType: 'library', + sourceRoot: 'libs/test-lib-c/nested-2/src', + prefix: 'tmp', + tags: ['tag1', 'tag2', 'tag3'], + }, + 'tmp-other-test-lib-a': { + root: 'libs/other/test-lib-a', + name: 'tmp-other-test-lib-a', + projectType: 'library', + sourceRoot: 'libs/other/test-lib-a/src', + prefix: 'tmp', + tags: [], + }, + }, +}; + +const tsConfigString1 = JSON.stringify({ + compilerOptions: { + paths: { + '@tmp/test-lib-a': 'libs/test-lib-a/src/index.ts', + '@tmp/test-lib-b': 'libs/test-lib-b/src/index.ts', + '@tmp/test-lib-c/nested-1': 'libs/test-lib-c/nested-1/src/index.ts', + '@tmp/test-lib-c/nested-2': 'libs/test-lib-c/nested-2/src/index.ts', + '@tmp/other/test-lib-a/nested-2': 'libs/other/test-lib-a/src/index.ts', + }, + }, +}); + +describe('qwik-nx-vite plugin', () => { + jest + .spyOn(fileUtils, 'readWorkspaceConfig') + .mockReturnValue(workspaceConfig1); + jest.spyOn(fs, 'readFileSync').mockReturnValue(tsConfigString1); + + const getDecoratedPaths = async (options?: QwikNxVitePluginOptions) => { + const plugin = qwikNxVite(options); + const vendorRoots = []; + const qwikViteMock = { + name: 'vite-plugin-qwik', + api: { + getOptions: () => ({ vendorRoots }), + }, + }; + await (plugin.configResolved as any)({ plugins: [qwikViteMock] }); + return vendorRoots; + }; + + it('Without filters', async () => { + const paths = await getDecoratedPaths(); + + expect(paths).toEqual([ + `${workspaceRoot}/libs/test-lib-a/src`, + `${workspaceRoot}/libs/test-lib-b/src`, + `${workspaceRoot}/libs/test-lib-c/nested-1/src`, + `${workspaceRoot}/libs/test-lib-c/nested-2/src`, + `${workspaceRoot}/libs/other/test-lib-a/src`, + ]); + }); + + describe('Name filter', () => { + describe('As string', () => { + it('Exclude', async () => { + const paths = await getDecoratedPaths({ + excludeProjects: { + name: ['tmp-test-lib-b', 'tmp-test-lib-c-nested-2'], + }, + }); + expect(paths).toEqual([ + `${workspaceRoot}/libs/test-lib-a/src`, + `${workspaceRoot}/libs/test-lib-c/nested-1/src`, + `${workspaceRoot}/libs/other/test-lib-a/src`, + ]); + }); + it('Include', async () => { + const paths = await getDecoratedPaths({ + includeProjects: { + name: ['tmp-test-lib-b', 'tmp-test-lib-c-nested-2'], + }, + }); + expect(paths).toEqual([ + `${workspaceRoot}/libs/test-lib-b/src`, + `${workspaceRoot}/libs/test-lib-c/nested-2/src`, + ]); + }); + it('Both', async () => { + const paths = await getDecoratedPaths({ + includeProjects: { name: ['tmp-test-lib-c-nested-2'] }, + excludeProjects: { name: ['tmp-test-lib-b'] }, + }); + expect(paths).toEqual([ + `${workspaceRoot}/libs/test-lib-c/nested-2/src`, + ]); + }); + }); + describe('As regexp', () => { + it('Exclude', async () => { + const paths = await getDecoratedPaths({ + excludeProjects: { name: /lib-a/ }, + }); + expect(paths).toEqual([ + `${workspaceRoot}/libs/test-lib-b/src`, + `${workspaceRoot}/libs/test-lib-c/nested-1/src`, + `${workspaceRoot}/libs/test-lib-c/nested-2/src`, + ]); + }); + it('Exclude - ends with', async () => { + const paths = await getDecoratedPaths({ + excludeProjects: { name: /tmp-test-lib-\w$/ }, + }); + expect(paths).toEqual([ + `${workspaceRoot}/libs/test-lib-c/nested-1/src`, + `${workspaceRoot}/libs/test-lib-c/nested-2/src`, + `${workspaceRoot}/libs/other/test-lib-a/src`, + ]); + }); + it('Exclude - wrong value', async () => { + const paths = await getDecoratedPaths({ + excludeProjects: { name: /test-lib$/ }, + }); + expect(paths).toEqual([ + `${workspaceRoot}/libs/test-lib-a/src`, + `${workspaceRoot}/libs/test-lib-b/src`, + `${workspaceRoot}/libs/test-lib-c/nested-1/src`, + `${workspaceRoot}/libs/test-lib-c/nested-2/src`, + `${workspaceRoot}/libs/other/test-lib-a/src`, + ]); + }); + it('Include', async () => { + const paths = await getDecoratedPaths({ + includeProjects: { name: /nested/ }, + }); + expect(paths).toEqual([ + `${workspaceRoot}/libs/test-lib-c/nested-1/src`, + `${workspaceRoot}/libs/test-lib-c/nested-2/src`, + ]); + }); + + it('Include - with "global" flag', async () => { + const paths = await getDecoratedPaths({ + includeProjects: { name: /nested/g }, + }); + expect(paths).toEqual([ + `${workspaceRoot}/libs/test-lib-c/nested-1/src`, + `${workspaceRoot}/libs/test-lib-c/nested-2/src`, + ]); + }); + + it('Both', async () => { + const paths = await getDecoratedPaths({ + includeProjects: { name: /nested/ }, + excludeProjects: { name: /nested-2/ }, + }); + expect(paths).toEqual([ + `${workspaceRoot}/libs/test-lib-c/nested-1/src`, + ]); + }); + }); + }); + + describe('Path filter', () => { + it('Exclude', async () => { + const paths = await getDecoratedPaths({ + excludeProjects: { path: /other\/test/ }, + }); + expect(paths).toEqual([ + `${workspaceRoot}/libs/test-lib-a/src`, + `${workspaceRoot}/libs/test-lib-b/src`, + `${workspaceRoot}/libs/test-lib-c/nested-1/src`, + `${workspaceRoot}/libs/test-lib-c/nested-2/src`, + ]); + }); + it('Include', async () => { + const paths = await getDecoratedPaths({ + includeProjects: { path: /nested/ }, + }); + expect(paths).toEqual([ + `${workspaceRoot}/libs/test-lib-c/nested-1/src`, + `${workspaceRoot}/libs/test-lib-c/nested-2/src`, + ]); + }); + + it('Include - with "global" flag', async () => { + const paths = await getDecoratedPaths({ + includeProjects: { path: /nested/g }, + }); + expect(paths).toEqual([ + `${workspaceRoot}/libs/test-lib-c/nested-1/src`, + `${workspaceRoot}/libs/test-lib-c/nested-2/src`, + ]); + }); + + it('Both', async () => { + const paths = await getDecoratedPaths({ + includeProjects: { path: /lib-a/ }, + excludeProjects: { path: /other/ }, + }); + expect(paths).toEqual([`${workspaceRoot}/libs/test-lib-a/src`]); + }); + }); + + describe('Tags filter', () => { + it('Exclude', async () => { + const paths = await getDecoratedPaths({ + excludeProjects: { tags: ['tag1'] }, + }); + expect(paths).toEqual([ + `${workspaceRoot}/libs/test-lib-a/src`, + `${workspaceRoot}/libs/test-lib-b/src`, + `${workspaceRoot}/libs/test-lib-c/nested-1/src`, + `${workspaceRoot}/libs/other/test-lib-a/src`, + ]); + }); + it('Exclude multiple', async () => { + const paths = await getDecoratedPaths({ + excludeProjects: { tags: ['tag1', 'tag3'] }, + }); + expect(paths).toEqual([ + `${workspaceRoot}/libs/test-lib-a/src`, + `${workspaceRoot}/libs/test-lib-c/nested-1/src`, + `${workspaceRoot}/libs/other/test-lib-a/src`, + ]); + }); + it('Include', async () => { + const paths = await getDecoratedPaths({ + includeProjects: { tags: ['tag3'] }, + }); + expect(paths).toEqual([ + `${workspaceRoot}/libs/test-lib-b/src`, + `${workspaceRoot}/libs/test-lib-c/nested-2/src`, + ]); + }); + it('Include multiple', async () => { + const paths = await getDecoratedPaths({ + includeProjects: { tags: ['tag1', 'tag3'] }, + }); + expect(paths).toEqual([`${workspaceRoot}/libs/test-lib-c/nested-2/src`]); + }); + }); + describe('Custom filter', () => { + it('Exclude', async () => { + const paths = await getDecoratedPaths({ + excludeProjects: { customFilter: (p) => p.name === 'tmp-test-lib-a' }, + }); + expect(paths).toEqual([ + `${workspaceRoot}/libs/test-lib-b/src`, + `${workspaceRoot}/libs/test-lib-c/nested-1/src`, + `${workspaceRoot}/libs/test-lib-c/nested-2/src`, + `${workspaceRoot}/libs/other/test-lib-a/src`, + ]); + }); + it('Exclude', async () => { + const paths = await getDecoratedPaths({ + includeProjects: { customFilter: (p) => p.name === 'tmp-test-lib-a' }, + }); + expect(paths).toEqual([`${workspaceRoot}/libs/test-lib-a/src`]); + }); + }); +}); diff --git a/packages/qwik-nx/src/plugins/qwik-nx-vite.plugin.ts b/packages/qwik-nx/src/plugins/qwik-nx-vite.plugin.ts index fe6a0e42..51fefbfe 100644 --- a/packages/qwik-nx/src/plugins/qwik-nx-vite.plugin.ts +++ b/packages/qwik-nx/src/plugins/qwik-nx-vite.plugin.ts @@ -2,10 +2,32 @@ import { type QwikVitePlugin } from '@builder.io/qwik/optimizer'; import { type Plugin } from 'vite'; import { join } from 'path'; import { readWorkspaceConfig } from 'nx/src/project-graph/file-utils'; -import { workspaceRoot } from '@nrwl/devkit'; +import { ProjectConfiguration, workspaceRoot } from '@nrwl/devkit'; import { readFileSync } from 'fs'; -export function qwikNxVite() { +export interface ProjectFilter { + name?: string[] | RegExp; + path?: RegExp; + tags?: string[]; + customFilter?: (project: ProjectConfiguration) => boolean; +} + +export interface QwikNxVitePluginOptions { + includeProjects?: ProjectFilter; + excludeProjects?: ProjectFilter; + debug?: boolean; +} + +/** + * `qwikNxVite` plugin serves as an integration step between Qwik and Nx. + * At this point its main purpose is to provide Nx libraries as vendor roots for the Qwik. + * This is required in order for the optimizer to be able to work with entities imported from those libs. + * + * By default `qwikNxVite` plugin will provide Qwik with paths of all Nx projects, that are specified in the tsconfig.base.json. + * However, this behavior might not be always suitable, especially in cases when you have code that you don't want the optimizer to go through. + * It is possible to use specifically exclude or include certain projects using plugin options. + */ +export function qwikNxVite(options?: QwikNxVitePluginOptions): Plugin { const vitePlugin: Plugin = { name: 'vite-plugin-qwik-nx', enforce: 'pre', @@ -18,13 +40,7 @@ export function qwikNxVite() { throw new Error('Missing vite-plugin-qwik'); } - const workspaceConfig = readWorkspaceConfig({ format: 'nx' }); - - let vendorRoots = Object.values(workspaceConfig.projects).map((p) => - join(workspaceRoot, p.sourceRoot ?? p.root + '/src') - ); - - vendorRoots = getFilteredVendorRoots(vendorRoots); + const vendorRoots = getVendorRoots(options); qwikPlugin.api.getOptions().vendorRoots.push(...vendorRoots); }, @@ -33,36 +49,124 @@ export function qwikNxVite() { return vitePlugin; } -/** - * Project's source root is specified relatively to the workspace root (e.g. "libs/libname/src"). - * At the same time tsconfig.base.json has path specified as "libs/libname/src/index.ts" - * - * Since it is required to only those "vendorRoots" that are exportable (specified in tsconfig.base.json), - * need to ensure substring of a particular "vendorRoot" is present in the array of tsconfig's paths. - * - * Naive approach is to check every vendorRoot against every path, which is O(n^2). - * Instead, function below does it in O(n) time complexity by splitting each tsconfig path into parts and putting in into Set. - * Set will contain values like ["libs", "libs/libname", "libs/libname/src"], in other words it will contain all possible values of a vendorRoot. - */ -function getFilteredVendorRoots(vendorRoots: string[]): string[] { +/** Retrieves vendor roots and applies necessary filtering */ +function getVendorRoots(options?: QwikNxVitePluginOptions): string[] { + const workspaceConfig = readWorkspaceConfig({ format: 'nx' }); + const baseTsConfig = JSON.parse( readFileSync(join(workspaceRoot, 'tsconfig.base.json')).toString() ); const decoratedPaths = Object.values( baseTsConfig.compilerOptions.paths - ) - .flat() - .reduce((acc, path) => { - const pathChunks = path.split('/').filter(Boolean); - let pathChunk = ''; - do { - pathChunk = pathChunk + '/' + pathChunks.shift(); - acc.push(pathChunk); - } while (pathChunks.length); - return acc; - }, []) - .map((p) => join(workspaceRoot, p)); - - const decoratedPathsSet = new Set(decoratedPaths); - return vendorRoots.filter((p) => decoratedPathsSet.has(p)); + ).flat(); + + let projects = Object.values(workspaceConfig.projects); + + projects.forEach((p) => (p.sourceRoot ??= p.root)); + + projects = filterProjects(projects, options?.excludeProjects, true); + projects = filterProjects(projects, options?.includeProjects, false); + + if (options?.debug) { + console.log( + 'Projects after applying include\\exclude filters:', + projects.map((p) => p.name) + ); + } + + projects = projects.filter((p) => + decoratedPaths.some((path) => path.startsWith(p.sourceRoot)) + ); + + if (options?.debug) { + console.log( + 'Projects after excluding those not in tsconfig.base.json:', + projects.map((p) => p.name) + ); + } + + return projects.map((p) => p.sourceRoot).map((p) => join(workspaceRoot, p)); +} + +function filterProjects( + projects: ProjectConfiguration[], + filterConfig: ProjectFilter | undefined, + exclusive: boolean +): ProjectConfiguration[] { + if (filterConfig?.name) { + projects = filterProjectsByName(projects, filterConfig.name, exclusive); + } + if (filterConfig?.path) { + projects = filterProjectsByPath(projects, filterConfig.path, exclusive); + } + if (filterConfig?.tags?.length) { + projects = filterProjectsByTags(projects, filterConfig.tags, exclusive); + } + if (typeof filterConfig?.customFilter === 'function') { + projects = projects.filter((p) => { + const matches = filterConfig.customFilter(p); + return exclusive ? !matches : matches; + }); + } + return projects; +} + +function filterProjectsByName( + projects: ProjectConfiguration[], + options: string[] | RegExp, + exclusive: boolean +): ProjectConfiguration[] { + if (Array.isArray(options)) { + const optionsSet = new Set(options); + return projects.filter((p) => { + const matches = optionsSet.has(p.name); + return exclusive ? !matches : matches; + }); + } else if (options instanceof RegExp) { + return filterByRegex(projects, options, exclusive, (p) => p.name); + } +} + +function filterProjectsByPath( + projects: ProjectConfiguration[], + options: RegExp, + exclusive: boolean +): ProjectConfiguration[] { + if (options instanceof RegExp) { + return filterByRegex(projects, options, exclusive, (p) => p.sourceRoot); + } +} + +function filterByRegex( + projects: ProjectConfiguration[], + options: RegExp, + exclusive: boolean, + valueGetter: (p: ProjectConfiguration) => string +): ProjectConfiguration[] { + if (options instanceof RegExp) { + if (options.global) { + console.log(`"global" flag has been removed from the RegExp ${options}`); + options = new RegExp(options.source, options.flags.replace('g', '')); + } + return projects.filter((p) => { + const matches = (options as RegExp).test(valueGetter(p)); + return exclusive ? !matches : matches; + }); + } +} + +function filterProjectsByTags( + projects: ProjectConfiguration[], + tags: string[], + exclusive: boolean +): ProjectConfiguration[] { + if (exclusive) { + return projects.filter((p) => { + return tags.every((t) => !p.tags.includes(t)); + }); + } else { + return projects.filter((p) => { + return tags.every((t) => p.tags.includes(t)); + }); + } }