diff --git a/.gitignore b/.gitignore index 410230c5c..77c0d84c7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ dist .cache .nvmrc .node-version +entries.gen.tsx diff --git a/e2e/fixtures/ssr-catch-error/src/pages/_layout.tsx b/e2e/fixtures/ssr-catch-error/src/pages/_layout.tsx index 6b84445c1..484b7979c 100644 --- a/e2e/fixtures/ssr-catch-error/src/pages/_layout.tsx +++ b/e2e/fixtures/ssr-catch-error/src/pages/_layout.tsx @@ -19,5 +19,5 @@ export default async function RootLayout({ export const getConfig = async () => { return { render: 'static', - }; + } as const; }; diff --git a/e2e/fixtures/ssr-catch-error/src/pages/index.tsx b/e2e/fixtures/ssr-catch-error/src/pages/index.tsx index 86a3b5050..8e16eedda 100644 --- a/e2e/fixtures/ssr-catch-error/src/pages/index.tsx +++ b/e2e/fixtures/ssr-catch-error/src/pages/index.tsx @@ -12,5 +12,5 @@ export default async function HomePage() { export const getConfig = async () => { return { render: 'static', - }; + } as const; }; diff --git a/e2e/fixtures/ssr-catch-error/src/pages/invalid.tsx b/e2e/fixtures/ssr-catch-error/src/pages/invalid.tsx index f1bda111c..2293ac9d9 100644 --- a/e2e/fixtures/ssr-catch-error/src/pages/invalid.tsx +++ b/e2e/fixtures/ssr-catch-error/src/pages/invalid.tsx @@ -9,5 +9,5 @@ export default async function InvalidPage() { export const getConfig = async () => { return { render: 'dynamic', - }; + } as const; }; diff --git a/examples/01_template/src/pages/_layout.tsx b/examples/01_template/src/pages/_layout.tsx index 8fc8d3a3f..fb1a97c9e 100644 --- a/examples/01_template/src/pages/_layout.tsx +++ b/examples/01_template/src/pages/_layout.tsx @@ -40,5 +40,5 @@ const getData = async () => { export const getConfig = async () => { return { render: 'static', - }; + } as const; }; diff --git a/examples/01_template/src/pages/about.tsx b/examples/01_template/src/pages/about.tsx index d946bb7ac..15d4c90e1 100644 --- a/examples/01_template/src/pages/about.tsx +++ b/examples/01_template/src/pages/about.tsx @@ -28,5 +28,5 @@ const getData = async () => { export const getConfig = async () => { return { render: 'static', - }; + } as const; }; diff --git a/examples/01_template/src/pages/index.tsx b/examples/01_template/src/pages/index.tsx index 68235c94b..889b9d5f4 100644 --- a/examples/01_template/src/pages/index.tsx +++ b/examples/01_template/src/pages/index.tsx @@ -31,5 +31,5 @@ const getData = async () => { export const getConfig = async () => { return { render: 'static', - }; + } as const; }; diff --git a/examples/03_demo/src/pages/[slug].tsx b/examples/03_demo/src/pages/[slug].tsx index 210ddb723..5e267d4df 100644 --- a/examples/03_demo/src/pages/[slug].tsx +++ b/examples/03_demo/src/pages/[slug].tsx @@ -78,5 +78,5 @@ export const getConfig = async () => { return { render: 'static', staticPaths: pokemonPaths, - }; + } as const; }; diff --git a/examples/03_demo/src/pages/_layout.tsx b/examples/03_demo/src/pages/_layout.tsx index 5fba424c4..716d5e113 100644 --- a/examples/03_demo/src/pages/_layout.tsx +++ b/examples/03_demo/src/pages/_layout.tsx @@ -40,5 +40,5 @@ const getData = async () => { export const getConfig = async () => { return { render: 'static', - }; + } as const; }; diff --git a/examples/03_demo/src/pages/index.tsx b/examples/03_demo/src/pages/index.tsx index 393d6c37d..495e28ea5 100644 --- a/examples/03_demo/src/pages/index.tsx +++ b/examples/03_demo/src/pages/index.tsx @@ -51,5 +51,5 @@ export default async function HomePage() { export const getConfig = async () => { return { render: 'dynamic', - }; + } as const; }; diff --git a/examples/04_cssmodules/src/pages/_layout.tsx b/examples/04_cssmodules/src/pages/_layout.tsx index 9fbb2f85d..c7d19ed12 100644 --- a/examples/04_cssmodules/src/pages/_layout.tsx +++ b/examples/04_cssmodules/src/pages/_layout.tsx @@ -38,5 +38,5 @@ const getData = async () => { export const getConfig = async () => { return { render: 'static', - }; + } as const; }; diff --git a/examples/04_cssmodules/src/pages/about.tsx b/examples/04_cssmodules/src/pages/about.tsx index d150ce754..632198ba2 100644 --- a/examples/04_cssmodules/src/pages/about.tsx +++ b/examples/04_cssmodules/src/pages/about.tsx @@ -30,5 +30,5 @@ const getData = async () => { export const getConfig = async () => { return { render: 'static', - }; + } as const; }; diff --git a/examples/04_cssmodules/src/pages/index.tsx b/examples/04_cssmodules/src/pages/index.tsx index 36e155566..b281805d4 100644 --- a/examples/04_cssmodules/src/pages/index.tsx +++ b/examples/04_cssmodules/src/pages/index.tsx @@ -32,5 +32,5 @@ const getData = async () => { export const getConfig = async () => { return { render: 'static', - }; + } as const; }; diff --git a/examples/05_nossr/src/pages/_layout.tsx b/examples/05_nossr/src/pages/_layout.tsx index 8fc8d3a3f..fb1a97c9e 100644 --- a/examples/05_nossr/src/pages/_layout.tsx +++ b/examples/05_nossr/src/pages/_layout.tsx @@ -40,5 +40,5 @@ const getData = async () => { export const getConfig = async () => { return { render: 'static', - }; + } as const; }; diff --git a/examples/05_nossr/src/pages/about.tsx b/examples/05_nossr/src/pages/about.tsx index d946bb7ac..15d4c90e1 100644 --- a/examples/05_nossr/src/pages/about.tsx +++ b/examples/05_nossr/src/pages/about.tsx @@ -28,5 +28,5 @@ const getData = async () => { export const getConfig = async () => { return { render: 'static', - }; + } as const; }; diff --git a/examples/05_nossr/src/pages/index.tsx b/examples/05_nossr/src/pages/index.tsx index 68235c94b..889b9d5f4 100644 --- a/examples/05_nossr/src/pages/index.tsx +++ b/examples/05_nossr/src/pages/index.tsx @@ -31,5 +31,5 @@ const getData = async () => { export const getConfig = async () => { return { render: 'static', - }; + } as const; }; diff --git a/packages/waku/src/lib/middleware/dev-server-impl.ts b/packages/waku/src/lib/middleware/dev-server-impl.ts index 3020e0648..9c21e2e9a 100644 --- a/packages/waku/src/lib/middleware/dev-server-impl.ts +++ b/packages/waku/src/lib/middleware/dev-server-impl.ts @@ -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 @@ -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'], diff --git a/packages/waku/src/lib/plugins/vite-plugin-fs-router-typegen.ts b/packages/waku/src/lib/plugins/vite-plugin-fs-router-typegen.ts new file mode 100644 index 000000000..73dd33d9f --- /dev/null +++ b/packages/waku/src/lib/plugins/vite-plugin-fs-router-typegen.ts @@ -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 => 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 => { + 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; + } + } + + 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(); + }, + }; +}; diff --git a/packages/waku/src/router/create-pages-utils/inferred-path-types.ts b/packages/waku/src/router/create-pages-utils/inferred-path-types.ts index 17ca42a4e..355d1c4d7 100644 --- a/packages/waku/src/router/create-pages-utils/inferred-path-types.ts +++ b/packages/waku/src/router/create-pages-utils/inferred-path-types.ts @@ -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 = { @@ -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[] = [], @@ -64,7 +66,7 @@ type ReplaceHelper< */ type ReplaceTupleStaticPaths< Path extends string, - StaticPathSet extends string[], + StaticPathSet extends readonly string[], > = StaticPathSet extends unknown ? Join, StaticPathSet>, '/'> : never; @@ -73,11 +75,13 @@ type ReplaceTupleStaticPaths< type CollectPathsForStaticSlugPage = 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 - : StaticPaths extends string[][] + : StaticPaths extends ReadOnlyStringTupleList ? ReplaceTupleStaticPaths : never : never; @@ -113,7 +117,7 @@ export type CollectPaths = EachPage extends unknown export type AnyPage = { path: string; render: 'static' | 'dynamic'; - staticPaths?: string[] | string[][]; + staticPaths?: readonly string[] | readonly (readonly string[])[]; }; /** diff --git a/packages/waku/src/router/create-pages.ts b/packages/waku/src/router/create-pages.ts index 323254266..2acf4936b 100644 --- a/packages/waku/src/router/create-pages.ts +++ b/packages/waku/src/router/create-pages.ts @@ -84,18 +84,18 @@ export type GetSlugs = _GetSlugs; export type StaticSlugRoutePathsTuple< T extends string, Slugs extends unknown[] = GetSlugs, - Result extends string[] = [], + Result extends readonly string[] = [], > = Slugs extends [] ? Result : Slugs extends [infer _, ...infer Rest] - ? StaticSlugRoutePathsTuple + ? StaticSlugRoutePathsTuple : never; type StaticSlugRoutePaths = HasWildcardInPath extends true - ? string[] | string[][] - : StaticSlugRoutePathsTuple extends [string] - ? string[] + ? readonly string[] | readonly string[][] + : StaticSlugRoutePathsTuple extends readonly [string] + ? readonly string[] : StaticSlugRoutePathsTuple[]; /** Remove Slug from Path */ diff --git a/packages/waku/tests/create-pages.test.ts b/packages/waku/tests/create-pages.test.ts index 612b4da85..295947597 100644 --- a/packages/waku/tests/create-pages.test.ts +++ b/packages/waku/tests/create-pages.test.ts @@ -875,7 +875,7 @@ describe('createPages', () => { render: 'static', path: '/test/[a]/[b]', // @ts-expect-error: staticPaths should be an array of strings or [string, string][] - staticPaths: [['w']], + staticPaths: [['w']] as const, component: () => null, }), ]);