diff --git a/.github/workflows/code-analysis.yml b/.github/workflows/code-analysis.yml index 4359ca87c..45f681b44 100644 --- a/.github/workflows/code-analysis.yml +++ b/.github/workflows/code-analysis.yml @@ -61,6 +61,20 @@ jobs: - name: Main ESlint check run: pnpm lint + vite-optimize-deps: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name + name: Vite optimizeDeps audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Node.js environment + uses: ./.github/actions/node_env_setup + with: + node-version: ${{ env.node-version }} + + - name: Check optimizeDeps declarations are up to date + run: pnpm check:vite-optimize-deps + stylelint: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name name: Stylelint ${{ matrix.name }} diff --git a/apps/aurora/news/+vite-optimize-deps-ssr-exclude.internal b/apps/aurora/news/+vite-optimize-deps-ssr-exclude.internal new file mode 100644 index 000000000..fadf55777 --- /dev/null +++ b/apps/aurora/news/+vite-optimize-deps-ssr-exclude.internal @@ -0,0 +1 @@ +Excluded `i18next-fs-backend` and `remix-i18next/server` from the client `optimizeDeps` bundle — they were still being picked up by Vite's client-side dependency scanner despite only being declared in `ssr.optimizeDeps.include`. @sneridagh diff --git a/apps/aurora/news/+vite-optimize-deps.internal b/apps/aurora/news/+vite-optimize-deps.internal index a9ab4d7d1..ae4a04fa5 100644 --- a/apps/aurora/news/+vite-optimize-deps.internal +++ b/apps/aurora/news/+vite-optimize-deps.internal @@ -1 +1 @@ -Added `optimizeDeps.include` entries for non-addon workspace packages and app-level deps, eliminating lazy dependency discovery reloads on dev server startup. @arybakov05 +Added `optimizeDeps.include` entries for non-addon workspace packages and app-level deps to reduce lazy dependency discovery reloads on dev server startup. @arybakov05 diff --git a/apps/aurora/vite.config.ts b/apps/aurora/vite.config.ts index 3919fed8c..0f602f4d3 100644 --- a/apps/aurora/vite.config.ts +++ b/apps/aurora/vite.config.ts @@ -47,6 +47,13 @@ export default defineConfig(({ command, mode, isSsrBuild }) => { : []), ] as PluginOption[], optimizeDeps: { + // Server-only deps that Vite would otherwise still pick up for the + // client bundle — keep in sync with ssr.optimizeDeps.include below + exclude: [ + 'i18next-fs-backend', + 'i18next-fs-backend/cjs', + 'remix-i18next/server', + ], include: [ // App-level deps (in apps/aurora/package.json) 'i18next', @@ -66,6 +73,7 @@ export default defineConfig(({ command, mode, isSsrBuild }) => { '@plone/components > react-aria', '@plone/components > react-aria-components', '@plone/components > react-aria-components/DropZone', + '@plone/components > react-aria-components/Form', '@plone/components > react-aria-components/Group', '@plone/components > react-aria-components/Modal', '@plone/components > react-aria-components/Table', @@ -81,7 +89,7 @@ export default defineConfig(({ command, mode, isSsrBuild }) => { }, ssr: { optimizeDeps: { - include: ['i18next-fs-backend/cjs', 'remix-i18next/server'], + include: ['i18next-fs-backend/cjs', 'isbot', 'remix-i18next/server'], }, }, resolve: { diff --git a/docs/development/vite-optimize-deps.md b/docs/development/vite-optimize-deps.md index 5942c7cd7..e529675ea 100644 --- a/docs/development/vite-optimize-deps.md +++ b/docs/development/vite-optimize-deps.md @@ -9,7 +9,7 @@ myst: # Vite dependency pre-bundling -This guide shows you how to declare third-party dependencies for pre-bundling so Vite resolves them at startup rather than lazily during dev. +This guide shows you how to declare third-party dependencies for pre-bundling so Vite resolves more of them at startup instead of discovering them lazily during dev. Aurora runs in a pnpm monorepo. Vite treats workspace packages as source files and does not scan them upfront for their third-party dependencies. @@ -66,6 +66,37 @@ If a dependency exports subpaths you use (for example `some-lib/react` or `some- Vite does not discover subpath exports automatically from the main entry. If a dependency is only used in server-side code (for example `*.server.*` files), add it to `ssr.optimizeDeps.include` instead of `optimizeDeps.include` so it isn't pre-bundled for the browser. +Declaring it in `ssr.optimizeDeps.include` alone is not always enough — Vite's client-side dependency scanner can still pick it up through re-exports. +If that happens, also add it to the top-level `optimizeDeps.exclude` in `apps/aurora/vite.config.ts` so it's explicitly kept out of the client bundle. + +To keep the lists from drifting, run: + +```sh +pnpm check:vite-optimize-deps +``` + +The script inspects runtime imports in the workspace packages that require explicit declarations and reports missing or stale `pkg > dep` entries. + +Run it when: + +- you add or remove a runtime import in `@plone/components`, `@plone/helpers`, + `@plone/cmsui`, `@plone/layout`, or `@plone/plate` +- you switch an import to a different package subpath such as + `some-lib/react` +- you move an import between browser-only and server-only code and may need to + switch between `optimizeDeps.include` and `ssr.optimizeDeps.include` +- you touch one of the existing optimize-deps config files and want to verify + the list before opening a PR + +Recommended workflow: + +1. Make the code change that introduces or removes runtime imports. +2. Run `pnpm check:vite-optimize-deps`. +3. Copy the reported missing entries into the relevant `vite.extend.js` file or + `apps/aurora/vite.config.ts`. +4. Remove reported stale entries unless you have a concrete reason to keep + them. +5. Re-run the check until it passes. ### Existing core add-on files diff --git a/package.json b/package.json index 52e9ed0cb..c3f1f2426 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test": "pnpm --filter @plone/aurora run test", "test:all": "pnpm test:ci", "test:ci": "pnpm --filter @plone/* --filter \"!@plone/scripts\" --filter \"!@plone/theming\" run test --run", + "check:vite-optimize-deps": "node packages/scripts/check-vite-optimize-deps.js", "prettier": "prettier --check '{apps,packages}/**/*.{js,jsx,ts,tsx}'", "prettier:fix": "prettier --write '{apps,packages}/**/*.{js,jsx,ts,tsx}'", "stylelint": "stylelint '{apps,packages}/**/*.{css,scss,less}'", diff --git a/packages/cmsui/news/+vite-optimize-deps.internal b/packages/cmsui/news/+vite-optimize-deps.internal index 10750a5ab..25a7501c4 100644 --- a/packages/cmsui/news/+vite-optimize-deps.internal +++ b/packages/cmsui/news/+vite-optimize-deps.internal @@ -1 +1 @@ -Added `vite.extend.js` to pre-bundle CMS UI dependencies, reducing dev server startup reloads. @arybakov05 \ No newline at end of file +Added `vite.extend.js` and a dependency audit path to pre-bundle CMS UI runtime dependencies, reducing dev server startup reloads. @arybakov05 diff --git a/packages/cmsui/vite.extend.js b/packages/cmsui/vite.extend.js index 17024e6b5..e7f9fb629 100644 --- a/packages/cmsui/vite.extend.js +++ b/packages/cmsui/vite.extend.js @@ -5,7 +5,25 @@ export default function (config) { ...config.optimizeDeps, include: [ ...(config.optimizeDeps?.include ?? []), + '@plone/cmsui > @platejs/floating', + '@plone/cmsui > @platejs/link/react', + '@plone/cmsui > @tanstack/react-form', + '@plone/cmsui > class-variance-authority', + '@plone/cmsui > clsx', + '@plone/cmsui > jotai', + '@plone/cmsui > jotai-optics', + '@plone/cmsui > jotai/utils', '@plone/cmsui > jwt-decode', + '@plone/cmsui > lucide-react', + '@plone/cmsui > platejs', + '@plone/cmsui > platejs/react', + '@plone/cmsui > react-aria', + '@plone/cmsui > react-aria-components', + '@plone/cmsui > react-i18next', + '@plone/cmsui > react-router', + '@plone/cmsui > rrule', + '@plone/cmsui > tailwind-merge', + '@plone/cmsui > tailwind-variants', '@plone/cmsui > usehooks-ts', ], }, diff --git a/packages/layout/news/+vite-optimize-deps.internal b/packages/layout/news/+vite-optimize-deps.internal index e0077f0cc..3a5112b9e 100644 --- a/packages/layout/news/+vite-optimize-deps.internal +++ b/packages/layout/news/+vite-optimize-deps.internal @@ -1 +1 @@ -Added `vite.extend.js` to pre-bundle layout dependencies, reducing dev server startup reloads. @arybakov05 \ No newline at end of file +Added `vite.extend.js` and a dependency audit path to pre-bundle layout runtime dependencies, reducing dev server startup reloads. @arybakov05 diff --git a/packages/layout/vite.extend.js b/packages/layout/vite.extend.js index 467c1313c..57d59c575 100644 --- a/packages/layout/vite.extend.js +++ b/packages/layout/vite.extend.js @@ -5,8 +5,13 @@ export default function (config) { ...config.optimizeDeps, include: [ ...(config.optimizeDeps?.include ?? []), + '@plone/layout > clsx', '@plone/layout > lodash.sortby', '@plone/layout > pretty-bytes', + '@plone/layout > react-aria', + '@plone/layout > react-aria-components', + '@plone/layout > react-i18next', + '@plone/layout > react-router', '@plone/layout > rrule', ], }, diff --git a/packages/plate/news/+vite-optimize-deps.internal b/packages/plate/news/+vite-optimize-deps.internal index 35c6c680d..590e9793c 100644 --- a/packages/plate/news/+vite-optimize-deps.internal +++ b/packages/plate/news/+vite-optimize-deps.internal @@ -1 +1 @@ -Added `vite.extend.js` to pre-bundle platejs and Radix UI dependencies, reducing dev server startup reloads. @arybakov05 \ No newline at end of file +Added `vite.extend.js` and a dependency audit path to pre-bundle Plate runtime dependencies, reducing dev server startup reloads. @arybakov05 diff --git a/packages/plate/vite.extend.js b/packages/plate/vite.extend.js index 6e5e7dfe5..0ea56d5a6 100644 --- a/packages/plate/vite.extend.js +++ b/packages/plate/vite.extend.js @@ -5,43 +5,86 @@ export default function (config) { ...config.optimizeDeps, include: [ ...(config.optimizeDeps?.include ?? []), + '@plone/plate > @ai-sdk/react', + '@plone/plate > @ariakit/react', + '@plone/plate > @platejs/ai', '@plone/plate > @platejs/ai/react', + '@plone/plate > @platejs/autoformat', '@plone/plate > @platejs/basic-nodes', '@plone/plate > @platejs/basic-nodes/react', '@plone/plate > @platejs/basic-styles', + '@plone/plate > @platejs/basic-styles/react', '@plone/plate > @platejs/callout', + '@plone/plate > @platejs/callout/react', '@plone/plate > @platejs/caption', + '@plone/plate > @platejs/caption/react', '@plone/plate > @platejs/code-block', + '@plone/plate > @platejs/code-block/react', + '@plone/plate > @platejs/combobox', + '@plone/plate > @platejs/combobox/react', '@plone/plate > @platejs/comment', '@plone/plate > @platejs/comment/react', + '@plone/plate > @platejs/dnd', + '@plone/plate > @platejs/docx', + '@plone/plate > @platejs/emoji/react', '@plone/plate > @platejs/floating', '@plone/plate > @platejs/indent', + '@plone/plate > @platejs/indent/react', + '@plone/plate > @platejs/juice', '@plone/plate > @platejs/layout', + '@plone/plate > @platejs/layout/react', '@plone/plate > @platejs/link', '@plone/plate > @platejs/link/react', '@plone/plate > @platejs/list', '@plone/plate > @platejs/list/react', + '@plone/plate > @platejs/markdown', '@plone/plate > @platejs/media', + '@plone/plate > @platejs/media/react', '@plone/plate > @platejs/mention', + '@plone/plate > @platejs/mention/react', + '@plone/plate > @platejs/playwright', + '@plone/plate > @platejs/resizable', '@plone/plate > @platejs/selection/react', + '@plone/plate > @platejs/slash-command/react', '@plone/plate > @platejs/suggestion', '@plone/plate > @platejs/table', '@plone/plate > @platejs/table/react', '@plone/plate > @platejs/toc', + '@plone/plate > @platejs/toc/react', '@plone/plate > @platejs/toggle', '@plone/plate > @platejs/toggle/react', + '@plone/plate > @radix-ui/react-alert-dialog', '@plone/plate > @radix-ui/react-avatar', + '@plone/plate > @radix-ui/react-checkbox', + '@plone/plate > @radix-ui/react-context-menu', + '@plone/plate > @radix-ui/react-dialog', '@plone/plate > @radix-ui/react-dropdown-menu', + '@plone/plate > @radix-ui/react-popover', '@plone/plate > @radix-ui/react-separator', '@plone/plate > @radix-ui/react-slot', '@plone/plate > @radix-ui/react-toolbar', '@plone/plate > @radix-ui/react-tooltip', '@plone/plate > @udecode/cn', + '@plone/plate > ai', '@plone/plate > class-variance-authority', + '@plone/plate > clsx', + '@plone/plate > cmdk', + '@plone/plate > html2canvas-pro', + '@plone/plate > jotai', + '@plone/plate > lodash.debounce', '@plone/plate > lowlight', '@plone/plate > lucide-react', + '@plone/plate > pdf-lib', '@plone/plate > platejs', '@plone/plate > platejs/react', + '@plone/plate > react-dnd', + '@plone/plate > react-dnd-html5-backend', + '@plone/plate > react-lite-youtube-embed', + '@plone/plate > remark-gfm', + '@plone/plate > sonner', + '@plone/plate > tailwind-merge', + '@plone/plate > use-file-picker', + '@plone/plate > zod', ], }, }; diff --git a/packages/scripts/README.md b/packages/scripts/README.md index 554ea77f6..b9744ee0e 100644 --- a/packages/scripts/README.md +++ b/packages/scripts/README.md @@ -9,3 +9,31 @@ It scans and detects i18n messages from the code and adds them to the i18n machi See https://6.docs.plone.org/volto/development/i18n.html for more information. This script is installed in the `node_modules/.bin` directory and can be called via `yarn i18n` or directly in the `scripts` `package.json` part. + +## Aurora optimize-deps audit + +Aurora includes a repository-level audit script at +`packages/scripts/check-vite-optimize-deps.js`. + +Run it from the repo root with: + +```sh +pnpm check:vite-optimize-deps +``` + +Use it when you change runtime imports in Aurora workspace packages that feed +the app through Vite: + +- `@plone/components` +- `@plone/helpers` +- `@plone/cmsui` +- `@plone/layout` +- `@plone/plate` + +The script scans those packages for runtime imports of direct third-party +dependencies and compares them with the explicit entries declared in +`apps/aurora/vite.config.ts` and `packages/*/vite.extend.js`. + +If it reports missing entries, add them to the relevant optimize-deps config. +If it reports extra entries, remove them unless they are intentionally kept for +some unresolved import path. diff --git a/packages/scripts/check-vite-optimize-deps.js b/packages/scripts/check-vite-optimize-deps.js new file mode 100644 index 000000000..05c7ab5a1 --- /dev/null +++ b/packages/scripts/check-vite-optimize-deps.js @@ -0,0 +1,504 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const repoRoot = process.cwd(); + +const TARGETS = [ + { + id: 'components', + owner: '@plone/components', + packageDir: 'packages/components', + configFile: 'apps/aurora/vite.config.ts', + sourceRoots: ['src'], + }, + { + id: 'helpers', + owner: '@plone/helpers', + packageDir: 'packages/helpers', + configFile: 'apps/aurora/vite.config.ts', + sourceRoots: ['src'], + }, + { + id: 'cmsui', + owner: '@plone/cmsui', + packageDir: 'packages/cmsui', + configFile: 'packages/cmsui/vite.extend.js', + }, + { + id: 'layout', + owner: '@plone/layout', + packageDir: 'packages/layout', + configFile: 'packages/layout/vite.extend.js', + }, + { + id: 'plate', + owner: '@plone/plate', + packageDir: 'packages/plate', + configFile: 'packages/plate/vite.extend.js', + }, +]; + +const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']); + +function toPosix(filePath) { + return filePath.split(path.sep).join('/'); +} + +function shouldIgnoreFile(relativePath) { + const normalized = toPosix(relativePath); + + return ( + normalized.includes('/dist/') || + normalized.includes('/.storybook/') || + normalized.includes('/coverage/') || + normalized.includes('/news/') || + normalized.includes('/node_modules/') || + normalized.includes('/acceptance/') || + normalized.includes('/__tests__/') || + normalized.endsWith('.test.js') || + normalized.endsWith('.test.jsx') || + normalized.endsWith('.test.ts') || + normalized.endsWith('.test.tsx') || + normalized.endsWith('.spec.js') || + normalized.endsWith('.spec.jsx') || + normalized.endsWith('.spec.ts') || + normalized.endsWith('.spec.tsx') || + normalized.endsWith('.stories.js') || + normalized.endsWith('.stories.jsx') || + normalized.endsWith('.stories.ts') || + normalized.endsWith('.stories.tsx') || + normalized.endsWith('.story.js') || + normalized.endsWith('.story.jsx') || + normalized.endsWith('.story.ts') || + normalized.endsWith('.story.tsx') || + normalized.endsWith('/vite.config.js') || + normalized.endsWith('/vite.config.ts') || + normalized.endsWith('/vitest.config.js') || + normalized.endsWith('/vitest.config.ts') || + normalized.endsWith('/tsup.config.js') || + normalized.endsWith('/tsup.config.ts') || + normalized.endsWith('/vite.extend.js') || + normalized.endsWith('/vite.extend.ts') || + normalized.endsWith('/setupTesting.js') || + normalized.endsWith('/setupTesting.ts') + ); +} + +function listFiles(rootDir, sourceRoots = ['']) { + const results = []; + const stack = sourceRoots.length > 0 ? [...sourceRoots] : ['']; + + while (stack.length) { + const current = stack.pop(); + const absoluteCurrent = path.join(rootDir, current); + const entries = fs.readdirSync(absoluteCurrent, { withFileTypes: true }); + + for (const entry of entries) { + const relativePath = current + ? path.join(current, entry.name) + : entry.name; + + if (entry.isDirectory()) { + if (shouldIgnoreFile(relativePath)) { + continue; + } + stack.push(relativePath); + continue; + } + + if (!SOURCE_EXTENSIONS.has(path.extname(entry.name))) { + continue; + } + + if (shouldIgnoreFile(relativePath)) { + continue; + } + + results.push(relativePath); + } + } + + return results; +} + +function loadJson(relativePath) { + return JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8')); +} + +function getWorkspacePackages() { + const packageFiles = []; + for (const rootName of ['apps', 'packages']) { + const rootDir = path.join(repoRoot, rootName); + for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const packageFile = path.join(rootName, entry.name, 'package.json'); + if (fs.existsSync(path.join(repoRoot, packageFile))) { + packageFiles.push(packageFile); + } + } + } + + return new Set( + packageFiles.map((file) => loadJson(file).name).filter(Boolean), + ); +} + +function getPackageBase(specifier) { + if (specifier.startsWith('@')) { + const [scope, name] = specifier.split('/'); + return name ? `${scope}/${name}` : specifier; + } + const [name] = specifier.split('/'); + return name; +} + +function isRelative(specifier) { + return specifier.startsWith('.') || specifier.startsWith('/'); +} + +function isRuntimeImport(node) { + const clause = node.importClause; + if (!clause) { + return true; + } + if (clause.isTypeOnly) { + return false; + } + if (clause.name) { + return true; + } + if (!clause.namedBindings) { + return false; + } + if (ts.isNamespaceImport(clause.namedBindings)) { + return true; + } + return clause.namedBindings.elements.some((element) => !element.isTypeOnly); +} + +function isRuntimeExport(node) { + if (node.isTypeOnly) { + return false; + } + const clause = node.exportClause; + if (!clause) { + // `export * from 'pkg'` + return true; + } + if (ts.isNamespaceExport(clause)) { + // `export * as ns from 'pkg'` + return true; + } + // `export { a, type b } from 'pkg'` — only a type-only re-export list + // (every element marked `type`) should be treated as a non-runtime export. + return clause.elements.some((element) => !element.isTypeOnly); +} + +function isServerOnlyFile(relativeFile) { + return /\.server\.(js|jsx|ts|tsx)$/.test(toPosix(relativeFile)); +} + +function normalizeSpecifier(specifier) { + return specifier.replace(/\?.*$/, ''); +} + +function shouldIncludeSpecifier( + specifier, + directDependencies, + workspacePackages, +) { + if (!specifier || isRelative(specifier) || specifier.startsWith('node:')) { + return false; + } + + const base = getPackageBase(specifier); + if (workspacePackages.has(base)) { + return false; + } + + return directDependencies.has(base); +} + +function collectSpecifiers(packageDir, directDependencies, workspacePackages) { + const target = TARGETS.find((item) => item.packageDir === packageDir); + const files = listFiles( + path.join(repoRoot, packageDir), + target?.sourceRoots ?? [''], + ); + + // Track, per specifier, whether it was seen from a client-reachable file + // and/or from a `*.server.*` file — only specifiers seen exclusively from + // `*.server.*` files belong in ssr.optimizeDeps.include; anything else + // (client-only, or used from both) belongs in optimizeDeps.include. + const seenFrom = new Map(); + + const record = (specifier, isServerFile) => { + const entry = seenFrom.get(specifier) ?? { client: false, server: false }; + if (isServerFile) { + entry.server = true; + } else { + entry.client = true; + } + seenFrom.set(specifier, entry); + }; + + for (const relativeFile of files) { + const absoluteFile = path.join(repoRoot, packageDir, relativeFile); + if (!SOURCE_EXTENSIONS.has(path.extname(absoluteFile))) { + continue; + } + + const isServerFile = isServerOnlyFile(relativeFile); + const sourceText = fs.readFileSync(absoluteFile, 'utf8'); + const sourceFile = ts.createSourceFile( + absoluteFile, + sourceText, + ts.ScriptTarget.Latest, + true, + absoluteFile.endsWith('.tsx') || absoluteFile.endsWith('.jsx') + ? ts.ScriptKind.TSX + : ts.ScriptKind.TS, + ); + + const visit = (node) => { + if ( + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) && + isRuntimeImport(node) + ) { + const specifier = normalizeSpecifier(node.moduleSpecifier.text); + if ( + shouldIncludeSpecifier( + specifier, + directDependencies, + workspacePackages, + ) + ) { + record(specifier, isServerFile); + } + } + + if ( + ts.isExportDeclaration(node) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) && + isRuntimeExport(node) + ) { + const specifier = normalizeSpecifier(node.moduleSpecifier.text); + if ( + shouldIncludeSpecifier( + specifier, + directDependencies, + workspacePackages, + ) + ) { + record(specifier, isServerFile); + } + } + + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length === 1 && + ts.isStringLiteral(node.arguments[0]) + ) { + const specifier = normalizeSpecifier(node.arguments[0].text); + if ( + shouldIncludeSpecifier( + specifier, + directDependencies, + workspacePackages, + ) + ) { + record(specifier, isServerFile); + } + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + } + + const client = []; + const serverOnly = []; + for (const [specifier, seen] of seenFrom) { + (seen.client ? client : serverOnly).push(specifier); + } + + return { + client: client.sort((a, b) => a.localeCompare(b)), + serverOnly: serverOnly.sort((a, b) => a.localeCompare(b)), + }; +} + +function isPropertyNamed(node, name) { + return ( + (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) && + ts.isIdentifier(node.name) && + node.name.text === name + ); +} + +// Only entries actually declared in an `include` array count as declared — +// entries in a sibling `exclude` array must not be treated as covered. +function collectDeclaredEntries(configFile, owner) { + const contents = fs.readFileSync(path.join(repoRoot, configFile), 'utf8'); + const sourceFile = ts.createSourceFile( + configFile, + contents, + ts.ScriptTarget.Latest, + true, + configFile.endsWith('.ts') || configFile.endsWith('.tsx') + ? ts.ScriptKind.TS + : ts.ScriptKind.JS, + ); + + const prefix = `${owner} > `; + const client = new Set(); + const ssr = new Set(); + + const visit = (node, ancestorNames) => { + if ( + isPropertyNamed(node, 'include') && + node.initializer && + ts.isArrayLiteralExpression(node.initializer) + ) { + const target = ancestorNames.includes('ssr') ? ssr : client; + for (const element of node.initializer.elements) { + if ( + ts.isStringLiteralLike(element) && + element.text.startsWith(prefix) + ) { + target.add(element.text.slice(prefix.length)); + } + } + } + + const nextAncestors = + (ts.isPropertyAssignment(node) || + ts.isShorthandPropertyAssignment(node)) && + ts.isIdentifier(node.name) + ? [...ancestorNames, node.name.text] + : ancestorNames; + + ts.forEachChild(node, (child) => visit(child, nextAncestors)); + }; + + visit(sourceFile, []); + + return { + client: [...client].sort((a, b) => a.localeCompare(b)), + ssr: [...ssr].sort((a, b) => a.localeCompare(b)), + }; +} + +function diffEntries(expected, declared) { + const expectedSet = new Set(expected); + const declaredSet = new Set(declared); + + return { + missing: expected.filter((entry) => !declaredSet.has(entry)), + extra: declared.filter((entry) => !expectedSet.has(entry)), + }; +} + +function formatEntries(owner, entries) { + return entries.map((entry) => ` '${owner} > ${entry}',`).join('\n'); +} + +const workspacePackages = getWorkspacePackages(); + +const results = TARGETS.map((target) => { + const packageJson = loadJson(path.join(target.packageDir, 'package.json')); + const directDependencies = new Set( + Object.keys(packageJson.dependencies ?? {}), + ); + + const expected = collectSpecifiers( + target.packageDir, + directDependencies, + workspacePackages, + ); + const declared = collectDeclaredEntries(target.configFile, target.owner); + const clientDiff = diffEntries(expected.client, declared.client); + const ssrDiff = diffEntries(expected.serverOnly, declared.ssr); + + return { + ...target, + expected, + declared, + clientDiff, + ssrDiff, + }; +}); + +let hasDiff = false; +const reportedOk = new Set(); + +function reportSection(label, owner, diff, suggested) { + let printed = false; + + if (diff.missing.length) { + console.log(` Missing ${label} entries:`); + for (const entry of diff.missing) { + console.log(` ${owner} > ${entry}`); + } + printed = true; + } + + if (diff.extra.length) { + console.log(` Extra ${label} entries:`); + for (const entry of diff.extra) { + console.log(` ${owner} > ${entry}`); + } + printed = true; + } + + if (printed && suggested.length) { + console.log(` Suggested ${label} block:`); + console.log(formatEntries(owner, suggested)); + } +} + +for (const result of results) { + const clientOk = + !result.clientDiff.missing.length && !result.clientDiff.extra.length; + const ssrOk = !result.ssrDiff.missing.length && !result.ssrDiff.extra.length; + + if (clientOk && ssrOk) { + if (!reportedOk.has(result.configFile)) { + console.log(`OK ${result.configFile}`); + reportedOk.add(result.configFile); + } + continue; + } + + hasDiff = true; + console.log(`CHECK ${result.configFile}`); + + if (!clientOk) { + reportSection( + 'optimizeDeps.include', + result.owner, + result.clientDiff, + result.expected.client, + ); + } + + if (!ssrOk) { + reportSection( + 'ssr.optimizeDeps.include', + result.owner, + result.ssrDiff, + result.expected.serverOnly, + ); + } +} + +if (hasDiff) { + process.exitCode = 1; +} diff --git a/packages/scripts/news/+vite-optimize-deps-audit-fixes.internal b/packages/scripts/news/+vite-optimize-deps-audit-fixes.internal new file mode 100644 index 000000000..4e59a7366 --- /dev/null +++ b/packages/scripts/news/+vite-optimize-deps-audit-fixes.internal @@ -0,0 +1 @@ +Fixed the `check-vite-optimize-deps` audit script to ignore entries declared only in `optimizeDeps.exclude`, to correctly skip type-only named re-exports, and to check `ssr.optimizeDeps.include` separately for dependencies used only in `*.server.*` files. @sneridagh diff --git a/packages/scripts/news/+vite-optimize-deps-audit.internal b/packages/scripts/news/+vite-optimize-deps-audit.internal new file mode 100644 index 000000000..b5513c5f8 --- /dev/null +++ b/packages/scripts/news/+vite-optimize-deps-audit.internal @@ -0,0 +1 @@ +Added a workspace dependency audit script for Aurora Vite `optimizeDeps` declarations. @sneridagh diff --git a/packages/scripts/package.json b/packages/scripts/package.json index eed12d7e3..b589d912f 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -50,6 +50,7 @@ "wait-on": "^8.0.0" }, "devDependencies": { - "release-it": "catalog:" + "release-it": "catalog:", + "typescript": "catalog:" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8db8d53e..b4de4d677 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1559,6 +1559,9 @@ importers: release-it: specifier: 'catalog:' version: 19.0.5(@types/node@24.10.1)(magicast@0.3.5) + typescript: + specifier: 'catalog:' + version: 5.9.2 packages/theming: dependencies: @@ -6295,6 +6298,7 @@ packages: html2canvas-pro@1.5.12: resolution: {integrity: sha512-egtJIe6YXMKSLX/ls400OJD6tzEVtATJOE++mnXmxMWyqcu9HDXDoLiWeXnGv45QW2ZaIiDlXw46Gxqrqw6SEw==} engines: {node: '>=16.0.0'} + deprecated: All 1.5.x series are deprecated. Known rendering bugs exist and no further maintenance. Please upgrade to the latest 2.x release. htmlparser2@5.0.1: resolution: {integrity: sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==} @@ -6805,7 +6809,6 @@ packages: resolution: {integrity: sha512-Quz3MvAwHxVYNXsOByL7xI5EB2WYOeFswqaHIA3qOK3isRWTxiplBEocmmru6XmxDB2L7jDNYtYA4FyimoAFEw==} engines: {node: '>=8.17.0'} hasBin: true - bundledDependencies: [] jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}