diff --git a/.env.sample b/.env.sample new file mode 100644 index 00000000..140892fd --- /dev/null +++ b/.env.sample @@ -0,0 +1,2 @@ +# Add your env variables here +DEPLOYMENT_ENV="staging" \ No newline at end of file diff --git a/.env.test b/.env.test index cf6972a2..140892fd 100644 --- a/.env.test +++ b/.env.test @@ -1 +1,2 @@ -# Add your env variables here \ No newline at end of file +# Add your env variables here +DEPLOYMENT_ENV="staging" \ No newline at end of file diff --git a/.gitignore b/.gitignore index a501a03d..2373fc8e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ node_modules /build .env coverage -.history \ No newline at end of file +.history +.react-router \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index d887475e..00b93fb3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,4 @@ { - "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true, "editor.formatOnType": false, "editor.renderWhitespace": "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*" } } diff --git a/README.md b/README.md index 9976a7bc..0fc12bc2 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/app/entry.client.tsx b/app/entry.client.tsx index ee945c63..a93f6dac 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -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" @@ -37,7 +37,7 @@ async function hydrate() { document, - + ) diff --git a/app/entry.server.tsx b/app/entry.server.tsx index a06ae392..20582d77 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -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: + 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 @@ -40,16 +41,17 @@ export default async function handleRequest( const { pipe, abort } = renderToPipeableStream( - + , { [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, }) @@ -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) }, } @@ -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> {} + +/** + * Declare our loaders and actions context type + */ +declare module "react-router" { + interface AppLoadContext extends Omit {} +} + +export const server = await createHonoServer({ + configure(server) { + server.use("*", i18next(i18nextOpts)) + }, + defaultLogger: false, + getLoadContext, +}) diff --git a/app/env.server.ts b/app/env.server.ts new file mode 100644 index 00000000..e6d22bb3 --- /dev/null +++ b/app/env.server.ts @@ -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 +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 + +declare global { + interface Window { + env: CLIENT_ENV + } + namespace NodeJS { + interface ProcessEnv extends APP_ENV {} + } +} diff --git a/app/library/icon/README.md b/app/library/icon/README.md index 313361fa..b90c40fd 100644 --- a/app/library/icon/README.md +++ b/app/library/icon/README.md @@ -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 diff --git a/app/library/language-switcher/LanguageSwitcher.tsx b/app/library/language-switcher/LanguageSwitcher.tsx index 706e1e9b..a13393ad 100644 --- a/app/library/language-switcher/LanguageSwitcher.tsx +++ b/app/library/language-switcher/LanguageSwitcher.tsx @@ -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 = () => { diff --git a/app/localization/README.md b/app/localization/README.md index e69de29b..b1bfd974 100644 --- a/app/localization/README.md +++ b/app/localization/README.md @@ -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. \ No newline at end of file diff --git a/app/localization/i18n.server.ts b/app/localization/i18n.server.ts index be9850c3..fedcd405 100644 --- a/app/localization/i18n.server.ts +++ b/app/localization/i18n.server.ts @@ -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: { @@ -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 -} diff --git a/app/localization/resource.ts b/app/localization/resource.ts index 11ffe78d..c44b8ff8 100644 --- a/app/localization/resource.ts +++ b/app/localization/resource.ts @@ -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 diff --git a/app/root.tsx b/app/root.tsx index bd0b5206..000d3fdc 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,34 +1,30 @@ -import type { LinksFunction } from "@remix-run/node" -import { type LoaderFunctionArgs, json } from "@remix-run/node" -import { Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from "@remix-run/react" import { useTranslation } from "react-i18next" +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router" +import type { LinksFunction } from "react-router" import { useChangeLanguage } from "remix-i18next/react" +import type { Route } from "./+types/root" import { LanguageSwitcher } from "./library/language-switcher" -import { returnLanguageFromRequest } from "./localization/i18n.server" import tailwindcss from "./tailwind.css?url" -export async function loader({ request }: LoaderFunctionArgs) { - const locale = await returnLanguageFromRequest(request) - return json({ locale }) +export async function loader({ context }: Route.LoaderArgs) { + if (!context) throw new Error("No context") + const { lang, clientEnv } = context + return { lang, clientEnv } } export const links: LinksFunction = () => [{ rel: "stylesheet", href: tailwindcss }] export const handle = { - // In the handle export, we can add a i18n key with namespaces our route - // will need to load. This key can be a single string or an array of strings. - // TIP: In most cases, you should set this to your defaultNS from your i18n config - // or if you did not set one, set it to the i18next default namespace "translation" i18n: "common", } -export default function App() { - const { locale } = useLoaderData() +export default function App({ loaderData }: Route.ComponentProps) { + const { lang, clientEnv } = loaderData const { i18n } = useTranslation() - useChangeLanguage(locale) + useChangeLanguage(lang) return ( - + @@ -40,6 +36,8 @@ export default function App() { + {/* biome-ignore lint/security/noDangerouslySetInnerHtml: We set the window.env variable to the client env */} +