diff --git a/.eslintrc.json b/.eslintrc.json index 9b7f4acfc..5a3722062 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -20,6 +20,15 @@ "rules": { "@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_", + "destructuredArrayIgnorePattern": "^_" + } + ], "react/prop-types": "off" }, "overrides": [ diff --git a/CHANGELOG.md b/CHANGELOG.md index 04c1f2f59..e2ebd40c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - breaking: no index html #289 - fix(vercel): option for static build #310 +- feat(router): createPages #293 ## [0.18.1] - 2023-12-15 ### Changed diff --git a/examples/07_router/src/routes/bar/page.tsx b/examples/07_router/src/components/BarPage.tsx similarity index 63% rename from examples/07_router/src/routes/bar/page.tsx rename to examples/07_router/src/components/BarPage.tsx index b29c9b368..b9787fc1b 100644 --- a/examples/07_router/src/routes/bar/page.tsx +++ b/examples/07_router/src/components/BarPage.tsx @@ -1,4 +1,4 @@ -import { Counter } from '../../components/Counter.js'; +import { Counter } from './Counter.js'; const Bar = () => (
diff --git a/examples/07_router/src/routes/foo/page.tsx b/examples/07_router/src/components/FooPage.tsx similarity index 63% rename from examples/07_router/src/routes/foo/page.tsx rename to examples/07_router/src/components/FooPage.tsx index 061f477c5..458f6f961 100644 --- a/examples/07_router/src/routes/foo/page.tsx +++ b/examples/07_router/src/components/FooPage.tsx @@ -1,4 +1,4 @@ -import { Counter } from '../../components/Counter.js'; +import { Counter } from './Counter.js'; const Foo = () => (
diff --git a/examples/07_router/src/routes/layout.tsx b/examples/07_router/src/components/HomeLayout.tsx similarity index 84% rename from examples/07_router/src/routes/layout.tsx rename to examples/07_router/src/components/HomeLayout.tsx index 979b5673e..ee5fdd0db 100644 --- a/examples/07_router/src/routes/layout.tsx +++ b/examples/07_router/src/components/HomeLayout.tsx @@ -41,6 +41,15 @@ const HomeLayout = ({ children }: { children: ReactNode }) => ( Bar +
  • + Baz +
  • +
  • + Nested / Foo +
  • +
  • + Nested / Bar +
  • Nested / Baz
  • diff --git a/examples/07_router/src/routes/page.tsx b/examples/07_router/src/components/HomePage.tsx similarity index 100% rename from examples/07_router/src/routes/page.tsx rename to examples/07_router/src/components/HomePage.tsx diff --git a/examples/07_router/src/routes/nested/baz/page.tsx b/examples/07_router/src/components/NestedBazPage.tsx similarity index 79% rename from examples/07_router/src/routes/nested/baz/page.tsx rename to examples/07_router/src/components/NestedBazPage.tsx index f8b6ae245..ed7488f72 100644 --- a/examples/07_router/src/routes/nested/baz/page.tsx +++ b/examples/07_router/src/components/NestedBazPage.tsx @@ -1,5 +1,6 @@ const Baz = () => (
    +

    Nested

    Baz

    ); diff --git a/examples/07_router/src/routes/nested/qux/page.tsx b/examples/07_router/src/components/NestedQuxPage.tsx similarity index 79% rename from examples/07_router/src/routes/nested/qux/page.tsx rename to examples/07_router/src/components/NestedQuxPage.tsx index a91f5ecda..10da1dacc 100644 --- a/examples/07_router/src/routes/nested/qux/page.tsx +++ b/examples/07_router/src/components/NestedQuxPage.tsx @@ -1,5 +1,6 @@ const Qux = () => (
    +

    Nested

    Qux

    ); diff --git a/examples/07_router/src/entries.tsx b/examples/07_router/src/entries.tsx index ef88ac541..6f9a89037 100644 --- a/examples/07_router/src/entries.tsx +++ b/examples/07_router/src/entries.tsx @@ -1,32 +1,84 @@ -import { defineRouter } from 'waku/router/server'; - -const STATIC_PATHS = ['/', '/foo', '/bar', '/nested/baz', '/nested/qux']; - -export default defineRouter( - // existsPath - async (path: string) => (STATIC_PATHS.includes(path) ? 'static' : null), - // getComponent (id is "**/layout" or "**/page") - async (id, unstable_setShouldSkip) => { - unstable_setShouldSkip({}); // always skip if possible - switch (id) { - case 'layout': - return import('./routes/layout.js'); - case 'page': - return import('./routes/page.js'); - case 'foo/page': - return import('./routes/foo/page.js'); - case 'bar/page': - return import('./routes/bar/page.js'); - case 'nested/layout': - return import('./routes/nested/layout.js'); - case 'nested/baz/page': - return import('./routes/nested/baz/page.js'); - case 'nested/qux/page': - return import('./routes/nested/qux/page.js'); - default: - return null; - } - }, - // getPathsForBuild - async () => STATIC_PATHS, -); +import { lazy } from 'react'; +import { createPages } from 'waku/router/server'; + +// The use of `lazy` is optional and you can use import statements too. +const HomeLayout = lazy(() => import('./components/HomeLayout.js')); +const HomePage = lazy(() => import('./components/HomePage.js')); +const FooPage = lazy(() => import('./components/FooPage.js')); +const BarPage = lazy(() => import('./components/BarPage.js')); +const NestedBazPage = lazy(() => import('./components/NestedBazPage.js')); +const NestedQuxPage = lazy(() => import('./components/NestedQuxPage.js')); + +export default createPages(async ({ createPage, createLayout }) => { + createLayout({ + render: 'static', + path: '/', + component: HomeLayout, + }); + + createPage({ + render: 'static', + path: '/', + component: HomePage, + }); + + createPage({ + render: 'static', + path: '/foo', + component: FooPage, + }); + + createPage({ + render: 'static', + path: '/bar', + component: BarPage, + }); + + createPage({ + render: 'dynamic', + path: '/baz', + // Inline component is also possible. + component: () =>

    Baz

    , + }); + + createPage({ + render: 'static', + path: '/nested/baz', + component: NestedBazPage, + }); + + createPage({ + render: 'static', + path: '/nested/qux', + component: NestedQuxPage, + }); + + createPage({ + render: 'static', + path: '/nested/[id]', + staticPaths: ['foo', 'bar'], + component: ({ id }: { id: string }) => ( + <> +

    Nested

    +

    Static: {id}

    + + ), + }); + + createPage({ + render: 'dynamic', + path: '/nested/[id]', + component: ({ id }: { id: string }) => ( + <> +

    Nested

    +

    Dynamic: {id}

    + + ), + }); + + createPage({ + render: 'dynamic', + path: '/any/[...all]', // `/[...all]` is impossible. + component: ({ all }: { all: string }) =>

    Catch-all: {all}

    , + }); +}); diff --git a/examples/07_router/src/routes/nested/layout.tsx b/examples/07_router/src/routes/nested/layout.tsx deleted file mode 100644 index 8a10d1751..000000000 --- a/examples/07_router/src/routes/nested/layout.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { ReactNode } from 'react'; - -import { Counter } from '../../components/Counter.js'; - -const Nested = ({ children }: { children: ReactNode }) => ( -
    -

    Nested

    - - {children} -
    -); - -export default Nested; diff --git a/packages/waku/src/client.ts b/packages/waku/src/client.ts index 8cf4bffd7..5cbe0b6de 100644 --- a/packages/waku/src/client.ts +++ b/packages/waku/src/client.ts @@ -74,7 +74,7 @@ export const fetchRSC = cache( encodeInput(input) + (searchParamsString ? '?' + searchParamsString : ''); const response = prefetched[url] || fetch(url); - delete prefetched[input]; + delete prefetched[url]; const data = createFromFetch>( checkStatus(response), options, diff --git a/packages/waku/src/lib/handlers/dev-worker-api.ts b/packages/waku/src/lib/handlers/dev-worker-api.ts index bdf1f0b0b..962dbe521 100644 --- a/packages/waku/src/lib/handlers/dev-worker-api.ts +++ b/packages/waku/src/lib/handlers/dev-worker-api.ts @@ -201,7 +201,6 @@ export async function renderRscWithWorker( messageCallbacks.delete(id); } }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { ssr: _removed, ...copiedConfig } = rr.config as any; // HACK type const copied = { ...rr, config: copiedConfig }; delete copied.stream; diff --git a/packages/waku/src/lib/handlers/dev-worker-impl.ts b/packages/waku/src/lib/handlers/dev-worker-impl.ts index c6b924218..7af2fbab3 100644 --- a/packages/waku/src/lib/handlers/dev-worker-impl.ts +++ b/packages/waku/src/lib/handlers/dev-worker-impl.ts @@ -29,8 +29,7 @@ if (HAS_MODULE_REGISTER) { const controllerMap = new Map(); const handleRender = async (mesg: MessageReq & { type: 'render' }) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { id, type, hasModuleIdCallback, ...rest } = mesg; + const { id, type: _removed, hasModuleIdCallback, ...rest } = mesg; const rr: RenderRequest = rest; try { const stream = new ReadableStream({ diff --git a/packages/waku/src/router/server.ts b/packages/waku/src/router/server.ts index e1c9ae75b..f116f5990 100644 --- a/packages/waku/src/router/server.ts +++ b/packages/waku/src/router/server.ts @@ -13,6 +13,7 @@ import { SHOULD_SKIP_ID, } from './common.js'; import type { RouteProps, ShouldSkip } from './common.js'; +import { joinPath } from '../lib/utils/path.js'; const ShoudSkipComponent = ({ shouldSkip }: { shouldSkip: ShouldSkip }) => createElement('meta', { @@ -57,7 +58,7 @@ export function defineRouter( delete shouldSkip[id]; } }); - const component = typeof mod === 'function' ? mod : mod?.default; + const component = mod && 'default' in mod ? mod.default : mod; if (!component) { return []; } @@ -136,3 +137,308 @@ globalThis.__WAKU_ROUTER_PREFETCH__ = (path) => { return { renderEntries, getBuildConfig, getSsrConfig }; } + +// createPages API (a wrapper around defineRouter) + +// FIXME we should extract some functions (and type utils) +// out of this file, and add unit tests for them. +// Like: `src/router/utils.ts` and `src/router/utils.test.ts` + +type IsValidPathItem = T extends `/${infer _}` + ? false + : T extends '[]' | '' + ? false + : true; +type IsValidPath = T extends `/${infer L}/${infer R}` + ? IsValidPathItem extends true + ? IsValidPath<`/${R}`> + : false + : T extends `/${infer U}` + ? IsValidPathItem + : false; +type HasSlugInPath = T extends `/[${K}]/${infer _}` + ? true + : T extends `/${infer _}/${infer U}` + ? HasSlugInPath<`/${U}`, K> + : T extends `/[${K}]` + ? true + : false; +type PathWithSlug = IsValidPath extends true + ? HasSlugInPath extends true + ? T + : never + : never; +type PathWithoutSlug = T extends '/' + ? T + : IsValidPath extends true + ? HasSlugInPath extends true + ? never + : T + : never; + +type CreatePage = ( + page: + | { + render: 'static'; + path: PathWithoutSlug; + component: FunctionComponent; + } + | { + render: 'static'; + path: PathWithSlug; + staticPaths: string[] | string[][]; + component: FunctionComponent>; + } + | { + render: 'dynamic'; + path: PathWithoutSlug; + component: FunctionComponent; + } + | { + render: 'dynamic'; + path: PathWithSlug; + component: FunctionComponent>; + }, +) => void; + +type CreateLayout = (layout: { + render: 'static'; + path: PathWithoutSlug; + component: FunctionComponent; +}) => void; + +type ParsedPath = { name: string; isSlug: boolean; isWildcard: boolean }[]; +const parsePath = (path: string): ParsedPath => + path + .replace(/^\//, '') + .split('/') + .map((name) => { + const isSlug = name.startsWith('[') && name.endsWith(']'); + if (isSlug) { + name = name.slice(1, -1); + } + const isWildcard = name.startsWith('...'); + if (isWildcard) { + name = name.slice(3); + } + return { name, isSlug, isWildcard }; + }); + +const getDynamicMapping = (parsedPath: ParsedPath, actual: string[]) => { + if (parsedPath.length !== actual.length) { + return null; + } + const mapping: Record = {}; + for (let i = 0; i < parsedPath.length; i++) { + const { name, isSlug } = parsedPath[i]!; + if (isSlug) { + mapping[name] = actual[i]!; + } else { + if (name !== actual[i]) { + return null; + } + } + } + return mapping; +}; + +const getWildcardMapping = (parsedPath: ParsedPath, actual: string[]) => { + if (parsedPath.length > actual.length) { + return null; + } + const mapping: Record = {}; + let wildcardStartIndex = -1; + for (let i = 0; i < parsedPath.length; i++) { + const { name, isSlug, isWildcard } = parsedPath[i]!; + if (isWildcard) { + wildcardStartIndex = i; + break; + } else if (isSlug) { + mapping[name] = actual[i]!; + } else { + if (name !== actual[i]) { + return null; + } + } + } + let wildcardEndIndex = -1; + for (let i = 0; i < parsedPath.length; i++) { + const { name, isSlug, isWildcard } = parsedPath[parsedPath.length - i - 1]!; + if (isWildcard) { + wildcardEndIndex = actual.length - i - 1; + break; + } else if (isSlug) { + mapping[name] = actual[actual.length - i - 1]!; + } else { + if (name !== actual[actual.length - i - 1]) { + return null; + } + } + } + if (wildcardStartIndex === -1 || wildcardEndIndex === -1) { + throw new Error('Invalid wildcard path'); + } + mapping[parsedPath[wildcardStartIndex]!.name] = actual + .slice(wildcardStartIndex, wildcardEndIndex + 1) + .join('/'); + return mapping; +}; + +export function createPages( + fn: (fns: { + createPage: CreatePage; + createLayout: CreateLayout; + }) => Promise, +): ReturnType { + let configured = false; + const staticPathSet = new Set(); + const dynamicPathMap = new Map< + string, + [ParsedPath, FunctionComponent] + >(); + const wildcardPathMap = new Map< + string, + [ParsedPath, FunctionComponent] + >(); + const staticComponentMap = new Map>(); + const registerStaticComponent = ( + id: string, + component: FunctionComponent, + ) => { + if ( + staticComponentMap.has(id) && + staticComponentMap.get(id) !== component + ) { + throw new Error(`Duplicated component for: ${id}`); + } + staticComponentMap.set(id, component); + }; + + const createPage: CreatePage = (page) => { + if (configured) { + throw new Error('no longer available'); + } + const parsedPath = parsePath(page.path); + const numSlugs = parsedPath.filter(({ isSlug }) => isSlug).length; + const numWildcards = parsedPath.filter( + ({ isWildcard }) => isWildcard, + ).length; + if (page.render === 'static' && numSlugs === 0) { + staticPathSet.add(page.path); + const id = joinPath(page.path, 'page').replace(/^\//, ''); + registerStaticComponent(id, page.component); + } else if (page.render === 'static' && numSlugs > 0 && numWildcards === 0) { + const staticPaths = ( + page as { + staticPaths: string[] | string[][]; + } + ).staticPaths.map((item) => (Array.isArray(item) ? item : [item])); + for (const staticPath of staticPaths) { + if (staticPath.length !== numSlugs) { + throw new Error('staticPaths does not match with slug pattern'); + } + const mapping: Record = {}; + let slugIndex = 0; + const pathItems = parsedPath.map(({ name, isSlug }) => { + if (isSlug) { + return (mapping[name] = staticPath[slugIndex++]!); + } + return name; + }); + staticPathSet.add('/' + joinPath(...pathItems)); + const id = joinPath(...pathItems, 'page'); + const WrappedComponent = (props: Record) => + createElement(page.component as any, { ...props, ...mapping }); + registerStaticComponent(id, WrappedComponent); + } + } else if (page.render === 'dynamic' && numWildcards === 0) { + if (dynamicPathMap.has(page.path)) { + throw new Error(`Duplicated dynamic path: ${page.path}`); + } + dynamicPathMap.set(page.path, [parsedPath, page.component]); + } else if (page.render === 'dynamic' && numWildcards === 1) { + if (wildcardPathMap.has(page.path)) { + throw new Error(`Duplicated dynamic path: ${page.path}`); + } + wildcardPathMap.set(page.path, [parsedPath, page.component]); + } else { + throw new Error('Invalid page configuration'); + } + }; + + const createLayout: CreateLayout = (layout) => { + if (configured) { + throw new Error('no longer available'); + } + const id = joinPath(layout.path, 'layout').replace(/^\//, ''); + registerStaticComponent(id, layout.component); + }; + + const ready = fn({ createPage, createLayout }).then(() => { + configured = true; + }); + + return defineRouter( + async (path: string) => { + await ready; + if (staticPathSet.has(path)) { + return 'static'; + } + for (const [parsedPath] of dynamicPathMap.values()) { + const mapping = getDynamicMapping(parsedPath, path.split('/').slice(1)); + if (mapping) { + return 'dynamic'; + } + } + for (const [parsedPath] of wildcardPathMap.values()) { + const mapping = getWildcardMapping( + parsedPath, + path.split('/').slice(1), + ); + if (mapping) { + return 'dynamic'; + } + } + return null; // not found + }, + async (id, unstable_setShouldSkip) => { + await ready; + const staticComponent = staticComponentMap.get(id); + if (staticComponent) { + unstable_setShouldSkip({}); + return staticComponent; + } + for (const [parsedPath, Component] of dynamicPathMap.values()) { + const mapping = getDynamicMapping( + [...parsedPath, { name: 'page', isSlug: false, isWildcard: false }], + id.split('/'), + ); + if (mapping) { + if (Object.keys(mapping).length === 0) { + unstable_setShouldSkip(); + return Component; + } + const WrappedComponent = (props: Record) => + createElement(Component, { ...props, ...mapping }); + unstable_setShouldSkip(); + return WrappedComponent; + } + } + for (const [parsedPath, Component] of wildcardPathMap.values()) { + const mapping = getWildcardMapping( + [...parsedPath, { name: 'page', isSlug: false, isWildcard: false }], + id.split('/'), + ); + if (mapping) { + const WrappedComponent = (props: Record) => + createElement(Component, { ...props, ...mapping }); + unstable_setShouldSkip(); + return WrappedComponent; + } + } + unstable_setShouldSkip({}); // negative cache + return null; // not found + }, + async () => staticPathSet, + ); +}