Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/pages-routing/markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion docs/guide/pages-routing/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/server-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions docs/reference/structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
31 changes: 26 additions & 5 deletions packages/md/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 })) {
Expand Down Expand Up @@ -79,6 +92,8 @@ export function voidMarkdown(options?: MarkdownOptions): Array<Plugin> {
let mdPages: ReadonlyArray<MdPage> = [];
let root = '';
let pagesDir = '';
// 'dev' on the dev server, 'prod' for builds and preview.
let mdEnvironment: 'dev' | 'prod' = 'prod';
const svelteModules = new Map<string, string>();
const solidModules = new Map<string, string>();
const clientScripts = new Map<string, string>();
Expand All @@ -100,21 +115,26 @@ export function voidMarkdown(options?: MarkdownOptions): Array<Plugin> {
}

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,
Expand All @@ -137,6 +157,7 @@ export function voidMarkdown(options?: MarkdownOptions): Array<Plugin> {
},

config(config, env) {
mdEnvironment = env.command === 'serve' && !env.isPreview ? 'dev' : 'prod';
if (env.command === 'serve') {
return;
}
Expand Down
56 changes: 56 additions & 0 deletions packages/md/test/unit/plugin-source-dir.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
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'),
'<script>\ndocument.body.dataset.md = "debug";\n</script>\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(
Expand Down
5 changes: 4 additions & 1 deletion packages/react/src/plugin-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 6 additions & 3 deletions packages/react/src/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu
const pagesOptimizeEntries = `${relative(root, pagesDir)}/**/*.{js,jsx,ts,tsx}`;
let pendingRescan: Promise<void> | 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(() => {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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,
Expand Down
75 changes: 72 additions & 3 deletions packages/react/test/unit/plugin-islands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ type HookWithHandler<T> = { handler: T };
type IslandsPluginHooks = {
configResolved?: (config: {
root: string;
command: 'build';
command: 'serve' | 'build';
plugins: ReadonlyArray<unknown>;
}) => void;
buildStart?: (() => Promise<void>) | HookWithHandler<() => Promise<void>>;
Expand All @@ -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 {
Expand Down Expand Up @@ -178,3 +179,71 @@ export default function Layout({ children }) { return <div>{children}</div>; }`,
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 <Toggle />; }`,
);
writeFile('Toggle.tsx', `export default function Toggle() { return <button>off</button>; }`);

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 <Toggle />; }`,
);
writeFile(
'x.prod.island.tsx',
`import Counter from "./Counter" with { island: "load" }
export default function X() { return <Counter />; }`,
);
writeFile('Toggle.tsx', `export default function Toggle() { return <button>off</button>; }`);
writeFile('Counter.tsx', `export default function Counter() { return <button>0</button>; }`);

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 <Toggle />; }`,
);
writeFile(
'x.prod.island.tsx',
`import Counter from "./Counter" with { island: "load" }
export default function X() { return <Counter />; }`,
);
writeFile('Toggle.tsx', `export default function Toggle() { return <button>off</button>; }`);
writeFile('Counter.tsx', `export default function Counter() { return <button>0</button>; }`);

const { buildStart, loadClientEntry } = setupPlugin({ dev: true });
await buildStart();
const clientEntry = loadClientEntry();

expect(clientEntry).toContain('pages/Toggle');
expect(clientEntry).not.toContain('pages/Counter');
});
});
61 changes: 61 additions & 0 deletions packages/react/test/unit/plugin-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <h1>Home</h1>; }\n');
writePage('debug.dev.tsx', 'export default function Debug() { return <h1>Debug</h1>; }\n');
writePage('home.dev.tsx', 'export default function Home() { return <h1>Dev</h1>; }\n');
writePage('home.prod.tsx', 'export default function Home() { return <h1>Prod</h1>; }\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 <h1>Debug</h1>; }\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');
});
});
Loading
Loading