Skip to content

Commit

Permalink
feat: entries gen for fs router projects (#886)
Browse files Browse the repository at this point in the history
This PR will add a vite plugin for running on `waku dev` that will
generate `entries.gen.tsx`

Tasks left
- [x] Attempt to find prettier config
- [x] Test with more calls to `getConfig`
- [x] Disable generation when not using fs-router

---------

Co-authored-by: Tyler <[email protected]>
Co-authored-by: Daishi Kato <[email protected]>
  • Loading branch information
3 people authored Sep 29, 2024
1 parent 7553b57 commit 46a44cf
Show file tree
Hide file tree
Showing 21 changed files with 238 additions and 29 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ dist
.cache
.nvmrc
.node-version
entries.gen.tsx
2 changes: 1 addition & 1 deletion e2e/fixtures/ssr-catch-error/src/pages/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ export default async function RootLayout({
export const getConfig = async () => {
return {
render: 'static',
};
} as const;
};
2 changes: 1 addition & 1 deletion e2e/fixtures/ssr-catch-error/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ export default async function HomePage() {
export const getConfig = async () => {
return {
render: 'static',
};
} as const;
};
2 changes: 1 addition & 1 deletion e2e/fixtures/ssr-catch-error/src/pages/invalid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ export default async function InvalidPage() {
export const getConfig = async () => {
return {
render: 'dynamic',
};
} as const;
};
2 changes: 1 addition & 1 deletion examples/01_template/src/pages/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,5 @@ const getData = async () => {
export const getConfig = async () => {
return {
render: 'static',
};
} as const;
};
2 changes: 1 addition & 1 deletion examples/01_template/src/pages/about.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ const getData = async () => {
export const getConfig = async () => {
return {
render: 'static',
};
} as const;
};
2 changes: 1 addition & 1 deletion examples/01_template/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,5 @@ const getData = async () => {
export const getConfig = async () => {
return {
render: 'static',
};
} as const;
};
2 changes: 1 addition & 1 deletion examples/03_demo/src/pages/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,5 @@ export const getConfig = async () => {
return {
render: 'static',
staticPaths: pokemonPaths,
};
} as const;
};
2 changes: 1 addition & 1 deletion examples/03_demo/src/pages/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,5 @@ const getData = async () => {
export const getConfig = async () => {
return {
render: 'static',
};
} as const;
};
2 changes: 1 addition & 1 deletion examples/03_demo/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,5 @@ export default async function HomePage() {
export const getConfig = async () => {
return {
render: 'dynamic',
};
} as const;
};
2 changes: 1 addition & 1 deletion examples/04_cssmodules/src/pages/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,5 @@ const getData = async () => {
export const getConfig = async () => {
return {
render: 'static',
};
} as const;
};
2 changes: 1 addition & 1 deletion examples/04_cssmodules/src/pages/about.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ const getData = async () => {
export const getConfig = async () => {
return {
render: 'static',
};
} as const;
};
2 changes: 1 addition & 1 deletion examples/04_cssmodules/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@ const getData = async () => {
export const getConfig = async () => {
return {
render: 'static',
};
} as const;
};
2 changes: 1 addition & 1 deletion examples/05_nossr/src/pages/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,5 @@ const getData = async () => {
export const getConfig = async () => {
return {
render: 'static',
};
} as const;
};
2 changes: 1 addition & 1 deletion examples/05_nossr/src/pages/about.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ const getData = async () => {
export const getConfig = async () => {
return {
render: 'static',
};
} as const;
};
2 changes: 1 addition & 1 deletion examples/05_nossr/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,5 @@ const getData = async () => {
export const getConfig = async () => {
return {
render: 'static',
};
} as const;
};
2 changes: 2 additions & 0 deletions packages/waku/src/lib/middleware/dev-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { rscManagedPlugin } from '../plugins/vite-plugin-rsc-managed.js';
import { rscDelegatePlugin } from '../plugins/vite-plugin-rsc-delegate.js';
import { mergeUserViteConfig } from '../utils/merge-vite-config.js';
import type { ClonableModuleNode, Middleware } from './types.js';
import { fsRouterTypegenPlugin } from '../plugins/vite-plugin-fs-router-typegen.js';

// TODO there is huge room for refactoring in this file

Expand Down Expand Up @@ -109,6 +110,7 @@ const createMainViteServer = (
rscIndexPlugin(config),
rscTransformPlugin({ isClient: true, isBuild: false }),
rscHmrPlugin(),
fsRouterTypegenPlugin(config),
],
optimizeDeps: {
include: ['react-server-dom-webpack/client', 'react-dom'],
Expand Down
202 changes: 202 additions & 0 deletions packages/waku/src/lib/plugins/vite-plugin-fs-router-typegen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import type { Plugin } from 'vite';
import { readdir, writeFile } from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs';
import { SRC_ENTRIES, EXTENSIONS } from '../constants.js';
import { joinPath } from '../utils/path.js';

const SRC_PAGES = 'pages';

const srcToName = (src: string) => {
const split = src
.split('/')
.map((part) => part[0]!.toUpperCase() + part.slice(1));

if (split.at(-1) === '_layout.tsx') {
return split.slice(0, -1).join('') + '_Layout';
} else if (split.at(-1) === 'index.tsx') {
return split.slice(0, -1).join('') + 'Index';
} else if (split.at(-1)?.startsWith('[...')) {
const fileName = split
.at(-1)!
.replace('-', '_')
.replace('.tsx', '')
.replace('[...', '')
.replace(']', '');
return (
split.slice(0, -1).join('') +
'Wild' +
fileName[0]!.toUpperCase() +
fileName.slice(1)
);
} else if (split.at(-1)?.startsWith('[')) {
const fileName = split
.at(-1)!
.replace('-', '_')
.replace('.tsx', '')
.replace('[', '')
.replace(']', '');
return (
split.slice(0, -1).join('') +
'Slug' +
fileName[0]!.toUpperCase() +
fileName.slice(1)
);
} else {
const fileName = split.at(-1)!.replace('-', '_').replace('.tsx', '');
return (
split.slice(0, -1).join('') +
fileName[0]!.toUpperCase() +
fileName.slice(1)
);
}
};

export const fsRouterTypegenPlugin = (opts: { srcDir: string }): Plugin => {
let entriesFilePossibilities: string[] | undefined;
let pagesDir: string | undefined;
let outputFile: string | undefined;
let formatter = (s: string): Promise<string> => Promise.resolve(s);
return {
name: 'vite-plugin-fs-router-typegen',
apply: 'serve',
async configResolved(config) {
pagesDir = joinPath(config.root, opts.srcDir, SRC_PAGES);
entriesFilePossibilities = EXTENSIONS.map((ext) =>
joinPath(config.root, opts.srcDir, SRC_ENTRIES + ext),
);
outputFile = joinPath(config.root, opts.srcDir, `${SRC_ENTRIES}.gen.tsx`);

try {
const prettier = await import('prettier');
// Get user's prettier config
const config = await prettier.resolveConfig(outputFile);

formatter = (s) =>
prettier.format(s, { ...config, parser: 'typescript' });
} catch {
// ignore
}
},
configureServer(server) {
if (
!entriesFilePossibilities ||
!pagesDir ||
!outputFile ||
entriesFilePossibilities.some((entriesFile) =>
existsSync(entriesFile),
) ||
!existsSync(pagesDir)
) {
return;
}

// Recursively collect `.tsx` files in the given directory
const collectFiles = async (dir: string): Promise<string[]> => {
if (!pagesDir) return [];
const results: string[] = [];
const files = await readdir(dir, {
withFileTypes: true,
recursive: true,
});

for (const file of files) {
if (file.name.endsWith('.tsx')) {
results.push('/' + file.name);
}
}
return results;
};

const fileExportsGetConfig = (filePath: string) => {
if (!pagesDir) return false;
const file = readFileSync(pagesDir + filePath).toString();

return (
file.includes('const getConfig') ||
file.includes('function getConfig')
);
};

const generateFile = (filePaths: string[]): string => {
const fileInfo = [];
for (const filePath of filePaths) {
// where to import the component from
const src = filePath.slice(1);
const hasGetConfig = fileExportsGetConfig(filePath);

if (filePath === '/_layout.tsx') {
fileInfo.push({
type: 'layout',
path: filePath.replace('_layout.tsx', ''),
src,
hasGetConfig,
});
} else if (filePath === '/index.tsx') {
fileInfo.push({
type: 'page',
path: filePath.replace('index.tsx', ''),
src,
hasGetConfig,
});
} else {
fileInfo.push({
type: 'page',
path: filePath.replace('.tsx', ''),
src,
hasGetConfig,
});
}
}

let result = `import { createPages } from 'waku';
import type { PathsForPages } from 'waku/router';\n\n`;

for (const file of fileInfo) {
const moduleName = srcToName(file.src);
result += `import ${moduleName}${file.hasGetConfig ? `, { getConfig as ${moduleName}_getConfig }` : ''} from './${SRC_PAGES}/${file.src.replace('.tsx', '')}';\n`;
}

result += `\nconst _pages = createPages(async (pagesFns) => [\n`;

for (const file of fileInfo) {
const moduleName = srcToName(file.src);
result += ` pagesFns.${file.type === 'layout' ? 'createLayout' : 'createPage'}({ path: '${file.path}', component: ${moduleName}, ${file.hasGetConfig ? `...(await ${moduleName}_getConfig())` : `render: '${file.type === 'layout' ? 'static' : 'dynamic'}'`} }),\n`;
}

result += `]);
declare module 'waku/router' {
interface RouteConfig {
paths: PathsForPages<typeof _pages>;
}
}
export default _pages;
`;

return result;
};

const updateGeneratedFile = async () => {
if (!pagesDir || !outputFile) return;
const files = await collectFiles(pagesDir);
const formatted = await formatter(generateFile(files));
await writeFile(outputFile, formatted, 'utf-8');
};

server.watcher.add(opts.srcDir);
server.watcher.on('change', async (file) => {
if (!outputFile || outputFile.endsWith(file)) return;

await updateGeneratedFile();
});
server.watcher.on('add', async (file) => {
if (!outputFile || outputFile.endsWith(file)) return;

await updateGeneratedFile();
});

void updateGeneratedFile();
},
};
};
20 changes: 12 additions & 8 deletions packages/waku/src/router/create-pages-utils/inferred-path-types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { PathWithoutSlug } from '../create-pages.js';
import type { Join, ReplaceAll, Split } from '../util-types.js';

type ReadOnlyStringTupleList = readonly (readonly string[])[];

type StaticSlugPage = {
path: string;
render: 'static';
staticPaths: (string | string[])[];
staticPaths: readonly string[] | ReadOnlyStringTupleList;
};

type DynamicPage = {
Expand Down Expand Up @@ -36,8 +38,8 @@ type ReplaceSlugSet<
* the result will be `/foo/a/b` | `/foo/c/d`.
*/
type ReplaceHelper<
SplitPath extends string[],
StaticSlugs extends string[],
SplitPath extends readonly string[],
StaticSlugs extends readonly string[],
// SlugCountArr is a counter for the number of slugs added to result so far
SlugCountArr extends null[] = [],
Result extends string[] = [],
Expand All @@ -64,7 +66,7 @@ type ReplaceHelper<
*/
type ReplaceTupleStaticPaths<
Path extends string,
StaticPathSet extends string[],
StaticPathSet extends readonly string[],
> = StaticPathSet extends unknown
? Join<ReplaceHelper<Split<Path, '/'>, StaticPathSet>, '/'>
: never;
Expand All @@ -73,11 +75,13 @@ type ReplaceTupleStaticPaths<
type CollectPathsForStaticSlugPage<Page extends StaticSlugPage> = Page extends {
path: infer Path extends string;
render: 'static';
staticPaths: infer StaticPaths extends string[] | string[][];
staticPaths: infer StaticPaths extends
| readonly string[]
| ReadOnlyStringTupleList;
}
? StaticPaths extends string[]
? StaticPaths extends readonly string[]
? ReplaceSlugSet<Path, StaticPaths[number]>
: StaticPaths extends string[][]
: StaticPaths extends ReadOnlyStringTupleList
? ReplaceTupleStaticPaths<Path, StaticPaths[number]>
: never
: never;
Expand Down Expand Up @@ -113,7 +117,7 @@ export type CollectPaths<EachPage extends AnyPage> = EachPage extends unknown
export type AnyPage = {
path: string;
render: 'static' | 'dynamic';
staticPaths?: string[] | string[][];
staticPaths?: readonly string[] | readonly (readonly string[])[];
};

/**
Expand Down
Loading

0 comments on commit 46a44cf

Please sign in to comment.