Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React router v7 #6

Merged
merged 12 commits into from
Nov 22, 2024
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 .env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Add your env variables here
DEPLOYMENT_ENV="staging"
3 changes: 2 additions & 1 deletion .env.test
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
# Add your env variables here
# Add your env variables here
DEPLOYMENT_ENV="staging"
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ node_modules
/build
.env
coverage
.history
.history
.react-router
17 changes: 13 additions & 4 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"editor.formatOnType": false,
"editor.renderWhitespace": "all",
Expand All @@ -25,10 +24,20 @@
"[yaml]": {
"editor.defaultFormatter": "redhat.vscode-yaml"
},
"[typescriptreact]": {
"biome.enabled": true,
"editor.defaultFormatter": "biomejs.biome",
"[javascript][typescript][typescriptreact][javascriptreact][json][jsonc][vue][astro][svelte][css][graphql]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
"typescript.tsdk": "node_modules/typescript/lib",
"explorer.fileNesting.patterns": {
"*.ts": "${basename}.*.${extname}",
".env": ".env.*",
"*.tsx": "${basename}.*.${extname},${basename}.*.ts",
"package.json": "*.json, *.yml, *.config.js, *.config.ts, *.yaml",
"readme*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*",
"Readme*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*",
"README*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*",
"Dockerfile": "*.dockerfile, .devcontainer.*, .dockerignore, captain-definition, compose.*, docker-compose.*, dockerfile*"
}
}
38 changes: 32 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ It includes a basic setup for a project with Remix.run and:
- lefthook hooks
- CI checks for quality control
- remix-development-tools
- Hono server
- .env var handling for server and client
- SEO robots.txt, sitemap-index and sitemap built in.

## Internationalization

Expand All @@ -32,21 +35,44 @@ Features included out of the box:
- language switcher
- language detector (uses the request to detect the language, falls back to your fallback language)

## How to use
## Hono server

1. Initialize the repository with our CLI:
```bash
npx f42 init -t base-stack -o ./your-project-name-here
This stack uses Hono for the server. More information about Hono can be found [here](https://honojs.dev/).
Another important thing to note is that we use a dependency called `react-router-hono-server` which is a wrapper for Hono that allows us to use Hono in our React Router application.

The server comes preconfigured with:
- i18next middleware
- caching middleware for assets
- easily extendable global application context
- .env injection into context

In order to add your own middleware, extend the context, or anything along those lines, all you have to do is edit the server
inside the `entry.server.tsx` file.

## .env handling

This stack parses your `.env` file and injects it into the server context. For the client side, in the `root.tsx` file, we use the `useLoaderData` hook to get the `clientEnv` from the server and set it as a global variable on the `window` called `env`.
If you need to access the env variables in both environments, you can create a polyEnv helper like this:
```ts
// app/utils/env.ts
// This will return the process.env on the server and window.env on the client
export const polyEnv = typeof process !== "undefined" ? process.env : window.env;
```
The server will fail at runtime if you don't set your `.env` file properly.

## Getting started

1. Fork the repository

2. Install the dependencies:
```bash
npm install
pnpm install
```
3. Read through the README.md files in the project to understand our decisions.

4. Run the cleanup script:
```bash
npm run cleanup
pnpm cleanup
```

This will remove everything in the project related to the base-stack like README.md etc.
Expand Down
4 changes: 2 additions & 2 deletions app/entry.client.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { RemixBrowser } from "@remix-run/react"
import i18next from "i18next"
import LanguageDetector from "i18next-browser-languagedetector"
import Fetch from "i18next-fetch-backend"
import { StrictMode, startTransition } from "react"
import { hydrateRoot } from "react-dom/client"
import { I18nextProvider, initReactI18next } from "react-i18next"
import { HydratedRouter } from "react-router/dom"
import { getInitialNamespaces } from "remix-i18next/client"
import i18n from "~/localization/i18n"

Expand Down Expand Up @@ -37,7 +37,7 @@ async function hydrate() {
document,
<I18nextProvider i18n={i18next}>
<StrictMode>
<RemixBrowser />
<HydratedRouter />
</StrictMode>
</I18nextProvider>
)
Expand Down
70 changes: 55 additions & 15 deletions app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,34 @@
import { resolve } from "node:path"
import { PassThrough } from "node:stream"
import type { EntryContext } from "@remix-run/node"
import { RemixServer } from "@remix-run/react"
import { Response } from "@remix-run/web-fetch"
import { createReadableStreamFromReadable } from "@react-router/node"
import type { Context } from "hono"
import { createInstance } from "i18next"
import Backend from "i18next-fs-backend"
import { isbot } from "isbot"
import { renderToPipeableStream } from "react-dom/server"
import { I18nextProvider, initReactI18next } from "react-i18next"
import { type AppLoadContext, type EntryContext, ServerRouter } from "react-router"
import { createHonoServer } from "react-router-hono-server/node"
import { i18next } from "remix-hono/i18next"
import { getClientEnv, initEnv } from "./env.server"
import i18n from "./localization/i18n" // your i18n configuration file
import i18next, { returnLanguageFromRequest } from "./localization/i18n.server"
import i18nextOpts from "./localization/i18n.server"
import { resources } from "./localization/resource"

const ABORT_DELAY = 5000

export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
context: EntryContext,
appContext: AppLoadContext
) {
const callbackName = isbot(request.headers.get("user-agent")) ? "onAllReady" : "onShellReady"
const instance = createInstance()
const lng = await returnLanguageFromRequest(request)
const ns = i18next.getRouteNamespaces(remixContext)
const lng = appContext.lang
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const ns = i18nextOpts.getRouteNamespaces(context as any)

await instance
.use(initReactI18next) // Tell our instance to use react-i18next
.use(Backend) // Setup our backend
.init({
...i18n, // spread the configuration
lng, // The locale we detected above
Expand All @@ -40,16 +41,17 @@ export default async function handleRequest(

const { pipe, abort } = renderToPipeableStream(
<I18nextProvider i18n={instance}>
<RemixServer context={remixContext} url={request.url} />
<ServerRouter abortDelay={ABORT_DELAY} context={context} url={request.url} />
</I18nextProvider>,
{
[callbackName]: () => {
const body = new PassThrough()

const stream = createReadableStreamFromReadable(body)
responseHeaders.set("Content-Type", "text/html")

resolve(
new Response(body, {
// @ts-expect-error - We purposely do not define the body as existent so it's not used inside loaders as it's injected there as well
appContext.body(stream, {
headers: responseHeaders,
status: didError ? 500 : responseStatusCode,
})
Expand All @@ -62,7 +64,7 @@ export default async function handleRequest(
},
onError(error: unknown) {
didError = true

// biome-ignore lint/suspicious/noConsole: We console log the error
console.error(error)
},
}
Expand All @@ -71,3 +73,41 @@ export default async function handleRequest(
setTimeout(abort, ABORT_DELAY)
})
}

// Code below used to initialize our own Hono server!
// Setup the .env vars
const env = initEnv()

const getLoadContext = async (c: Context) => {
// get the locale from the context
const locale = i18next.getLocale(c)
// get t function for the default namespace
const t = await i18next.getFixedT(c)

const clientEnv = getClientEnv()
return {
lang: locale,
t,
env,
clientEnv,
// We do not add this to AppLoadContext type because it's not needed in the loaders, but it's used above to handle requests
body: c.body,
}
}

interface LoadContext extends Awaited<ReturnType<typeof getLoadContext>> {}

/**
* Declare our loaders and actions context type
*/
declare module "react-router" {
interface AppLoadContext extends Omit<LoadContext, "body"> {}
}

export const server = await createHonoServer({
configure(server) {
server.use("*", i18next(i18nextOpts))
},
defaultLogger: false,
getLoadContext,
})
56 changes: 56 additions & 0 deletions app/env.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { z } from "zod"

const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
DEPLOYMENT_ENV: z.enum(["staging", "production"]),
})

type APP_ENV = z.infer<typeof envSchema>
let env: APP_ENV
/**
* Helper method used for initializing .env vars in your entry.server.ts file. It uses
* zod to validate your .env and throws if it's not valid.
* @returns Initialized env vars
*/
export const initEnv = () => {
const envData = envSchema.safeParse(process.env)

if (!envData.success) {
// biome-ignore lint/suspicious/noConsole: We want this to be logged
console.error("❌ Invalid environment variables:", envData.error.flatten().fieldErrors)
throw new Error("Invalid environment variables")
}

env = envData.data

// Do not log the message when running tests
if (env.NODE_ENV !== "test") {
// biome-ignore lint/suspicious/noConsole: We want this to be logged
console.log("✅ Environment variables loaded successfully")
}
return envData.data
}

/**
* Helper method for you to return client facing .env vars, only return vars that are needed on the client.
* Otherwise you would expose your server vars to the client if you returned them from here as this is
* directly sent in the root to the client and set on the window.env
* @returns Subset of the whole process.env to be passed to the client and used there
*/
export const getClientEnv = () => {
const serverEnv = env
return {
NODE_ENV: serverEnv.NODE_ENV,
}
}

type CLIENT_ENV = ReturnType<typeof getClientEnv>

declare global {
interface Window {
env: CLIENT_ENV
}
namespace NodeJS {
interface ProcessEnv extends APP_ENV {}
}
}
6 changes: 3 additions & 3 deletions app/library/icon/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Icon generation and spritesheets

This directory is the output directory for the icons. The icons are generated from the `resources/icons` directory.
This directory is the output directory for the icons. The icons are generated from the `resources/icons` directory.

The icons are generated using the `scripts/icons.ts` script.
The icons are generated using the `vite-plugin-icons-spritesheet` package.

All the icons are generated as symbols inside of a spritesheet svg element and the `Icon.tsx`
All the icons are generated as symbols inside of a spritesheet svg element and the `Icon.tsx`
component uses the spritesheet to display the icons.

The `Icon.tsx` component is a simple component that takes a `name` prop and displays the icon. It is fully
Expand Down
2 changes: 1 addition & 1 deletion app/library/language-switcher/LanguageSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Link, useLocation } from "@remix-run/react"
import { useTranslation } from "react-i18next"
import { Link, useLocation } from "react-router"
import { supportedLanguages } from "~/localization/resource"

const LanguageSwitcher = () => {
Expand Down
19 changes: 19 additions & 0 deletions app/localization/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Localization

Localization works by using the `i18next` package. Everything is configured inside of this folder.
The localization works by using the `/resources/locales` folder. This folder contains all the translations for the different languages. You can add new translations by adding new files to this folder and then changing the `resources.ts` file to include the new language.

The server part is set up in the `entry.server.tsx` file, and the client part, conversely, is in the `entry.client.tsx` file and also the `root.tsx` file.

The language is changed by setting the `lng` search parameter in the url.

## Server-side

Due to the fact that the server does not care about loading in additional resources as they are not send over the wire we
pass in `resources` to the `i18next` instance. This provides all the languages to your server which allows it to render
the correct language on the server.

## Client-side

The client-side is a bit more complicated. We do not want to load in all the languages on the client side as it would
be a lot of requests. Instead, we use the fetch backend to load in the language files on the client side. We have a resource route inside of the `routes` directory which is in charge of loading in the resources. This route is called `resource.locales` and it is used to load in the languages. The `resource.locales` route is set up to only load in the languages and namespaces that are needed. In production we cache these responses and in development we don't cache them.
6 changes: 0 additions & 6 deletions app/localization/i18n.server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { resolve } from "node:path"
import { RemixI18Next } from "remix-i18next/server"
import i18n from "~/localization/i18n" // your i18n configuration file
import type { Language } from "~/localization/resource"

const i18next = new RemixI18Next({
detection: {
Expand All @@ -19,8 +18,3 @@ const i18next = new RemixI18Next({
})

export default i18next

export const returnLanguageFromRequest = async (request: Request) => {
const lang = await i18next.getLocale(request)
return lang as Language
}
2 changes: 1 addition & 1 deletion app/localization/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import english from "../../resources/locales/en/common.json"

const languages = ["en", "bs"] as const
export const supportedLanguages = [...languages]
export type Language = (typeof languages)[number]
type Language = (typeof languages)[number]

type Resource = {
common: typeof english
Expand Down
Loading