diff --git a/docs/guide/pages-routing/markdown.md b/docs/guide/pages-routing/markdown.md index 52718ae90..688cb1663 100644 --- a/docs/guide/pages-routing/markdown.md +++ b/docs/guide/pages-routing/markdown.md @@ -241,6 +241,8 @@ import pages from '@void/md/pages'; // [{ path: "/docs/getting-started", title: "Getting Started", frontmatter: {...}, headings: [...] }, ...] ``` +The list holds only the pages for the current [environment](./overview.md#environment-only-pages): `.dev` pages on the dev server, `.prod` pages in builds. The suffix is dropped from `path`: `debug.dev.md` is listed as `/debug`. + Each entry has this shape: ```ts diff --git a/docs/guide/pages-routing/overview.md b/docs/guide/pages-routing/overview.md index 79da4da10..3c5074ccb 100644 --- a/docs/guide/pages-routing/overview.md +++ b/docs/guide/pages-routing/overview.md @@ -109,7 +109,29 @@ A companion can export: - [**Actions**](./actions-and-forms), which handle mutations from forms and programmatic calls. Export a single `action` or multiple [named actions](./actions-and-forms#named-actions) when a page has several mutations - `ssr = false` to opt a route out of server-rendered component HTML while keeping server loaders and client-side routing -File-based routing rules are the same as [server routing](../server-routing.md): `[param]` for dynamic segments, `[...param]` for catch-all, `(group)/` for route groups. +File-based routing rules are the same as [server routing](../server-routing.md): `[param]` for dynamic segments, `[...param]` for catch-all, `(group)/` for route groups, and a `.dev` or `.prod` suffix for [environment-only pages](#environment-only-pages). + +### Environment-only pages + +Add a `.dev` or `.prod` suffix before the extension to include a page in only one environment. The suffix is not part of the URL: + +``` +pages/ + about.tsx → /about, always + debug.dev.tsx → /debug, development only + stats.prod.tsx → /stats, production only + home.dev.tsx → /home in development + home.prod.tsx → /home in production +``` + +`.dev` pages run only on the dev server; `.prod` pages run in builds, deployments, and production previews. + +- A `.dev` page and a `.prod` page can share a URL. A plain page and a suffixed page for the same URL is an error wherever both run: `home.tsx` with `home.dev.tsx` fails on the dev server, and `home.tsx` with `home.prod.tsx` fails the build. +- The suffix goes **before** `.island`: `chart.dev.island.tsx` is a development-only island page. `chart.island.dev.tsx` is not an island page. +- A companion repeats the suffix: `home.dev.server.ts` belongs to `home.dev.tsx`. +- Suffixes apply to page files only. Layouts take no suffix. +- A resource used only by `.dev` pages and their companions is not provisioned for production. +- The dev server and builds generate types for their own environment. `void prepare` generates types for both. ## How Navigation Works diff --git a/docs/guide/server-routing.md b/docs/guide/server-routing.md index 35cd3d3a6..8ee13e8f9 100644 --- a/docs/guide/server-routing.md +++ b/docs/guide/server-routing.md @@ -29,7 +29,7 @@ routes/ `.dev` routes run only in development; `.prod` routes run in builds, deployments, and production previews. A resource used only by `.dev` routes is not provisioned for production. -Suffixes apply only in `routes/`, not in `pages/`, `middleware/`, `crons/`, or `queues/`. `void prepare` generates types for both environments. +Suffixes apply in `routes/` and [`pages/`](./pages-routing/overview.md#environment-only-pages), not in `middleware/`, `crons/`, or `queues/`. `void prepare` generates types for both environments. Each file exports named HTTP method constants to handle specific methods: diff --git a/docs/reference/structure.md b/docs/reference/structure.md index b42e701c3..0397e7bbf 100644 --- a/docs/reference/structure.md +++ b/docs/reference/structure.md @@ -85,6 +85,7 @@ Full-stack server-rendered pages with co-located data loading. Available with Vu - **`.server.ts` (or `.tsx`, `.js`, `.jsx`, `.mts`, `.mjs`) files** export `loader` for `GET` data and `action` for mutations, paired with the page component of the same name - **Layouts** nest automatically and persist across navigations - **Markdown** pages are supported with frontmatter +- **Environment suffix**: `debug.dev.tsx` is served in development only, `stats.prod.tsx` in production only See [Pages Routing](/guide/pages-routing/overview) for the full guide. diff --git a/packages/md/src/plugin.ts b/packages/md/src/plugin.ts index 08b104baf..84b74ee8a 100644 --- a/packages/md/src/plugin.ts +++ b/packages/md/src/plugin.ts @@ -3,7 +3,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'pathslash'; import { glob } from 'tinyglobby'; import { normalizePath, type Plugin, type ResolvedConfig, type ViteDevServer } from 'vite'; -import { resolvePagesDir } from 'void/pages'; +import { pageFileEnvironment, resolvePagesDir } from 'void/pages'; import type { CompileOptions } from './compile.ts'; import { emitReactComponent } from './emit-react.ts'; import { emitSolidComponent } from './emit-solid.ts'; @@ -44,6 +44,19 @@ function stripPageSuffix(file: string): string { .replace(/^index$/, ''); } +// The listed path drops the .dev / .prod suffix, like page routes do: +// debug.dev.md -> debug, index.dev.md -> ''. +function mdRouteName(file: string, env: 'dev' | 'prod' | null): string { + let name = file.slice(0, -'.md'.length); + if (env) { + name = name.slice(0, -('.' + env).length); + } + if (name === 'index') { + return ''; + } + return name.endsWith('/index') ? name.slice(0, -'/index'.length) : name; +} + function hasMdFilesWithScript(dir: string): boolean { try { for (const entry of readdirSync(dir, { withFileTypes: true, recursive: true })) { @@ -79,6 +92,8 @@ export function voidMarkdown(options?: MarkdownOptions): Array { let mdPages: ReadonlyArray = []; let root = ''; let pagesDir = ''; + // 'dev' on the dev server, 'prod' for builds and preview. + let mdEnvironment: 'dev' | 'prod' = 'prod'; const svelteModules = new Map(); const solidModules = new Map(); const clientScripts = new Map(); @@ -100,21 +115,26 @@ export function voidMarkdown(options?: MarkdownOptions): Array { } const mdFiles = await glob('**/*.md', { cwd: pagesDir }); + // Skip pages for the other environment, so their scripts are not added either. + const liveFiles = mdFiles.filter((file) => { + const fileEnv = pageFileEnvironment(file); + return !fileEnv || fileEnv === mdEnvironment; + }); const pages = await Promise.all( - mdFiles.map(async (file) => { + liveFiles.map(async (file) => { const content = readFileSync(join(pagesDir, file), 'utf-8'); const { body, clientCode } = extractScript(content); const result = extractMetadata(body); - const routeName = stripPageSuffix(file); - const componentId = routeName || 'index'; + // componentId keeps the suffix so it matches the page id from scanPages. + const componentId = stripPageSuffix(file) || 'index'; if (clientCode) { clientScripts.set(componentId, clientCode); } return { - path: '/' + routeName, + path: '/' + mdRouteName(file, pageFileEnvironment(file)), title: result.title, frontmatter: result.frontmatter, headings: result.headings, @@ -137,6 +157,7 @@ export function voidMarkdown(options?: MarkdownOptions): Array { }, config(config, env) { + mdEnvironment = env.command === 'serve' && !env.isPreview ? 'dev' : 'prod'; if (env.command === 'serve') { return; } diff --git a/packages/md/test/unit/plugin-source-dir.test.ts b/packages/md/test/unit/plugin-source-dir.test.ts index 9177f7c43..0ca6ebbce 100644 --- a/packages/md/test/unit/plugin-source-dir.test.ts +++ b/packages/md/test/unit/plugin-source-dir.test.ts @@ -65,6 +65,62 @@ describe('voidMarkdown sourceDir support', () => { expect(load('\0' + 'virtual:void-md-client')).toContain('virtual:void-md-script:posts/hello'); }); + async function scanForCommand(root: string, command: 'serve' | 'build') { + const plugin = createPlugin(); + const config = getHookHandler(plugin.config) as ( + config: { root: string }, + env: { command: 'serve' | 'build'; isPreview?: boolean }, + ) => unknown; + const configResolved = getHookHandler(plugin.configResolved) as ( + config: import('vite').ResolvedConfig, + ) => void; + const buildStart = getHookHandler(plugin.buildStart) as () => Promise; + const load = getHookHandler(plugin.load) as (id: string) => string | undefined; + + config({ root }, { command }); + configResolved({ + root, + plugins: [{ name: 'void-react:pages' }], + } as unknown as import('vite').ResolvedConfig); + await buildStart.call({} as never); + + const source = load('\0void-md-pages')!; + const pages = JSON.parse(source.slice('export default '.length, -1)) as Array<{ + path: string; + }>; + return { + paths: pages.map((page) => page.path), + clientEntry: load('\0' + 'virtual:void-md-client'), + }; + } + + it('lists .dev markdown pages without the suffix only on the dev server', async () => { + const root = createTempRoot(); + const pagesDir = join(root, 'src', 'pages'); + writeFileSync( + join(pagesDir, 'debug.dev.md'), + '\n\n# Debug\n', + ); + writeFileSync(join(pagesDir, 'index.dev.md'), '# Dev home\n'); + writeFileSync(join(pagesDir, 'posts', 'hello.md'), '# Hello\n'); + + const serve = await scanForCommand(root, 'serve'); + expect(serve.paths).toEqual(['/', '/debug', '/posts/hello']); + expect(serve.clientEntry).toContain('virtual:void-md-script:debug.dev'); + + const build = await scanForCommand(root, 'build'); + expect(build.paths).toEqual(['/posts/hello']); + expect(build.clientEntry).not.toContain('debug.dev'); + }); + + it('lists .prod markdown pages only in builds', async () => { + const root = createTempRoot(); + writeFileSync(join(root, 'src', 'pages', 'posts', 'stats.prod.md'), '# Stats\n'); + + expect((await scanForCommand(root, 'serve')).paths).toEqual([]); + expect((await scanForCommand(root, 'build')).paths).toEqual(['/posts/stats']); + }); + it('adds the markdown client entry for sourceDir pages with scripts and islands', () => { const root = createTempRoot(); writeFileSync( diff --git a/packages/react/src/plugin-islands.ts b/packages/react/src/plugin-islands.ts index c7bcce16c..fe513d5ac 100644 --- a/packages/react/src/plugin-islands.ts +++ b/packages/react/src/plugin-islands.ts @@ -73,7 +73,10 @@ export function islandsPlugin( async buildStart() { if (existsSync(pagesDir)) { - pageScan = await scanPages(root, { pagesDir }); + pageScan = await scanPages(root, { + pagesDir, + environment: isDev ? 'dev' : 'prod', + }); // Pre-scan island pages to populate the manifest eagerly. // This is needed because the client build may load the virtual // islands-client entry before island page transforms run. diff --git a/packages/react/src/plugin-pages.ts b/packages/react/src/plugin-pages.ts index 1e38ad0d6..8746022b3 100644 --- a/packages/react/src/plugin-pages.ts +++ b/packages/react/src/plugin-pages.ts @@ -74,7 +74,9 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu const pagesOptimizeEntries = `${relative(root, pagesDir)}/**/*.{js,jsx,ts,tsx}`; let pendingRescan: Promise | null = null; const rescanPages = async () => { - pageScan = existsSync(pagesDir) ? await scanPages(root, { pagesDir }) : null; + pageScan = existsSync(pagesDir) + ? await scanPages(root, { pagesDir, environment: isDev ? 'dev' : 'prod' }) + : null; }; const runRescan = () => { pendingRescan ??= rescanPages().finally(() => { @@ -174,6 +176,8 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu }, config(_, env) { + // Set before the early return: pages/ may be created after the server starts. + isDev = env.command === 'serve'; if (!existsSync(pagesDir)) { return; } @@ -182,8 +186,7 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu // virtual module and user components share the same React context objects. // Without this, Vite pre-bundles @void/react, creating separate // context instances that break useContext (React contexts use object identity). - if (env.command === 'serve') { - isDev = true; + if (isDev) { return { resolve: { dedupe: REACT_DEDUPE, diff --git a/packages/react/test/unit/plugin-islands.test.ts b/packages/react/test/unit/plugin-islands.test.ts index f737e3536..fd0d37f01 100644 --- a/packages/react/test/unit/plugin-islands.test.ts +++ b/packages/react/test/unit/plugin-islands.test.ts @@ -9,7 +9,7 @@ type HookWithHandler = { handler: T }; type IslandsPluginHooks = { configResolved?: (config: { root: string; - command: 'build'; + command: 'serve' | 'build'; plugins: ReadonlyArray; }) => void; buildStart?: (() => Promise) | HookWithHandler<() => Promise>; @@ -25,11 +25,12 @@ function writeFile(relativePath: string, content: string): void { writeFileSync(full, content); } -function setupPlugin() { +function setupPlugin(options?: { dev?: boolean }) { const plugins = islandsPlugin(PAGES_DIR); const plugin = plugins[0] as IslandsPluginHooks; - const config = { root: TMP, command: 'build' as const, plugins: [] }; + const command = options?.dev ? 'serve' : 'build'; + const config = { root: TMP, command: command as 'serve' | 'build', plugins: [] }; plugin.configResolved?.(config); return { @@ -178,3 +179,71 @@ export default function Layout({ children }) { return
{children}
; }`, expect(clientEntry).not.toContain('Sidebar'); }); }); + +describe('islandsPlugin — environment suffixes', () => { + beforeEach(() => { + mkdirSync(PAGES_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it('registers no .dev island page in a build', async () => { + writeFile( + 'x.dev.island.tsx', + `import Toggle from "./Toggle" with { island: "load" } +export default function X() { return ; }`, + ); + writeFile('Toggle.tsx', `export default function Toggle() { return ; }`); + + const { buildStart, loadClientEntry } = setupPlugin(); + await buildStart(); + + expect(loadClientEntry()).not.toContain('pages/Toggle'); + }); + + it('registers the .prod island page of a .dev + .prod pair in a build', async () => { + writeFile( + 'x.dev.island.tsx', + `import Toggle from "./Toggle" with { island: "load" } +export default function X() { return ; }`, + ); + writeFile( + 'x.prod.island.tsx', + `import Counter from "./Counter" with { island: "load" } +export default function X() { return ; }`, + ); + writeFile('Toggle.tsx', `export default function Toggle() { return ; }`); + writeFile('Counter.tsx', `export default function Counter() { return ; }`); + + const { buildStart, loadClientEntry } = setupPlugin(); + await buildStart(); + const clientEntry = loadClientEntry(); + + expect(clientEntry).toContain('pages/Counter'); + expect(clientEntry).not.toContain('pages/Toggle'); + }); + + it('registers the .dev island page of a .dev + .prod pair in dev', async () => { + writeFile( + 'x.dev.island.tsx', + `import Toggle from "./Toggle" with { island: "load" } +export default function X() { return ; }`, + ); + writeFile( + 'x.prod.island.tsx', + `import Counter from "./Counter" with { island: "load" } +export default function X() { return ; }`, + ); + writeFile('Toggle.tsx', `export default function Toggle() { return ; }`); + writeFile('Counter.tsx', `export default function Counter() { return ; }`); + + const { buildStart, loadClientEntry } = setupPlugin({ dev: true }); + await buildStart(); + const clientEntry = loadClientEntry(); + + expect(clientEntry).toContain('pages/Toggle'); + expect(clientEntry).not.toContain('pages/Counter'); + }); +}); diff --git a/packages/react/test/unit/plugin-pages.test.ts b/packages/react/test/unit/plugin-pages.test.ts index 11d6de15a..d297b80b4 100644 --- a/packages/react/test/unit/plugin-pages.test.ts +++ b/packages/react/test/unit/plugin-pages.test.ts @@ -203,3 +203,64 @@ describe('pagesPlugin dev config', () => { expect(recovered).toContain('"about"'); }); }); + +describe('pagesPlugin environment suffixes', () => { + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + function writeSuffixPages(): void { + writePage('index.tsx', 'export default function Index() { return

Home

; }\n'); + writePage('debug.dev.tsx', 'export default function Debug() { return

Debug

; }\n'); + writePage('home.dev.tsx', 'export default function Home() { return

Dev

; }\n'); + writePage('home.prod.tsx', 'export default function Home() { return

Prod

; }\n'); + } + + async function loadModules(command: 'serve' | 'build') { + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + getHookHandler(plugin.config)({}, { command }); + await getHookHandler(plugin.buildStart).call({}); + const load = getHookHandler(plugin.load); + return { + renderer: await load('\0virtual:void-pages-renderer'), + client: await load('\0virtual:void-pages-client'), + }; + } + + it('leaves .dev pages out of the build renderer and client entry', async () => { + writeSuffixPages(); + + const { renderer, client } = await loadModules('build'); + + for (const code of [renderer, client]) { + expect(code).not.toContain('debug.dev'); + expect(code).not.toContain('home.dev'); + expect(code).toContain('home.prod.tsx'); + expect(code).toContain('index.tsx'); + } + }); + + it('keeps .dev pages and leaves out .prod pages in serve', async () => { + writeSuffixPages(); + + const { renderer, client } = await loadModules('serve'); + + for (const code of [renderer, client]) { + expect(code).toContain('debug.dev.tsx'); + expect(code).toContain('home.dev.tsx'); + expect(code).not.toContain('home.prod'); + } + }); + + it('scans for dev when pages/ is created after the serve config', async () => { + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + expect(getHookHandler(plugin.config)({}, { command: 'serve' })).toBeUndefined(); + + writePage('debug.dev.tsx', 'export default function Debug() { return

Debug

; }\n'); + await getHookHandler(plugin.buildStart).call({}); + const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer'); + + expect(renderer).toContain('debug.dev.tsx'); + expect(renderer).toContain('/@react-refresh'); + }); +}); diff --git a/packages/solid/src/plugin-islands.ts b/packages/solid/src/plugin-islands.ts index fd2f0d29b..138ebb476 100644 --- a/packages/solid/src/plugin-islands.ts +++ b/packages/solid/src/plugin-islands.ts @@ -88,7 +88,10 @@ export function islandsPlugin( async buildStart() { if (existsSync(pagesDir)) { - pageScan = await scanPages(root, { pagesDir }); + pageScan = await scanPages(root, { + pagesDir, + environment: isDev ? 'dev' : 'prod', + }); // Pre-scan island pages to populate the manifest eagerly. // This is needed because the client build may load the virtual // islands-client entry before island page transforms run. diff --git a/packages/solid/src/plugin-pages.ts b/packages/solid/src/plugin-pages.ts index 6195176e1..65899de54 100644 --- a/packages/solid/src/plugin-pages.ts +++ b/packages/solid/src/plugin-pages.ts @@ -57,7 +57,9 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu const normalizedPagesDir = normalizePath(pagesDir); let pendingRescan: Promise | null = null; const rescanPages = async () => { - pageScan = existsSync(pagesDir) ? await scanPages(root, { pagesDir }) : null; + pageScan = existsSync(pagesDir) + ? await scanPages(root, { pagesDir, environment: isDev ? 'dev' : 'prod' }) + : null; }; const runRescan = () => { pendingRescan ??= rescanPages().finally(() => { @@ -157,12 +159,13 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu }, config(_, env) { + // Set before the early return: pages/ may be created after the server starts. + isDev = env.command === 'serve'; if (!existsSync(pagesDir)) { return; } - if (env.command === 'serve') { - isDev = true; + if (isDev) { return { optimizeDeps: { // Pre-include Solid entries discovered late via dynamic imports diff --git a/packages/solid/test/unit/plugin-islands.test.ts b/packages/solid/test/unit/plugin-islands.test.ts index 08fc22072..ed64f1495 100644 --- a/packages/solid/test/unit/plugin-islands.test.ts +++ b/packages/solid/test/unit/plugin-islands.test.ts @@ -12,19 +12,20 @@ function writeFile(relativePath: string, content: string): void { writeFileSync(full, content); } -function setupPlugin() { +function setupPlugin(options?: { dev?: boolean }) { const plugins = islandsPlugin(PAGES_DIR); const plugin = plugins[0] as unknown as { configResolved(config: { root: string; - command: 'build'; + command: 'serve' | 'build'; plugins: ReadonlyArray; }): void; buildStart: { call(thisArg: unknown): Promise | void }; load: { handler(id: string): string | undefined }; }; - const config = { root: TMP, command: 'build' as const, plugins: [] }; + const command = options?.dev ? 'serve' : 'build'; + const config = { root: TMP, command: command as 'serve' | 'build', plugins: [] }; plugin.configResolved(config); return { @@ -125,3 +126,77 @@ export default function Layout(props) { return
{props.children}
; }`, expect(clientEntry).not.toContain('Sidebar'); }); }); + +describe('islandsPlugin — environment suffixes', () => { + beforeEach(() => { + mkdirSync(PAGES_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it('registers no .dev island page in a build', async () => { + writeFile( + 'x.dev.island.tsx', + `import Toggle from "./Toggle" with { island: "load" } +export default function X() { return ; }`, + ); + writeFile('Toggle.tsx', `export default function Toggle() { return ; }`); + + const { buildStart, loadClientEntry } = setupPlugin(); + await buildStart(); + + expect(loadClientEntry()).not.toContain('pages/Toggle'); + }); + + it('registers the .prod island page of a .dev + .prod pair in a build', async () => { + writeFile( + 'x.dev.island.tsx', + `import Toggle from "./Toggle" with { island: "load" } +export default function X() { return ; }`, + ); + writeFile( + 'x.prod.island.tsx', + `import Counter from "./Counter" with { island: "load" } +export default function X() { return ; }`, + ); + writeFile('Toggle.tsx', `export default function Toggle() { return ; }`); + writeFile( + 'Counter.tsx', + `export default function Counter() { return ; }`, + ); + + const { buildStart, loadClientEntry } = setupPlugin(); + await buildStart(); + const clientEntry = loadClientEntry(); + + expect(clientEntry).toContain('pages/Counter'); + expect(clientEntry).not.toContain('pages/Toggle'); + }); + + it('registers the .dev island page of a .dev + .prod pair in dev', async () => { + writeFile( + 'x.dev.island.tsx', + `import Toggle from "./Toggle" with { island: "load" } +export default function X() { return ; }`, + ); + writeFile( + 'x.prod.island.tsx', + `import Counter from "./Counter" with { island: "load" } +export default function X() { return ; }`, + ); + writeFile('Toggle.tsx', `export default function Toggle() { return ; }`); + writeFile( + 'Counter.tsx', + `export default function Counter() { return ; }`, + ); + + const { buildStart, loadClientEntry } = setupPlugin({ dev: true }); + await buildStart(); + const clientEntry = loadClientEntry(); + + expect(clientEntry).toContain('pages/Toggle'); + expect(clientEntry).not.toContain('pages/Counter'); + }); +}); diff --git a/packages/solid/test/unit/plugin-pages.test.ts b/packages/solid/test/unit/plugin-pages.test.ts index c39dbbf6e..632ee463f 100644 --- a/packages/solid/test/unit/plugin-pages.test.ts +++ b/packages/solid/test/unit/plugin-pages.test.ts @@ -150,3 +150,58 @@ describe('pagesPlugin dev HMR', () => { expect(recovered).toContain('"about"'); }); }); + +describe('pagesPlugin environment suffixes', () => { + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it('leaves .dev pages out of the build renderer and client entry', async () => { + writePage('index.tsx', 'export default function Page() { return

Home

; }\n'); + writePage('debug.dev.tsx', 'export default function Page() { return

Debug

; }\n'); + writePage('home.dev.tsx', 'export default function Page() { return

Dev

; }\n'); + writePage('home.prod.tsx', 'export default function Page() { return

Prod

; }\n'); + + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + getHookHandler(plugin.config)({}, { command: 'build' }); + await getHookHandler(plugin.buildStart).call({}); + const load = getHookHandler(plugin.load); + + for (const id of ['\0virtual:void-pages-renderer', '\0virtual:void-pages-client']) { + const code = await load(id); + expect(code).not.toContain('debug.dev'); + expect(code).not.toContain('home.dev'); + expect(code).toContain('home.prod.tsx'); + } + }); + + it('keeps .dev pages and leaves out .prod pages in serve', async () => { + writePage('index.tsx', 'export default function Page() { return

Home

; }\n'); + writePage('debug.dev.tsx', 'export default function Page() { return

Debug

; }\n'); + writePage('home.dev.tsx', 'export default function Page() { return

Dev

; }\n'); + writePage('home.prod.tsx', 'export default function Page() { return

Prod

; }\n'); + + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + getHookHandler(plugin.config)({}, { command: 'serve' }); + await getHookHandler(plugin.buildStart).call({}); + const load = getHookHandler(plugin.load); + + for (const id of ['\0virtual:void-pages-renderer', '\0virtual:void-pages-client']) { + const code = await load(id); + expect(code).toContain('debug.dev.tsx'); + expect(code).toContain('home.dev.tsx'); + expect(code).not.toContain('home.prod'); + } + }); + + it('scans for dev when pages/ is created after the serve config', async () => { + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + expect(getHookHandler(plugin.config)({}, { command: 'serve' })).toBeUndefined(); + + writePage('debug.dev.tsx', 'export default function Page() { return

Debug

; }\n'); + await getHookHandler(plugin.buildStart).call({}); + const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer'); + + expect(renderer).toContain('debug.dev.tsx'); + }); +}); diff --git a/packages/svelte/src/plugin-islands.ts b/packages/svelte/src/plugin-islands.ts index af30c975f..b8dfa8d60 100644 --- a/packages/svelte/src/plugin-islands.ts +++ b/packages/svelte/src/plugin-islands.ts @@ -92,7 +92,10 @@ export function islandsPlugin( async buildStart() { if (existsSync(pagesDir)) { - pageScan = await scanPages(root, { pagesDir }); + pageScan = await scanPages(root, { + pagesDir, + environment: isDev ? 'dev' : 'prod', + }); // Pre-scan island pages to populate the manifest eagerly. // This is needed because the client build may load the virtual // islands-client entry before island page transforms run. diff --git a/packages/svelte/src/plugin-pages.ts b/packages/svelte/src/plugin-pages.ts index 8dec2b458..cba27c0ec 100644 --- a/packages/svelte/src/plugin-pages.ts +++ b/packages/svelte/src/plugin-pages.ts @@ -58,7 +58,9 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu const normalizedPagesDir = normalizePath(pagesDir); let pendingRescan: Promise | null = null; const rescanPages = async () => { - pageScan = existsSync(pagesDir) ? await scanPages(root, { pagesDir }) : null; + pageScan = existsSync(pagesDir) + ? await scanPages(root, { pagesDir, environment: isDev ? 'dev' : 'prod' }) + : null; }; const runRescan = () => { pendingRescan ??= rescanPages().finally(() => { @@ -158,12 +160,13 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu }, config(_, env) { + // Set before the early return: pages/ may be created after the server starts. + isDev = env.command === 'serve'; if (!existsSync(pagesDir)) { return; } - if (env.command === 'serve') { - isDev = true; + if (isDev) { return { optimizeDeps: SVELTE_OPTIMIZE_DEPS, environments: { diff --git a/packages/svelte/test/unit/plugin-islands.test.ts b/packages/svelte/test/unit/plugin-islands.test.ts index 757abc4c8..2bb5b6041 100644 --- a/packages/svelte/test/unit/plugin-islands.test.ts +++ b/packages/svelte/test/unit/plugin-islands.test.ts @@ -12,7 +12,7 @@ type HookWithHandler = { handler: T }; type IslandsPluginHooks = { configResolved?: (config: { root: string; - command: 'build'; + command: 'serve' | 'build'; plugins: ReadonlyArray; }) => void; buildStart?: (() => Promise) | HookWithHandler<() => Promise>; @@ -25,11 +25,12 @@ function writeFile(relativePath: string, content: string): void { writeFileSync(full, content); } -function setupPlugin() { +function setupPlugin(options?: { dev?: boolean }) { const plugins = islandsPlugin(PAGES_DIR); const plugin = plugins[0] as IslandsPluginHooks; - const config = { root: TMP, command: 'build' as const, plugins: [] }; + const command = options?.dev ? 'serve' : 'build'; + const config = { root: TMP, command: command as 'serve' | 'build', plugins: [] }; plugin.configResolved?.(config); return { @@ -282,3 +283,81 @@ describe('islandsPlugin — island style loader', () => { expect(watched).toContain(normalizePath(partial)); }); }); + +describe('islandsPlugin — environment suffixes', () => { + beforeEach(() => { + mkdirSync(PAGES_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it('registers no .dev island page in a build', async () => { + writeFile( + 'x.dev.island.svelte', + ` +`, + ); + writeFile('Toggle.svelte', ``); + + const { buildStart, loadClientEntry } = setupPlugin(); + await buildStart(); + + expect(loadClientEntry()).not.toContain('pages/Toggle'); + }); + + it('registers the .prod island page of a .dev + .prod pair in a build', async () => { + writeFile( + 'x.dev.island.svelte', + ` +`, + ); + writeFile( + 'x.prod.island.svelte', + ` +`, + ); + writeFile('Toggle.svelte', ``); + writeFile('Counter.svelte', ``); + + const { buildStart, loadClientEntry } = setupPlugin(); + await buildStart(); + const clientEntry = loadClientEntry(); + + expect(clientEntry).toContain('pages/Counter'); + expect(clientEntry).not.toContain('pages/Toggle'); + }); + + it('registers the .dev island page of a .dev + .prod pair in dev', async () => { + writeFile( + 'x.dev.island.svelte', + ` +`, + ); + writeFile( + 'x.prod.island.svelte', + ` +`, + ); + writeFile('Toggle.svelte', ``); + writeFile('Counter.svelte', ``); + + const { buildStart, loadClientEntry } = setupPlugin({ dev: true }); + await buildStart(); + const clientEntry = loadClientEntry(); + + expect(clientEntry).toContain('pages/Toggle'); + expect(clientEntry).not.toContain('pages/Counter'); + }); +}); diff --git a/packages/svelte/test/unit/plugin-pages.test.ts b/packages/svelte/test/unit/plugin-pages.test.ts index 25b949d66..96a46443d 100644 --- a/packages/svelte/test/unit/plugin-pages.test.ts +++ b/packages/svelte/test/unit/plugin-pages.test.ts @@ -136,3 +136,58 @@ describe('pagesPlugin dev HMR', () => { }); }); }); + +describe('pagesPlugin environment suffixes', () => { + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it('leaves .dev pages out of the build renderer and client entry', async () => { + writePage('index.svelte', '

Home

\n'); + writePage('debug.dev.svelte', '

Debug

\n'); + writePage('home.dev.svelte', '

Dev

\n'); + writePage('home.prod.svelte', '

Prod

\n'); + + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + getHookHandler(plugin.config)({}, { command: 'build' }); + await getHookHandler(plugin.buildStart).call({}); + const load = getHookHandler(plugin.load); + + for (const id of ['\0virtual:void-pages-renderer', '\0virtual:void-pages-client']) { + const code = await load(id); + expect(code).not.toContain('debug.dev'); + expect(code).not.toContain('home.dev'); + expect(code).toContain('home.prod.svelte'); + } + }); + + it('keeps .dev pages and leaves out .prod pages in serve', async () => { + writePage('index.svelte', '

Home

\n'); + writePage('debug.dev.svelte', '

Debug

\n'); + writePage('home.dev.svelte', '

Dev

\n'); + writePage('home.prod.svelte', '

Prod

\n'); + + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + getHookHandler(plugin.config)({}, { command: 'serve' }); + await getHookHandler(plugin.buildStart).call({}); + const load = getHookHandler(plugin.load); + + for (const id of ['\0virtual:void-pages-renderer', '\0virtual:void-pages-client']) { + const code = await load(id); + expect(code).toContain('debug.dev.svelte'); + expect(code).toContain('home.dev.svelte'); + expect(code).not.toContain('home.prod'); + } + }); + + it('scans for dev when pages/ is created after the serve config', async () => { + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + expect(getHookHandler(plugin.config)({}, { command: 'serve' })).toBeUndefined(); + + writePage('debug.dev.svelte', '

Debug

\n'); + await getHookHandler(plugin.buildStart).call({}); + const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer'); + + expect(renderer).toContain('debug.dev.svelte'); + }); +}); diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 1a91a2511..2374f96e8 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -165,7 +165,9 @@ HTTP methods are determined by **named exports** in each file (detected by `dete `listRouteFiles()` uses `ROUTE_EXTENSIONS`, shared with HTTP/WebSocket filename parsing, resource-name inference, and route binding inference. Both route scanners use it, with `isWebSocketPatternFile()` sharing the WebSocket filename regex to distinguish `.ws.*` files. `listConventionFiles()` keeps fixed script extensions for crons and queues. Void sets no JSX options: route JSX follows framework plugins, tsconfig (TS files only) or `oxc.jsx`. Page `.server.tsx` and `.server.jsx` companions supply server metadata and handlers; exclude them from component inventories and component manifests. -The recorded `env` is enforced by `isRouteEnabledForEnvironment()`. The scanners take an optional `environment` (`'dev' | 'prod'`); when it is omitted no filtering happens. `dev` is the Vite dev server, `prod` is every build, `void deploy`, and `vite preview`. A dev-only route is absent from the production bundle, types, generated route/WebSocket config, and inferred bindings. `void prepare` boots no Vite, passes no `environment`, and generates types for both environments. `voidPlugin()` scans unfiltered source during construction for binding-name reservation and plugin membership, then recomputes environment-specific bindings in its `config` hook before emitting Cloudflare config. Binding inference filters only recognized route files under `paths.routesDir`; the suffix has no effect in `pages/`, `middleware/`, `crons/`, `queues/`, or other source files. +The recorded `env` is enforced by `isRouteEnabledForEnvironment()`. The scanners take an optional `environment` (`'dev' | 'prod'`); when it is omitted no filtering happens. `dev` is the Vite dev server, `prod` is every build, `void deploy`, and `vite preview`. A dev-only route is absent from the production bundle, types, generated route/WebSocket config, and inferred bindings. `void prepare` boots no Vite, passes no `environment`, and generates types for both environments. `voidPlugin()` scans unfiltered source during construction for binding-name reservation and plugin membership, then recomputes environment-specific bindings in its `config` hook before emitting Cloudflare config. Binding inference filters recognized route files under `paths.routesDir`, and (with `filterPages`, off for framework apps) Void page files and their `.server` companions under `paths.pagesDir`, read with `pageFileEnvironment()`. The suffix has no effect in `middleware/`, `crons/`, `queues/`, or other source files. + +Void pages use the same suffix. `scanPages()` takes `environment` and drops pages for the other environment before reading their companion. The URL drops the suffix; the page id keeps it (`home.dev`), so manifest and `@void/md` script keys still match. Core passes `analysis.environment` through `scanRoutes()` (unset for framework apps). Adapter pages and islands plugins pass `dev` in serve and `prod` in build. `hasIslandPages()` scans with `prod` (builds only) and `watchPagesForRescan()` with `dev` (dev server only). An unfiltered scan runs the same-route check and shadow warning once for `dev` and once for `prod`: a `.dev` + `.prod` pair passes, a plain + `.dev` page clashes. `generateRouteTypes()` writes one `PageActionMap` key for a pair and joins the two shapes with `&`. `@void/md` filters with `pageFileEnvironment()` (exported from `void/pages`) and drops the suffix from the listed `path`. Layouts are never filtered: `layout.dev.tsx` is a page, and `_layouts/x.dev.vue` is a layout named `x.dev`. ## Code Generation (`void gen`) diff --git a/packages/void/src/application-analysis.ts b/packages/void/src/application-analysis.ts index 6d3a4008d..3eff5a858 100644 --- a/packages/void/src/application-analysis.ts +++ b/packages/void/src/application-analysis.ts @@ -59,7 +59,7 @@ export function analyzeApplicationSync( const scanDirs = config.inference?.scanDirs ?? (framework ? FRAMEWORK_SCAN_DIRS : undefined); const inferred = inferProjectBindings( root, - { paths, environment, bindings: config.inference?.bindings }, + { paths, environment, filterPages: !framework, bindings: config.inference?.bindings }, scanDirs, ); const authConfigPath = findVoidAuthConfig(root, paths); diff --git a/packages/void/src/application-routing.ts b/packages/void/src/application-routing.ts index fd21d1660..15e4d69b8 100644 --- a/packages/void/src/application-routing.ts +++ b/packages/void/src/application-routing.ts @@ -12,6 +12,7 @@ export async function completeApplicationAnalysis( paths: analysis.paths, // A framework app's pages/ follows that framework's rules, not Void Pages. checkPageRoutes: !analysis.framework, + environment: analysis.framework ? undefined : analysis.environment, inventory: { routes: analysis.routes, middleware: analysis.middleware, diff --git a/packages/void/src/codegen/route-types.ts b/packages/void/src/codegen/route-types.ts index 1ceb5592b..9f6851042 100644 --- a/packages/void/src/codegen/route-types.ts +++ b/packages/void/src/codegen/route-types.ts @@ -102,19 +102,33 @@ export function generateRouteTypes( lines.push(' action: never;'); lines.push(' };'); lines.push(''); + // A .dev + .prod page pair is a union of two page shapes. A type parameter + // makes these helpers read each shape on its own, as in void/routes. + lines.push(' type PageActionBody

= [Extract] extends [never]'); + lines.push(' ? Record'); + lines.push(' : Extract["body"];'); + lines.push(''); + lines.push(' type PageNamedActionBody = P extends { actions: infer A }'); + lines.push(' ? K extends keyof A'); + lines.push(' ? A[K] extends { body: infer B }'); + lines.push(' ? B'); + lines.push(' : Record'); + lines.push(' : never'); + lines.push(' : never;'); + lines.push(''); + lines.push(' type PageActionUrl = P extends { body: any }'); + lines.push(' ? K'); + lines.push(' : P extends { actions: infer A }'); + lines.push(' ? `${K}?${keyof A & string}`'); + lines.push(' : never;'); + lines.push(''); lines.push(' export type ResolveActionBody ='); lines.push(' ParseActionUrl["base"] extends keyof PageActionMap'); lines.push(' ? ParseActionUrl["action"] extends never'); - lines.push(' ? PageActionMap[ParseActionUrl["base"]] extends { body: infer B }'); - lines.push(' ? B'); - lines.push(' : Record'); - lines.push(' : PageActionMap[ParseActionUrl["base"]] extends { actions: infer A }'); - lines.push(' ? ParseActionUrl["action"] extends keyof A'); - lines.push(' ? A[ParseActionUrl["action"]] extends { body: infer B }'); - lines.push(' ? B'); - lines.push(' : Record'); - lines.push(' : never'); - lines.push(' : never'); + lines.push(' ? PageActionBody["base"]]>'); + lines.push( + ' : PageNamedActionBody["base"]], ParseActionUrl["action"]>', + ); lines.push(' : Record;'); lines.push(''); lines.push(' export type ResolveActionParams ='); @@ -136,17 +150,9 @@ export function generateRouteTypes( lines.push(' ? { params: ResolveActionParams }'); lines.push(' : { params?: never });'); lines.push(''); - lines.push(' export type ActionUrl ='); - lines.push(' | {'); - lines.push( - ' [K in keyof PageActionMap]: PageActionMap[K] extends { body: any } ? K : never;', - ); - lines.push(' }[keyof PageActionMap]'); - lines.push(' | {'); - lines.push(' [K in keyof PageActionMap]: PageActionMap[K] extends { actions: infer A }'); - lines.push(' ? `${K & string}?${keyof A & string}`'); - lines.push(' : never;'); - lines.push(' }[keyof PageActionMap]'); + lines.push(' export type ActionUrl = {'); + lines.push(' [K in keyof PageActionMap]: PageActionUrl;'); + lines.push(' }[keyof PageActionMap]'); lines.push(''); lines.push(' interface RouteMap {'); @@ -224,6 +230,10 @@ export function generateRouteTypes( if (actionPages.length > 0 && pagesDir) { lines.push(' interface PageActionMap {'); + // A .dev + .prod page pair shares one pattern: write one key and join the shapes + // with |. The page at that URL is one environment's page or the other's, so each + // page's own action body must type-check. + const bodiesByPattern = new Map>>(); for (const page of actionPages) { const hasAction = page.methods.includes('action'); const hasActions = page.methods.includes('actions'); @@ -234,29 +244,45 @@ export function generateRouteTypes( ); } - lines.push(` ${JSON.stringify(page.pattern)}: {`); + const body: Array = []; // Page actions are only detected in a companion .server file. const importPath = relative(outDir, `${pagesDir}/${page.serverPath!}`); if (hasActions) { const actionsImport = `(typeof import(${JSON.stringify(importPath)}))["actions"]`; - lines.push(` actions: { [K in keyof ${actionsImport}]: {`); - lines.push( + body.push(` actions: { [K in keyof ${actionsImport}]: {`); + body.push( ` body: ExtractInput<${actionsImport}[K]["__validators"]> extends { body: infer B } ? B : Record;`, ); - lines.push(' } };'); + body.push(' } };'); } else { const importExpr = `(typeof import(${JSON.stringify(importPath)}))["action"]`; - lines.push( + body.push( ` body: ExtractInput<${importExpr}["__validators"]> extends { body: infer B } ? B : Record;`, ); } if (page.params.length > 0) { const paramsType = page.params.map((p) => `${p}: string`).join('; '); - lines.push(` params: { ${paramsType} };`); + body.push(` params: { ${paramsType} };`); + } + + const bodies = bodiesByPattern.get(page.pattern); + if (bodies) { + bodies.push(body); + } else { + bodiesByPattern.set(page.pattern, [body]); } + } + for (const [pattern, bodies] of bodiesByPattern) { + lines.push(` ${JSON.stringify(pattern)}: {`); + bodies.forEach((body, index) => { + if (index > 0) { + lines.push(' } | {'); + } + lines.push(...body); + }); lines.push(' };'); } diff --git a/packages/void/src/pages/index.ts b/packages/void/src/pages/index.ts index d3a2d3444..428c45828 100644 --- a/packages/void/src/pages/index.ts +++ b/packages/void/src/pages/index.ts @@ -3,6 +3,7 @@ export { resolveLayoutChain, resolveNamedLayout, resolveEffectiveLayoutChain, + pageFileEnvironment, } from './scan.ts'; export type { PageDefinition, diff --git a/packages/void/src/pages/islands-plugin.ts b/packages/void/src/pages/islands-plugin.ts index bf0c7ce45..3573d4f65 100644 --- a/packages/void/src/pages/islands-plugin.ts +++ b/packages/void/src/pages/islands-plugin.ts @@ -68,7 +68,10 @@ export async function hasIslandPages(root: string, pagesDir: string): Promise page.island); + // Only builds call this, so count the pages a build keeps. + return (await scanPages(root, { pagesDir, environment: 'prod' })).pages.some( + (page) => page.island, + ); } catch { // buildStart scans the pages again and reports the error. return false; @@ -327,7 +330,8 @@ export function watchPagesForRescan( } debounceTimer = setTimeout(() => { debounceTimer = null; - pending = scanPages(root, { pagesDir }) + // Only the dev server watches pages, so keep the dev pages. + pending = scanPages(root, { pagesDir, environment: 'dev' }) .then( (pageScan) => { onRescan(pageScan); diff --git a/packages/void/src/pages/scan.ts b/packages/void/src/pages/scan.ts index e84a19abb..46cc5546c 100644 --- a/packages/void/src/pages/scan.ts +++ b/packages/void/src/pages/scan.ts @@ -1,10 +1,14 @@ import { existsSync } from 'node:fs'; -import { join, dirname, basename, extname } from 'pathslash'; +import { join, dirname, basename, extname, toSlash } from 'pathslash'; import { glob } from 'tinyglobby'; import { parseSync } from 'vite'; import { voidWarn } from '../log.ts'; import { readProjectPaths, type ProjectPaths } from '../project-paths.ts'; -import { parseRouteFilename } from '../router/parse-filename.ts'; +import { + isRouteEnabledForEnvironment, + parseRouteFilename, + type RouteEnvironment, +} from '../router/parse-filename.ts'; import { bindingNames } from '../shared/ast-bindings.ts'; import { isIdentifier, @@ -59,6 +63,8 @@ type PageScanOptions = { pagesDir?: string; /** Report pages that share a route. Off when another framework owns pages/ (Nuxt nests users.vue and users/index.vue). */ checkRoutes?: boolean; + /** Keep only pages for this environment (`.dev` / `.prod` suffix). Unset keeps every page. */ + environment?: RouteEnvironment; }; type ResolvedPageRendering = { @@ -210,6 +216,31 @@ function resolvePageRendering( return { ssrMode, prerenderMode, renderMode, shouldPrerender }; } +/** + * The environment a file under pages/ is limited to, read from its name the way + * scanPages does: `home.dev.tsx`, `home.dev.island.vue` and `home.dev.server.ts` + * give 'dev'. Files the scanner skips (`_` segments, other extensions) give null. + */ +export function pageFileEnvironment(relativePath: string): RouteEnvironment | null { + const segments = toSlash(relativePath).split('/'); + if (segments.some((segment) => segment.startsWith('_'))) { + return null; + } + const file = segments[segments.length - 1]; + const ext = extname(file); + let base = basename(file, ext); + if (ROUTE_EXTENSIONS_SET.has(ext) && base.endsWith('.server')) { + base = base.slice(0, -'.server'.length); + } else if (COMPONENT_EXTENSIONS.includes(ext.slice(1))) { + if (base.endsWith('.island')) { + base = base.slice(0, -'.island'.length); + } + } else { + return null; + } + return base.endsWith('.dev') ? 'dev' : base.endsWith('.prod') ? 'prod' : null; +} + export async function scanPages(root: string, options?: PageScanOptions): Promise { const pagesDir = options?.pagesDir ?? options?.paths?.pagesDir ?? readProjectPaths(root).pagesDir; if (!existsSync(pagesDir)) { @@ -283,6 +314,10 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis ? file.replace(`.island${ext}`, '.ts') : file.replace(ext, '.ts'); const route = parseRouteFilename(parseName); + // Skip pages for the other environment before reading their companion. + if (!isRouteEnabledForEnvironment(route, options?.environment)) { + continue; + } const componentId = file .replace(ext, '') @@ -366,6 +401,7 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis island, metadata: metadata ? { ...metadata } : undefined, layout, + ...(route.env ? { env: route.env } : {}), }); } @@ -383,8 +419,16 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis }); if (options?.checkRoutes !== false) { - assertOnePagePerRoute(pages); - warnShadowedPages(pagesDir, pages); + // At most one page per route in each environment. A scan for every + // environment checks dev and prod on their own, so a .dev + .prod pair is fine. + const environments = options?.environment ? [options.environment] : (['dev', 'prod'] as const); + for (const environment of environments) { + const live = pages.filter((page) => + isRouteEnabledForEnvironment({ env: page.env ?? null }, environment), + ); + assertOnePagePerRoute(live); + warnShadowedPages(pagesDir, live); + } } // Validate: throw when regular pages are under island layouts diff --git a/packages/void/src/pages/types.ts b/packages/void/src/pages/types.ts index 50fbdc860..43fb8788f 100644 --- a/packages/void/src/pages/types.ts +++ b/packages/void/src/pages/types.ts @@ -1,3 +1,5 @@ +import type { RouteEnvironment } from '../router/parse-filename.ts'; + export type ExplicitBoolean = true | false | undefined; export type SsrMode = 'auto' | 'server' | 'client'; @@ -43,6 +45,8 @@ export type PageDefinition = { metadata?: { title?: string; description?: string }; /** Named layout selection: "name", "!name", or false */ layout?: string | false; + /** Environment from a `.dev` / `.prod` file suffix. Unset means both. */ + env?: RouteEnvironment; }; export type LayoutDefinition = { diff --git a/packages/void/src/plugin-inference.ts b/packages/void/src/plugin-inference.ts index 4763c78a8..9d24b843f 100644 --- a/packages/void/src/plugin-inference.ts +++ b/packages/void/src/plugin-inference.ts @@ -3,6 +3,7 @@ import { join, toSlash } from 'pathslash'; import { globSync } from 'tinyglobby'; import { applyBindingOverrides } from './application-features.ts'; import { inferBindingsFromSource, mergeBindings, type InferredBindings } from './inference/walk.ts'; +import { pageFileEnvironment } from './pages/scan.ts'; import { readProjectPaths, type ProjectPaths } from './project-paths.ts'; import { isRouteEnabledForEnvironment, @@ -113,6 +114,8 @@ export type InferenceOptions = { paths?: ProjectPaths; /** Filter route files for a Vite serve/build environment. Other source files are always scanned. */ environment?: RouteEnvironment; + /** Also filter Void page files and their `.server` companions by environment. Off for framework apps. */ + filterPages?: boolean; }; function extractAstroFrontmatter(code: string): string { @@ -151,6 +154,8 @@ export function inferProjectBindings( const allBindings: Array = []; const scannedFiles = new Set(); const routesPrefix = `${toSlash(paths.routesDir).replace(/\/$/, '')}/`; + const pagesDir = toSlash(paths.pagesDir); + const pagesPrefix = pagesDir.endsWith('/') ? pagesDir : `${pagesDir}/`; const scanSourceFiles = (dirPath: string): Array => globSync(conventionFileGlobs(dirPath, SOURCE_GLOB, paths.routesDir), { cwd: dirPath, @@ -173,6 +178,12 @@ export function inferProjectBindings( return; } } + if (options?.environment && options.filterPages && normalizedFile.startsWith(pagesPrefix)) { + const env = pageFileEnvironment(normalizedFile.slice(pagesPrefix.length)); + if (!isRouteEnabledForEnvironment({ env }, options.environment)) { + return; + } + } allBindings.push( deriveFromSource(file, 'bindings', (code) => inferBindingsFromFile(file, code)), diff --git a/packages/void/src/router/scan.ts b/packages/void/src/router/scan.ts index 88d2361be..bfd07780e 100644 --- a/packages/void/src/router/scan.ts +++ b/packages/void/src/router/scan.ts @@ -76,6 +76,8 @@ export async function scanRoutes( paths: ProjectPaths; inventory: Omit; checkPageRoutes?: boolean; + /** Keep only pages for this environment. Unset keeps every page. */ + environment?: RouteEnvironment; }, ): Promise { const { paths, inventory } = options; @@ -83,6 +85,7 @@ export async function scanRoutes( output: options.output, paths, checkRoutes: options.checkPageRoutes, + environment: options.environment, }); const routes = inventory.routes .map((route) => ({ diff --git a/packages/void/src/routes-stub.ts b/packages/void/src/routes-stub.ts index dcff20047..6d12a4c4d 100644 --- a/packages/void/src/routes-stub.ts +++ b/packages/void/src/routes-stub.ts @@ -27,19 +27,26 @@ export type ParseActionUrl = U extends `${infer Base}?${infer : { base: U; action: never }; /** Resolve body type for a useForm URL (single action or named action) */ +// A .dev + .prod page pair is a union of two page shapes. A type parameter +// makes these read each shape on its own. A shape with named actions has no +// bare-URL body, so it must not add the untyped fallback to the other's. +type PageActionBody

= [Extract] extends [never] + ? Record + : Extract['body']; + +type PageNamedActionBody = P extends { actions: infer A } + ? K extends keyof A + ? A[K] extends { body: infer B } + ? B + : Record + : never + : never; + export type ResolveActionBody = ParseActionUrl['base'] extends keyof PageActionMap ? ParseActionUrl['action'] extends never - ? PageActionMap[ParseActionUrl['base']] extends { body: infer B } - ? B - : Record - : PageActionMap[ParseActionUrl['base']] extends { actions: infer A } - ? ParseActionUrl['action'] extends keyof A - ? A[ParseActionUrl['action']] extends { body: infer B } - ? B - : Record - : never - : never + ? PageActionBody['base']]> + : PageNamedActionBody['base']], ParseActionUrl['action']> : Record; /** Resolve params type */ @@ -63,13 +70,13 @@ type ActionFormBaseOptions = { resetOnSuccess?: boolean }; export type ActionFormOptions = ActionFormBaseOptions & (HasActionParams extends true ? { params: ResolveActionParams } : { params?: never }); +type PageActionUrl = P extends { body: unknown } + ? K + : P extends { actions: infer A } + ? `${K}?${keyof A & string}` + : never; + /** Valid useForm URLs — single-action pages + "page?name" combos + default actions */ -export type ActionUrl = - | { - [K in keyof PageActionMap]: PageActionMap[K] extends { body: unknown } ? K : never; - }[keyof PageActionMap] - | { - [K in keyof PageActionMap]: PageActionMap[K] extends { actions: infer A } - ? `${K & string}?${keyof A & string}` - : never; - }[keyof PageActionMap]; +export type ActionUrl = { + [K in keyof PageActionMap]: PageActionUrl; +}[keyof PageActionMap]; diff --git a/packages/void/test/integration/page-action-types-typecheck.test.ts b/packages/void/test/integration/page-action-types-typecheck.test.ts new file mode 100644 index 000000000..c1c20165a --- /dev/null +++ b/packages/void/test/integration/page-action-types-typecheck.test.ts @@ -0,0 +1,166 @@ +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { generateRouteTypes } from '../../src/codegen/route-types.ts'; +import type { PageDefinition } from '../../src/pages/types.ts'; +import type { ScanResult } from '../../src/router/scan.ts'; + +const voidDist = join(import.meta.dirname, '..', '..', 'dist'); +const roots: Array = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function page(env: 'dev' | 'prod', methods: Array): PageDefinition { + return { + pattern: '/contact', + componentPath: `contact.${env}.vue`, + serverPath: `contact.${env}.server.ts`, + componentId: `contact.${env}`, + params: [], + catchAll: false, + methods, + ssrMode: 'auto', + prerenderMode: 'auto', + renderMode: 'server', + shouldPrerender: false, + island: false, + env, + }; +} + +const schema = (body: string) => + [ + "import { defineHandler } from 'void';", + 'type Schema = {', + " readonly '~standard': {", + ' readonly version: 1;', + ' readonly vendor: string;', + ' readonly validate: (value: unknown) => { value: T };', + ' readonly types?: { readonly input: T; readonly output: T };', + ' };', + '};', + `declare const body: Schema<${body}>;`, + ].join('\n'); + +function typecheck(pages: Array, files: Record) { + const root = mkdtempSync(join(tmpdir(), 'void-page-action-types-')); + roots.push(root); + const { dts } = generateRouteTypes( + { + routes: [], + middleware: [], + jobs: [], + queues: [], + pages: { pages, layouts: [], namedLayouts: [] }, + } satisfies ScanResult, + join(root, 'routes'), + join(root, '.void'), + join(root, 'pages'), + ); + const all: Record = { + 'tsconfig.json': JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + lib: ['ES2022'], + types: [], + strict: true, + noEmit: true, + skipLibCheck: true, + allowImportingTsExtensions: true, + paths: { + void: [join(voidDist, 'index.d.mts')], + 'void/routes': [join(voidDist, 'routes-stub.d.mts')], + }, + }, + files: ['.void/routes.d.ts', 'check.ts'], + }), + '.void/routes.d.ts': dts, + ...files, + }; + for (const [path, content] of Object.entries(all)) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), content); + } + const tsc = join( + dirname(createRequire(import.meta.url).resolve('typescript/package.json')), + 'bin/tsc', + ); + return spawnSync(process.execPath, [tsc, '-p', join(root, 'tsconfig.json')], { + encoding: 'utf8', + }); +} + +// The same constraint every adapter's useForm puts on its URL. +const useForm = [ + "import type { ActionUrl, ResolveActionBody } from 'void/routes';", + 'declare function useForm(url: U, body: ResolveActionBody): void;', +]; + +describe('page action types for a .dev + .prod page pair', () => { + it('accepts the body of each page when the two bodies differ', () => { + const companion = (age: string) => + `${schema(`{ email: string; age: ${age} }`)} +export const action = defineHandler.withValidator({ body })(() => ({ ok: true }));`; + const result = typecheck([page('dev', ['action']), page('prod', ['action'])], { + 'pages/contact.dev.server.ts': companion('string'), + 'pages/contact.prod.server.ts': companion('number'), + 'check.ts': [ + ...useForm, + "useForm('/contact', { email: 'a@b.c', age: '1' });", + "useForm('/contact', { email: 'a@b.c', age: 1 });", + '// @ts-expect-error the body is still typed', + "useForm('/contact', { email: 1, age: 1 });", + ].join('\n'), + }); + expect(result.stdout + result.stderr).toBe(''); + expect(result.status).toBe(0); + }); + + it('accepts both URLs when only one page of the pair has named actions', () => { + const result = typecheck([page('dev', ['action']), page('prod', ['actions'])], { + 'pages/contact.dev.server.ts': `${schema('{ email: string }')} +export const action = defineHandler.withValidator({ body })(() => ({ ok: true }));`, + 'pages/contact.prod.server.ts': `${schema('{ title: string }')} +export const actions = { save: defineHandler.withValidator({ body })(() => ({ ok: true })) };`, + 'check.ts': [ + ...useForm, + "useForm('/contact', { email: 'a@b.c' });", + '// @ts-expect-error the single action body is still typed', + "useForm('/contact', { email: 1 });", + "useForm('/contact?save', { title: 'x' });", + '// @ts-expect-error the named action body is still typed', + "useForm('/contact?save', { title: 1 });", + ].join('\n'), + }); + expect(result.stdout + result.stderr).toBe(''); + expect(result.status).toBe(0); + }); + + it('accepts the named actions of each page when the two sets differ', () => { + const companion = (name: string) => + `${schema('{ title: string }')} +export const actions = { ${name}: defineHandler.withValidator({ body })(() => ({ ok: true })) };`; + const result = typecheck([page('dev', ['actions']), page('prod', ['actions'])], { + 'pages/contact.dev.server.ts': companion('save'), + 'pages/contact.prod.server.ts': companion('publish'), + 'check.ts': [ + ...useForm, + "useForm('/contact?save', { title: 'x' });", + "useForm('/contact?publish', { title: 'x' });", + '// @ts-expect-error neither page has this action', + "useForm('/contact?remove', { title: 'x' });", + ].join('\n'), + }); + expect(result.stdout + result.stderr).toBe(''); + expect(result.status).toBe(0); + }); +}); diff --git a/packages/void/test/integration/tsx-server.test.ts b/packages/void/test/integration/tsx-server.test.ts index 7ce483106..d6583b022 100644 --- a/packages/void/test/integration/tsx-server.test.ts +++ b/packages/void/test/integration/tsx-server.test.ts @@ -15,6 +15,12 @@ const companionPages = [ { name: 'foo', extension: 'tsx' }, { name: 'bar', extension: 'jsx' }, ]; +// Environment-only pages as [file, text]; each has a server-rendered companion with an action. +const environmentPages = [ + ['debug.dev', 'DEV_ONLY_PAGE'], + ['greeting.dev', 'Development page'], + ['greeting.prod', 'Production page'], +]; describe.each(['react', 'hono/jsx'])('server JSX with %s', (jsxImportSource) => { let playground: IsolatedPlayground; @@ -79,6 +85,20 @@ export const loader = defineHandler(() => ({ html: renderToStaticMarkup(

{base export const actions = { save: defineHandler(c => c.html(renderToStaticMarkup(${name}_COMPANION_ACTION_ONLY))) };`, ); } + for (const [file, text] of environmentPages) { + writeFileSync( + join(root, `pages/${file}.tsx`), + 'export default function Page({ text }) { return

{text}

; }', + ); + writeFileSync( + join(root, `pages/${file}.server.tsx`), + `import { defineHandler } from 'void'; +export const ssr = true; +export const prerender = false; +export const loader = defineHandler(() => ({ text: ${JSON.stringify(text)} })); +export const action = defineHandler(c => c.text(${JSON.stringify(text)}));`, + ); + } } const dev = await startServer( @@ -158,6 +178,37 @@ export const actions = { save: defineHandler(c => c.html(renderToStaticMarkup( { + const debug = await fetch(`${bases[env]}/debug`); + expect(debug.status).toBe(env === 'dev' ? 200 : 404); + if (env === 'dev') { + expect(await debug.text()).toContain('DEV_ONLY_PAGE'); + } + const greeting = await fetch(`${bases[env]}/greeting`); + expect(greeting.status).toBe(200); + expect(await greeting.text()).toContain( + `

${env === 'dev' ? 'Development' : 'Production'} page

`, + ); + }); + + it('leaves .dev pages out of the production types and bundles', () => { + const prodTypes = readFileSync(join(playground.root, '.void/routes.d.ts'), 'utf8'); + expect(devTypes).toContain('pages/greeting.dev.server.tsx'); + expect(devTypes).not.toContain('greeting.prod'); + expect(prodTypes).toContain('pages/greeting.prod.server.tsx'); + expect(prodTypes).not.toContain('greeting.dev'); + expect(prodTypes).not.toContain('debug.dev'); + for (const graph of readdirSync(join(playground.root, '.void')).filter( + (file) => file.startsWith('jsx-') && file.endsWith('.json'), + )) { + const modules = readFileSync(join(playground.root, '.void', graph), 'utf8'); + expect(modules, graph).not.toContain('debug.dev'); + expect(modules, graph).not.toContain('greeting.dev'); + } + const client = readFileSync(join(playground.root, '.void/jsx-client.json'), 'utf8'); + expect(client).toContain('pages/greeting.prod.tsx'); + }); + it.each(companionPages.flatMap((page) => ['dev', 'prod'].map((env) => ({ ...page, env }))))( 'executes .server.$extension companion loaders and actions in $env', async ({ name, env }) => { diff --git a/packages/void/test/unit/pages-islands-rescan.test.ts b/packages/void/test/unit/pages-islands-rescan.test.ts index 36a911353..97130700b 100644 --- a/packages/void/test/unit/pages-islands-rescan.test.ts +++ b/packages/void/test/unit/pages-islands-rescan.test.ts @@ -3,7 +3,8 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { ViteDevServer } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; -import { watchPagesForRescan } from '../../src/pages/islands-plugin.ts'; +import { hasIslandPages, watchPagesForRescan } from '../../src/pages/islands-plugin.ts'; +import type { PageScanResult } from '../../src/pages/types.ts'; const TMP = join(import.meta.dirname, '__tmp_pages_islands_rescan__'); const PAGES_DIR = join(TMP, 'pages'); @@ -15,7 +16,7 @@ function writePage(relativePath: string, source: string): string { return full; } -function setup(onRescan: () => void) { +function setup(onRescan: (scan: PageScanResult) => void) { const watcher = new EventEmitter(); const server = { watcher, environments: {} } as unknown as ViteDevServer; const { flush } = watchPagesForRescan(server, PAGES_DIR, TMP, onRescan); @@ -61,4 +62,29 @@ describe('watchPagesForRescan', () => { await expect(emitAdd(writePage('about.md', '# About\n'))).rejects.toThrow('rescan failed'); }); + + it('keeps only dev pages', async () => { + writePage('x.dev.island.tsx', 'export default () => null;'); + const onRescan = vi.fn(); + const { emitAdd } = setup(onRescan); + + await emitAdd(writePage('x.prod.island.tsx', 'export default () => null;')); + const scan = onRescan.mock.calls[0]![0] as PageScanResult; + expect(scan.pages.map((page) => page.componentPath)).toEqual(['x.dev.island.tsx']); + }); +}); + +describe('hasIslandPages', () => { + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it('counts only the island pages a build keeps', async () => { + writePage('index.tsx', 'export default () => null;'); + writePage('x.dev.island.tsx', 'export default () => null;'); + expect(await hasIslandPages(TMP, PAGES_DIR)).toBe(false); + + writePage('y.prod.island.tsx', 'export default () => null;'); + expect(await hasIslandPages(TMP, PAGES_DIR)).toBe(true); + }); }); diff --git a/packages/void/test/unit/pages-scan.test.ts b/packages/void/test/unit/pages-scan.test.ts index d7c732687..4278f5a9a 100644 --- a/packages/void/test/unit/pages-scan.test.ts +++ b/packages/void/test/unit/pages-scan.test.ts @@ -11,9 +11,12 @@ import { resolveNamedLayout, resolveEffectiveLayoutChain, MAX_REVALIDATE, + pageFileEnvironment, } from '../../src/pages/scan.ts'; +import * as sourceAnalysisCache from '../../src/shared/source-analysis-cache.ts'; vi.mock('tinyglobby', { spy: true }); +vi.mock('../../src/shared/source-analysis-cache.ts', { spy: true }); const TMP = join(import.meta.dirname, '__tmp_pages_scan__'); @@ -973,6 +976,133 @@ const example = 'export const layout = "home"'; } }); }); + + describe('environment suffixes', () => { + const page = 'export default () => null;'; + const ids = async (environment?: 'dev' | 'prod') => + (await scanPages(TMP, { environment })).pages.map((p) => p.componentId); + + it('keeps a .dev page only in dev and in unfiltered scans', async () => { + writeFile('debug.dev.tsx', page); + + for (const environment of ['dev', undefined] as const) { + const { pages } = await scanPages(TMP, { environment }); + expect(pages).toHaveLength(1); + expect(pages[0]).toMatchObject({ + pattern: '/debug', + componentId: 'debug.dev', + componentPath: 'debug.dev.tsx', + env: 'dev', + }); + } + expect(await ids('prod')).toEqual([]); + }); + + it('allows a .dev + .prod pair on one route', async () => { + writeFile('home.dev.tsx', page); + writeFile('home.prod.tsx', page); + + expect(await ids('dev')).toEqual(['home.dev']); + expect(await ids('prod')).toEqual(['home.prod']); + expect(await ids()).toEqual(['home.dev', 'home.prod']); + }); + + it('rejects a plain page and a .dev page on one route where both run', async () => { + writeFile('home.tsx', page); + writeFile('home.dev.tsx', page); + const message = [ + 'pages: Multiple page files resolve to the same route:', + ' "/home": "home.dev.tsx", "home.tsx"', + ].join('\n'); + + await expect(scanPages(TMP, { environment: 'dev' })).rejects.toThrow(message); + await expect(scanPages(TMP)).rejects.toThrow(message); + expect(await ids('prod')).toEqual(['home']); + }); + + it('rejects a plain page and a .prod page on one route where both run', async () => { + writeFile('home.tsx', page); + writeFile('home.prod.tsx', page); + const message = [ + 'pages: Multiple page files resolve to the same route:', + ' "/home": "home.prod.tsx", "home.tsx"', + ].join('\n'); + + await expect(scanPages(TMP, { environment: 'prod' })).rejects.toThrow(message); + await expect(scanPages(TMP)).rejects.toThrow(message); + expect(await ids('dev')).toEqual(['home']); + }); + + it('rejects two .dev pages on one route', async () => { + writeFile('home.dev.tsx', page); + writeFile('home/index.dev.tsx', page); + + await expect(scanPages(TMP, { environment: 'dev' })).rejects.toThrow( + ' "/home": "home.dev.tsx", "home/index.dev.tsx"', + ); + }); + + it('warns about shadowed pages only when both run in one environment', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + writeFile('env-a/[id].dev.tsx', page); + writeFile('env-a/[slug].prod.tsx', page); + writeFile('env-b/[id].tsx', page); + writeFile('env-b/[slug].dev.tsx', page); + await scanPages(TMP); + + expect(log).toHaveBeenCalledOnce(); + expect(log.mock.calls[0][0]).toContain( + "pages: Page 'env-b/[slug].dev.tsx' (/env-b/:slug) is never used: 'env-b/[id].tsx' (/env-b/:id)", + ); + } finally { + log.mockRestore(); + } + }); + + it('reads the suffix before .island', async () => { + writeFile('x.dev.island.tsx', page); + + const { pages } = await scanPages(TMP, { environment: 'dev' }); + expect(pages[0]).toMatchObject({ pattern: '/x', componentId: 'x.dev', island: true }); + expect(await ids('prod')).toEqual([]); + }); + + it('never reads the companion of a page left out', async () => { + writeFile('x.dev.tsx', page); + writeFile('x.dev.server.ts', 'export const loader = () => ({});'); + + const dev = await scanPages(TMP, { environment: 'dev' }); + expect(dev.pages[0]).toMatchObject({ serverPath: 'x.dev.server.ts', methods: ['loader'] }); + + writeFile('x.dev.server.ts', 'export const loader = ('); + const derive = vi.mocked(sourceAnalysisCache.deriveFromSource); + derive.mockClear(); + expect((await scanPages(TMP, { environment: 'prod' })).pages).toEqual([]); + expect(derive.mock.calls.map(([path]) => path)).toEqual([]); + }); + + it('drops the suffix from an index page but keeps it in the id', async () => { + writeFile('index.dev.tsx', page); + + const { pages } = await scanPages(TMP, { environment: 'dev' }); + expect(pages[0]).toMatchObject({ pattern: '/', componentId: 'index.dev' }); + }); + + it('does not scope layouts', async () => { + writeFile('layout.dev.tsx', page); + writeFile('_layouts/a.dev.vue', ''); + + const dev = await scanPages(TMP, { environment: 'dev' }); + expect(dev.pages.map((p) => p.pattern)).toEqual(['/layout']); + expect(dev.layouts).toEqual([]); + const prod = await scanPages(TMP, { environment: 'prod' }); + expect(prod.pages).toEqual([]); + for (const scan of [dev, prod]) { + expect(scan.namedLayouts.map((layout) => layout.name)).toEqual(['a.dev']); + } + }); + }); }); describe('resolveLayoutChain', () => { @@ -1233,3 +1363,58 @@ describe('scanPages between saves', () => { }); }); }); + +describe('pageFileEnvironment', () => { + it.each([ + ['home.tsx', null], + ['home.dev.tsx', 'dev'], + ['home.dev.island.vue', 'dev'], + ['home.prod.server.ts', 'prod'], + ['docs.dev.md', 'dev'], + ['_lib/x.dev.ts', null], + ['utils.dev.ts', null], + ['layout.dev.tsx', 'dev'], + ['_layouts/x.dev.vue', null], + ])('%s -> %s', (file, env) => { + expect(pageFileEnvironment(file)).toBe(env); + }); + + describe('matches scanPages', () => { + beforeEach(() => { + mkdirSync(join(TMP, 'pages'), { recursive: true }); + }); + + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it('for every page and companion the scanner keeps', async () => { + const files = [ + 'debug.dev.tsx', + 'home.dev.tsx', + 'home.prod.tsx', + 'about.tsx', + 'x.dev.island.tsx', + 'y.dev.tsx', + 'index.dev.tsx', + 'layout.dev.tsx', + '(g)/[id].prod.vue', + 'notes.prod.md', + ]; + for (const file of files) { + writeFile(file, 'export default () => null;'); + } + writeFile('y.dev.server.ts', 'export const loader = () => ({});'); + + const { pages } = await scanPages(TMP, { checkRoutes: false }); + expect(pages.map((page) => page.componentPath).sort()).toEqual([...files].sort()); + for (const page of pages) { + expect(pageFileEnvironment(page.componentPath)).toBe(page.env ?? null); + if (page.serverPath) { + expect(pageFileEnvironment(page.serverPath)).toBe(page.env ?? null); + } + } + expect(pages.find((page) => page.serverPath)?.serverPath).toBe('y.dev.server.ts'); + }); + }); +}); diff --git a/packages/void/test/unit/plugin-inference.test.ts b/packages/void/test/unit/plugin-inference.test.ts index 4e6e6a166..36914624b 100644 --- a/packages/void/test/unit/plugin-inference.test.ts +++ b/packages/void/test/unit/plugin-inference.test.ts @@ -2,7 +2,9 @@ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { analyzeApplicationSync } from '../../src/application-analysis.ts'; import { LATEST_KNOWN_COMPAT_DATE } from '../../src/cli/wrangler.ts'; +import { readConfig } from '../../src/config.ts'; import { voidPlugin } from '../../src/index.ts'; import { inferProjectBindings, detectFramework } from '../../src/plugin-inference.ts'; import { getHookHandler } from '../helpers/vite-hooks.ts'; @@ -255,6 +257,49 @@ describe('inferProjectBindings', () => { expect(result.needsKV).toBe(true); }); + it('filters Void page files and companions by environment with filterPages', () => { + const root = createTempRoot(); + mkdirSync(join(root, 'pages/_lib'), { recursive: true }); + writeFileSync(join(root, 'pages/debug.dev.tsx'), 'export default () => null;'); + writeFileSync( + join(root, 'pages/debug.dev.server.ts'), + "import { db } from 'void/db'; export const loader = () => db.select();", + ); + writeFileSync(join(root, 'pages/_lib/x.dev.ts'), "import { kv } from 'void/kv';"); + const infer = (environment?: 'dev' | 'prod', filterPages = true) => + inferProjectBindings(root, { environment, filterPages }, ['pages']); + + expect(infer('dev').needsD1).toBe(true); + expect(infer(undefined).needsD1).toBe(true); + // No binding for a feature only a .dev page uses. + expect(infer('prod')).toMatchObject({ needsD1: false, needsKV: true }); + // Framework apps own pages/, so their files are not filtered. + expect(infer('prod', false).needsD1).toBe(true); + + const analyze = (environment: 'dev' | 'prod') => + analyzeApplicationSync(root, { config: readConfig(root), environment }).bindings.needsD1; + expect(analyze('dev')).toBe(true); + expect(analyze('prod')).toBe(false); + }); + + it('keeps the bindings of .dev page files in a framework app', () => { + const root = createTempRoot(); + mkdirSync(join(root, 'pages'), { recursive: true }); + writeFileSync(join(root, 'package.json'), JSON.stringify({ dependencies: { nuxt: '1.0.0' } })); + writeFileSync(join(root, 'pages/debug.dev.vue'), ''); + writeFileSync( + join(root, 'pages/debug.dev.server.ts'), + "import { db } from 'void/db'; export const loader = () => db.select();", + ); + + expect(detectFramework(root)?.name).toBe('nuxt'); + // Nuxt owns pages/, so a .dev file there is not a Void page and is not filtered. + expect( + analyzeApplicationSync(root, { config: readConfig(root), environment: 'prod' }).bindings + .needsD1, + ).toBe(true); + }); + it('detects auth from void/client import with auth specifier in src/', () => { const root = createTempRoot(); writeFileSync( diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index 8f3be1045..51fd7cd82 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -364,6 +364,31 @@ describe('routingPlugin', () => { }, ); + it('ignores a .prod page added next to its .dev page in dev', async () => { + const root = createTempRoot(); + mkdirSync(join(root, 'pages'), { recursive: true }); + const action = 'export const action = () => ({});'; + writeFileSync(join(root, 'pages', 'home.dev.tsx'), 'export default () => null;'); + writeFileSync(join(root, 'pages', 'home.dev.server.ts'), action); + + const plugin = createRoutingPlugin(root, { isAuthEnabled: () => false }); + runConfig(plugin, { command: 'serve', mode: 'development' }); + resolveConfig(plugin, 'serve'); + await runBuildStart(plugin); + const { emit, logError } = startDevServer(plugin, PAGE_MODULE_IDS); + const typesPath = join(root, '.void', 'routes.d.ts'); + const before = readFileSync(typesPath, 'utf8'); + expect(before).toContain('pages/home.dev.server.ts'); + + writeFileSync(join(root, 'pages', 'home.prod.server.ts'), action); + const prodPath = join(root, 'pages', 'home.prod.tsx'); + writeFileSync(prodPath, 'export default () => null;'); + await emit('add', prodPath); + + expect(logError).not.toHaveBeenCalled(); + expect(readFileSync(typesPath, 'utf8')).toBe(before); + }); + it('keeps page and route modules for saves that change no scanned facts', async () => { const root = createTempRoot(); mkdirSync(join(root, 'pages'), { recursive: true }); diff --git a/packages/void/test/unit/route-types.test.ts b/packages/void/test/unit/route-types.test.ts index 8c2fc9942..cab3198a7 100644 --- a/packages/void/test/unit/route-types.test.ts +++ b/packages/void/test/unit/route-types.test.ts @@ -269,6 +269,52 @@ describe('generateRouteTypes', () => { expect(dts).toContain('params: { id: string }'); }); + it('joins a .dev + .prod page pair into one PageActionMap key', () => { + const page = { + ...pageRenderingDefaults, + pattern: '/users/:id', + params: ['id'], + catchAll: false, + }; + const scan = makeScan({ + pages: [ + { + ...page, + componentPath: 'users/[id].dev.vue', + serverPath: 'users/[id].dev.server.ts', + methods: ['action'], + componentId: 'users/[id].dev', + env: 'dev', + }, + { + ...page, + componentPath: 'users/[id].prod.vue', + serverPath: 'users/[id].prod.server.ts', + methods: ['actions'], + componentId: 'users/[id].prod', + env: 'prod', + }, + ], + }); + + const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void', '/project/pages'); + const actionMap = dts.split(' interface PageActionMap {')[1]!; + const dev = '(typeof import("../pages/users/[id].dev.server.ts"))["action"]'; + const prod = '(typeof import("../pages/users/[id].prod.server.ts"))["actions"]'; + + expect(actionMap.split('"/users/:id"')).toHaveLength(2); + expect(actionMap).toContain( + [ + ' "/users/:id": {', + ` body: ExtractInput<${dev}["__validators"]> extends { body: infer B } ? B : Record;`, + ' params: { id: string };', + ' } | {', + ` actions: { [K in keyof ${prod}]: {`, + ].join('\n'), + ); + expect(actionMap).toContain([' params: { id: string };', ' };', ' }'].join('\n')); + }); + it('generates WebSocketRouteMap entries', () => { const scan = makeScan({ websockets: [ @@ -378,6 +424,10 @@ describe('generateRouteTypes', () => { expect(dts).toContain('[K in keyof'); expect(dts).toContain('["actions"]'); expect(dts).toContain('ParseActionUrl["action"]'); + // A named-actions shape must not add the untyped fallback to a bare-URL body. + expect(dts).toContain( + 'type PageActionBody

= [Extract] extends [never]', + ); }); it('errors when both action and actions are present', () => { diff --git a/packages/void/test/unit/scan.test.ts b/packages/void/test/unit/scan.test.ts index 6f8025d20..d6fad45e2 100644 --- a/packages/void/test/unit/scan.test.ts +++ b/packages/void/test/unit/scan.test.ts @@ -102,6 +102,50 @@ describe('scanRoutes', () => { }, ); + it.each([undefined, 'dev', 'prod'] as const)( + 'keeps pages for environment=%s and writes one action key for a .dev + .prod pair', + async (environment) => { + createFile('pages/debug.dev.tsx', 'export default () => null;'); + createFile('pages/home.dev.tsx', 'export default () => null;'); + createFile('pages/home.dev.server.ts', 'export const action = () => ({});'); + createFile('pages/home.prod.tsx', 'export default () => null;'); + createFile('pages/home.prod.server.ts', 'export const actions = { save: () => ({}) };'); + + const result = await scanRoutes(TMP, { environment }); + const expected = [ + ...(environment !== 'prod' ? ['debug.dev.tsx', 'home.dev.tsx'] : []), + ...(environment !== 'dev' ? ['home.prod.tsx'] : []), + ]; + expect(result.pages.pages.map((page) => page.componentPath).sort()).toEqual(expected); + + const { dts } = generateRouteTypes( + result, + join(TMP, 'routes'), + join(TMP, '.void'), + join(TMP, 'pages'), + ); + const actionMap = dts.split(' interface PageActionMap {')[1]!; + expect(actionMap.split('"/home": {')).toHaveLength(2); + for (const env of ['dev', 'prod'] as const) { + const companion = `typeof import("../pages/home.${env}.server.ts")`; + if (environment === undefined || environment === env) { + expect(actionMap).toContain(companion); + } else { + expect(dts).not.toContain(`home.${env}`); + } + } + expect(actionMap.includes('} | {')).toBe(environment === undefined); + }, + ); + + it('does not filter the pages of a framework app', async () => { + createFile('package.json', JSON.stringify({ dependencies: { nuxt: '1.0.0' } })); + createFile('pages/debug.dev.vue', ''); + + const result = await scanRoutes(TMP, { environment: 'prod' }); + expect(result.pages.pages.map((page) => page.componentPath)).toEqual(['debug.dev.vue']); + }); + it.each(['tsx', 'jsx'])('keeps .%s out of cron and queue discovery', async (ext) => { createFile(`routes/hello.${ext}`, 'export const GET = () =>

Hello

;'); createFile(`crons/sync.${ext}`, 'export const cron = "0 * * * *"; export default () => {};'); diff --git a/packages/vue/src/plugin-islands.ts b/packages/vue/src/plugin-islands.ts index f5da7dc0e..5f2902baf 100644 --- a/packages/vue/src/plugin-islands.ts +++ b/packages/vue/src/plugin-islands.ts @@ -72,7 +72,10 @@ export function islandsPlugin( async buildStart() { if (existsSync(pagesDir)) { - pageScan = await scanPages(root, { pagesDir }); + pageScan = await scanPages(root, { + pagesDir, + environment: isDev ? 'dev' : 'prod', + }); // Pre-scan island pages to populate the manifest eagerly. // This is needed because the client build may load the virtual // islands-client entry before island page transforms run. diff --git a/packages/vue/src/plugin-pages.ts b/packages/vue/src/plugin-pages.ts index d189df207..ea609ec2f 100644 --- a/packages/vue/src/plugin-pages.ts +++ b/packages/vue/src/plugin-pages.ts @@ -126,7 +126,9 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Rea const normalizedPagesDir = normalizePath(pagesDir); let pendingRescan: Promise | null = null; const rescanPages = async () => { - pageScan = existsSync(pagesDir) ? await scanPages(root, { pagesDir }) : null; + pageScan = existsSync(pagesDir) + ? await scanPages(root, { pagesDir, environment: isDev ? 'dev' : 'prod' }) + : null; }; const runRescan = () => { pendingRescan ??= rescanPages().finally(() => { @@ -261,12 +263,13 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Rea }, config(_, env) { + // Set before the early return: pages/ may be created after the server starts. + isDev = env.command === 'serve'; if (!existsSync(pagesDir)) { return; } - if (env.command === 'serve') { - isDev = true; + if (isDev) { return { optimizeDeps: { // Pre-include Vue so it's not discovered late via dynamic diff --git a/packages/vue/test/unit/plugin-islands.test.ts b/packages/vue/test/unit/plugin-islands.test.ts index 19f32ebf1..e198b8d57 100644 --- a/packages/vue/test/unit/plugin-islands.test.ts +++ b/packages/vue/test/unit/plugin-islands.test.ts @@ -271,3 +271,81 @@ import Sidebar from "./Sidebar.vue" with { island: "load" } expect(clientEntry).not.toContain('Sidebar'); }); }); + +describe('islandsPlugin — environment suffixes', () => { + beforeEach(() => { + mkdirSync(PAGES_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it('registers no .dev island page in a build', async () => { + writeFile( + 'x.dev.island.vue', + ` +`, + ); + writeFile('Toggle.vue', ``); + + const { buildStart, loadClientEntry } = setupPlugin(); + await buildStart(); + + expect(loadClientEntry()).not.toContain('pages/Toggle'); + }); + + it('registers the .prod island page of a .dev + .prod pair in a build', async () => { + writeFile( + 'x.dev.island.vue', + ` +`, + ); + writeFile( + 'x.prod.island.vue', + ` +`, + ); + writeFile('Toggle.vue', ``); + writeFile('Counter.vue', ``); + + const { buildStart, loadClientEntry } = setupPlugin(); + await buildStart(); + const clientEntry = loadClientEntry(); + + expect(clientEntry).toContain('pages/Counter'); + expect(clientEntry).not.toContain('pages/Toggle'); + }); + + it('registers the .dev island page of a .dev + .prod pair in dev', async () => { + writeFile( + 'x.dev.island.vue', + ` +`, + ); + writeFile( + 'x.prod.island.vue', + ` +`, + ); + writeFile('Toggle.vue', ``); + writeFile('Counter.vue', ``); + + const { buildStart, loadClientEntry } = setupPlugin({ dev: true }); + await buildStart(); + const clientEntry = loadClientEntry(); + + expect(clientEntry).toContain('pages/Toggle'); + expect(clientEntry).not.toContain('pages/Counter'); + }); +}); diff --git a/packages/vue/test/unit/plugin-pages.test.ts b/packages/vue/test/unit/plugin-pages.test.ts index 487accf07..9c16617e7 100644 --- a/packages/vue/test/unit/plugin-pages.test.ts +++ b/packages/vue/test/unit/plugin-pages.test.ts @@ -300,3 +300,73 @@ describe('pagesPlugin dev HMR', () => { expect(recovered).toContain('"about"'); }); }); + +describe('pagesPlugin environment suffixes', () => { + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it('leaves .dev pages out of the build renderer and client entry', async () => { + writePage('index.vue', '\n'); + writePage('debug.dev.vue', '\n'); + writePage('home.dev.vue', '\n'); + writePage('home.prod.vue', '\n'); + + const plugin = pagesPlugin(PAGES_DIR).find((p) => p.name === 'void-vue:pages') as + | PagesPluginHooks + | undefined; + if (!plugin) { + throw new Error('Expected void-vue:pages plugin.'); + } + getHookHandler(plugin.config)({}, { command: 'build' }); + await getHookHandler(plugin.buildStart).call({}); + const load = getHookHandler(plugin.load); + + for (const id of ['\0virtual:void-pages-renderer', '\0virtual:void-pages-client']) { + const code = await load(id); + expect(code).not.toContain('debug.dev'); + expect(code).not.toContain('home.dev'); + expect(code).toContain('home.prod.vue'); + } + }); + + it('keeps .dev pages and leaves out .prod pages in serve', async () => { + writePage('index.vue', '\n'); + writePage('debug.dev.vue', '\n'); + writePage('home.dev.vue', '\n'); + writePage('home.prod.vue', '\n'); + + const plugin = pagesPlugin(PAGES_DIR).find((p) => p.name === 'void-vue:pages') as + | PagesPluginHooks + | undefined; + if (!plugin) { + throw new Error('Expected void-vue:pages plugin.'); + } + getHookHandler(plugin.config)({}, { command: 'serve' }); + await getHookHandler(plugin.buildStart).call({}); + const load = getHookHandler(plugin.load); + + for (const id of ['\0virtual:void-pages-renderer', '\0virtual:void-pages-client']) { + const code = await load(id); + expect(code).toContain('debug.dev.vue'); + expect(code).toContain('home.dev.vue'); + expect(code).not.toContain('home.prod'); + } + }); + + it('scans for dev when pages/ is created after the serve config', async () => { + const plugin = pagesPlugin(PAGES_DIR).find((p) => p.name === 'void-vue:pages') as + | PagesPluginHooks + | undefined; + if (!plugin) { + throw new Error('Expected void-vue:pages plugin.'); + } + expect(getHookHandler(plugin.config)({}, { command: 'serve' })).toBeUndefined(); + + writePage('debug.dev.vue', '\n'); + await getHookHandler(plugin.buildStart).call({}); + const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer'); + + expect(renderer).toContain('debug.dev.vue'); + }); +});