-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 14f396f
Showing
23 changed files
with
4,303 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
node_modules | ||
|
||
/.cache | ||
/build | ||
/public/build | ||
|
||
.idea | ||
*.iml | ||
|
||
app/.generated |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
# Remix Experiment : Build-time route modules | ||
|
||
## Motivation | ||
|
||
Since I've discovered the remix.config.js `routes` function, I've had something in mind. A magical feature that would enable developers to provide packages made of remix route modules that you can integrate in any remix app very easily. You can see these packages as sub-apps. Examples include : | ||
|
||
- A blog that you can integrate in any remix app | ||
- CRUD functionality for any relational database | ||
- A resource route that integrates tailwindcss in your app without a build step | ||
|
||
You would do something like this in remix.config.js, where the `build*RouteModule` functions would return an object that describes the route module. That object would be customized for your use case with the provided options. | ||
|
||
```javascript | ||
module.exports = { | ||
async routes(defineRoutes) { | ||
return defineRoutes(route => { | ||
route(blogRootPath, buildBlogLayoutRouteModule(blogOptions), () => { | ||
route(undefined, buildBlogIndexRouteModule(blogOptions), {index: true}) | ||
route(":slug", buildBlogArticleRouteModule(blogOptions)) | ||
}); | ||
}) | ||
} | ||
}; | ||
``` | ||
|
||
Currently this is not really possible : | ||
|
||
- Small problem : route modules have to be in your `app/` folder | ||
- Big problem (even if you solve the previous) : library authors can't know your app's unique parameters. There has to be something customizable, but route modules **have** to be files, there's no way around it. The remix compiler won't work with an object or anything else. | ||
|
||
So, the next best thing is for library authors to give functions that you as the app developer are responsible to put in the right places (a loader, a default export, an action...). This is hard to document and error-prone. | ||
|
||
This experiment aims to solve this by taking a different approach that what I originally imagined : **generating files**. | ||
|
||
## Proposed API | ||
|
||
With a little bit of esbuild magic, you can do this in remix.config.js : | ||
|
||
```javascript | ||
module.exports = { | ||
async routes(defineRoutes) { | ||
return defineRoutes(route => { | ||
route("/any/path/you/:want", buildRouteModule("path/to/route-template.tsx", {foo: "bar"})) | ||
}) | ||
} | ||
} | ||
``` | ||
|
||
and have this in the route-template.tsx file : | ||
|
||
```javascript | ||
import {json, Link, LoaderFunction, useLoaderData} from "remix"; | ||
|
||
// Almost normal route module | ||
|
||
// buildTimeOptions will be replaced by what you gave in the remix config file | ||
// @ts-ignore | ||
const { foo } = buildTimeOptions; | ||
|
||
export async function loader() { | ||
return json({ data: foo }) | ||
} | ||
|
||
export default function Component() { | ||
const { data } = useLoaderData(); | ||
|
||
return ( | ||
<p>{data} and {foo} are the same thing</p> | ||
) | ||
} | ||
``` | ||
|
||
and it should work. | ||
|
||
## Benefits | ||
|
||
### Multiple routes with almost the same code | ||
|
||
Currently, if you want similar behaviours at different urls you have two solutions : | ||
|
||
- Extract the common logic in functions and components and wire them in different route modules (that limits what you can extract and it's a little verbose) | ||
- Have a single route module that match several urls (but you're restricted on what URLs you use, and the logic to infer parameters based on the url can get complicated) | ||
|
||
What I propose gives a third option that I think is better in some cases | ||
|
||
- Make a generic route module and apply it several times to different paths in remix.config.js | ||
```javascript | ||
module.exports = { | ||
async routes(defineRoutes) { | ||
return defineRoutes(route => { | ||
route("/any/path/you/:want", buildRouteModule("path/to/route-template.tsx", {foo: "bar"})) | ||
route("/completely/different/path", buildRouteModule("path/to/route-template.tsx", {foo: "kung"})) | ||
}) | ||
} | ||
} | ||
``` | ||
|
||
### Library author | ||
|
||
This is the really good benefit in my opinion. | ||
|
||
Take a look at the `blog-route-package` folder and imagine it's a package published on npm. In any remix app, you can add this package to your dependencies and write this (in fact, this is the demo implemented in this repository) : | ||
|
||
```javascript | ||
// remix.config.js | ||
|
||
const {registerBlog} = require("blog-route-package/register"); | ||
|
||
/** | ||
* @type {import('@remix-run/dev/config').AppConfig} | ||
*/ | ||
module.exports = { | ||
async routes(defineRoutes) { | ||
return defineRoutes(route => { | ||
registerBlog(route, "/blog", { | ||
repo: "BenoitAverty/remix-test-build-time-route-modules", | ||
contentFolder: "blog", | ||
blogTitle: "My generated blog" | ||
}); | ||
}) | ||
} | ||
}; | ||
``` | ||
|
||
With just this one function call, I've added a blog to my remix app. It has several route modules but I didn't have to create a single file, I didn't have to look at the docs to know which functions to put in which loader or action. I just had to tell the package where my content is located. | ||
|
||
I think this could be great to add Authentication, complex register/forgot password flows, behaviours that require multiple resource routes... | ||
|
||
## Implementation | ||
|
||
For the "routes package", take a look at the `blog-route-package` folder and imagine it's published on NPM. | ||
|
||
The implementation of the `buildRouteModule` function is in `route-module-builder/moduleBuilder.js` file. I plan to publish this to npm, but to have a really good developer experience it need to be integrated to remix. That could make live-reload work when templates are changed, and also avoid the `app/.generated` folder that I need to make because remix only accepts route modules inside the app folder. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
import { hydrate } from "react-dom"; | ||
import { RemixBrowser } from "remix"; | ||
|
||
hydrate(<RemixBrowser />, document); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import { renderToString } from "react-dom/server"; | ||
import { RemixServer } from "remix"; | ||
import type { EntryContext } from "remix"; | ||
|
||
export default function handleRequest( | ||
request: Request, | ||
responseStatusCode: number, | ||
responseHeaders: Headers, | ||
remixContext: EntryContext | ||
) { | ||
let markup = renderToString( | ||
<RemixServer context={remixContext} url={request.url} /> | ||
); | ||
|
||
responseHeaders.set("Content-Type", "text/html"); | ||
|
||
return new Response("<!DOCTYPE html>" + markup, { | ||
status: responseStatusCode, | ||
headers: responseHeaders | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,178 @@ | ||
import { | ||
Link, | ||
Links, | ||
LiveReload, | ||
Meta, | ||
Outlet, | ||
Scripts, | ||
ScrollRestoration, | ||
useCatch | ||
} from "remix"; | ||
import type { LinksFunction } from "remix"; | ||
|
||
import globalStylesUrl from "~/styles/global.css"; | ||
import darkStylesUrl from "~/styles/dark.css"; | ||
|
||
// https://remix.run/api/app#links | ||
export let links: LinksFunction = () => { | ||
return [ | ||
{ rel: "stylesheet", href: globalStylesUrl }, | ||
{ | ||
rel: "stylesheet", | ||
href: darkStylesUrl, | ||
media: "(prefers-color-scheme: dark)" | ||
} | ||
]; | ||
}; | ||
|
||
// https://remix.run/api/conventions#default-export | ||
// https://remix.run/api/conventions#route-filenames | ||
export default function App() { | ||
return ( | ||
<Document> | ||
<Layout> | ||
<Outlet /> | ||
</Layout> | ||
</Document> | ||
); | ||
} | ||
|
||
// https://remix.run/docs/en/v1/api/conventions#errorboundary | ||
export function ErrorBoundary({ error }: { error: Error }) { | ||
console.error(error); | ||
return ( | ||
<Document title="Error!"> | ||
<Layout> | ||
<div> | ||
<h1>There was an error</h1> | ||
<p>{error.message}</p> | ||
<hr /> | ||
<p> | ||
Hey, developer, you should replace this with what you want your | ||
users to see. | ||
</p> | ||
</div> | ||
</Layout> | ||
</Document> | ||
); | ||
} | ||
|
||
// https://remix.run/docs/en/v1/api/conventions#catchboundary | ||
export function CatchBoundary() { | ||
let caught = useCatch(); | ||
|
||
let message; | ||
switch (caught.status) { | ||
case 401: | ||
message = ( | ||
<p> | ||
Oops! Looks like you tried to visit a page that you do not have access | ||
to. | ||
</p> | ||
); | ||
break; | ||
case 404: | ||
message = ( | ||
<p>Oops! Looks like you tried to visit a page that does not exist.</p> | ||
); | ||
break; | ||
|
||
default: | ||
throw new Error(caught.data || caught.statusText); | ||
} | ||
|
||
return ( | ||
<Document title={`${caught.status} ${caught.statusText}`}> | ||
<Layout> | ||
<h1> | ||
{caught.status}: {caught.statusText} | ||
</h1> | ||
{message} | ||
</Layout> | ||
</Document> | ||
); | ||
} | ||
|
||
function Document({ | ||
children, | ||
title | ||
}: { | ||
children: React.ReactNode; | ||
title?: string; | ||
}) { | ||
return ( | ||
<html lang="en"> | ||
<head> | ||
<meta charSet="utf-8" /> | ||
<meta name="viewport" content="width=device-width,initial-scale=1" /> | ||
{title ? <title>{title}</title> : null} | ||
<Meta /> | ||
<Links /> | ||
</head> | ||
<body> | ||
{children} | ||
<ScrollRestoration /> | ||
<Scripts /> | ||
{process.env.NODE_ENV === "development" && <LiveReload />} | ||
</body> | ||
</html> | ||
); | ||
} | ||
|
||
function Layout({ children }: { children: React.ReactNode }) { | ||
return ( | ||
<div className="remix-app"> | ||
<header className="remix-app__header"> | ||
<div className="container remix-app__header-content"> | ||
<Link to="/" title="Remix" className="remix-app__header-home-link"> | ||
<RemixLogo /> | ||
</Link> | ||
<nav aria-label="Main navigation" className="remix-app__header-nav"> | ||
<ul> | ||
<li> | ||
<Link to="/">Home</Link> | ||
</li> | ||
<li> | ||
<a href="https://remix.run/docs">Remix Docs</a> | ||
</li> | ||
<li> | ||
<a href="https://github.com/remix-run/remix">GitHub</a> | ||
</li> | ||
</ul> | ||
</nav> | ||
</div> | ||
</header> | ||
<div className="remix-app__main"> | ||
<div className="container remix-app__main-content">{children}</div> | ||
</div> | ||
<footer className="remix-app__footer"> | ||
<div className="container remix-app__footer-content"> | ||
<p>© You!</p> | ||
</div> | ||
</footer> | ||
</div> | ||
); | ||
} | ||
|
||
function RemixLogo() { | ||
return ( | ||
<svg | ||
viewBox="0 0 659 165" | ||
version="1.1" | ||
xmlns="http://www.w3.org/2000/svg" | ||
xmlnsXlink="http://www.w3.org/1999/xlink" | ||
aria-labelledby="remix-run-logo-title" | ||
role="img" | ||
width="106" | ||
height="30" | ||
fill="currentColor" | ||
> | ||
<title id="remix-run-logo-title">Remix Logo</title> | ||
<path d="M0 161V136H45.5416C53.1486 136 54.8003 141.638 54.8003 145V161H0Z M133.85 124.16C135.3 142.762 135.3 151.482 135.3 161H92.2283C92.2283 158.927 92.2653 157.03 92.3028 155.107C92.4195 149.128 92.5411 142.894 91.5717 130.304C90.2905 111.872 82.3473 107.776 67.7419 107.776H54.8021H0V74.24H69.7918C88.2407 74.24 97.4651 68.632 97.4651 53.784C97.4651 40.728 88.2407 32.816 69.7918 32.816H0V0H77.4788C119.245 0 140 19.712 140 51.2C140 74.752 125.395 90.112 105.665 92.672C122.32 96 132.057 105.472 133.85 124.16Z" /> | ||
<path d="M229.43 120.576C225.59 129.536 218.422 133.376 207.158 133.376C194.614 133.376 184.374 126.72 183.35 112.64H263.478V101.12C263.478 70.1437 243.254 44.0317 205.11 44.0317C169.526 44.0317 142.902 69.8877 142.902 105.984C142.902 142.336 169.014 164.352 205.622 164.352C235.83 164.352 256.822 149.76 262.71 123.648L229.43 120.576ZM183.862 92.6717C185.398 81.9197 191.286 73.7277 204.598 73.7277C216.886 73.7277 223.542 82.4317 224.054 92.6717H183.862Z" /> | ||
<path d="M385.256 66.5597C380.392 53.2477 369.896 44.0317 349.672 44.0317C332.52 44.0317 320.232 51.7117 314.088 64.2557V47.1037H272.616V161.28H314.088V105.216C314.088 88.0638 318.952 76.7997 332.52 76.7997C345.064 76.7997 348.136 84.9917 348.136 100.608V161.28H389.608V105.216C389.608 88.0638 394.216 76.7997 408.04 76.7997C420.584 76.7997 423.4 84.9917 423.4 100.608V161.28H464.872V89.5997C464.872 65.7917 455.656 44.0317 424.168 44.0317C404.968 44.0317 391.4 53.7597 385.256 66.5597Z" /> | ||
<path d="M478.436 47.104V161.28H519.908V47.104H478.436ZM478.18 36.352H520.164V0H478.18V36.352Z" /> | ||
<path d="M654.54 47.1035H611.788L592.332 74.2395L573.388 47.1035H527.564L568.78 103.168L523.98 161.28H566.732L589.516 130.304L612.3 161.28H658.124L613.068 101.376L654.54 47.1035Z" /> | ||
</svg> | ||
); | ||
} |
Oops, something went wrong.