diff --git a/.changeset/next-entry-discovery.md b/.changeset/next-entry-discovery.md new file mode 100644 index 0000000000..7ddfe61dae --- /dev/null +++ b/.changeset/next-entry-discovery.md @@ -0,0 +1,5 @@ +--- +'@workflow/next': patch +--- + +Discover workflows imported by every Next.js file convention entrypoint. diff --git a/packages/next/src/builder-eager.test.ts b/packages/next/src/builder-eager.test.ts new file mode 100644 index 0000000000..36b8f52f21 --- /dev/null +++ b/packages/next/src/builder-eager.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { createNextEntrypointMatcher } from './builder-eager.js'; + +const pageExtensions = ['js', 'jsx', 'ts', 'tsx', 'mts', 'cts']; +const isNextEntrypoint = createNextEntrypointMatcher(pageExtensions); + +describe('isNextEntrypoint', () => { + it.each([ + 'pages/index.tsx', + 'pages/api/run.ts', + 'src/pages/api/run.ts', + 'app/page.tsx', + 'app/dashboard/error.tsx', + 'app/@modal/default.tsx', + 'app/blog/opengraph-image1.tsx', + 'app/global-error.tsx', + 'app/robots.ts', + 'src/app/robots.ts', + 'instrumentation.ts', + 'instrumentation-client.ts', + 'instrumentation-client.mjs', + 'proxy.ts', + 'mdx-components.tsx', + 'mdx-components.mjs', + 'src/instrumentation-client.ts', + 'src/mdx-components.tsx', + ])('includes %s', (entry) => { + expect(isNextEntrypoint(entry)).toBe(true); + }); + + it.each([ + 'app/component.tsx', + 'app/error.test.tsx', + 'app/_components/error.tsx', + 'app/blog/global-error.tsx', + 'app/blog/robots.ts', + 'app/blog/opengraph-image12.tsx', + 'pages/types.d.ts', + 'pages/types.d.mts', + 'pages/types.d.cts', + 'app/page.d.ts', + 'app/page.vue', + ])('excludes %s', (entry) => { + expect(isNextEntrypoint(entry)).toBe(false); + }); + + it('supports compound page extensions', () => { + expect( + createNextEntrypointMatcher(['tsx', 'page.tsx'])('app/error.page.tsx') + ).toBe(true); + }); +}); diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index b6b9c33daf..ca4d006583 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -23,6 +23,48 @@ const importEsm = new Function('specifier', 'return import(specifier)') as ( specifier: string ) => Promise; +const appEntrypoint = + /^(?:page|route|layout|default|error|loading|template|not-found|forbidden|unauthorized|sitemap|(?:icon|apple-icon|opengraph-image|twitter-image)\d?)$/; +const appRootEntrypoint = /^(?:global-error|global-not-found|robots|manifest)$/; +const rootEntrypoint = /^(?:instrumentation|middleware|proxy)$/; +const rootModuleEntrypoint = + /^(?:instrumentation-client|mdx-components)\.(?:mjs|[jt]sx?)$/; +const rootModuleNames = ['instrumentation-client', 'mdx-components']; +const rootModuleExtensions = ['js', 'mjs', 'tsx', 'ts', 'jsx']; + +export function createNextEntrypointMatcher(pageExtensions: readonly string[]) { + const extensions = [...pageExtensions].sort((a, b) => b.length - a.length); + + return (entry: string): boolean => { + if (/\.d\.(?:cts|mts|ts)$/.test(entry)) return false; + + const sourceEntry = entry.replace(/^src\//, ''); + const path = sourceEntry.split('/'); + const filename = path[path.length - 1]; + if (rootModuleEntrypoint.test(sourceEntry)) return true; + + const extension = extensions.find((extension) => + sourceEntry.endsWith(`.${extension}`) + ); + if (!extension) return false; + + const name = filename.slice(0, -extension.length - 1); + if (path[0] === 'pages') return true; + + if (path[0] === 'app') { + const segments = path.slice(1, -1); + if (segments.some((segment) => segment.startsWith('_'))) return false; + + return ( + appEntrypoint.test(name) || + (path.length === 2 && appRootEntrypoint.test(name)) + ); + } + + return rootEntrypoint.test(sourceEntry.slice(0, -extension.length - 1)); + }; +} + // Create the eager Next builder dynamically by extending the ESM BaseBuilder. // Exported as getNextBuilderEager() to allow CommonJS modules to import from // the ESM @workflow/builders package via dynamic import at runtime. @@ -622,30 +664,33 @@ export async function getNextBuilderEager( protected async getInputFiles(): Promise { const inputFiles = await super.getInputFiles(); + const isNextEntrypoint = createNextEntrypointMatcher( + this.config.pageExtensions + ); + const inputFileSet = new Set(inputFiles); + const rootModuleFiles = new Set( + rootModuleNames.flatMap((name) => { + const file = ['src', ''] + .flatMap((directory) => + rootModuleExtensions.map((extension) => + join(this.config.workingDir, directory, `${name}.${extension}`) + ) + ) + .find((candidate) => inputFileSet.has(candidate)); + return file ? [file] : []; + }) + ); + return inputFiles.filter((file) => { const entry = relative(this.config.workingDir, file).replaceAll( '\\', '/' ); - - // Match App Router route, page, and layout entrypoints in app/ or src/app/. - if (/^(?:app|src\/app)\/(?:.*\/)?(?:route|page|layout)\./.test(entry)) { - return true; + const rootModule = entry.startsWith('src/') ? entry.slice(4) : entry; + if (rootModuleEntrypoint.test(rootModule)) { + return rootModuleFiles.has(file); } - - // Match every Pages Router entrypoint in pages/ or src/pages/. - if (/^(?:pages|src\/pages)\//.test(entry)) { - return true; - } - - // Match Next.js root entrypoints at the project root or under src/. - return ['instrumentation', 'middleware', 'proxy'].some((name) => - this.config.pageExtensions.some( - (extension) => - entry === `${name}.${extension}` || - entry === `src/${name}.${extension}` - ) - ); + return isNextEntrypoint(entry); }); }