├── app ├── README.md ├── lib │ ├── cn.ts │ ├── utils.ts │ ├── source.ts │ ├── merge-refs.ts │ └── get-llm-text.ts ├── app │ ├── (home) │ │ ├── code │ │ │ ├── install.mdx │ │ │ └── hero.mdx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── favicon.ico │ ├── api │ │ └── search │ │ │ └── route.ts │ ├── llms-full.txt │ │ └── route.ts │ ├── global.css │ ├── llms.mdx │ │ └── [[...slug]] │ │ │ └── route.ts │ ├── docs │ │ ├── layout.tsx │ │ └── [[...slug]] │ │ │ └── page.tsx │ ├── layout.tsx │ └── layout.config.tsx ├── postcss.config.mjs ├── vercel.json ├── cli.json ├── components │ ├── steps.tsx │ ├── ui │ │ ├── button.tsx │ │ └── collapsible.tsx │ ├── image-zoom.tsx │ ├── files.tsx │ ├── github-info.tsx │ ├── tabs.unstyled.tsx │ ├── tabs.tsx │ └── rate.tsx ├── .gitignore ├── next.config.mjs ├── mdx-components.tsx ├── tsconfig.json ├── source.config.ts ├── styles │ └── image-zoom.css └── package.json ├── .husky └── pre-commit ├── src ├── core.ts ├── index.ts ├── next.ts ├── react.ts ├── tools.ts ├── format.ts ├── global.ts ├── hooks.ts ├── link.ts ├── locales.ts ├── match.ts ├── dynamic.ts ├── inject.ts ├── types.ts ├── declarations.ts ├── navigation.ts ├── resolvers.ts ├── translation.ts └── patch.ts ├── .prettierignore ├── packages ├── next │ ├── src │ │ ├── hooks.ts │ │ ├── patch.ts │ │ ├── link │ │ │ └── index.ts │ │ ├── root.ts │ │ ├── index.ts │ │ ├── params.ts │ │ ├── cache.ts │ │ ├── headers.ts │ │ ├── translation.ts │ │ ├── link_client.tsx │ │ ├── request.ts │ │ ├── state.ts │ │ ├── router.ts │ │ ├── link.tsx │ │ ├── rsc.tsx │ │ ├── navigation.ts │ │ └── middleware.ts │ ├── tsconfig.json │ ├── README.md │ └── package.json ├── tools │ ├── src │ │ ├── format.ts │ │ ├── declarations.ts │ │ ├── index.ts │ │ ├── negotiator.ts │ │ ├── match.ts │ │ └── resolvers.ts │ ├── tsconfig.json │ ├── README.md │ ├── package.json │ └── test │ │ └── match.legacy.test.ts ├── core │ ├── src │ │ ├── index.ts │ │ ├── global.ts │ │ ├── dynamic.ts │ │ └── chunk.ts │ ├── tsconfig.json │ ├── test │ │ └── messages.json │ ├── README.md │ └── package.json ├── declarations │ ├── src │ │ └── bin.ts │ ├── tsconfig.json │ ├── README.md │ └── package.json ├── format │ ├── src │ │ ├── index.ts │ │ ├── types.ts │ │ └── formatters.ts │ ├── tsconfig.json │ ├── README.md │ ├── package.json │ └── test │ │ └── inject.legacy.test.ts ├── locales │ ├── tsconfig.json │ ├── src │ │ ├── index.ts │ │ └── generated.ts │ ├── README.md │ └── package.json ├── global │ ├── tsconfig.json │ ├── README.md │ ├── package.json │ └── src │ │ └── index.ts └── react │ ├── src │ ├── index.ts │ ├── client.ts │ ├── translation.ts │ ├── hooks.ts │ ├── inject.ts │ ├── patch.ts │ ├── types.ts │ └── context.ts │ ├── tsconfig.json │ ├── test │ ├── messages.json │ └── legacy.test.tsx │ ├── README.md │ └── package.json ├── assets ├── banner.webp ├── package.json └── logo.svg ├── .vscode └── extensions.json ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc ├── docs ├── meta.json ├── support.mdx ├── acknowledgment.mdx ├── edge.mdx ├── tools.mdx ├── contributing.mdx ├── quick-start.mdx ├── examples.mdx ├── typescript.mdx ├── react.mdx ├── index.mdx ├── migration.mdx └── dynamic-import.mdx ├── .oxlintrc.json ├── LICENSE ├── tsconfig.json ├── CONTRIBUTING.md ├── package.json └── README.md /app/README.md: -------------------------------------------------------------------------------- 1 | # Intl-T App 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | bunx lint-staged 2 | -------------------------------------------------------------------------------- /src/core.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/core"; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/core"; 2 | -------------------------------------------------------------------------------- /src/next.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/next"; 2 | -------------------------------------------------------------------------------- /src/react.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/react"; 2 | -------------------------------------------------------------------------------- /src/tools.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/tools"; 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | app/**/*.mdx 2 | docs/**/*.mdx 3 | -------------------------------------------------------------------------------- /src/format.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/format"; 2 | -------------------------------------------------------------------------------- /src/global.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/global"; 2 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/react/hooks"; 2 | -------------------------------------------------------------------------------- /src/link.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/next/link"; 2 | -------------------------------------------------------------------------------- /src/locales.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/locales"; 2 | -------------------------------------------------------------------------------- /src/match.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/tools/match"; 2 | -------------------------------------------------------------------------------- /src/dynamic.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/core/dynamic"; 2 | -------------------------------------------------------------------------------- /src/inject.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/format/inject"; 2 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type * from "@intl-t/core/types"; 2 | -------------------------------------------------------------------------------- /src/declarations.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/declarations"; 2 | -------------------------------------------------------------------------------- /src/navigation.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/next/navigation"; 2 | -------------------------------------------------------------------------------- /src/resolvers.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/tools/resolvers"; 2 | -------------------------------------------------------------------------------- /app/lib/cn.ts: -------------------------------------------------------------------------------- 1 | export { twMerge as cn } from "tailwind-merge"; 2 | -------------------------------------------------------------------------------- /packages/next/src/hooks.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/react/hooks"; 2 | -------------------------------------------------------------------------------- /packages/next/src/patch.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/react/patch"; 2 | -------------------------------------------------------------------------------- /packages/tools/src/format.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/format"; 2 | -------------------------------------------------------------------------------- /src/translation.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/core/translation"; 2 | -------------------------------------------------------------------------------- /app/app/(home)/code/install.mdx: -------------------------------------------------------------------------------- 1 | ```package-install 2 | intl-t 3 | ``` 4 | -------------------------------------------------------------------------------- /packages/tools/src/declarations.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/declarations"; 2 | -------------------------------------------------------------------------------- /assets/banner.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nivandres/intl-t/HEAD/assets/banner.webp -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@intl-t/assets", 3 | "private": true 4 | } 5 | -------------------------------------------------------------------------------- /app/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nivandres/intl-t/HEAD/app/app/favicon.ico -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["oxc.oxc-vscode", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/next/src/link/index.ts: -------------------------------------------------------------------------------- 1 | export * from "../link"; 2 | export { Link as default } from "../link"; 3 | -------------------------------------------------------------------------------- /src/patch.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/react/patch"; 2 | export { default } from "@intl-t/react/patch"; 3 | -------------------------------------------------------------------------------- /app/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/core/dynamic"; 2 | export * from "@intl-t/core/translation"; 3 | -------------------------------------------------------------------------------- /packages/declarations/src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import main from "./index.js"; 3 | 4 | main(process.argv).catch(console.error); 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: nivandres 2 | custom: ["https://www.paypal.com/ncp/payment/PMH5ASCL7J8B6", "https://www.paypal.com/paypalme/nivnnd"] 3 | -------------------------------------------------------------------------------- /app/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://openapi.vercel.sh/vercel.json", 3 | "buildCommand": "bun run build", 4 | "installCommand": "bun install" 5 | } 6 | -------------------------------------------------------------------------------- /packages/format/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/format/formatters"; 2 | export * from "@intl-t/format/inject"; 3 | export * from "@intl-t/format/types"; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .next 3 | .source 4 | .vercel 5 | *lock.json 6 | bun.lock* 7 | desktop.ini 8 | dist 9 | node_modules 10 | tsconfig.tsbuildinfo 11 | -------------------------------------------------------------------------------- /packages/tools/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/tools/format"; 2 | export * from "@intl-t/tools/match"; 3 | export * from "@intl-t/tools/negotiator"; 4 | export * from "@intl-t/tools/resolvers"; 5 | -------------------------------------------------------------------------------- /app/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /packages/tools/src/negotiator.ts: -------------------------------------------------------------------------------- 1 | export function negotiator({ headers }: { headers: Headers | Record }) { 2 | return (headers instanceof Headers ? headers.get("Accept-Language") : headers["Accept-Language"])?.split(","); 3 | } 4 | -------------------------------------------------------------------------------- /app/cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "aliases": { 3 | "uiDir": "./components/ui", 4 | "componentsDir": "./components", 5 | "blockDir": "./components", 6 | "cssDir": "./styles", 7 | "libDir": "./lib" 8 | }, 9 | "baseDir": "", 10 | "commands": {} 11 | } 12 | -------------------------------------------------------------------------------- /packages/locales/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "dist", 6 | "rootDir": "src" 7 | }, 8 | "include": ["src"], 9 | "exclude": ["node_modules", "dist"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/declarations/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "rootDir": "src", 6 | "outDir": "dist" 7 | }, 8 | "include": ["src"], 9 | "exclude": ["node_modules", "dist", "test"] 10 | } 11 | -------------------------------------------------------------------------------- /app/app/api/search/route.ts: -------------------------------------------------------------------------------- 1 | import { source } from "@/lib/source"; 2 | import { createFromSource } from "fumadocs-core/search/server"; 3 | 4 | export const { GET } = createFromSource(source, { 5 | // https://docs.orama.com/open-source/supported-languages 6 | language: "english", 7 | }); 8 | -------------------------------------------------------------------------------- /packages/global/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "dist", 6 | "rootDir": "src" 7 | }, 8 | "references": [{ "path": "../locales" }], 9 | "include": ["src"], 10 | "exclude": ["node_modules", "dist"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/next/src/root.ts: -------------------------------------------------------------------------------- 1 | import { unstable_rootParams as rootParams } from "next/server"; 2 | 3 | export async function getRootParamsLocale() { 4 | const { locale } = await rootParams(); 5 | // @ts-ignore 6 | if (this?.settings) this.settings.locale = locale; 7 | return locale as L; 8 | } 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "bracketSpacing": true, 4 | "arrowParens": "avoid", 5 | "trailingComma": "all", 6 | "useTabs": false, 7 | "tabWidth": 2, 8 | "semi": true, 9 | "importOrder": ["^[^\\.](.*)$", "^\\.(.*)$"], 10 | "plugins": ["@trivago/prettier-plugin-sort-imports"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/react/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "@intl-t/react/client"; 2 | export * from "@intl-t/react/context"; 3 | export * from "@intl-t/react/hooks"; 4 | export * from "@intl-t/react/inject"; 5 | export * from "@intl-t/react/patch"; 6 | export * from "@intl-t/react/translation"; 7 | export * from "@intl-t/react/types"; 8 | -------------------------------------------------------------------------------- /app/app/(home)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { baseOptions } from "@/app/layout.config"; 2 | import { HomeLayout } from "fumadocs-ui/layouts/home"; 3 | import type { ReactNode } from "react"; 4 | 5 | export default function Layout({ children }: { children: ReactNode }) { 6 | return {children}; 7 | } 8 | -------------------------------------------------------------------------------- /packages/locales/src/index.ts: -------------------------------------------------------------------------------- 1 | import { LocaleMapping } from "@intl-t/locales/generated"; 2 | 3 | export type LocaleMapper = T extends [infer L extends string, ...infer R extends string[]] ? L | `${L}-${R[number]}` : never; 4 | 5 | export type Locale = (Intl.UnicodeBCP47LocaleIdentifier & {}) | LocaleMapper; 6 | -------------------------------------------------------------------------------- /app/components/steps.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | 3 | export function Steps({ children }: { children: ReactNode }) { 4 | return
{children}
; 5 | } 6 | 7 | export function Step({ children }: { children: ReactNode }) { 8 | return
{children}
; 9 | } 10 | -------------------------------------------------------------------------------- /app/lib/source.ts: -------------------------------------------------------------------------------- 1 | import { docs } from "@/.source"; 2 | import { loader } from "fumadocs-core/source"; 3 | 4 | // See https://fumadocs.vercel.app/docs/headless/source-api for more info 5 | export const source = loader({ 6 | // it assigns a URL to your pages 7 | baseUrl: "/docs", 8 | source: docs.toFumadocsSource(), 9 | }); 10 | -------------------------------------------------------------------------------- /packages/format/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "rootDir": "src", 6 | "outDir": "dist" 7 | }, 8 | "references": [{ "path": "../global" }, { "path": "../locales" }], 9 | "include": ["src"], 10 | "exclude": ["node_modules", "dist", "test"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "rootDir": "src", 6 | "outDir": "dist" 7 | }, 8 | "references": [{ "path": "../format" }, { "path": "../global" }, { "path": "../locales" }], 9 | "include": ["src"], 10 | "exclude": ["node_modules", "dist", "test"] 11 | } 12 | -------------------------------------------------------------------------------- /app/lib/merge-refs.ts: -------------------------------------------------------------------------------- 1 | import type * as React from "react"; 2 | 3 | export function mergeRefs(...refs: (React.Ref | undefined)[]): React.RefCallback { 4 | return value => { 5 | refs.forEach(ref => { 6 | if (typeof ref === "function") { 7 | ref(value); 8 | } else if (ref) { 9 | ref.current = value; 10 | } 11 | }); 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /app/app/llms-full.txt/route.ts: -------------------------------------------------------------------------------- 1 | import { getLLMText } from "@/lib/get-llm-text"; 2 | import { source } from "@/lib/source"; 3 | 4 | // cached forever 5 | export const revalidate = false; 6 | 7 | export async function GET() { 8 | const scan = source.getPages().map(getLLMText); 9 | const scanned = await Promise.all(scan); 10 | 11 | return new Response(scanned.join("\n\n")); 12 | } 13 | -------------------------------------------------------------------------------- /packages/tools/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "rootDir": "src", 6 | "outDir": "dist" 7 | }, 8 | "references": [{ "path": "../declarations" }, { "path": "../format" }, { "path": "../global" }, { "path": "../locales" }], 9 | "include": ["src"], 10 | "exclude": ["node_modules", "dist", "test"] 11 | } 12 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # deps 2 | /node_modules 3 | 4 | # generated content 5 | .contentlayer 6 | .content-collections 7 | .source 8 | 9 | # test & build 10 | /coverage 11 | /.next/ 12 | /out/ 13 | /build 14 | *.tsbuildinfo 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | /.pnp 20 | .pnp.js 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # others 26 | .env*.local 27 | .vercel 28 | next-env.d.ts -------------------------------------------------------------------------------- /packages/next/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "rootDir": "src", 6 | "outDir": "dist" 7 | }, 8 | "references": [{ "path": "../core" }, { "path": "../global" }, { "path": "../locales" }, { "path": "../react" }, { "path": "../tools" }], 9 | "include": ["src"], 10 | "exclude": ["node_modules", "dist", "test"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "rootDir": "src", 6 | "outDir": "dist" 7 | }, 8 | "references": [{ "path": "../core" }, { "path": "../global" }, { "path": "../locales" }, { "path": "../format" }, { "path": "../tools" }], 9 | "include": ["src"], 10 | "exclude": ["node_modules", "dist", "test"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/next/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./cache"; 2 | export * from "./headers"; 3 | export * from "./link_client"; 4 | export * from "./link"; 5 | export * from "./middleware"; 6 | export * from "./navigation"; 7 | export * from "./params"; 8 | export * from "./request"; 9 | export * from "./root"; 10 | export * from "./router"; 11 | export * from "./rsc"; 12 | export * from "./state"; 13 | export * from "./translation"; 14 | -------------------------------------------------------------------------------- /docs/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "pages": [ 3 | "---Introduction---", 4 | "index", 5 | "quick-start", 6 | "---Usage---", 7 | "...", 8 | "tools", 9 | "---Integrations---", 10 | "react", 11 | "next", 12 | "edge", 13 | "---Guides---", 14 | "dynamic-import", 15 | "migration", 16 | "examples", 17 | "---", 18 | "contributing", 19 | "acknowledgment", 20 | "support" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.oxlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/oxlint/configuration_schema.json", 3 | "plugins": ["import", "jest", "jsdoc", "node", "oxc", "promise", "regex", "typescript", "unicorn", "vitest"], 4 | "rules": { 5 | "no-async-promise-executor": "off", 6 | "no-callback-in-promise": "off", 7 | "no-unused-expressions": "off", 8 | "typescript/no-this-alias": "off", 9 | "unicorn/no-thenable": "off", 10 | "valid-params": "off" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/core/test/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "hello world", 3 | "bye": "goodbye world", 4 | "common": { 5 | "yes": "yes", 6 | "no": "no", 7 | "hello": "Hello {name}", 8 | "values": { 9 | "name": "John" 10 | } 11 | }, 12 | "pages": { 13 | "landing": { 14 | "title": "Welcome", 15 | "hero": { 16 | "paragraph": "content", 17 | "features": ["hi {name}. This is Feature 1", "hi {name}. This is Feature 2"] 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/react/test/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "hello world", 3 | "bye": "goodbye world", 4 | "common": { 5 | "yes": "yes", 6 | "no": "no", 7 | "hello": "Hello {name}", 8 | "values": { 9 | "name": "John" 10 | } 11 | }, 12 | "pages": { 13 | "landing": { 14 | "title": "Welcome", 15 | "hero": { 16 | "paragraph": "content", 17 | "features": ["hi {name}. This is Feature 1", "hi {name}. This is Feature 2"] 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import React from "react"; 3 | 4 | export interface ButtonProps extends React.ButtonHTMLAttributes { 5 | type?: "submit" | "button" | "reset"; 6 | } 7 | 8 | export function Button({ children, className, ...props }: ButtonProps) { 9 | return ( 10 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /packages/core/src/global.ts: -------------------------------------------------------------------------------- 1 | import type { Translation } from "@intl-t/core/types"; 2 | 3 | export * from "@intl-t/global"; 4 | 5 | export default interface Global { 6 | Translation: Translation; 7 | } 8 | 9 | export type GlobalTranslation = Global extends { Translation: infer T } ? T : Translation; 10 | export type GlobalSettings = GlobalTranslation["settings"]; 11 | export type GlobalPathSeparator = string extends GlobalSettings["ps"] ? "." : GlobalSettings["ps"]; 12 | export type GlobalLocale = GlobalSettings["allowedLocale"]; 13 | export type GlobalNode = GlobalSettings["tree"]; 14 | -------------------------------------------------------------------------------- /app/app/global.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "fumadocs-ui/css/black.css"; 3 | @import "fumadocs-ui/css/preset.css"; 4 | @import "fumadocs-twoslash/twoslash.css"; 5 | 6 | :root { 7 | --fd-layout-width: 2000px; 8 | main { 9 | @apply bg-[linear-gradient(to_right,#f0f0f0_1px,transparent_1.5px),linear-gradient(to_bottom,#f0f0f0_1px,transparent_1.5px)] bg-[size:6rem_4rem]; 10 | } 11 | } 12 | .dark { 13 | --color-fd-secondary: #0a0a0d; 14 | main { 15 | @apply bg-[linear-gradient(to_right,#0f0f0f_1px,transparent_1.5px),linear-gradient(to_bottom,#0f0f0f_1px,transparent_1.5px)] bg-[size:6rem_4rem]; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/next/src/params.ts: -------------------------------------------------------------------------------- 1 | import type { Locale } from "@intl-t/locales"; 2 | 3 | export interface StaticParamsConfig { 4 | locales?: L[]; 5 | param?: S; 6 | } 7 | 8 | export function createStaticParams(config: StaticParamsConfig) { 9 | return generateStaticParams.bind(config); 10 | } 11 | 12 | export async function generateStaticParams(this: StaticParamsConfig) { 13 | const { locales = [], param } = this; 14 | return locales.map(locale => ({ [param as string]: locale })) satisfies any[]; 15 | } 16 | -------------------------------------------------------------------------------- /app/app/llms.mdx/[[...slug]]/route.ts: -------------------------------------------------------------------------------- 1 | import { getLLMText } from "@/lib/get-llm-text"; 2 | import { source } from "@/lib/source"; 3 | import { notFound } from "next/navigation"; 4 | import { type NextRequest, NextResponse } from "next/server"; 5 | 6 | export const revalidate = false; 7 | 8 | export async function GET(_req: NextRequest, { params }: { params: Promise<{ slug?: string[] }> }) { 9 | const { slug } = await params; 10 | const page = source.getPage(slug); 11 | if (!page) notFound(); 12 | 13 | return new NextResponse(await getLLMText(page)); 14 | } 15 | 16 | export function generateStaticParams() { 17 | return source.generateParams(); 18 | } 19 | -------------------------------------------------------------------------------- /app/next.config.mjs: -------------------------------------------------------------------------------- 1 | import { createMDX } from "fumadocs-mdx/next"; 2 | 3 | const withMDX = createMDX(); 4 | 5 | /** @type {import('next').NextConfig} */ 6 | const config = { 7 | reactStrictMode: true, 8 | serverExternalPackages: ["typescript", "twoslash"], 9 | images: { 10 | remotePatterns: [ 11 | { hostname: "raw.githubusercontent.com" }, 12 | { 13 | hostname: "img.shields.io", 14 | }, 15 | ], 16 | }, 17 | async rewrites() { 18 | return [ 19 | { 20 | source: "/docs/:path*.mdx", 21 | destination: "/llms.mdx/:path*", 22 | }, 23 | ]; 24 | }, 25 | }; 26 | 27 | export default withMDX(config); 28 | -------------------------------------------------------------------------------- /app/app/docs/layout.tsx: -------------------------------------------------------------------------------- 1 | import { baseOptions } from "@/app/layout.config"; 2 | import { GithubInfo } from "@/components/github-info"; 3 | import { source } from "@/lib/source"; 4 | import { DocsLayout, DocsLayoutProps } from "fumadocs-ui/layouts/docs"; 5 | import type { ReactNode } from "react"; 6 | 7 | const docOptions: DocsLayoutProps = { 8 | ...baseOptions, 9 | tree: source.pageTree, 10 | links: [ 11 | { 12 | type: "custom", 13 | children: , 14 | }, 15 | ], 16 | }; 17 | 18 | export default function Layout({ children }: { children: ReactNode }) { 19 | return {children}; 20 | } 21 | -------------------------------------------------------------------------------- /packages/next/src/cache.ts: -------------------------------------------------------------------------------- 1 | import type { Translation } from "@intl-t/core"; 2 | import { cache } from "react"; 3 | 4 | export interface Cache { 5 | locale: string; 6 | t: Translation; 7 | } 8 | 9 | export const getCache = cache(() => ({}) as Partial); 10 | 11 | export function getCachedRequestLocale() { 12 | const locale = getCache().locale; 13 | // @ts-ignore 14 | if (this?.settings) this.settings.locale = locale; 15 | return locale; 16 | } 17 | 18 | export function setCachedRequestLocale(locale?: string) { 19 | getCache().locale = locale; 20 | // @ts-ignore 21 | if (this?.settings) ((this.settings.locale = locale), this?.t?.then?.()); 22 | return locale; 23 | } 24 | -------------------------------------------------------------------------------- /packages/next/src/headers.ts: -------------------------------------------------------------------------------- 1 | import { cache } from "react"; 2 | 3 | export const LOCALE_HEADERS_KEY = "x-locale"; 4 | export const PATH_HEADERS_KEY = "x-path"; 5 | 6 | export const getHeaders = cache(async () => { 7 | try { 8 | const { headers } = await import("next/headers"); 9 | return await headers(); 10 | } catch { 11 | return new Headers(); 12 | } 13 | }); 14 | 15 | export async function getHeadersRequestLocale(key = LOCALE_HEADERS_KEY) { 16 | const locale = (await getHeaders()).get(key); 17 | // @ts-ignore 18 | if (this?.settings) this.settings.locale = locale; 19 | return locale; 20 | } 21 | 22 | export async function getHeadersRequestPathname(key = PATH_HEADERS_KEY) { 23 | return (await getHeaders()).get(key); 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | on: 3 | push: 4 | paths-ignore: 5 | - 'app/**' 6 | - 'docs/**' 7 | - '**/*.md' 8 | - '**/*.yml' 9 | pull_request: 10 | paths-ignore: 11 | - 'app/**' 12 | - 'docs/**' 13 | - '**/*.md' 14 | - '**/*.yml' 15 | jobs: 16 | build: 17 | name: Build, lint, and test 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout repo 21 | uses: actions/checkout@v2 22 | - name: Setup Bun 23 | uses: oven-sh/setup-bun@v2 24 | - name: Install deps 25 | run: bun install 26 | - name: Lint 27 | run: bun run lint 28 | - name: Build 29 | run: bun run build 30 | - name: Test 31 | run: bun test 32 | -------------------------------------------------------------------------------- /packages/next/src/translation.ts: -------------------------------------------------------------------------------- 1 | import { TranslationNode, type TranslationFC } from "@intl-t/react"; 2 | import { getCachedRequestLocale } from "./cache"; 3 | import "./patch"; 4 | import { setRequestLocale } from "./request"; 5 | import { TranslationProvider, getTranslation } from "./rsc"; 6 | import { isRSC } from "./state"; 7 | 8 | if (isRSC) { 9 | TranslationNode.Provider = TranslationProvider as TranslationFC; 10 | TranslationNode.hook = getTranslation; 11 | TranslationNode.setLocale = setRequestLocale as any; 12 | TranslationNode.getLocale = getCachedRequestLocale; 13 | } 14 | 15 | export { createTranslation, Translation, TranslationNode } from "@intl-t/react"; 16 | export default TranslationNode; 17 | export { getLocales } from "@intl-t/core/dynamic"; 18 | -------------------------------------------------------------------------------- /app/lib/get-llm-text.ts: -------------------------------------------------------------------------------- 1 | import { source } from "@/lib/source"; 2 | import type { InferPageType } from "fumadocs-core/source"; 3 | import { remarkInclude } from "fumadocs-mdx/config"; 4 | import { remark } from "remark"; 5 | import remarkGfm from "remark-gfm"; 6 | import remarkMdx from "remark-mdx"; 7 | 8 | const processor = remark() 9 | .use(remarkMdx) 10 | // needed for Fumadocs MDX 11 | .use(remarkInclude) 12 | .use(remarkGfm); 13 | 14 | export async function getLLMText(page: InferPageType) { 15 | const processed = await processor.process({ 16 | path: page.data.info.fullPath, 17 | value: await page.data.getText("raw"), 18 | }); 19 | 20 | return `# ${page.data.title} 21 | URL: ${page.url} 22 | ${page.data.description || ""} 23 | ${processed.value}`; 24 | } 25 | -------------------------------------------------------------------------------- /app/mdx-components.tsx: -------------------------------------------------------------------------------- 1 | import * as FilesComponents from "@/components/files"; 2 | import * as GithubInfoComponents from "@/components/github-info"; 3 | import * as StepsComponents from "@/components/steps"; 4 | import * as TabsComponents from "@/components/tabs"; 5 | import * as Twoslash from "fumadocs-twoslash/ui"; 6 | import defaultMdxComponents from "fumadocs-ui/mdx"; 7 | import type { MDXComponents } from "mdx/types"; 8 | import { ImageZoom } from "./components/image-zoom"; 9 | 10 | export function getMDXComponents(components?: MDXComponents): MDXComponents { 11 | return { 12 | ...components, 13 | ...defaultMdxComponents, 14 | img: props => , 15 | ...GithubInfoComponents, 16 | ...FilesComponents, 17 | ...TabsComponents, 18 | ...StepsComponents, 19 | ...Twoslash, 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "ESNext", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "bundler", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "incremental": true, 18 | "paths": { 19 | "@/.source": ["./.source/index.ts"], 20 | "@/*": ["./*"] 21 | }, 22 | "plugins": [ 23 | { 24 | "name": "next" 25 | } 26 | ] 27 | }, 28 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 29 | "exclude": ["node_modules"] 30 | } 31 | -------------------------------------------------------------------------------- /packages/locales/README.md: -------------------------------------------------------------------------------- 1 | # @intl-t/locales 2 | 3 | `@intl-t/locales` provides locale typing and helpers shared across intl-t packages, ensuring consistent ISO codes and metadata when negotiating and formatting languages. 4 | 5 | [**→ Check out the Intl-T Website**](https://intl-t.dev) 6 | 7 | [![npm version](https://img.shields.io/npm/v/intl-t.svg?label=intl-t)](https://www.npmjs.com/package/intl-t) 8 | [![Discord Chat](https://img.shields.io/discord/1063280542759526400?label=Chat&logo=discord&color=blue)](https://discord.gg/5EbCXKpdyw) 9 | [![Donate via PayPal](https://img.shields.io/badge/PayPal-Donate-blue?logo=paypal)](https://www.paypal.com/ncp/payment/PMH5ASCL7J8B6) [![Star on Github](https://img.shields.io/github/stars/nivandres/intl-t)](https://github.com/nivandres/intl-t) 10 | 11 | [![Banner](https://raw.githubusercontent.com/nivandres/intl-t/main/assets/banner.webp)](https://intl-t.dev/) 12 | -------------------------------------------------------------------------------- /packages/next/src/link_client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { Locale } from "@intl-t/locales"; 4 | import { resolveHref } from "@intl-t/tools/resolvers"; 5 | import NL from "next/link"; 6 | import type { ComponentProps } from "react"; 7 | import { LinkProps } from "./link"; 8 | import { useLocale, usePathname } from "./router"; 9 | 10 | type NL = typeof NL; 11 | 12 | export function LC({ 13 | href = "", 14 | locale, 15 | currentLocale, 16 | // @ts-ignore 17 | config = this || {}, 18 | Link = config.Link || (NL as LC), 19 | ...props 20 | }: LinkProps & ComponentProps) { 21 | if (!href && locale) href = usePathname(); 22 | // @ts-ignore 23 | config.getLocale ||= () => useLocale()[0]; 24 | href = resolveHref(href, { ...config, locale, currentLocale }); 25 | return ; 26 | } 27 | -------------------------------------------------------------------------------- /packages/global/README.md: -------------------------------------------------------------------------------- 1 | # @intl-t/global 2 | 3 | `@intl-t/global` provides a tiny shared state layer for intl-t, exposing global settings and helpers used by formatting and runtime packages while keeping the core API framework-agnostic. 4 | 5 | [**→ Check out the Intl-T Website**](https://intl-t.dev) 6 | 7 | [![npm version](https://img.shields.io/npm/v/intl-t.svg?label=intl-t)](https://www.npmjs.com/package/intl-t) 8 | [![Discord Chat](https://img.shields.io/discord/1063280542759526400?label=Chat&logo=discord&color=blue)](https://discord.gg/5EbCXKpdyw) 9 | [![Donate via PayPal](https://img.shields.io/badge/PayPal-Donate-blue?logo=paypal)](https://www.paypal.com/ncp/payment/PMH5ASCL7J8B6) [![Star on Github](https://img.shields.io/github/stars/nivandres/intl-t)](https://github.com/nivandres/intl-t) 10 | 11 | [![Banner](https://raw.githubusercontent.com/nivandres/intl-t/main/assets/banner.webp)](https://intl-t.dev/) 12 | -------------------------------------------------------------------------------- /packages/tools/README.md: -------------------------------------------------------------------------------- 1 | # @intl-t/tools 2 | 3 | `@intl-t/tools` bundles small utilities used across intl-t, including locale negotiation, URL/path matching, format helpers, and inject/resolver helpers for working with your translation data and runtime. 4 | 5 | [**→ Check out the Intl-T Website**](https://intl-t.dev) 6 | 7 | [![npm version](https://img.shields.io/npm/v/intl-t.svg?label=intl-t)](https://www.npmjs.com/package/intl-t) 8 | [![Discord Chat](https://img.shields.io/discord/1063280542759526400?label=Chat&logo=discord&color=blue)](https://discord.gg/5EbCXKpdyw) 9 | [![Donate via PayPal](https://img.shields.io/badge/PayPal-Donate-blue?logo=paypal)](https://www.paypal.com/ncp/payment/PMH5ASCL7J8B6) [![Star on Github](https://img.shields.io/github/stars/nivandres/intl-t)](https://github.com/nivandres/intl-t) 10 | 11 | [![Banner](https://raw.githubusercontent.com/nivandres/intl-t/main/assets/banner.webp)](https://intl-t.dev/) 12 | -------------------------------------------------------------------------------- /packages/declarations/README.md: -------------------------------------------------------------------------------- 1 | # @intl-t/declarations 2 | 3 | `@intl-t/declarations` is a tiny CLI/library to generate TypeScript declaration files from your translation JavaScript objects, keeping autocompletion and strict types aligned with your source. 4 | 5 | [**→ Check out the Intl-T Website**](https://intl-t.dev) 6 | 7 | [![npm version](https://img.shields.io/npm/v/intl-t.svg?label=intl-t)](https://www.npmjs.com/package/intl-t) 8 | [![Discord Chat](https://img.shields.io/discord/1063280542759526400?label=Chat&logo=discord&color=blue)](https://discord.gg/5EbCXKpdyw) 9 | [![Donate via PayPal](https://img.shields.io/badge/PayPal-Donate-blue?logo=paypal)](https://www.paypal.com/ncp/payment/PMH5ASCL7J8B6) [![Star on Github](https://img.shields.io/github/stars/nivandres/intl-t)](https://github.com/nivandres/intl-t) 10 | 11 | [![Banner](https://raw.githubusercontent.com/nivandres/intl-t/main/assets/banner.webp)](https://intl-t.dev/) 12 | -------------------------------------------------------------------------------- /packages/format/README.md: -------------------------------------------------------------------------------- 1 | # @intl-t/format 2 | 3 | `@intl-t/format` offers formatting and injection utilities for intl-t: ICU message parsing, variable interpolation, and helpers to build rich outputs (strings or elements) with consistent, type-friendly behavior. 4 | 5 | [**→ Check out the Intl-T Website**](https://intl-t.dev) 6 | 7 | [![npm version](https://img.shields.io/npm/v/intl-t.svg?label=intl-t)](https://www.npmjs.com/package/intl-t) 8 | [![Discord Chat](https://img.shields.io/discord/1063280542759526400?label=Chat&logo=discord&color=blue)](https://discord.gg/5EbCXKpdyw) 9 | [![Donate via PayPal](https://img.shields.io/badge/PayPal-Donate-blue?logo=paypal)](https://www.paypal.com/ncp/payment/PMH5ASCL7J8B6) [![Star on Github](https://img.shields.io/github/stars/nivandres/intl-t)](https://github.com/nivandres/intl-t) 10 | 11 | [![Banner](https://raw.githubusercontent.com/nivandres/intl-t/main/assets/banner.webp)](https://intl-t.dev/) 12 | -------------------------------------------------------------------------------- /packages/next/README.md: -------------------------------------------------------------------------------- 1 | # @intl-t/next 2 | 3 | `@intl-t/next` brings intl-t to Next.js, with helpers for navigation, middleware, RSC/server components, and locale-aware links that integrate with App Router and caching while keeping full types on your translation tree. 4 | 5 | [**→ Check out the Intl-T Website**](https://intl-t.dev) 6 | 7 | [![npm version](https://img.shields.io/npm/v/intl-t.svg?label=intl-t)](https://www.npmjs.com/package/intl-t) 8 | [![Discord Chat](https://img.shields.io/discord/1063280542759526400?label=Chat&logo=discord&color=blue)](https://discord.gg/5EbCXKpdyw) 9 | [![Donate via PayPal](https://img.shields.io/badge/PayPal-Donate-blue?logo=paypal)](https://www.paypal.com/ncp/payment/PMH5ASCL7J8B6) [![Star on Github](https://img.shields.io/github/stars/nivandres/intl-t)](https://github.com/nivandres/intl-t) 10 | 11 | [![Banner](https://raw.githubusercontent.com/nivandres/intl-t/main/assets/banner.webp)](https://intl-t.dev/) 12 | -------------------------------------------------------------------------------- /packages/react/README.md: -------------------------------------------------------------------------------- 1 | # @intl-t/react 2 | 3 | `@intl-t/react` provides lightweight React bindings for intl-t: a provider, typed useTranslation hook, and component-safe variable injection so you can render translations and rich content with full TypeScript safety. 4 | 5 | [**→ Check out the Intl-T Website**](https://intl-t.dev) 6 | 7 | [![npm version](https://img.shields.io/npm/v/intl-t.svg?label=intl-t)](https://www.npmjs.com/package/intl-t) 8 | [![Discord Chat](https://img.shields.io/discord/1063280542759526400?label=Chat&logo=discord&color=blue)](https://discord.gg/5EbCXKpdyw) 9 | [![Donate via PayPal](https://img.shields.io/badge/PayPal-Donate-blue?logo=paypal)](https://www.paypal.com/ncp/payment/PMH5ASCL7J8B6) [![Star on Github](https://img.shields.io/github/stars/nivandres/intl-t)](https://github.com/nivandres/intl-t) 10 | 11 | [![Banner](https://raw.githubusercontent.com/nivandres/intl-t/main/assets/banner.webp)](https://intl-t.dev/) 12 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # @intl-t/core 2 | 3 | `@intl-t/core` is the typed, node-based i18n engine behind intl-t. It builds the translation tree with safe keys/values, variable inheritance, dynamic imports, and ICU-compatible formatting used by all framework integrations. 4 | 5 | [**→ Check out the Intl-T Website**](https://intl-t.dev) 6 | 7 | [![npm version](https://img.shields.io/npm/v/intl-t.svg?label=intl-t)](https://www.npmjs.com/package/intl-t) 8 | [![Discord Chat](https://img.shields.io/discord/1063280542759526400?label=Chat&logo=discord&color=blue)](https://discord.gg/5EbCXKpdyw) 9 | [![Donate via PayPal](https://img.shields.io/badge/PayPal-Donate-blue?logo=paypal)](https://www.paypal.com/ncp/payment/PMH5ASCL7J8B6) [![Star on Github](https://img.shields.io/github/stars/nivandres/intl-t)](https://github.com/nivandres/intl-t) 10 | 11 | [![Banner](https://raw.githubusercontent.com/nivandres/intl-t/main/assets/banner.webp)](https://intl-t.dev/) 12 | -------------------------------------------------------------------------------- /packages/react/src/client.ts: -------------------------------------------------------------------------------- 1 | import { state } from "@intl-t/global"; 2 | import { resolveLocale } from "@intl-t/tools/resolvers"; 3 | 4 | export const LOCALE_CLIENT_KEY = "LOCALE"; 5 | 6 | "localStorage" in globalThis ? null : (globalThis.localStorage = undefined as any); 7 | 8 | export function setClientLocale(locale: string, key = LOCALE_CLIENT_KEY) { 9 | // @ts-ignore-error optional binding 10 | if (this?.settings) this.settings.locale = locale; 11 | locale && localStorage?.setItem(key, locale); 12 | return locale; 13 | } 14 | 15 | export function getClientLocale(key = LOCALE_CLIENT_KEY) { 16 | // @ts-ignore-error optional binding 17 | const settings = this?.settings; 18 | const r = resolveLocale.bind(settings); 19 | // @ts-expect-error location type from browser 20 | const locale = localStorage?.getItem(key) || r(location.pathname) || r(state.locale); 21 | if (settings) settings.locale = locale; 22 | return locale; 23 | } 24 | -------------------------------------------------------------------------------- /packages/global/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@intl-t/global", 3 | "description": "Intl-T Global State", 4 | "version": "1.0.4", 5 | "type": "module", 6 | "license": "MIT", 7 | "homepage": "https://intl-t.dev", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/nivandres/intl-t.git", 11 | "directory": "packages/global" 12 | }, 13 | "author": { 14 | "name": "Ivan Vargas", 15 | "email": "nivnnd@gmail.com", 16 | "url": "https://nivan.dev" 17 | }, 18 | "files": [ 19 | "dist/*" 20 | ], 21 | "main": "./dist/index.js", 22 | "module": "./dist/index.js", 23 | "types": "./dist/index.d.ts", 24 | "exports": { 25 | ".": { 26 | "types": "./dist/index.d.ts", 27 | "default": "./dist/index.js" 28 | } 29 | }, 30 | "scripts": { 31 | "build": "tsc -b", 32 | "typecheck": "tsc --noEmit", 33 | "bump": "bun pm version", 34 | "release": "bun publish --access public" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/locales/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@intl-t/locales", 3 | "description": "Locale type", 4 | "version": "1.0.4", 5 | "type": "module", 6 | "license": "MIT", 7 | "homepage": "https://intl-t.dev", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/nivandres/intl-t.git", 11 | "directory": "packages/locales" 12 | }, 13 | "author": { 14 | "name": "Ivan Vargas", 15 | "email": "nivnnd@gmail.com", 16 | "url": "https://nivan.dev" 17 | }, 18 | "files": [ 19 | "dist/*" 20 | ], 21 | "main": "./dist/index.js", 22 | "module": "./dist/index.js", 23 | "types": "./dist/index.d.ts", 24 | "exports": { 25 | ".": { 26 | "types": "./dist/index.d.ts", 27 | "default": "./dist/index.js" 28 | }, 29 | "./*": { 30 | "types": "./dist/*.d.ts", 31 | "default": "./dist/*.js" 32 | } 33 | }, 34 | "scripts": { 35 | "bump": "bun pm version", 36 | "release": "bun publish --access public" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/react/src/translation.ts: -------------------------------------------------------------------------------- 1 | import { TranslationNode } from "@intl-t/core"; 2 | import { injectVariables as iv } from "@intl-t/format"; 3 | import { hydration } from "@intl-t/global"; 4 | import { getClientLocale } from "@intl-t/react/client"; 5 | import { TranslationProvider, useTranslation } from "@intl-t/react/context"; 6 | import { injectReactChunks as ir } from "@intl-t/react/inject"; 7 | import "@intl-t/react/patch"; 8 | import { TranslationFC } from "@intl-t/react/types"; 9 | 10 | export const injectVariables = ((str: string, ...args: any[]) => ir(iv(str, ...args), ...args)) as typeof iv; 11 | 12 | TranslationNode.injectVariables = injectVariables; 13 | TranslationNode.Provider = TranslationProvider as TranslationFC; 14 | TranslationNode.hook = useTranslation; 15 | !hydration && (TranslationNode.getLocale = getClientLocale); 16 | 17 | export { createTranslation, Translation, TranslationNode } from "@intl-t/core"; 18 | export default TranslationNode; 19 | export { getLocales } from "@intl-t/core/dynamic"; 20 | -------------------------------------------------------------------------------- /packages/next/src/request.ts: -------------------------------------------------------------------------------- 1 | // import { getRootParamsLocale } from "./root"; 2 | import type { Locale } from "@intl-t/locales"; 3 | import { getCachedRequestLocale, setCachedRequestLocale } from "./cache"; 4 | import { getHeadersRequestLocale, getHeadersRequestPathname } from "./headers"; 5 | 6 | export { setCachedRequestLocale as setRequestLocale } from "./cache"; 7 | 8 | export function getRequestLocale(preventDynamic: true): L | undefined; 9 | export function getRequestLocale(preventDynamic?: boolean): Promise | L | undefined; 10 | // @ts-ignore 11 | export function getRequestLocale(preventDynamic: boolean = this?.settings?.preventDynamic || false) { 12 | return ( 13 | // @ts-ignore 14 | getCachedRequestLocale.call(this) || 15 | // Missing workStore in unstable_rootParams. 16 | // getRootParamsLocale.call(this) || 17 | // @ts-ignore 18 | (!preventDynamic && getHeadersRequestLocale.call(this).then(setCachedRequestLocale)) || 19 | undefined 20 | ); 21 | } 22 | 23 | export const getRequestPathname = getHeadersRequestPathname; 24 | -------------------------------------------------------------------------------- /packages/tools/src/match.ts: -------------------------------------------------------------------------------- 1 | import type { Locale } from "@intl-t/locales"; 2 | 3 | export function match( 4 | requestLocales?: Locale[] | Locale | null, 5 | // @ts-ignore-error optional binding 6 | availableLocales: L[] | readonly L[] = this?.allowedLocales || requestLocales || [], 7 | // @ts-ignore-error optional binding 8 | defaultLocale: L | null = this?.mainLocale || this?.defaultLocale || availableLocales[0], 9 | ): L { 10 | requestLocales = typeof requestLocales === "string" ? [requestLocales] : requestLocales || []; 11 | let matchedLocale: L | undefined; 12 | for (let i = 0; i < requestLocales.length; i++) { 13 | const locale = requestLocales[i]; 14 | let [language, region] = locale.split("-"); 15 | if ((matchedLocale = availableLocales.find(l => l.startsWith(locale)))) break; 16 | if ((matchedLocale = availableLocales.find(l => l.startsWith(language)))) break; 17 | matchedLocale = availableLocales.find(l => l.includes(region)); 18 | } 19 | return matchedLocale || defaultLocale || (undefined as unknown as L); 20 | } 21 | 22 | export { match as matchLocale }; 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 nivandres 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/next/src/state.ts: -------------------------------------------------------------------------------- 1 | import type { Locale } from "@intl-t/locales"; 2 | import { setClientLocale } from "@intl-t/react"; 3 | import React from "react"; 4 | import { getRequestLocale, getRequestPathname } from "./request"; 5 | import { setRequestLocale } from "./request"; 6 | import { useLocale, usePathname } from "./router"; 7 | 8 | export { isClient } from "@intl-t/global"; 9 | export const isRSC = !("useEffect" in React); 10 | 11 | export function getLocale(preventDynamic: true, defaultLocale?: L): L | undefined; 12 | export function getLocale(preventDynamic?: boolean, defaultLocale?: L): Promise | L | undefined; 13 | export function getLocale(preventDynamic = false, defaultLocale?: L) { 14 | // @ts-ignore-error optional binding 15 | return isRSC ? (getRequestLocale.call(this, preventDynamic as boolean) as any) : useLocale.call(this, defaultLocale); 16 | } 17 | export function setLocale(locale: L) { 18 | // @ts-ignore-error optional binding 19 | return isRSC ? setRequestLocale.call(this, locale) : setClientLocale.call(this, locale); 20 | } 21 | export const getPathname = isRSC ? getRequestPathname : usePathname; 22 | -------------------------------------------------------------------------------- /app/source.config.ts: -------------------------------------------------------------------------------- 1 | import { rehypeCodeDefaultOptions } from "fumadocs-core/mdx-plugins"; 2 | import { defineConfig, defineDocs, frontmatterSchema, metaSchema } from "fumadocs-mdx/config"; 3 | import { transformerTwoslash } from "fumadocs-twoslash"; 4 | import { createFileSystemTypesCache } from "fumadocs-twoslash/cache-fs"; 5 | import { remarkAutoTypeTable, createGenerator } from "fumadocs-typescript"; 6 | 7 | // You can customise Zod schemas for frontmatter and `meta.json` here 8 | // see https://fumadocs.vercel.app/docs/mdx/collections#define-docs 9 | export const docs = defineDocs({ 10 | docs: { 11 | schema: frontmatterSchema, 12 | }, 13 | meta: { 14 | schema: metaSchema, 15 | }, 16 | dir: "../docs", 17 | }); 18 | 19 | const generator = createGenerator(); 20 | 21 | export default defineConfig({ 22 | lastModifiedTime: "git", 23 | mdxOptions: { 24 | remarkPlugins: [[remarkAutoTypeTable, { generator }]], 25 | rehypeCodeOptions: { 26 | themes: { 27 | light: "github-light", 28 | dark: "tokyo-night", 29 | }, 30 | transformers: [...(rehypeCodeDefaultOptions.transformers ?? []), transformerTwoslash({ typesCache: createFileSystemTypesCache() })], 31 | }, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /packages/declarations/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@intl-t/declarations", 3 | "description": "Generate type declaration files from JavaScript Object", 4 | "version": "1.0.4", 5 | "type": "module", 6 | "license": "MIT", 7 | "homepage": "https://intl-t.dev", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/nivandres/intl-t.git", 11 | "directory": "packages/declarations" 12 | }, 13 | "author": { 14 | "name": "Ivan Vargas", 15 | "email": "nivnnd@gmail.com", 16 | "url": "https://nivan.dev" 17 | }, 18 | "files": [ 19 | "dist/*" 20 | ], 21 | "main": "./dist/index.js", 22 | "module": "./dist/index.js", 23 | "types": "./dist/index.d.ts", 24 | "bin": { 25 | "declarations": "./dist/bin.js" 26 | }, 27 | "exports": { 28 | ".": { 29 | "types": "./dist/index.d.ts", 30 | "default": "./dist/index.js" 31 | } 32 | }, 33 | "keywords": [ 34 | "translation", 35 | "json2dts", 36 | "declarations", 37 | "typescript", 38 | "formatter" 39 | ], 40 | "scripts": { 41 | "build": "tsc -b", 42 | "typecheck": "tsc --noEmit", 43 | "bump": "bun pm version", 44 | "release": "bun publish --access public" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /docs/support.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Support 3 | description: Support for Intl-T 4 | --- 5 | 6 | ## Hello there 7 | 8 | This translation library was originally built for my own projects, aiming to provide the best possible developer experience: high performance, ultra-lightweight, fully customizable, and with TypeScript autocomplete everywhere. It uses a translation object-based approach and offers a super flexible syntax, integrating the best features from other i18n libraries. It includes its own ICU message format, works out of the box with React and Next.js, supports static rendering, and has zero dependencies. While it's still under active development and may not yet be recommended for large-scale production projects, I am committed to improving it further. Feel free to use it, contribute, or reach out with feedback. Thank you! 9 | 10 | ## Supporting 11 | 12 | If you find this project useful, [consider supporting its development ☕](https://www.paypal.com/ncp/payment/PMH5ASCL7J8B6) or [leave a ⭐ on the Github Repo.](https://github.com/nivandres/intl-t) Also, if you need direct support or help, please don't hesitate to contact me. 13 | 14 | 15 | 16 | --- 17 | 18 | Thank you for using Intl-T! 19 | -------------------------------------------------------------------------------- /app/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/app/global.css"; 2 | import { RootProvider } from "fumadocs-ui/provider"; 3 | import type { Metadata } from "next"; 4 | import { Noto_Sans } from "next/font/google"; 5 | import type { ReactNode } from "react"; 6 | 7 | const inter = Noto_Sans({ 8 | subsets: ["latin"], 9 | }); 10 | 11 | export const metadata: Metadata = { 12 | title: "Intl-T", 13 | description: 14 | "Intl-T is a fully typed object-based i18n translation library for TypeScript, React, and Next.js. Fully Type-Safe, Fast & Lightweight, Framework Agnostic, Rich API, Formatting helpers, Next.js Navigation.", 15 | authors: { 16 | name: "Ivan Vargas", 17 | url: "https://nivan.dev", 18 | }, 19 | creator: "@nivandres", 20 | openGraph: { 21 | images: { 22 | url: "https://raw.githubusercontent.com/nivandres/intl-t/main/assets/banner.webp", 23 | alt: "Intl-T Banner Image", 24 | }, 25 | }, 26 | }; 27 | 28 | export default function Layout({ children }: { children: ReactNode }) { 29 | return ( 30 | 31 | 32 | {children} 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; 4 | import { forwardRef, useEffect, useState } from "react"; 5 | import { cn } from "../../lib/cn"; 6 | 7 | const Collapsible = CollapsiblePrimitive.Root; 8 | 9 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; 10 | 11 | const CollapsibleContent = forwardRef>( 12 | ({ children, ...props }, ref) => { 13 | const [mounted, setMounted] = useState(false); 14 | 15 | useEffect(() => { 16 | setMounted(true); 17 | }, []); 18 | 19 | return ( 20 | 29 | {children} 30 | 31 | ); 32 | }, 33 | ); 34 | 35 | CollapsibleContent.displayName = CollapsiblePrimitive.CollapsibleContent.displayName; 36 | 37 | export { Collapsible, CollapsibleTrigger, CollapsibleContent }; 38 | -------------------------------------------------------------------------------- /packages/tools/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@intl-t/tools", 3 | "description": "i18n utilities", 4 | "version": "1.0.4", 5 | "type": "module", 6 | "license": "MIT", 7 | "homepage": "https://intl-t.dev", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/nivandres/intl-t.git", 11 | "directory": "packages/tools" 12 | }, 13 | "author": { 14 | "name": "Ivan Vargas", 15 | "email": "nivnnd@gmail.com", 16 | "url": "https://nivan.dev" 17 | }, 18 | "files": [ 19 | "dist/*" 20 | ], 21 | "main": "./dist/index.js", 22 | "module": "./dist/index.js", 23 | "types": "./dist/index.d.ts", 24 | "exports": { 25 | ".": { 26 | "types": "./dist/index.d.ts", 27 | "default": "./dist/index.js" 28 | }, 29 | "./*": { 30 | "types": "./dist/*.d.ts", 31 | "default": "./dist/*.js" 32 | } 33 | }, 34 | "keywords": [ 35 | "intl", 36 | "formatter" 37 | ], 38 | "scripts": { 39 | "build": "tsc -b", 40 | "test": "bun test", 41 | "typecheck": "tsc --noEmit", 42 | "bump": "bun pm version", 43 | "release": "bun publish --access public" 44 | }, 45 | "dependencies": { 46 | "@intl-t/declarations": "workspace:^", 47 | "@intl-t/format": "workspace:^", 48 | "@intl-t/global": "workspace:^", 49 | "@intl-t/locales": "workspace:^" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/format/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@intl-t/format", 3 | "description": "Format utilities, variable injection, ICU Support", 4 | "version": "1.0.4", 5 | "type": "module", 6 | "license": "MIT", 7 | "homepage": "https://intl-t.dev", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/nivandres/intl-t.git", 11 | "directory": "packages/format" 12 | }, 13 | "author": { 14 | "name": "Ivan Vargas", 15 | "email": "nivnnd@gmail.com", 16 | "url": "https://nivan.dev" 17 | }, 18 | "files": [ 19 | "dist/*" 20 | ], 21 | "main": "./dist/index.js", 22 | "module": "./dist/index.js", 23 | "types": "./dist/index.d.ts", 24 | "exports": { 25 | ".": { 26 | "types": "./dist/index.d.ts", 27 | "default": "./dist/index.js" 28 | }, 29 | "./*": { 30 | "types": "./dist/*.d.ts", 31 | "default": "./dist/*.js" 32 | } 33 | }, 34 | "keywords": [ 35 | "translation", 36 | "icu", 37 | "formatter", 38 | "inject", 39 | "intl", 40 | "translate", 41 | "format" 42 | ], 43 | "scripts": { 44 | "build": "tsc -b", 45 | "test": "bun test", 46 | "typecheck": "tsc --noEmit", 47 | "bump": "bun pm version", 48 | "release": "bun publish --access public" 49 | }, 50 | "dependencies": { 51 | "@intl-t/global": "workspace:^" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/components/image-zoom.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Image, type ImageProps } from "fumadocs-core/framework"; 4 | import { type ImgHTMLAttributes } from "react"; 5 | import Zoom, { type UncontrolledProps } from "react-medium-image-zoom"; 6 | import "../styles/image-zoom.css"; 7 | 8 | export type ImageZoomProps = ImageProps & { 9 | /** 10 | * Image props when zoom in 11 | */ 12 | zoomInProps?: ImgHTMLAttributes; 13 | 14 | /** 15 | * Props for `react-medium-image-zoom` 16 | */ 17 | rmiz?: UncontrolledProps; 18 | }; 19 | 20 | function getImageSrc(src: ImageProps["src"]): string { 21 | if (typeof src === "string") return src; 22 | 23 | if (typeof src === "object") { 24 | // Next.js 25 | if ("default" in src) return (src as { default: { src: string } }).default.src; 26 | return src.src; 27 | } 28 | 29 | return ""; 30 | } 31 | 32 | export function ImageZoom({ zoomInProps, children, rmiz, ...props }: ImageZoomProps) { 33 | return ( 34 | 44 | {children ?? } 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /packages/format/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Base = string | number; 2 | export type Key = Base | symbol; 3 | export type Value = Base | null | undefined | boolean | Base[] | Date | Function; 4 | export type Values = Record; 5 | 6 | export type Display = N extends `${infer A}{${string}}${infer B}` ? `${A}${string}${Display}` : N; 7 | export type Content = N extends Base ? Display : N extends { base: infer B } ? Display : Base; 8 | 9 | type VFSO = { 10 | [K in V]: T; 11 | } & VFS1; 12 | type VFSR = VFSO; 13 | type VFS2 = S extends `${infer V},${string}` ? V : S; 14 | type VFS1 = S extends `${string}{{${infer V}}}${infer C}` 15 | ? VFSO, C> 16 | : S extends `${string}{${infer V}}${infer C}` 17 | ? VFSO, C> 18 | : S extends `${string}<${infer V}>${infer C}` 19 | ? VFSR 20 | : {}; 21 | export type VariablesFromString = VFS1; 22 | export type Override = T & U extends never ? Omit & U : T & U; 23 | export type VariablesFromNode = "values" extends keyof N ? N["values"] : N extends string ? VariablesFromString : {}; 24 | export type Variables = Override>; 25 | -------------------------------------------------------------------------------- /app/app/docs/[[...slug]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { source } from "@/lib/source"; 2 | import { getMDXComponents } from "@/mdx-components"; 3 | import { createRelativeLink } from "fumadocs-ui/mdx"; 4 | import { DocsPage, DocsBody, DocsDescription, DocsTitle } from "fumadocs-ui/page"; 5 | import { notFound } from "next/navigation"; 6 | 7 | export default async function Page(props: { params: Promise<{ slug?: string[] }> }) { 8 | const params = await props.params; 9 | const page = source.getPage(params.slug); 10 | if (!page) notFound(); 11 | 12 | const MDXContent = page.data.body; 13 | 14 | return ( 15 | 16 | {page.data.title} 17 | {page.data.description} 18 | 19 | 24 | 25 | 26 | ); 27 | } 28 | 29 | export async function generateStaticParams() { 30 | return source.generateParams(); 31 | } 32 | 33 | export async function generateMetadata(props: { params: Promise<{ slug?: string[] }> }) { 34 | const params = await props.params; 35 | const page = source.getPage(params.slug); 36 | if (!page) notFound(); 37 | 38 | return { 39 | title: page.data.title, 40 | description: page.data.description, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /packages/core/src/dynamic.ts: -------------------------------------------------------------------------------- 1 | import type { Locale, Node, Promisable, ResolveNode } from "@intl-t/core/types"; 2 | import { isClient } from "@intl-t/global"; 3 | 4 | export function getLocale( 5 | node: N | Promisable | ((locale?: Locale) => Promisable), 6 | locale?: Locale, 7 | preload = !isClient, 8 | ): N { 9 | if (preload && typeof node === "function") return getLocale(node(locale), locale, preload) as N; 10 | return node as N; 11 | } 12 | 13 | export async function getLocales( 14 | node: T | ((locale: L) => Promisable), 15 | allowedLocales: readonly L[], 16 | preload?: boolean, 17 | ): Promise<{ 18 | [K in L]: T extends (locale: K) => infer N ? ResolveNode : ResolveNode; 19 | }>; 20 | export async function getLocales( 21 | locales: T & Record, 22 | preload?: boolean, 23 | ): Promise<{ 24 | [K in L & keyof T]: ResolveNode; 25 | }>; 26 | export async function getLocales( 27 | node: any, 28 | list?: readonly any[] | boolean, 29 | preload: boolean | undefined = typeof list === "boolean" ? list : void 0, 30 | ) { 31 | let locales = 32 | typeof node === "object" ? node : typeof list !== "object" ? {} : list.reduce((acc, locale) => ({ ...acc, [locale]: node }), {}); 33 | await Promise.all(Object.keys(locales).map(async locale => (locales[locale] = await getLocale(locales[locale], locale, preload)))); 34 | return locales as any; 35 | } 36 | -------------------------------------------------------------------------------- /docs/acknowledgment.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Acknowledgment 3 | description: Acknowledgment, references, and links for Intl-T 4 | --- 5 | 6 | ## References 7 | 8 | ### Acknowledgment 9 | 10 | - [dx_over_dt's Stack Overflow answer,](https://stackoverflow.com/a/64418639/29393046) demonstrates how to override the `[Symbol.iterator]` method of a `String` object to prevent character-by-character rendering in React. This idea helped shape earlier versions of Intl-T. 11 | 12 | - [Kent C. Dodds's blog post,](https://kentcdodds.com/blog/rendering-a-function-with-react) explores a clever way to trick React into rendering functions as children. Although, this approach is no longer supported due to `react-reconciler`. It provides historical context that eventually led to the strategy used in Intl-T. 13 | 14 | ### Mentions 15 | 16 | - Featured in [Next.js Weekly issue #90](https://nextjsweekly.com/issues/90) as an unique node-based system for organizing translations 17 | 18 | - [First Reddit Post](https://www.reddit.com/r/nextjs/comments/1l6x8wg/i_develop_a_fullytyped_objectbased_i18n) shared on r/nextjs, introducing `intl-t` and received feedback from the community. 19 | 20 | --- 21 | 22 | ## Links 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | --- 32 | -------------------------------------------------------------------------------- /packages/tools/test/match.legacy.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "bun:test"; 2 | import { match as m } from "../src"; 3 | 4 | describe("match locales", () => { 5 | it("should match language to language", () => { 6 | expect(m(["es"], ["es"])).toBe("es"); 7 | expect(m("es", ["es"])).toBe("es"); 8 | expect(m(["es"], ["ar", "es"])).toBe("es"); 9 | expect(m(["es"], ["es", "ar"])).toBe("es"); 10 | expect(m(["es"], ["ar", "es", "ar"])).toBe("es"); 11 | expect(m(["en", "es", "en"], ["ar", "es", "ar"])).toBe("es"); 12 | expect(m(["ar", "en-US"], ["ar", "ar-US"])).toBe("ar"); 13 | }); 14 | it("should match language region to language region", () => { 15 | expect(m(["es-MX"], ["es-MX"])).toBe("es-MX"); 16 | expect(m(["es-MX"], ["es", "es-MX"])).toBe("es-MX"); 17 | }); 18 | it("should match language region to language", () => { 19 | expect(m(["ar-CA"], ["ar", "ar-US", "es-CA"])).toBe("ar"); 20 | }); 21 | it("should match language region to region", () => { 22 | expect(m(["es-MX"], ["en", "en-MX"])).toBe("en-MX"); 23 | expect(m(["ar-US"], ["es", "fr-US"])).toBe("fr-US"); 24 | }); 25 | it("should match default locale", () => { 26 | expect(m(["ar"], ["es"], "en")).toBe("en"); 27 | expect(m(["en-MX"], ["es-US"], "ar")).toBe("ar"); 28 | }); 29 | }); 30 | 31 | describe("general cases", () => { 32 | it("cases", () => { 33 | expect(m(["es-CO", "en-US"], ["en", "es"], "en")).toBe("es"); 34 | expect(m(["es-CO", "en-US"], ["en", "es-MX"], "en")).toBe("es-MX"); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /docs/edge.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Edge 3 | description: Special Edge Runtimes Compatibility Warning 4 | --- 5 | 6 | ### Edge Runtimes Compatibility Warning 7 | 8 | Edge environments, such as Cloudflare Workers, Vercel Edge Functions, and Cloudflare Pages, are only partially supported and have various limitations and caveats. 9 | 10 | Since `new Function` cannot be executed in edge environments, Translation Node Proxies created from a new function cannot be functions anymore. This means you won't be able to call them directly. Instead, you'll need to use methods like `.use` or `.get` to perform actions. Also some variable injections may not work as expected. 11 | 12 | ```ts 13 | // From 14 | const t = getTranslation(); 15 | t("hello"); 16 | t.greetings({ name: "John" }); 17 | 18 | // To 19 | const t = getTranslation(); // Also hooks lose their proxy properties 20 | t.get("hello"); // or t.hello 21 | t.greetings.use({ name: "John" }); // .use and .get are the same 22 | ``` 23 | 24 | The upside is that, since these proxies become string objects rather than function objects, this offers better compatibility in some environments and eliminates the need for workarounds such as React Patch. However, this limitation only applies to edge environments. 25 | 26 | The current solution is a temporary workaround. In the future, full compatibility will be achieved, as there are ways in JavaScript that can provide the desired behavior and even allow you to choose between string objects or function objects as needed, thus avoiding the need for workarounds like the React Patch. 27 | -------------------------------------------------------------------------------- /app/styles/image-zoom.css: -------------------------------------------------------------------------------- 1 | [data-rmiz] { 2 | display: block; 3 | position: relative; 4 | } 5 | 6 | [data-rmiz-ghost] { 7 | pointer-events: none; 8 | position: absolute; 9 | } 10 | 11 | [data-rmiz-btn-zoom], 12 | [data-rmiz-btn-unzoom] { 13 | display: none; 14 | } 15 | 16 | [data-rmiz-content="found"] img { 17 | cursor: zoom-in; 18 | } 19 | 20 | [data-rmiz-modal][open] { 21 | width: 100vw /* fallback */; 22 | width: 100dvw; 23 | 24 | height: 100vh /* fallback */; 25 | height: 100dvh; 26 | 27 | background-color: transparent; 28 | max-width: none; 29 | max-height: none; 30 | margin: 0; 31 | padding: 0; 32 | position: fixed; 33 | overflow: hidden; 34 | } 35 | 36 | [data-rmiz-modal]:focus-visible { 37 | outline: none; 38 | } 39 | 40 | [data-rmiz-modal-overlay] { 41 | transition: background-color 0.3s; 42 | position: absolute; 43 | inset: 0; 44 | } 45 | 46 | [data-rmiz-modal-overlay="visible"] { 47 | background-color: var(--color-fd-background); 48 | } 49 | 50 | [data-rmiz-modal-overlay="hidden"] { 51 | background-color: transparent; 52 | } 53 | 54 | [data-rmiz-modal-content] { 55 | width: 100%; 56 | height: 100%; 57 | position: relative; 58 | } 59 | 60 | [data-rmiz-modal]::backdrop { 61 | display: none; 62 | } 63 | 64 | [data-rmiz-modal-img] { 65 | cursor: zoom-out; 66 | image-rendering: high-quality; 67 | transform-origin: 0 0; 68 | transition: transform 0.3s; 69 | position: absolute; 70 | } 71 | 72 | @media (prefers-reduced-motion: reduce) { 73 | [data-rmiz-modal-overlay], 74 | [data-rmiz-modal-img] { 75 | transition-duration: 0.01ms !important; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@intl-t/core", 3 | "description": "A Fully-Typed Node-Based i18n Translation Library", 4 | "version": "1.0.4", 5 | "type": "module", 6 | "license": "MIT", 7 | "homepage": "https://intl-t.dev", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/nivandres/intl-t.git", 11 | "directory": "packages/core" 12 | }, 13 | "author": { 14 | "name": "Ivan Vargas", 15 | "email": "nivnnd@gmail.com", 16 | "url": "https://nivan.dev" 17 | }, 18 | "files": [ 19 | "dist/*" 20 | ], 21 | "main": "./dist/index.js", 22 | "module": "./dist/index.js", 23 | "types": "./dist/index.d.ts", 24 | "exports": { 25 | ".": { 26 | "types": "./dist/index.d.ts", 27 | "default": "./dist/index.js" 28 | }, 29 | "./*": { 30 | "types": "./dist/*.d.ts", 31 | "default": "./dist/*.js" 32 | } 33 | }, 34 | "keywords": [ 35 | "translation", 36 | "i18n", 37 | "intl", 38 | "typescript", 39 | "object-based", 40 | "node-based", 41 | "translate", 42 | "tree" 43 | ], 44 | "scripts": { 45 | "build": "tsc -b", 46 | "test": "bun test", 47 | "typecheck": "tsc --noEmit", 48 | "bump": "bun pm version", 49 | "release": "bun publish --access public" 50 | }, 51 | "dependencies": { 52 | "@intl-t/format": "workspace:^", 53 | "@intl-t/global": "workspace:^", 54 | "@intl-t/locales": "workspace:^" 55 | }, 56 | "peerDependencies": { 57 | "typescript": "^5" 58 | }, 59 | "peerDependenciesMeta": { 60 | "typescript": { 61 | "optional": true 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "intl-t-app", 3 | "private": true, 4 | "type": "module", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/nivandres/intl-t.git", 8 | "directory": "app" 9 | }, 10 | "author": "Ivan Vargas ", 11 | "scripts": { 12 | "build": "next build --turbo", 13 | "dev": "next dev --turbo", 14 | "start": "next start", 15 | "typecheck": "tsc --noEmit", 16 | "postinstall": "fumadocs-mdx" 17 | }, 18 | "dependencies": { 19 | "@radix-ui/react-collapsible": "^1.1.12", 20 | "@radix-ui/react-slot": "^1.2.3", 21 | "@radix-ui/react-tabs": "^1.1.13", 22 | "class-variance-authority": "^0.7.1", 23 | "clsx": "^2.1.1", 24 | "feed": "^5.1.0", 25 | "fumadocs-core": "^15.8.5", 26 | "fumadocs-mdx": "^12.0.3", 27 | "fumadocs-twoslash": "^3.1.7", 28 | "fumadocs-typescript": "^4.0.8", 29 | "fumadocs-ui": "^15.7.10", 30 | "intl-t": "latest", 31 | "lucide-react": "^0.546.0", 32 | "next": "^15.5.2", 33 | "react": "^19.2.0", 34 | "react-dom": "^19.1.1", 35 | "react-medium-image-zoom": "^5.4.0", 36 | "remark": "^15.0.1", 37 | "remark-gfm": "^4.0.1", 38 | "remark-mdx": "^3.1.1", 39 | "tailwind-merge": "^3.3.1", 40 | "twoslash": "^0.3.4" 41 | }, 42 | "devDependencies": { 43 | "@tailwindcss/postcss": "^4.1.13", 44 | "@types/mdx": "^2.0.13", 45 | "@types/node": "24.8.0", 46 | "@types/react": "^19.1.12", 47 | "@types/react-dom": "^19.1.9", 48 | "postcss": "^8.5.6", 49 | "tailwindcss": "^4.1.13", 50 | "tw-animate-css": "^1.4.0", 51 | "typescript": "^5.9.2" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/app/layout.config.tsx: -------------------------------------------------------------------------------- 1 | import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared"; 2 | import { Book, Download, Github, MessageSquare } from "lucide-react"; 3 | 4 | /** 5 | * Shared layout configurations 6 | * 7 | * you can customise layouts individually from: 8 | * Home Layout: app/(home)/layout.tsx 9 | * Docs Layout: app/docs/layout.tsx 10 | */ 11 | export const baseOptions: BaseLayoutProps = { 12 | nav: { 13 | title: ( 14 | <> 15 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | Intl-T 28 | 29 | ), 30 | }, 31 | // see https://fumadocs.dev/docs/ui/navigation/links 32 | links: [ 33 | { 34 | icon: , 35 | text: "Docs", 36 | url: "/docs", 37 | }, 38 | { 39 | icon: , 40 | text: "Contact", 41 | url: "https://discord.gg/5EbCXKpdyw", 42 | }, 43 | { 44 | icon: , 45 | text: "Github", 46 | url: "https://github.com/nivandres/intl-t", 47 | }, 48 | { 49 | icon: , 50 | text: "NPM", 51 | url: "https://www.npmjs.com/package/intl-t", 52 | }, 53 | ], 54 | githubUrl: "https://github.com/nivandres/intl-t", 55 | }; 56 | -------------------------------------------------------------------------------- /packages/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@intl-t/react", 3 | "description": "A Fully-Typed Object-Based i18n Translation Library for React", 4 | "version": "1.0.4", 5 | "type": "module", 6 | "license": "MIT", 7 | "homepage": "https://intl-t.dev", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/nivandres/intl-t.git", 11 | "directory": "packages/react" 12 | }, 13 | "author": { 14 | "name": "Ivan Vargas", 15 | "email": "nivnnd@gmail.com", 16 | "url": "https://nivan.dev" 17 | }, 18 | "files": [ 19 | "dist/*" 20 | ], 21 | "main": "./dist/index.js", 22 | "module": "./dist/index.js", 23 | "types": "./dist/index.d.ts", 24 | "exports": { 25 | ".": { 26 | "types": "./dist/index.d.ts", 27 | "default": "./dist/index.js" 28 | }, 29 | "./*": { 30 | "types": "./dist/*.d.ts", 31 | "default": "./dist/*.js" 32 | } 33 | }, 34 | "keywords": [ 35 | "translation", 36 | "i18n", 37 | "intl", 38 | "typescript", 39 | "react", 40 | "node", 41 | "nodes", 42 | "object-based", 43 | "node-based", 44 | "translate", 45 | "formatter", 46 | "tree", 47 | "next", 48 | "next.js" 49 | ], 50 | "scripts": { 51 | "build": "tsc -b", 52 | "test": "bun test", 53 | "typecheck": "tsc --noEmit", 54 | "bump": "bun pm version", 55 | "release": "bun publish --access public" 56 | }, 57 | "dependencies": { 58 | "@intl-t/core": "workspace:^", 59 | "@intl-t/global": "workspace:^", 60 | "@intl-t/format": "workspace:^" 61 | }, 62 | "peerDependencies": { 63 | "react": ">=18 <20" 64 | }, 65 | "peerDependenciesMeta": { 66 | "react": { 67 | "optional": true 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /packages/next/src/router.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { Locale } from "@intl-t/locales"; 4 | import { useLocale } from "@intl-t/react"; 5 | import { resolvePath, resolveHref, ResolveConfig } from "@intl-t/tools/resolvers"; 6 | import { useRouter as ur, usePathname as up } from "next/navigation"; 7 | 8 | export interface Options { 9 | locale?: Locale; 10 | } 11 | 12 | declare module "next/dist/shared/lib/app-router-context.shared-runtime" { 13 | interface NavigateOptions extends Options {} 14 | interface PrefetchOptions extends Options {} 15 | interface AppRouterInstance extends Options { 16 | pathname?: string; 17 | } 18 | } 19 | 20 | export { useLocale }; 21 | export const usePathname: typeof up = () => resolvePath(up()); 22 | 23 | export interface RouterConfig extends ResolveConfig { 24 | useRouter?: typeof ur; 25 | } 26 | 27 | const state: Record string> = {}; 28 | 29 | // @ts-ignore 30 | function useResolvedRouter({ useRouter = ur, ...config }: RouterConfig = this || {}) { 31 | const router = useRouter(); 32 | let path = state.path?.(); 33 | let locale = state.locale?.(); 34 | const handler = (method: keyof typeof router) => 35 | ((href, { locale, ...options } = {}) => 36 | router[method as "push"](resolveHref(href, { ...config, locale }), options as any)) as typeof router.push; 37 | return { 38 | ...router, 39 | push: handler("push"), 40 | replace: handler("replace"), 41 | prefetch: handler("prefetch"), 42 | get pathname() { 43 | state.path ||= usePathname; 44 | return (path ||= state.path()); 45 | }, 46 | get locale() { 47 | state.locale ||= () => useLocale()[0]; 48 | return (locale ||= state.locale()); 49 | }, 50 | }; 51 | } 52 | 53 | export const useRouter: typeof ur = useResolvedRouter; 54 | -------------------------------------------------------------------------------- /packages/global/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Locale } from "@intl-t/locales"; 2 | 3 | export type Intl = typeof Intl; 4 | export type TimeZone = Intl.DateTimeFormatOptions["timeZone"]; 5 | export type LocaleOptions = Intl.LocaleOptions; 6 | export type FormatOptions = 7 | | Intl.CollatorOptions 8 | | Intl.DateTimeFormatOptions 9 | | Intl.DisplayNamesOptions 10 | | Intl.ListFormatOptions 11 | | Intl.NumberFormatOptions 12 | | Intl.PluralRulesOptions 13 | | Intl.RelativeTimeFormatOptions 14 | | Intl.SegmenterOptions; 15 | 16 | export interface State { 17 | timeZone: TimeZone; 18 | locale: L; 19 | now: Date; 20 | isClient: boolean; 21 | hydration: boolean; 22 | enabledEval: boolean; 23 | disabledEval: boolean; 24 | behavior: "function" | "object" | "flexible"; 25 | localeOptions: Intl.ResolvedDateTimeFormatOptions; 26 | formatOptions?: FormatOptions; 27 | formatFallback?: string; 28 | } 29 | 30 | export const isClient = "window" in globalThis; 31 | export const hydration = "process" in globalThis; 32 | export const enabledEval = "eval" in globalThis; 33 | export const disabledEval = !enabledEval; 34 | 35 | let localeOptions: Intl.ResolvedDateTimeFormatOptions; 36 | let locale: Locale; 37 | let now: Date; 38 | 39 | export const state: State = { 40 | isClient, 41 | hydration, 42 | enabledEval, 43 | disabledEval, 44 | behavior: "function", 45 | get localeOptions() { 46 | return (localeOptions ??= Intl.DateTimeFormat().resolvedOptions()); 47 | }, 48 | get timeZone() { 49 | return this.localeOptions.timeZone; 50 | }, 51 | get locale() { 52 | return (locale ??= isClient ? (navigator["language" as keyof typeof navigator] as string)?.split(",")[0] : this.localeOptions.locale); 53 | }, 54 | get now() { 55 | return (now ??= new Date()); 56 | }, 57 | }; 58 | 59 | export default state; 60 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowArbitraryExtensions": true, 4 | "composite": true, 5 | "declaration": true, 6 | "jsx": "react-jsx", 7 | "lib": ["ESNext"], 8 | "module": "ESNext", 9 | "moduleResolution": "bundler", 10 | "outDir": "dist", 11 | "rootDir": "src", 12 | "skipLibCheck": true, 13 | "strict": true, 14 | "target": "ESNext", 15 | "paths": { 16 | "@intl-t/core": ["./packages/core/src"], 17 | "@intl-t/core/*": ["./packages/core/src/*"], 18 | "@intl-t/declarations": ["./packages/declarations/src"], 19 | "@intl-t/declarations/*": ["./packages/declarations/src/*"], 20 | "@intl-t/format": ["./packages/format/src"], 21 | "@intl-t/format/*": ["./packages/format/src/*"], 22 | "@intl-t/global": ["./packages/global/src"], 23 | "@intl-t/global/*": ["./packages/global/src/*"], 24 | "@intl-t/locales": ["./packages/locales/src"], 25 | "@intl-t/locales/*": ["./packages/locales/src/*"], 26 | "@intl-t/next": ["./packages/next/src"], 27 | "@intl-t/next/*": ["./packages/next/src/*"], 28 | "@intl-t/react": ["./packages/react/src"], 29 | "@intl-t/react/*": ["./packages/react/src/*"], 30 | "@intl-t/tools": ["./packages/tools/src"], 31 | "@intl-t/tools/*": ["./packages/tools/src/*"], 32 | "intl-t": ["./packages/core/src/index.ts"], 33 | "intl-t/*": ["./packages/core/src/*"] 34 | } 35 | }, 36 | "references": [ 37 | { "path": "packages/core" }, 38 | { "path": "packages/declarations" }, 39 | { "path": "packages/format" }, 40 | { "path": "packages/global" }, 41 | { "path": "packages/locales" }, 42 | { "path": "packages/next" }, 43 | { "path": "packages/react" }, 44 | { "path": "packages/tools" } 45 | ], 46 | "include": ["src"], 47 | "exclude": ["node_modules", "test", "app", "dist", "packages"] 48 | } 49 | -------------------------------------------------------------------------------- /packages/next/src/link.tsx: -------------------------------------------------------------------------------- 1 | import type { Locale } from "@intl-t/locales"; 2 | import { ResolveConfig, resolveHref } from "@intl-t/tools/resolvers"; 3 | import { default as NL, LinkProps as LP } from "next/link"; 4 | import type { FC, ReactNode, ComponentProps } from "react"; 5 | import { LC } from "./link_client"; 6 | import { getRequestLocale } from "./request"; 7 | import { getPathname, isRSC } from "./state"; 8 | 9 | export type NL = typeof NL; 10 | 11 | export interface LinkConfig = NL> { 12 | Link?: LC; 13 | preventDynamic?: boolean; 14 | } 15 | 16 | export interface LinkProps = NL> extends LinkConfig, Omit { 17 | href?: string; 18 | locale?: L; 19 | currentLocale?: L; 20 | config?: ResolveConfig & LinkConfig; 21 | children?: ReactNode; 22 | } 23 | 24 | export { LC }; 25 | 26 | export async function LS>({ 27 | href = "", 28 | locale, 29 | currentLocale, 30 | // @ts-ignore 31 | config = this || {}, 32 | Link = config.Link || (NL as unknown as LC), 33 | preventDynamic = config.preventDynamic ?? true, 34 | ...props 35 | }: LinkProps & Omit, keyof LinkProps>) { 36 | if (!href && locale) 37 | if (preventDynamic) { 38 | const { allowedLocales, defaultLocale, pathPrefix, redirectPath } = config; 39 | config = { allowedLocales, defaultLocale, pathPrefix, redirectPath } as any; 40 | return ; 41 | } else href = (await getPathname()) || ""; 42 | // @ts-ignore 43 | config.getLocale ||= getRequestLocale.bind(null, preventDynamic); 44 | href = await resolveHref.call(config, href, { ...config, locale, currentLocale }); 45 | return ; 46 | } 47 | 48 | export const Link: typeof LS = isRSC ? LS : (LC as any); 49 | -------------------------------------------------------------------------------- /packages/react/src/hooks.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { hydration as h } from "@intl-t/global"; 4 | import { Locale } from "@intl-t/locales"; 5 | import { getClientLocale, setClientLocale, LOCALE_CLIENT_KEY } from "@intl-t/react/client"; 6 | import { TranslationContext } from "@intl-t/react/context"; 7 | import { ReactState, ReactSetState } from "@intl-t/react/types"; 8 | import { useState, useEffect, useContext, useMemo } from "react"; 9 | 10 | export function useLocale( 11 | // @ts-ignore-error optional binding 12 | defaultLocale: L | undefined | null = this?.locale, 13 | { 14 | hydration = h, 15 | path, 16 | }: { 17 | hydration?: boolean; 18 | path?: string; 19 | // @ts-ignore-error optional binding 20 | } = this?.settings || {}, 21 | ) { 22 | path &&= `${LOCALE_CLIENT_KEY}${path}`; 23 | // @ts-ignore-error optional binding 24 | const t = this; 25 | const context = !defaultLocale && useContext(TranslationContext)?.localeState; 26 | if (context) return context as never; 27 | const state = useState((!hydration && getClientLocale.call(t, path)) || defaultLocale) as any; 28 | const setState = state[1]; 29 | if (hydration && !defaultLocale) 30 | useEffect(() => { 31 | const locale = getClientLocale.call(t, path); 32 | if (locale) setState(locale); 33 | }, []); 34 | state[1] = (l: any) => { 35 | setClientLocale.call(t, l, path); 36 | setState(l); 37 | return l; 38 | }; 39 | t && 40 | useMemo(() => { 41 | const { settings } = t; 42 | if (settings.setLocale) { 43 | const { setLocale } = settings; 44 | settings.setLocale = (l: any) => (setState(l), setLocale(l)); 45 | } else settings.setLocale = state[1]; 46 | }, [t.settings]); 47 | state.setLocale = state[1]; 48 | state.locale = state[0]; 49 | state.toString = () => state[0]; 50 | return state as L & ReactState & { locale: L; setLocale: ReactSetState }; 51 | } 52 | -------------------------------------------------------------------------------- /packages/next/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@intl-t/next", 3 | "description": "A Fully-Typed Object-Based i18n Translation Library for Next.js", 4 | "version": "1.0.4", 5 | "type": "module", 6 | "license": "MIT", 7 | "homepage": "https://intl-t.dev", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/nivandres/intl-t.git", 11 | "directory": "packages/next" 12 | }, 13 | "author": { 14 | "name": "Ivan Vargas", 15 | "email": "nivnnd@gmail.com", 16 | "url": "https://nivan.dev" 17 | }, 18 | "files": [ 19 | "dist/*" 20 | ], 21 | "main": "./dist/index.js", 22 | "module": "./dist/index.js", 23 | "types": "./dist/index.d.ts", 24 | "exports": { 25 | ".": { 26 | "types": "./dist/index.d.ts", 27 | "default": "./dist/index.js" 28 | }, 29 | "./*": { 30 | "types": "./dist/*.d.ts", 31 | "default": "./dist/*.js" 32 | }, 33 | "./link": { 34 | "types": "./dist/link/index.d.ts", 35 | "default": "./dist/link/index.js" 36 | } 37 | }, 38 | "keywords": [ 39 | "translation", 40 | "i18n", 41 | "intl", 42 | "typescript", 43 | "react", 44 | "node", 45 | "nodes", 46 | "object-based", 47 | "node-based", 48 | "translate", 49 | "formatter", 50 | "tree", 51 | "next", 52 | "next.js" 53 | ], 54 | "scripts": { 55 | "build": "tsc -b", 56 | "test": "bun test", 57 | "typecheck": "tsc --noEmit", 58 | "bump": "bun pm version", 59 | "release": "bun publish --access public" 60 | }, 61 | "dependencies": { 62 | "@intl-t/core": "workspace:^", 63 | "@intl-t/global": "workspace:^", 64 | "@intl-t/react": "workspace:^", 65 | "@intl-t/tools": "workspace:^" 66 | }, 67 | "peerDependencies": { 68 | "next": ">=14", 69 | "react": ">=18 <20" 70 | }, 71 | "peerDependenciesMeta": { 72 | "next": { 73 | "optional": true 74 | }, 75 | "react": { 76 | "optional": true 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/components/files.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cva } from "class-variance-authority"; 4 | import { File as FileIcon, Folder as FolderIcon, FolderOpen } from "lucide-react"; 5 | import { type HTMLAttributes, type ReactNode, useState } from "react"; 6 | import { cn } from "../lib/cn"; 7 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; 8 | 9 | const itemVariants = cva( 10 | "flex flex-row items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-fd-accent hover:text-fd-accent-foreground [&_svg]:size-4", 11 | ); 12 | 13 | export function Files({ className, ...props }: HTMLAttributes): React.ReactElement { 14 | return ( 15 |
16 | {props.children} 17 |
18 | ); 19 | } 20 | 21 | export interface FileProps extends HTMLAttributes { 22 | name: string; 23 | icon?: ReactNode; 24 | } 25 | 26 | export interface FolderProps extends HTMLAttributes { 27 | name: string; 28 | 29 | disabled?: boolean; 30 | 31 | /** 32 | * Open folder by default 33 | * 34 | * @default false 35 | */ 36 | defaultOpen?: boolean; 37 | } 38 | 39 | export function File({ name, icon = , className, ...rest }: FileProps): React.ReactElement { 40 | return ( 41 |
42 | {icon} 43 | {name} 44 |
45 | ); 46 | } 47 | 48 | export function Folder({ name, defaultOpen = false, ...props }: FolderProps): React.ReactElement { 49 | const [open, setOpen] = useState(defaultOpen); 50 | 51 | return ( 52 | 53 | 54 | {open ? : } 55 | {name} 56 | 57 | 58 |
{props.children}
59 |
60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /packages/react/src/inject.ts: -------------------------------------------------------------------------------- 1 | import type { Values, Base } from "@intl-t/core/types"; 2 | import type { ReactChunk, ReactChunkProps } from "@intl-t/react/types"; 3 | import { createElement, type ReactNode } from "react"; 4 | 5 | const regex = /<(\w+)([^<>/]+)?(?:\/\s*>|>(?:(.*)<\s*\/\s*\1\s*>)?)/gm; 6 | const attributesRegex = /(\w+)(?:=(\w+|".*?"|'.*?'|{(.+?)}))?/g; 7 | 8 | export const Chunk: ReactChunk = ({ children, tagName, value, key, tagAttributes: _a, tagContent: _c, ...props }) => { 9 | if (value) return String(value); 10 | return createElement(tagName, { key, ...props }, children); 11 | }; 12 | 13 | export function injectReactChunks(content: string = "", variables: Values = {}) { 14 | if (!content || !content.includes("<")) return content; 15 | const matches = [...content.matchAll(regex)]; 16 | if (!matches.length) return content; 17 | const elements = [] as ReactNode[]; 18 | matches.forEach(match => { 19 | const [tag, tagName, tagAttributes, tagContent] = match; 20 | const props = { 21 | tagName, 22 | tagContent, 23 | tagAttributes, 24 | key: elements.length, 25 | children: injectReactChunks(tagContent, variables), 26 | } as ReactChunkProps; 27 | if (tagAttributes?.trim()) 28 | [...tagAttributes.matchAll(attributesRegex)].forEach(match => { 29 | let [, key, value, json = value] = match; 30 | try { 31 | props[key] = JSON.parse(json); 32 | } catch { 33 | props[key] = value; 34 | } 35 | }); 36 | let element: ReactNode; 37 | try { 38 | element = ((variables[tagName] as ReactChunk) || Chunk)(props) ?? null; 39 | } catch { 40 | props.value = variables[tagName] as Base; 41 | element = Chunk(props) ?? null; 42 | } 43 | const [start, ...end] = content.split(tag); 44 | if (start) elements.push(start); 45 | elements.push(element); 46 | content = end.length > 1 ? end.join(tag) : end[0]; 47 | }); 48 | if (content) elements.push(content); 49 | return elements.length > 1 ? elements : elements[0]; 50 | } 51 | 52 | export { injectReactChunks as injectReactChunk }; 53 | -------------------------------------------------------------------------------- /packages/react/src/patch.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { TranslationNode } from "@intl-t/core"; 3 | import _React from "react"; 4 | import _jsxDEV from "react/jsx-dev-runtime"; 5 | import _jsx from "react/jsx-runtime"; 6 | 7 | export { _React, _jsx, _jsxDEV }; 8 | 9 | export const createElement_ = _React.createElement; 10 | export const jsx_ = _jsx.jsx; 11 | export const jsxs_ = _jsx.jsxs; 12 | export const jsxDEV_ = _jsxDEV.jsxDEV; 13 | 14 | export const isArray = Array.isArray; 15 | export const check = child => (typeof child === "function" && child instanceof TranslationNode ? child.base : child); 16 | export const checkProps = props => (Object.entries(props || {}).forEach(([key, value]) => (props[key] = check(value))), props); 17 | 18 | export function patch({ React, jsx, jsxDEV }: { React?: any; jsx?: any; jsxDEV?: any }): void; 19 | export function patch(React?: any, jsx?: any, jsxDEV?: any): void; 20 | export function patch(React = _React as any, jsx = _jsx as any, jsxDEV = _jsxDEV as any) { 21 | if (React.React) return patch(React.React, React.jsx, React.jsxDEV); 22 | try { 23 | React.createElement = function createElement(type, props, ...children) { 24 | return createElement_(type, checkProps(props), ...children.map(check)); 25 | }; 26 | jsx.jsx = function jsx(type, props, key) { 27 | props.children = isArray(props.children) ? props.children.map(check) : check(props.children); 28 | return jsx_(type, typeof type === "string" ? checkProps(props) : props, key); 29 | }; 30 | jsx.jsxs = function jsxs(type, props, key) { 31 | props.children = isArray(props.children) ? props.children.map(check) : check(props.children); 32 | return jsxs_(type, typeof type === "string" ? checkProps(props) : props, key); 33 | }; 34 | jsxDEV.jsxDEV = function jsxDEV(type, props, key, isStatic, source) { 35 | props.children = isArray(props.children) ? props.children.map(check) : check(props.children); 36 | return jsxDEV_(type, typeof type === "string" ? checkProps(props) : props, key, isStatic, source); 37 | }; 38 | } catch {} 39 | } 40 | 41 | export default patch; 42 | 43 | patch(); 44 | -------------------------------------------------------------------------------- /app/app/(home)/code/hero.mdx: -------------------------------------------------------------------------------- 1 |
2 | 3 | ```ts twoslash include demo 4 | import { Translation } from "intl-t"; 5 | 6 | // ---cut-start--- 7 | const en = { 8 | homepage: { 9 | title: "Homepage", 10 | welcome: "Welcome, {name}!", 11 | features: ["feat 1", "feat 2", "feat 3"], 12 | counter: "{count, plural, =0 {Count is zero} =1 {Count is one} other {Count is #}}", 13 | }, 14 | } as const; 15 | // ---cut-end--- 16 | export const { useTranslation } = new Translation({ 17 | locales: { en }, 18 | }); 19 | // - config 20 | // ---cut--- 21 | // - usage 22 | ``` 23 | 24 |
25 | 26 | 27 | 28 | 29 | ```tsx twoslash title="page.tsx" 30 | import React from "react"; 31 | 32 | interface Props {} 33 | 34 | // @include: demo-usage 35 | export default function HomePage() { 36 | const { t } = useTranslation("homepage"); 37 | return ( 38 |
39 |

{t("title")}

40 | {t.welcome({ name: "Ivan" })} 41 |
    42 | {t.features.map(t => ( 43 |
  1. {t}
  2. 44 | ))} 45 |
46 | {t("", { /* type-safe */ }).toLowerCase()} 47 | // ^| 48 |
49 | ); 50 | } 51 | ``` 52 | 53 |
54 | 55 | 56 | ```jsonc title="en.json" 57 | { 58 | "homepage": { 59 | "title": "Homepage", 60 | "welcome": "Welcome, {name}!", 61 | "features": ["feat 1", "feat 2", "feat 3"], 62 | "counter": "{count, plural, 63 | =0 {Count is zero} 64 | =1 {Count is one} 65 | other {Count is #} 66 | }" 67 | }, 68 | /* other pages */ 69 | } 70 | ``` 71 | 72 | 73 | 74 | 75 | ```ts twoslash title="translation.ts" 76 | // @include: demo-config 77 | ``` 78 | 79 | 80 | 81 | 82 | ```html title="html" 83 |
84 |

Homepage

85 | Welcome, Ivan! 86 |
    87 |
  1. feat 1
  2. 88 |
  3. feat 2
  4. 89 |
  5. feat 3
  6. 90 |
91 | count is zero 92 |
93 | ``` 94 | 95 |
96 |
97 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Contributing to intl-t-monorepo 4 | 5 | Thank you for your interest in contributing to this project! This guide will help you get started with the development process. 6 | 7 | ## Development Setup 8 | 9 | ### Prerequisites 10 | 11 | - Bun installed on your system 12 | 13 | ### Getting Started 14 | 15 | 1. Fork the repository 16 | 2. Clone your fork: `git clone https://github.com/nivandres/intl-t.git` 17 | 3. Navigate to the project directory: `cd intl-t` 18 | 4. Install dependencies: `bun install` 19 | 5. Start development: `bun run dev` 20 | 21 | ## Working with the Monorepo 22 | 23 | This project uses a monorepo structure with Bun workspaces. All packages are located in the `packages/` directory. 24 | 25 | ## Development Workflow 26 | 27 | 1. Create a new branch: `git checkout -b feature/your-feature-name` 28 | 2. Make your changes 29 | 3. Check and fix code style and formatting issues: `bun run lint:fix` 30 | 4. Run tests: `bun run test` 31 | 5. Build the project: `bun run build` 32 | 6. Commit your changes using the conventions below 33 | 7. Push your branch to your fork 34 | 8. Open a pull request 35 | 36 | ## Commit Message Conventions 37 | 38 | We follow [Conventional Commits](https://www.conventionalcommits.org/) for clear and structured commit messages: 39 | 40 | - `feat:` New features 41 | - `fix:` Bug fixes 42 | - `docs:` Documentation changes 43 | - `style:` Code style changes (formatting, etc.) 44 | - `refactor:` Code changes that neither fix bugs nor add features 45 | - `perf:` Performance improvements 46 | - `test:` Adding or updating tests 47 | - `chore:` Maintenance tasks, dependencies, etc. 48 | 49 | ## Pull Request Guidelines 50 | 51 | 1. Update documentation if needed 52 | 2. Ensure all tests pass 53 | 3. Address any feedback from code reviews 54 | 4. Once approved, your PR will be merged 55 | 56 | ## Code of Conduct 57 | 58 | Please be respectful and constructive in all interactions within our community. 59 | 60 | ## Questions? 61 | 62 | If you have any questions, please [open an issue](https://github.com/nivandres/intl-t/issues/new) for discussion. 63 | 64 | Thank you for contributing to intl-t-monorepo! 65 | -------------------------------------------------------------------------------- /packages/format/src/formatters.ts: -------------------------------------------------------------------------------- 1 | import { inject } from "@intl-t/format/inject"; 2 | import { type State, state } from "@intl-t/global"; 3 | 4 | // @ts-ignore 5 | export function list(value: string[] = [], options?: Intl.ListFormatOptions, { locale = state.locale }: Partial = this) { 6 | return new Intl.ListFormat(locale, options).format(value); 7 | } 8 | 9 | // @ts-ignore 10 | export function number(value: number = 0, options?: Intl.NumberFormatOptions, { locale = state.locale }: Partial = this) { 11 | return new Intl.NumberFormat(locale, options).format(value); 12 | } 13 | 14 | // @ts-ignore 15 | export function currency(value: number = 0, options: Intl.NumberFormatOptions = {}, { locale = state.locale }: Partial = this) { 16 | options.style = "currency"; 17 | options.currency ??= "USD"; 18 | return new Intl.NumberFormat(locale, options).format(value); 19 | } 20 | 21 | // @ts-ignore 22 | export function date(value: Date = new Date(), options?: Intl.DateTimeFormatOptions, { locale = state.locale }: Partial = this) { 23 | return new Intl.DateTimeFormat(locale, options).format(value); 24 | } 25 | 26 | export const re = { 27 | se: [1000, "conds"], 28 | mi: [60000, "nutes"], 29 | ho: [3600000, "urs"], 30 | da: [86400000, "ys"], 31 | we: [604800000, "eks"], 32 | mo: [2592000000, "nths"], 33 | qu: [7884000000, "arters"], 34 | ye: [31536000000, "ars"], 35 | } as const; 36 | 37 | export function relative( 38 | value: Date | number = 0, 39 | options: Intl.RelativeTimeFormatOptions & Record = {}, 40 | // @ts-ignore 41 | { locale = state.locale, now = state.now }: Partial = this, 42 | ) { 43 | let { unit } = options; 44 | if (value instanceof Date) { 45 | value = value.getTime() - now.getTime(); 46 | unit 47 | ? (value = Math.floor(value / re[`${unit[0]}${unit[1]}` as "se"][0])) 48 | : Object.entries(re).find(([k1, [v, k2]]) => 49 | (value as number) >= v ? ((value = Math.floor((value as number) / v)), (options.unit = k1 + k2)) : false, 50 | ); 51 | } 52 | unit ??= "day"; 53 | return new Intl.RelativeTimeFormat(locale, options).format(value, unit); 54 | } 55 | 56 | export const format = Object.assign(state, { list, number, currency, date, relative, time: date, price: currency, inject }); 57 | -------------------------------------------------------------------------------- /packages/react/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TranslationSettings, 3 | Node, 4 | Base, 5 | Values, 6 | isArray, 7 | SearchWays, 8 | ArrayToString, 9 | FollowWayWithValues, 10 | State, 11 | Children, 12 | Content, 13 | TranslationType, 14 | } from "@intl-t/core/types"; 15 | import type { ReactNode, Key as ReactKey, Dispatch, SetStateAction } from "react"; 16 | 17 | export type { ReactNode, ReactKey }; 18 | 19 | export type ReactSetState = Dispatch>; 20 | export type ReactState = [T, ReactSetState]; 21 | 22 | export interface ReactChunkProps { 23 | children: ReactNode; 24 | tagName: string; 25 | tagAttributes: string; 26 | tagContent: string; 27 | value?: Base | null; 28 | key: ReactKey; 29 | [key: string]: unknown; 30 | } 31 | 32 | export type ReactChunk = (props: ReactChunkProps) => ReactNode | void; 33 | 34 | export interface TranslationProps< 35 | S extends TranslationSettings = TranslationSettings, 36 | N = Node, 37 | V = Values, 38 | A extends string[] = isArray>, 39 | D extends string = ArrayToString, 40 | > extends Partial> { 41 | children?: Content | ReactNode; 42 | key?: D; 43 | id?: D | A; 44 | i18nKey?: D | A; 45 | path?: D | A; 46 | variables?: Partial> & Values; 47 | locale?: S["allowedLocale"]; 48 | source?: Node; 49 | messages?: Node; 50 | hydrate?: boolean; 51 | preventDynamic?: boolean; 52 | settings?: Partial; 53 | onLocaleChange?: ReactSetState; 54 | } 55 | 56 | export interface TranslationFC { 57 | >, D extends ArrayToString>(props: TranslationProps): ReactNode; 58 | } 59 | 60 | export type TranslationNodeFC = TranslationFC & { 61 | g: TranslationNodeFC; 62 | global: TranslationNodeFC; 63 | locale: S["allowedLocale"]; 64 | locales: S["allowedLocales"]; 65 | settings: S; 66 | node: N; 67 | values: V; 68 | t: TranslationType; 69 | } & { 70 | [L in S["allowedLocale"]]: TranslationNodeFC; 71 | } & { 72 | [C in Children]: TranslationNodeFC; 73 | }; 74 | -------------------------------------------------------------------------------- /packages/core/src/chunk.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ArrayToString, 3 | Base, 4 | Children, 5 | Content, 6 | FollowWayWithValues, 7 | isArray, 8 | Node, 9 | SearchWays, 10 | State, 11 | TranslationSettings, 12 | TranslationType, 13 | Values, 14 | } from "@intl-t/core/types"; 15 | import type { ReactNode, Key as ReactKey, Dispatch, SetStateAction } from "react"; 16 | 17 | // export * from "@intl-t/react/types"; 18 | 19 | export type { ReactNode, ReactKey }; 20 | 21 | export type ReactSetState = Dispatch>; 22 | export type ReactState = [T, ReactSetState]; 23 | 24 | export interface ChunkProps { 25 | children: ReactNode; 26 | tagName: string; 27 | tagAttributes: string; 28 | tagContent: string; 29 | value?: Base | null; 30 | key: ReactKey; 31 | [key: string]: any; 32 | } 33 | 34 | export type Chunk = (props: ChunkProps) => ReactNode | void; 35 | 36 | export interface TranslationProps< 37 | S extends TranslationSettings = TranslationSettings, 38 | N = Node, 39 | V = Values, 40 | A extends string[] = isArray>, 41 | D extends string = ArrayToString, 42 | > extends Partial> { 43 | children?: Content | ReactNode; 44 | key?: D; 45 | id?: D | A; 46 | i18nKey?: D | A; 47 | path?: D | A; 48 | variables?: Partial> & Values; 49 | locale?: S["allowedLocale"]; 50 | source?: Node; 51 | messages?: Node; 52 | hydrate?: boolean; 53 | preventDynamic?: boolean; 54 | settings?: Partial; 55 | onLocaleChange?: ReactSetState; 56 | } 57 | 58 | export interface TranslationFC { 59 | >, D extends ArrayToString>(props: TranslationProps): ReactNode; 60 | } 61 | 62 | export type TranslationNodeFC = TranslationFC & { 63 | g: TranslationNodeFC; 64 | global: TranslationNodeFC; 65 | locale: S["allowedLocale"]; 66 | locales: S["allowedLocales"]; 67 | settings: S; 68 | node: N; 69 | values: V; 70 | t: TranslationType; 71 | } & { 72 | [L in S["allowedLocale"]]: TranslationNodeFC; 73 | } & { 74 | [C in Children]: TranslationNodeFC; 75 | }; 76 | -------------------------------------------------------------------------------- /packages/tools/src/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { state } from "@intl-t/global"; 2 | import type { Locale } from "@intl-t/locales"; 3 | import { match } from "@intl-t/tools/match"; 4 | 5 | type Awaitable = T & Promise; 6 | 7 | // @ts-ignore-error optional binding 8 | export function resolveLocale(path: string = "", locales: L[] = this?.allowedLocales || []) { 9 | const locale = path.match(/^\/([a-z]{2}(?:-[A-Za-z]+)*)(?:$|\/)/)?.[1] as L; 10 | if (!locale || !locales) return locale; 11 | return locales.includes(locale) ? locale : undefined; 12 | } 13 | 14 | type LL = L | null | undefined | "" | `/${string}` | Promise; 15 | 16 | export interface ResolveConfig { 17 | pathPrefix?: "always" | "default" | "optional" | "hidden"; 18 | allowedLocales?: L[] | readonly L[]; 19 | redirectPath?: string; 20 | defaultLocale?: L; 21 | } 22 | 23 | export interface LocalizedHref extends ResolveConfig { 24 | locale?: LL; 25 | currentLocale?: L; 26 | config?: ResolveConfig; 27 | getLocale?: () => LL; 28 | } 29 | 30 | export function resolveHref( 31 | href = "", 32 | { 33 | locale = resolveLocale(href), 34 | currentLocale, 35 | redirectPath, 36 | // @ts-ignore-error optional binding 37 | config = this || {}, 38 | allowedLocales = config.allowedLocales, 39 | pathPrefix = config.pathPrefix || "always", 40 | defaultLocale = config.defaultLocale, 41 | getLocale = () => match(state.locale, allowedLocales, undefined), 42 | }: // @ts-ignore-error optional binding 43 | LocalizedHref = this || {}, 44 | ): Awaitable<`/${L | ""}${string}`> { 45 | if (href[0] !== "/") return href as any; 46 | if (pathPrefix == "hidden" && locale) pathPrefix = "always"; 47 | if (pathPrefix == "hidden") locale = ""; 48 | else locale ||= currentLocale || getLocale() || (redirectPath as L); 49 | const fn = (locale: LL) => { 50 | if (pathPrefix == "default" && locale == defaultLocale) locale = ""; 51 | locale &&= `/${locale}`; 52 | return locale + href; 53 | }; 54 | return locale instanceof Promise ? (new Promise(async r => r(fn(await locale) as any)) as any) : (fn(locale) as any); 55 | } 56 | 57 | export function resolvePath(pathname: string, locales: string[] = []) { 58 | const [, _, p] = pathname.match(/(\/[a-z]{2}(?:-[A-Za-z]+)*)(\/.*|$)/) || []; 59 | if (!locales[0] || !p) return p || "/"; 60 | return locales.some(l => _?.includes(l)) ? p : _ + p; 61 | } 62 | -------------------------------------------------------------------------------- /packages/react/test/legacy.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "bun:test"; 2 | import { injectReactChunks as ir, createTranslation as ct } from "../src"; 3 | import * as en from "./messages.json"; 4 | 5 | describe("react plugin", () => { 6 | it("should work", () => { 7 | const t = ct({ locales: { en } }); 8 | expect(t.hello.base).toBe("hello world"); 9 | expect(t.Translation).toBeFunction(); 10 | expect(t.useTranslation).toBeFunction(); 11 | }); 12 | it("translation strings should be rendered correctly", () => { 13 | const t = ct({ locales: { en } }); 14 | expect([...t.hello][0]).toBe("hello world"); 15 | }); 16 | }); 17 | 18 | describe("variable injection", () => { 19 | it("should work with simple variables", () => { 20 | expect(ir("test", { a: "a" })).toEqual("a"); 21 | expect(ir("test", { a: ({ children }) => `${children}a` })).toEqual("testa"); 22 | expect( 23 | ir(" ", { 24 | a: ({ children }) => children, 25 | }), 26 | ).toEqual(["", " ", ""]); 27 | expect(ir("hola que tal amigo como ", { a: "a", b: "b" })).toEqual([ 28 | "hola ", 29 | "b", 30 | " que ", 31 | "a", 32 | " tal ", 33 | "b", 34 | " amigo ", 35 | "b", 36 | " como ", 37 | "a", 38 | ]); 39 | }); 40 | it("should work with complex variables", () => { 41 | expect( 42 | ir('hello ', { 43 | div: ({ children }) => children, 44 | a: ({ children }) => children, 45 | }), 46 | ).toEqual(["hello ", "a"]); 47 | expect(ir("
hello
")).toEqual((
hello
) as any); 48 | expect(ir("hello", { juan: ({ children }) =>
{children}
})).toEqual((
hello
) as any); 49 | const a = ir("icons icon", { globe: () => "🌎" }); 50 | expect(a).toEqual(["icons ", "🌎", " icon"]); 51 | expect(ir("more items content o ", { globe: () => "🌍" })).toEqual(["more items ", "🌍", " o ", "🌍"]); 52 | }); 53 | // it("should work integrated", () => { 54 | // const t = ct({ locales: { en: { hello: `hello ` } } }); 55 | // expect(t.hello.use({ a: "hola" })).toEqual([ 56 | // "hello ", 57 | //
58 | // hola 59 | //
, 60 | // ] as any); 61 | // expect(t.hello.use({ div: ({ children }) => children, a: ({ children }) => children })).toEqual([ 62 | // "hello ", 63 | // "a", 64 | // ] as any); 65 | // }); 66 | }); 67 | -------------------------------------------------------------------------------- /docs/tools.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Tools 3 | description: Tools and utilities for Intl-T 4 | --- 5 | 6 | Intl-t provides a set of tools to help you with your translations. You can use each of them independently from `intl-t/tools` or `@intl-t/tools`. 7 | 8 | ```ts 9 | import { /* tools */ } from "intl-t/tools"; 10 | ``` 11 | 12 | ## Inject 13 | 14 | Inject variables into content, with built-in support for the ICU message format. 15 | 16 | ```ts 17 | import { inject } from "intl-t/tools"; 18 | 19 | const str = inject("Hello, {user}!", { user: "Ivan" }); // "Hello, Ivan!" 20 | 21 | // TypeScript Support 22 | typeof str; // `Hello, ${string}` 23 | 24 | // Full support for ICU message format 25 | 26 | // Extended keeping syntax and performance 27 | inject("One plus one equals {(1+1), =2 {two (#)} other {# (#)}}"); // "One plus one equals two (2)" 28 | inject("{a} plus {b} {(a+b), (typeof # != 'number') {is not a number. #} <0 {is negative. #} other {equals {(a+b)}. {a}+{b}=#}}"); 29 | // nested injections 30 | ``` 31 | 32 | ## React Injection 33 | 34 | To use the [React Chunk Injection](/docs/react#react-component-injection) function, import it from `intl-t/react`. 35 | 36 | ```ts 37 | import { injectReactChunk } from "intl-t/react"; 38 | ``` 39 | 40 | ## Match 41 | 42 | Function to match the best locale from the available ones. 43 | 44 | ```ts 45 | import { match } from "intl-t/tools"; 46 | 47 | const availableLocales = navigator.languages.split(","); // ["es", "en"]; // can be string | string[] 48 | const allowedLocales = ["en-US", "es-MX", "fr-FR", "zh-Hant"]; 49 | const defaultLocale = "en-US"; 50 | 51 | const locale = match(availableLocales, allowedLocales, defaultLocale); // "es-MX" 52 | ``` 53 | 54 | It finds the best locale by comparing the available locales with the allowed locales. Try it yourself. 55 | 56 | ## Negotiator 57 | 58 | Simple function to extract the locale from HTTP headers. 59 | 60 | ```ts 61 | import { negotiator } from "intl-t/tools"; 62 | 63 | negotiator({ headers }); 64 | ``` 65 | 66 | ## Formatters 67 | 68 | Formatters are used internally by the [inject](#inject) function, but they can also be used directly. 69 | 70 | ```ts 71 | import { format } from "intl-t/tools"; 72 | ``` 73 | 74 | ```ts 75 | // format params 76 | format.list(value: string[], options?: Intl.ListFormatOptions); 77 | format.number(value: number = 0, options?: Intl.NumberFormatOptions); 78 | format.currency(value: number = 0, options: Intl.NumberFormatOptions = {}); 79 | format.date(value: Date = new Date(), options?: Intl.DateTimeFormatOptions); 80 | format.relative(value: Date | number = 0, options: Intl.RelativeTimeFormatOptions & Record = {}); // relative time inferred from value 81 | format.time(value: Date = new Date(), options?: Intl.DateTimeFormatOptions); 82 | format.price(value: number = 0, options: Intl.NumberFormatOptions = {}); // uses USD by default 83 | ``` 84 | 85 | ## Resolvers 86 | 87 | Resolver functions are best used via [createNavigation](/docs/next#navigation), but you can also import them directly from `intl-t/tools` without bound values and types. 88 | -------------------------------------------------------------------------------- /docs/contributing.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Contributing 3 | description: Ideas and plans for future features and improvements 4 | --- 5 | 6 | ## Guidelines 7 | 8 | Contributions are welcome! If you have ideas, suggestions, or improvements, feel free to open an issue or pull request. 9 | 10 | ## Roadmap 11 | 12 | Here are some planned features and improvements for future: 13 | 14 | - **Translations Merging:** Support for dynamically merging and extending translation namespaces for each independent node in the tree, enabling on-demand loading and updates. 15 | - **CLI Tooling:** Command-line utilities for managing, validating, and extracting translations. 16 | - **Documentation Web:** A web-based documentation site for Intl-T, providing examples, guides, and API references. 17 | - **Editor Integrations:** VSCode plugin for enhanced translation management. 18 | - **Improved ICU Support:** More advanced ICU message format features. 19 | - **Performance Optimizations:** Further reduce bundle size and improve runtime efficiency. 20 | - **Plugins:** Support for third-party plugins and integrations. 21 | - **Testing:** Add robust testing for Intl-T to ensure its reliability. 22 | - **More frameworks:** Support for more popular frameworks and libraries, including backend frameworks. 23 | - **Implement new features:** For example, localization for pathnames. 24 | - **Modular and agnostic:** To ensure agnosticism, Intl-T will be a monorepo with separate modules for different environments and features (e.g., `@intl-t/next`, `@intl-t/tools`, `@intl-t/server`, etc.) 25 | - **Intl-T Server:** Easily self-host translations with a simple API that is agnostic and compatible with the intl-t client. 26 | - **Intl-T l10n Service:** A simple service for providing translation management to your application through intl-t API. 27 | - **Crowdin Integration:** Integration with Crowdin for efficient translation management and dynamic loading. 28 | 29 | ### Progress Table 30 | 31 | 🔴 Planned 32 | 🟡 In Progress 33 | 🔵 Almost there 34 | 🟢 Completed 35 | 36 | | Feature | Status | 37 | | ---------------------------------- | ------ | 38 | | Modular and agnostic | 🔵 | 39 | | Translations Merging | 🔴 | 40 | | CLI Tooling | 🔴 | 41 | | Documentation web | 🔵 | 42 | | Edge Runtime Support | 🟡 | 43 | | Editor Integration | 🔴 | 44 | | Improved ICU Support | 🔴 | 45 | | Performance Optimizations | 🔴 | 46 | | Testing | 🟡 | 47 | | More frameworks support | 🔴 | 48 | | `@intl-t/server` | 🔴 | 49 | | Intl-T Service | 🔴 | 50 | | Crowdin Integration | 🔴 | 51 | | Enhance dynamic import strategies | 🔴 | 52 | | Enhanced TypeScript type inference | 🔴 | 53 | | Debugging | 🔴 | 54 | | Plugins | 🔴 | 55 | 56 | Have a feature request? [Open an issue](https://github.com/nivandres/intl-t/issues) or join the [Discord](https://discord.gg/5EbCXKpdyw). 57 | -------------------------------------------------------------------------------- /packages/next/src/rsc.tsx: -------------------------------------------------------------------------------- 1 | import { TranslationNode } from "@intl-t/core"; 2 | import type { GlobalTranslation } from "@intl-t/core/global"; 3 | import type { isArray, SearchWays, ArrayToString } from "@intl-t/core/types"; 4 | import { TranslationProvider as TranslationClientProvider, TranslationProviderProps } from "@intl-t/react"; 5 | import { Suspense } from "react"; 6 | import { getCache } from "./cache"; 7 | import { getRequestLocale } from "./request"; 8 | import { createTranslation } from "./translation"; 9 | 10 | export async function TranslationProvider< 11 | T extends TranslationNode, 12 | A extends isArray>, 13 | D extends ArrayToString, 14 | // @ts-ignore-error optional binding 15 | >({ children, t = this, preventDynamic, hydrate, ...props }: TranslationProviderProps) { 16 | const cache = getCache(); 17 | t ||= cache.t ||= TranslationNode.t || (createTranslation(props.settings) as any); 18 | props.locale ||= cache.locale; 19 | preventDynamic ??= t.settings.preventDynamic; 20 | if (!(props.locale || preventDynamic)) { 21 | Object.assign(props, { t, preventDynamic: true }); 22 | return ( 23 | {children}}> 24 | {children} 25 | 26 | ); 27 | } 28 | t.settings.locale = cache.locale = props.locale!; 29 | t = (await t.current) as any; 30 | if (!children) return t(props.path || props.id || props.i18nKey).base; 31 | props.source = props.source || props.messages || ((hydrate ?? t.settings.hydrate) && { ...(t.node as any) }) || void 0; 32 | // @ts-ignore 33 | return ({children}) as never; 34 | } 35 | export const T = TranslationProvider; 36 | export { T as Tr, T as Trans }; 37 | 38 | export const TranslationDynamicRendering: typeof TranslationProvider = async ({ children, ...props }) => { 39 | props.locale ||= (await getRequestLocale.call(props.t)) as string; 40 | // @ts-ignore TS2589 41 | return {children}; 42 | }; 43 | 44 | function hook(...args: any[]) { 45 | const cache = getCache(); 46 | // @ts-ignore-error optional binding 47 | let t = this || (cache.t ||= TranslationNode.t); 48 | if (!t) throw new Error("Translation not found"); 49 | const locale = cache.locale ? (t.settings.locale = cache.locale) : getRequestLocale.call(t); 50 | if (locale instanceof Promise || (t = t.current).promise) { 51 | let tp: any, tc: any; 52 | return new Proxy(t, { 53 | get(_, p, receiver) { 54 | return p in Promise.prototype 55 | ? (cb: Function) => new Promise(async r => (await locale, (tp ||= tc = (await t.current)(...args)), r(tp), cb(tp))) 56 | : Reflect.get((tc ||= t(...args)), p, receiver); 57 | }, 58 | }); 59 | } 60 | return t(...args); 61 | } 62 | 63 | // @ts-ignore 64 | export declare const getTranslation: GlobalTranslation; 65 | // @ts-ignore 66 | export declare const getTranslations: GlobalTranslation; 67 | // @ts-ignore 68 | export { hook as getTranslation, hook as getTranslations }; 69 | -------------------------------------------------------------------------------- /packages/next/src/navigation.ts: -------------------------------------------------------------------------------- 1 | import type { TranslationSettings } from "@intl-t/core/types"; 2 | import type { Locale } from "@intl-t/locales"; 3 | import { ResolveConfig, resolveHref, resolveLocale, resolvePath } from "@intl-t/tools/resolvers"; 4 | import { redirect as r, permanentRedirect as pr, RedirectType } from "next/navigation"; 5 | import type { FC } from "react"; 6 | import { Link, LinkConfig, NL } from "./link"; 7 | import { createMiddleware, MiddlewareConfig } from "./middleware"; 8 | import { createStaticParams, StaticParamsConfig } from "./params"; 9 | import { RouterConfig, useRouter, useLocale, usePathname } from "./router"; 10 | import { getLocale, getPathname, setLocale } from "./state"; 11 | 12 | export * from "@intl-t/tools/match"; 13 | export * from "@intl-t/tools/negotiator"; 14 | export * from "@intl-t/tools/resolvers"; 15 | export * from "./link"; 16 | export * from "./middleware"; 17 | export * from "./params"; 18 | export * from "./router"; 19 | export * from "./state"; 20 | 21 | export function resolvedRedirect(href?: string, type?: RedirectType) { 22 | // @ts-ignore 23 | return r(resolveHref.bind(this || {})(href), type); 24 | } 25 | export function resolvedPermanentRedirect(href?: string, type?: RedirectType) { 26 | // @ts-ignore 27 | return pr(resolveHref.bind(this || {})(href), type); 28 | } 29 | 30 | export const redirect: typeof r = resolvedRedirect; 31 | export const permanentRedirect: typeof pr = resolvedPermanentRedirect; 32 | 33 | export interface IntlConfig = NL> 34 | extends ResolveConfig, 35 | StaticParamsConfig, 36 | MiddlewareConfig, 37 | RouterConfig, 38 | LinkConfig { 39 | settings?: Partial>; 40 | } 41 | 42 | export function createNavigation>( 43 | // @ts-ignore 44 | config: IntlConfig = this || {}, 45 | ) { 46 | const { allowedLocales } = config; 47 | if (!allowedLocales && Array.isArray(config.locales)) config.allowedLocales = config.locales; 48 | config.locales ||= allowedLocales as L[]; 49 | config.param ||= "locale"; 50 | config.pathPrefix ||= config.strategy == "domain" ? "hidden" : "default"; 51 | config.pathBase ||= config.pathPrefix == "hidden" ? "detect-latest" : "detect-default"; 52 | config.defaultLocale ||= allowedLocales?.[0]; 53 | config.redirectPath ||= "r"; 54 | return { 55 | config, 56 | useRouter: useRouter.bind(config), 57 | Link: Link.bind(config), 58 | redirect: redirect.bind(config), 59 | permanentRedirect: permanentRedirect.bind(config), 60 | getLocale: getLocale.bind(config), 61 | setLocale: setLocale.bind(config), 62 | resolvePath: resolvePath.bind(config), 63 | resolveHref: resolveHref.bind(config), 64 | resolveLocale: resolveLocale.bind(config), 65 | match: config.match!, 66 | middleware: createMiddleware(config), 67 | withMiddleware: config.withMiddleware!, 68 | generateStaticParams: createStaticParams(config), 69 | useLocale: useLocale, 70 | usePathname, 71 | getPathname, 72 | settings: Object.assign(config, config.settings), 73 | allowedLocales, 74 | locales: allowedLocales!, 75 | locale: allowedLocales![0], 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /packages/react/src/context.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { GlobalTranslation } from "@intl-t/core/global"; 4 | import type { isArray, SearchWays, ArrayToString, Locale, TranslationProps as TP, Content } from "@intl-t/core/types"; 5 | import { useLocale } from "@intl-t/react/hooks"; 6 | import { TranslationNode } from "@intl-t/react/translation"; 7 | import type { ReactState, ReactSetState, ReactNode } from "@intl-t/react/types"; 8 | import { createElement, createContext, useContext, useMemo, useState, useEffect } from "react"; 9 | 10 | export type TranslationContext = null | { 11 | reRender?: ReactSetState; 12 | localeState?: ReactState; 13 | t?: TranslationNode; 14 | }; 15 | 16 | export const TranslationContext = createContext(null); 17 | 18 | interface TranslationProps 19 | extends TP { 20 | t?: T; 21 | } 22 | 23 | export { TranslationProps as TranslationProviderProps }; 24 | 25 | export function TranslationProvider< 26 | T extends TranslationNode, 27 | A extends isArray>, 28 | D extends ArrayToString, 29 | >({ 30 | // @ts-ignore-error optional binding 31 | t = this, 32 | onLocaleChange, 33 | locale, 34 | children, 35 | i18nKey, 36 | id = i18nKey, 37 | path = id, 38 | messages, 39 | source = messages, 40 | variables, 41 | settings, 42 | ...state 43 | }: TranslationProps): ReactNode | Content { 44 | const context = useContext(TranslationContext) || {}; 45 | context.t = t ??= context.t ??= TranslationNode.t as any; 46 | context.reRender ??= useState(0)[1]; 47 | if (locale || onLocaleChange) context.localeState = [locale!, onLocaleChange!]; 48 | else ((context.localeState ??= useLocale.call(t, locale)), (locale = context.localeState?.[0])); 49 | children &&= createElement(TranslationContext, { value: context }, children as any); 50 | if (!t?.settings) return ((TranslationNode.context = { locale, source }), children); 51 | t.settings.locale = locale!; 52 | useMemo(() => Object.assign(t.settings, settings, state), [settings, t, state]); 53 | t = (t as any).current(path); 54 | useMemo(() => { 55 | source && t.setSource(source); 56 | }, [t, source]); 57 | useEffect(() => { 58 | t.then?.(() => context.reRender?.(p => p + 1)); 59 | }, [t, t.currentLocale]); 60 | variables && t.set(variables); 61 | return children || t.base; 62 | } 63 | export const T = TranslationProvider; 64 | export { T as Trans, T as Tr }; 65 | 66 | export function hook(...args: any[]) { 67 | const context = useContext(TranslationContext) || {}; 68 | // @ts-ignore-error optional binding 69 | let t = this || (context.t ||= TranslationNode.t); 70 | if (!t) throw new Error("Translation not found"); 71 | context.t ||= t; 72 | t.settings.locale = (context.localeState ||= useLocale.call(t))[0]; 73 | t = t.current; 74 | context.reRender ||= useState(0)[1]; 75 | useEffect(() => { 76 | t.then?.(() => context.reRender?.(p => p + 1)); 77 | }, [t]); 78 | return t(...args); 79 | } 80 | 81 | // @ts-ignore 82 | export declare const useTranslation: GlobalTranslation; 83 | // @ts-ignore 84 | export declare const useTranslations: GlobalTranslation; 85 | // @ts-ignore 86 | export { hook as useTranslation, hook as useTranslations }; 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "intl-t", 3 | "description": "A Fully-Typed Object-Based i18n Translation Library", 4 | "version": "1.0.4", 5 | "type": "module", 6 | "license": "MIT", 7 | "homepage": "https://intl-t.dev", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/nivandres/intl-t" 11 | }, 12 | "workspaces": { 13 | "packages": [ 14 | ".", 15 | "app", 16 | "assets", 17 | "packages/*" 18 | ] 19 | }, 20 | "scripts": { 21 | "start": "tsc --noEmit --watch", 22 | "start:app": "bun run --filter 'intl-t-app' start", 23 | "dev": "bun run test:watch", 24 | "dev:app": "bun run --filter 'intl-t-app' dev", 25 | "build": "tsc -b", 26 | "build:app": "bun run --filter 'intl-t-app' build", 27 | "test": "bun test", 28 | "test:watch": "bun test --watch", 29 | "test:coverage": "bun test --coverage", 30 | "clean": "tsc -b --clean", 31 | "setup": "bun install && tsc -b", 32 | "format": "prettier --write .", 33 | "lint": "oxlint --fix", 34 | "typecheck": "tsc && bun run --filter '!intl-t' typecheck", 35 | "check": "bun run format && bun run lint && bun run typecheck", 36 | "prepare": "bun husky", 37 | "prepublishOnly": "bun run build && bun run release", 38 | "bump": "bun run --filter '@intl-t/*' bump", 39 | "release": "bun run --filter '@intl-t/*' release" 40 | }, 41 | "keywords": [ 42 | "translation", 43 | "i18n", 44 | "intl", 45 | "typescript", 46 | "react", 47 | "node", 48 | "nodes", 49 | "object-based", 50 | "node-based", 51 | "translate", 52 | "formatter", 53 | "tree", 54 | "next", 55 | "next.js" 56 | ], 57 | "author": { 58 | "name": "Ivan Vargas", 59 | "email": "nivnnd@gmail.com", 60 | "url": "https://nivan.dev" 61 | }, 62 | "files": [ 63 | "dist/*" 64 | ], 65 | "main": "./dist/index.js", 66 | "module": "./dist/index.js", 67 | "types": "./dist/index.d.ts", 68 | "exports": { 69 | ".": { 70 | "types": "./dist/index.d.ts", 71 | "default": "./dist/index.js" 72 | }, 73 | "./*": { 74 | "types": "./dist/*.d.ts", 75 | "default": "./dist/*.js" 76 | } 77 | }, 78 | "lint-staged": { 79 | "*.{json,md,js,ts,tsx}": "prettier --check", 80 | "*.{js,ts,tsx}": "oxlint" 81 | }, 82 | "dependencies": { 83 | "@intl-t/core": "workspace:^", 84 | "@intl-t/declarations": "workspace:^", 85 | "@intl-t/format": "workspace:^", 86 | "@intl-t/global": "workspace:^", 87 | "@intl-t/locales": "workspace:^", 88 | "@intl-t/next": "workspace:^", 89 | "@intl-t/react": "workspace:^", 90 | "@intl-t/tools": "workspace:^" 91 | }, 92 | "devDependencies": { 93 | "@trivago/prettier-plugin-sort-imports": "^5.2.2", 94 | "@types/bun": "^1.3.0", 95 | "@types/react": "^19", 96 | "husky": "^9", 97 | "lint-staged": "^16.2.4", 98 | "next": "^15", 99 | "oxlint": "^1.22.0", 100 | "prettier": "^3.6.2", 101 | "react": "^19", 102 | "typescript": "^5" 103 | }, 104 | "peerDependencies": { 105 | "next": ">=14", 106 | "react": ">=18 <20", 107 | "typescript": "^5" 108 | }, 109 | "peerDependenciesMeta": { 110 | "typescript": { 111 | "optional": true 112 | }, 113 | "react": { 114 | "optional": true 115 | }, 116 | "next": { 117 | "optional": true 118 | } 119 | }, 120 | "packageManager": "bun@1.3.0" 121 | } 122 | -------------------------------------------------------------------------------- /app/components/github-info.tsx: -------------------------------------------------------------------------------- 1 | import { Star } from "lucide-react"; 2 | import { type AnchorHTMLAttributes } from "react"; 3 | import { cn } from "../lib/cn"; 4 | 5 | async function getRepoStarsAndForks( 6 | owner: string, 7 | repo: string, 8 | token?: string, 9 | ): Promise<{ 10 | stars: number; 11 | forks: number; 12 | }> { 13 | const endpoint = `https://api.github.com/repos/${owner}/${repo}`; 14 | const headers = new Headers({ 15 | "Content-Type": "application/json", 16 | }); 17 | 18 | if (token) headers.set("Authorization", `Bearer ${token}`); 19 | 20 | const response = await fetch(endpoint, { 21 | headers, 22 | next: { 23 | revalidate: 60, 24 | }, 25 | } as RequestInit); 26 | 27 | if (!response.ok) { 28 | const message = await response.text(); 29 | 30 | throw new Error(`Failed to fetch repository data: ${message}`); 31 | } 32 | 33 | const data = await response.json(); 34 | return { 35 | stars: data.stargazers_count, 36 | forks: data.forks_count, 37 | }; 38 | } 39 | 40 | export async function GithubInfo({ 41 | repo, 42 | owner, 43 | token, 44 | ...props 45 | }: AnchorHTMLAttributes & { 46 | owner: string; 47 | repo: string; 48 | token?: string; 49 | }) { 50 | const { stars } = await getRepoStarsAndForks(owner, repo, token); 51 | const humanizedStars = humanizeNumber(stars); 52 | 53 | return ( 54 | 64 |

65 | 66 | GitHub 67 | 68 | 69 | {owner}/{repo} 70 |

71 |

72 | 73 | {humanizedStars} 74 |

75 |
76 | ); 77 | } 78 | 79 | /** 80 | * Converts a number to a human-readable string with K suffix for thousands 81 | * @example 1500 -> "1.5K", 1000000 -> "1000000" 82 | */ 83 | function humanizeNumber(num: number): string { 84 | if (num < 1000) { 85 | return num.toString(); 86 | } 87 | 88 | if (num < 100000) { 89 | // For numbers between 1,000 and 99,999, show with one decimal (e.g., 1.5K) 90 | const value = (num / 1000).toFixed(1); 91 | // Remove trailing .0 if present 92 | const formattedValue = value.endsWith(".0") ? value.slice(0, -2) : value; 93 | 94 | return `${formattedValue}K`; 95 | } 96 | 97 | if (num < 1000000) { 98 | // For numbers between 10,000 and 999,999, show as whole K (e.g., 10K, 999K) 99 | return `${Math.floor(num / 1000)}K`; 100 | } 101 | 102 | // For 1,000,000 and above, just return the number 103 | return num.toString(); 104 | } 105 | -------------------------------------------------------------------------------- /packages/locales/src/generated.ts: -------------------------------------------------------------------------------- 1 | export type LocaleMapping = [ 2 | ["af", "NA", "ZA"], 3 | ["am"], 4 | ["ar", "AE", "BH", "DJ", "DZ", "EG", "EH", "JO", "KW", "LB", "LY", "MA", "OM", "PS", "QA", "SA", "SD", "TN", "YE"], 5 | ["as"], 6 | ["az"], 7 | ["ba"], 8 | ["be"], 9 | ["bg"], 10 | ["bi"], 11 | ["bn", "BD", "IN"], 12 | ["bo", "CN", "IN"], 13 | ["br"], 14 | ["bs"], 15 | ["ca", "AD", "ES"], 16 | ["ch", "GU", "MP"], 17 | ["co"], 18 | ["cs"], 19 | ["cy"], 20 | ["da"], 21 | ["de", "AT", "CH", "DE", "LI", "MF"], 22 | ["dv"], 23 | ["dz"], 24 | ["el", "CY", "GR"], 25 | [ 26 | "en", 27 | "AG", 28 | "AI", 29 | "AQ", 30 | "AU", 31 | "BB", 32 | "BM", 33 | "BS", 34 | "BZ", 35 | "CA", 36 | "CC", 37 | "CK", 38 | "CX", 39 | "DM", 40 | "FK", 41 | "FM", 42 | "GB", 43 | "GD", 44 | "GI", 45 | "GS", 46 | "GY", 47 | "HM", 48 | "IM", 49 | "IO", 50 | "JE", 51 | "JM", 52 | "KN", 53 | "KY", 54 | "LC", 55 | "LR", 56 | "MF", 57 | "MS", 58 | "NZ", 59 | "PN", 60 | "PW", 61 | "SB", 62 | "SH", 63 | "SL", 64 | "SS", 65 | "SX", 66 | "TC", 67 | "TT", 68 | "US", 69 | "VC", 70 | "ZM", 71 | ], 72 | ["eo"], 73 | [ 74 | "es", 75 | "AR", 76 | "BO", 77 | "CL", 78 | "CO", 79 | "CR", 80 | "CU", 81 | "DO", 82 | "EC", 83 | "ES", 84 | "GQ", 85 | "GT", 86 | "HN", 87 | "MX", 88 | "NI", 89 | "PA", 90 | "PE", 91 | "PR", 92 | "PY", 93 | "SV", 94 | "US", 95 | "UY", 96 | "VE", 97 | ], 98 | ["et"], 99 | ["eu"], 100 | ["fa", "AF", "IR"], 101 | ["fi"], 102 | ["fj"], 103 | ["fo"], 104 | [ 105 | "fr", 106 | "BE", 107 | "BF", 108 | "BL", 109 | "CA", 110 | "CF", 111 | "CG", 112 | "CH", 113 | "CM", 114 | "DJ", 115 | "FR", 116 | "GA", 117 | "GF", 118 | "GG", 119 | "GN", 120 | "GP", 121 | "LU", 122 | "MC", 123 | "MF", 124 | "ML", 125 | "MQ", 126 | "NC", 127 | "NE", 128 | "PF", 129 | "PM", 130 | "RE", 131 | "TD", 132 | "TF", 133 | "TG", 134 | "YT", 135 | ], 136 | ["fy"], 137 | ["ga"], 138 | ["gd"], 139 | ["gl"], 140 | ["gu"], 141 | ["ha", "GH", "NG"], 142 | ["he"], 143 | ["hi"], 144 | ["hr"], 145 | ["ht"], 146 | ["hu"], 147 | ["hy"], 148 | ["id"], 149 | ["ig"], 150 | ["ii"], 151 | ["is"], 152 | ["it", "CH", "IT", "SM", "VA"], 153 | ["iu"], 154 | ["ja"], 155 | ["ka"], 156 | ["kk"], 157 | ["kl"], 158 | ["km"], 159 | ["kn"], 160 | ["ko", "KP", "KR"], 161 | ["ku", "IQ", "IR", "SY", "TR", "Arab", "Latn"], 162 | ["ky"], 163 | ["lb"], 164 | ["lo"], 165 | ["lt"], 166 | ["lv"], 167 | ["mg"], 168 | ["mh"], 169 | ["mi"], 170 | ["mk"], 171 | ["ml"], 172 | ["mn", "Cyrl", "Mong"], 173 | ["mr"], 174 | ["ms", "BN", "MY"], 175 | ["mt"], 176 | ["my"], 177 | ["na"], 178 | ["nb"], 179 | ["ne"], 180 | ["nl", "BE", "NL", "SR"], 181 | ["nn"], 182 | ["no", "BV", "NO", "SJ"], 183 | ["nr"], 184 | ["ny"], 185 | ["oc", "ES", "FR", "IT"], 186 | ["or"], 187 | ["pa", "IN", "PK", "Arab", "Gurmukhi"], 188 | ["pl"], 189 | ["ps", "AF", "PK"], 190 | ["pt", "AO", "BR", "CV", "GQ", "GW", "MZ", "PT", "ST", "TL"], 191 | ["qu", "BO", "EC", "PE"], 192 | ["rm"], 193 | ["ro", "MD", "RO"], 194 | ["ru", "BY", "KG", "KZ", "RU"], 195 | ["rw"], 196 | ["sa"], 197 | ["se", "FI", "NO", "SE"], 198 | ["sg"], 199 | ["si"], 200 | ["sk"], 201 | ["sl"], 202 | ["sm", "AS", "WS"], 203 | ["sn"], 204 | ["so", "DJ", "SO"], 205 | ["sq", "AL", "MK", "XK"], 206 | ["sr", "BA", "HR", "ME", "RS", "Cyrl", "Latn"], 207 | ["ss"], 208 | ["st", "LS", "ZA"], 209 | ["sv", "AX", "FI", "SE"], 210 | ["sw", "BI", "CD", "KE", "RW", "TZ", "UG"], 211 | ["ta", "IN", "LK", "MY", "SG"], 212 | ["te"], 213 | ["tg"], 214 | ["th"], 215 | ["ti", "ER", "ET"], 216 | ["tk"], 217 | ["tn"], 218 | ["to"], 219 | ["tr"], 220 | ["ty"], 221 | ["uk"], 222 | ["ur", "IN", "PK"], 223 | ["uz", "Cyrl", "Latn"], 224 | ["vi"], 225 | ["wo", "GM", "MR", "SN"], 226 | ["xh"], 227 | ["yi", "IL", "US"], 228 | ["yo", "BJ", "NG"], 229 | ["zh", "CN", "HK", "MO", "SG", "TW", "Hans", "Hant"], 230 | ["zu"], 231 | ]; 232 | -------------------------------------------------------------------------------- /app/components/tabs.unstyled.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Primitive from "@radix-ui/react-tabs"; 4 | import { useEffectEvent } from "fumadocs-core/utils/use-effect-event"; 5 | import { type ComponentProps, createContext, useContext, useLayoutEffect, useMemo, useRef, useState } from "react"; 6 | import { mergeRefs } from "../lib/merge-refs"; 7 | 8 | type ChangeListener = (v: string) => void; 9 | const listeners = new Map(); 10 | 11 | function addChangeListener(id: string, listener: ChangeListener): void { 12 | const list = listeners.get(id) ?? []; 13 | list.push(listener); 14 | listeners.set(id, list); 15 | } 16 | 17 | function removeChangeListener(id: string, listener: ChangeListener): void { 18 | const list = listeners.get(id) ?? []; 19 | listeners.set( 20 | id, 21 | list.filter(item => item !== listener), 22 | ); 23 | } 24 | 25 | export interface TabsProps extends ComponentProps { 26 | /** 27 | * Identifier for Sharing value of tabs 28 | */ 29 | groupId?: string; 30 | 31 | /** 32 | * Enable persistent 33 | */ 34 | persist?: boolean; 35 | 36 | /** 37 | * If true, updates the URL hash based on the tab's id 38 | */ 39 | updateAnchor?: boolean; 40 | } 41 | 42 | const TabsContext = createContext<{ 43 | valueToIdMap: Map; 44 | } | null>(null); 45 | 46 | function useTabContext() { 47 | const ctx = useContext(TabsContext); 48 | if (!ctx) throw new Error("You must wrap your component in "); 49 | return ctx; 50 | } 51 | 52 | export const TabsList = Primitive.TabsList; 53 | 54 | export const TabsTrigger = Primitive.TabsTrigger; 55 | 56 | /** 57 | * @internal You better not use it 58 | */ 59 | export function Tabs({ 60 | ref, 61 | groupId, 62 | persist = false, 63 | updateAnchor = false, 64 | defaultValue, 65 | value: _value, 66 | onValueChange: _onValueChange, 67 | ...props 68 | }: TabsProps) { 69 | const tabsRef = useRef(null); 70 | const [value, setValue] = 71 | _value === undefined 72 | ? // eslint-disable-next-line react-hooks/rules-of-hooks -- not supposed to change controlled/uncontrolled 73 | useState(defaultValue) 74 | : [_value, _onValueChange ?? (() => undefined)]; 75 | 76 | const onChange = useEffectEvent((v: string) => setValue(v)); 77 | const valueToIdMap = useMemo(() => new Map(), []); 78 | 79 | useLayoutEffect(() => { 80 | if (!groupId) return; 81 | const previous = persist ? localStorage.getItem(groupId) : sessionStorage.getItem(groupId); 82 | 83 | if (previous) onChange(previous); 84 | addChangeListener(groupId, onChange); 85 | return () => { 86 | removeChangeListener(groupId, onChange); 87 | }; 88 | }, [groupId, persist]); 89 | 90 | useLayoutEffect(() => { 91 | const hash = window.location.hash.slice(1); 92 | if (!hash) return; 93 | 94 | for (const [value, id] of valueToIdMap.entries()) { 95 | if (id === hash) { 96 | onChange(value); 97 | tabsRef.current?.scrollIntoView(); 98 | break; 99 | } 100 | } 101 | }, [valueToIdMap]); 102 | 103 | return ( 104 | { 108 | if (updateAnchor) { 109 | const id = valueToIdMap.get(v); 110 | 111 | if (id) { 112 | window.history.replaceState(null, "", `#${id}`); 113 | } 114 | } 115 | 116 | if (groupId) { 117 | listeners.get(groupId)?.forEach(item => { 118 | item(v); 119 | }); 120 | 121 | if (persist) localStorage.setItem(groupId, v); 122 | else sessionStorage.setItem(groupId, v); 123 | } else { 124 | setValue(v); 125 | } 126 | }} 127 | {...props} 128 | > 129 | ({ valueToIdMap }), [valueToIdMap])}>{props.children} 130 | 131 | ); 132 | } 133 | 134 | export function TabsContent({ value, ...props }: ComponentProps) { 135 | const { valueToIdMap } = useTabContext(); 136 | 137 | if (props.id) { 138 | valueToIdMap.set(value, props.id); 139 | } 140 | 141 | return ( 142 | 143 | {props.children} 144 | 145 | ); 146 | } 147 | -------------------------------------------------------------------------------- /docs/quick-start.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Quick Start 3 | description: Get started quickly with Intl-T 4 | --- 5 | 6 | ## Installation 7 | 8 | Install with your favorite package manager: 9 | 10 | ```package-install 11 | intl-t 12 | ``` 13 | 14 | ## Setup 15 | 16 | 17 | 18 | 19 | ### Translations 20 | 21 | Set up your translation files. Create a directory for your translations, e.g., `i18n/messages/`, and add your translation files in JSON format. 22 | 23 | ```jsonc title="i18n/messages/en.json" 24 | { 25 | "homepage": { 26 | "welcome": "Welcome, {user}!", 27 | }, 28 | } 29 | ``` 30 | 31 | 32 | 33 | 34 | ### Configuration 35 | 36 | Create a translation instance and configure it with your translation files. 37 | 38 | 39 | 40 | 41 | ```ts title="i18n/translation.ts" 42 | import en from "./messages/en.json"; 43 | import es from "./messages/es.json"; 44 | 45 | import { Translation } from "intl-t"; 46 | 47 | export const t = new Translation({ locales: { en, es } }); 48 | ``` 49 | 50 | 51 | 52 | 53 | ```ts title="i18n/translation.ts" 54 | import en from "./messages/en.json"; 55 | import es from "./messages/es.json"; 56 | 57 | import { createTranslation } from "intl-t"; 58 | 59 | export const t = createTranslation({ locales: { en, es } }); 60 | ``` 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | ### Integration 69 | 70 | Depending on your framework, you may need to integrate the translation instance into your application. 71 | 72 | 73 | 74 | 75 | ```tsx title="i18n/translation.tsx" 76 | import { createTranslation } from "intl-t/react"; 77 | 78 | export const { Translation, useTranslation } = createTranslation(/* ... */); 79 | ``` 80 | 81 | ```tsx title="App.tsx" 82 | import { Translation } from "./i18n/translation"; 83 | 84 | export default function App() { 85 | return ( 86 | 87 | 88 | 89 | ); 90 | } 91 | ``` 92 | 93 | 94 | 95 | 96 | ```tsx title="i18n/translation.tsx" 97 | import { createTranslation } from "intl-t/next"; 98 | 99 | export const { Translation, useTranslation } = createTranslation({/* ... */}); 100 | ``` 101 | 102 | ```ts title="i18n/navigation.ts" 103 | import { createNavigation } from "intl-t/navigation"; 104 | 105 | export const { middleware, generateStaticParams, Link, redirect, useRouter } = createNavigation({ allowedLocales: ["en", "es"] as const }); 106 | ``` 107 | 108 | Wrap your application files inside `/app/[locale]`. 109 | 110 | ```tsx title="app/[locale]/layout.tsx" 111 | import { setRequestLocale } from "intl-t/next"; 112 | import { Translation } from "@/i18n/translation"; 113 | 114 | export { generateStaticParams } from "@/i18n/navigation" 115 | 116 | interface Props { 117 | children: React.ReactNode; 118 | params: Promise<{ locale: typeof Translation.locale }> 119 | } 120 | 121 | export default function RootLayout({ children, params }: Props) { 122 | const { locale } = await params; 123 | if (!Translation.locales.includes(locale)) return; 124 | setRequestLocale(locale); 125 | 126 | return ( 127 | 128 | 129 | 130 | {children} 131 | 132 | 133 | 134 | ); 135 | } 136 | ``` 137 | 138 | ```ts title="middleware.ts" 139 | export { middleware as default } from "@/i18n/navigation"; 140 | 141 | export const config = { 142 | matcher: ["/((?!api|static|.*\\..*|_next).*)"], 143 | }; 144 | ``` 145 | 146 | ```ts title="i18n/patch.ts" 147 | import patch from "@intl-t/react/patch"; 148 | import React from "react"; 149 | import jsx from "react/jsx-runtime"; 150 | 151 | process.env.NODE_ENV !== "development" && patch(React, jsx); 152 | ``` 153 | 154 | Then import the patch file in `i18n/translation.ts` as `import "./patch";`. 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | ## Usage 163 | 164 | Now you can use the translation instance in your code: 165 | 166 | ```ts twoslash 167 | import { Translation } from "intl-t"; 168 | 169 | const en = { 170 | homepage: { 171 | title: "Homepage", 172 | welcome: "Welcome, {user}!", 173 | hero: { 174 | cta: "Get started", 175 | }, 176 | }, 177 | } as const; 178 | 179 | const t = new Translation({ locales: { en } }); 180 | 181 | // ---cut--- 182 | t(""); 183 | // ^| 184 | ``` 185 | 186 | --- 187 | -------------------------------------------------------------------------------- /packages/next/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { Locale } from "@intl-t/locales"; 2 | import { match } from "@intl-t/tools/match"; 3 | import { negotiator } from "@intl-t/tools/negotiator"; 4 | import { ResolveConfig } from "@intl-t/tools/resolvers"; 5 | import { I18NDomains } from "next/dist/server/config-shared"; 6 | import { MiddlewareConfig as MG, NextFetchEvent, NextRequest, NextResponse } from "next/server"; 7 | import { LOCALE_HEADERS_KEY, PATH_HEADERS_KEY } from "./headers"; 8 | 9 | export const LOCALE_COOKIE_KEY = "locale"; 10 | 11 | export type Middleware = (req: NextRequest, ev: NextFetchEvent, res?: NextResponse) => NextResponse | Promise | undefined; 12 | export type MiddlewareFactory = (middleware: Middleware) => Middleware; 13 | 14 | export interface MiddlewareConfig extends MG, ResolveConfig { 15 | pathBase?: "always-default" | "detect-default" | "detect-latest" | "always-detect"; 16 | strategy?: "domain" | "param" | "headers"; 17 | detect?: false | string | string[] | ((req: NextRequest) => string[] | string); 18 | domains?: I18NDomains; 19 | config?: MG; 20 | middleware?: Middleware; 21 | withMiddleware?: MiddlewareFactory; 22 | match?: typeof match; 23 | } 24 | 25 | export const middlewareConfig: MG = { 26 | matcher: ["/((?!api|.*\\..*|_next).*)"], 27 | }; 28 | 29 | // @ts-ignore 30 | export function detect(req: NextRequest, domains: I18NDomains = this?.domains || []) { 31 | const { hostname } = req.nextUrl; 32 | const domain = domains.find(d => hostname.includes(d.domain)); 33 | return [domain?.defaultLocale || "", ...(domain?.locales || [])]; 34 | } 35 | 36 | export function createMiddleware(settings: MiddlewareConfig) { 37 | settings.config = middlewareConfig; 38 | settings.middleware = middleware.bind(settings); 39 | settings.withMiddleware = withMiddleware.bind(settings); 40 | settings.match = match.bind(settings); 41 | settings.domains && (settings.detect ??= detect.bind(settings)); 42 | return Object.assign(settings.middleware, settings, middlewareConfig); 43 | } 44 | 45 | export function middleware(req: NextRequest, ev: NextFetchEvent, res?: NextResponse) { 46 | let { 47 | allowedLocales = [], 48 | defaultLocale = allowedLocales[0], 49 | strategy = "param", 50 | pathPrefix = strategy == "domain" ? "hidden" : "default", 51 | pathBase = pathPrefix == "hidden" ? "detect-latest" : "detect-default", 52 | detect = req => negotiator(req), 53 | redirectPath = "r", 54 | match = () => "", 55 | // @ts-ignore 56 | } = this as MiddlewareConfig; 57 | res ||= NextResponse.next(); 58 | const { nextUrl, cookies } = req; 59 | let url = nextUrl.clone(); 60 | let [, locale, ...path] = nextUrl.pathname.split("/") as string[]; 61 | if (!allowedLocales.includes(locale as L)) { 62 | if (locale == redirectPath) ((pathBase = "detect-latest"), (pathPrefix = "always"), (strategy = "param"), (res = undefined)); 63 | else path.unshift(locale); 64 | if (pathBase == "always-default") locale = defaultLocale; 65 | else if (pathBase == "always-detect" || !(locale = cookies.get(LOCALE_COOKIE_KEY)?.value as string)) 66 | locale = match(typeof detect != "function" ? detect || null : detect(req)); 67 | else if (pathBase == "detect-default") locale = defaultLocale; 68 | else locale ||= defaultLocale; 69 | url.pathname = [locale, ...path].join("/"); 70 | if (strategy != "headers") { 71 | if (pathPrefix != "always" && (pathPrefix == "default" ? locale == defaultLocale : true)) 72 | res = res ? NextResponse.rewrite(url) : NextResponse.redirect(((url.pathname = path.join("/")), url)); 73 | else if (pathPrefix == "always") res = NextResponse.redirect(url); 74 | } 75 | res ||= NextResponse.redirect(url); 76 | } else if ((pathPrefix == "default" && locale == defaultLocale) || pathPrefix == "hidden") { 77 | url.pathname = path.join("/"); 78 | res = NextResponse.redirect(url); 79 | } else if (strategy == "headers") res = NextResponse.rewrite(((url.pathname = path.join("/")), url)); 80 | res.headers.set(PATH_HEADERS_KEY, (path.unshift(""), path.join("/"))); 81 | res.headers.set(LOCALE_HEADERS_KEY, locale); 82 | res.cookies.set(LOCALE_COOKIE_KEY, locale); 83 | return res; 84 | } 85 | 86 | export const i18nMiddleware = middleware; 87 | 88 | export function withMiddleware(middleware: Middleware) { 89 | // @ts-ignore 90 | const i18nMiddlewareBound = i18nMiddleware.bind(this); 91 | return (req: NextRequest, ev: NextFetchEvent, res?: NextResponse) => { 92 | res = i18nMiddlewareBound(req, ev, res); 93 | return middleware(req, ev, res); 94 | }; 95 | } 96 | 97 | export { withMiddleware as withI18nMiddleware }; 98 | -------------------------------------------------------------------------------- /app/app/(home)/page.tsx: -------------------------------------------------------------------------------- 1 | import { GithubInfo } from "@/components/github-info"; 2 | import { ImageZoom } from "@/components/image-zoom"; 3 | import { Button } from "@/components/ui/button"; 4 | import { getMDXComponents } from "@/mdx-components"; 5 | import "fumadocs-ui/components/banner"; 6 | import { Book, Github, Shield, Zap, Globe, Package, Layers, Code } from "lucide-react"; 7 | import Link from "next/link"; 8 | import React from "react"; 9 | import HeroCode from "./code/hero.mdx"; 10 | import InstallCode from "./code/install.mdx"; 11 | 12 | export default function HomePage() { 13 | return ( 14 | <> 15 |
16 |
17 |

Intl-T.

18 | 19 |

20 | A Fully-Typed Object-Based i18n Translation Library for TypeScript, React, and Next.js 21 |

22 |
23 | 24 | 28 | 29 |
30 |
31 |
32 | 33 |
34 |
35 |
36 | } 38 | title="Fully Type-Safe" 39 | desc="Auto-completion everywhere: at translations, keys, with variables, and more." 40 | /> 41 | } 43 | title="Fast & Lightweight" 44 | desc="Lightweight bundle with zero external dependencies, tree shakeable, and optimized for performance." 45 | /> 46 | } title="Framework Agnostic" desc="Works in TypeScript, React, Next.js but is compatible everywhere." /> 47 | } title="Rich API" desc="Object-based message nodes. Readable and maintainable. Super flexible." /> 48 | } 50 | title="Formatting Helpers" 51 | desc="Has out of the box variable injection with an extended ICU format support." 52 | /> 53 | } 55 | title="Next.js Navigation" 56 | desc="Seamless RSC integrations with a customizable navigation system, optimized for performance." 57 | /> 58 |
59 | 60 |
61 | 68 |
69 |

Installing

70 |

Use your preferred package manager.

71 |
72 | 73 |
74 |
75 | 76 | 80 | 81 | 82 | 86 | 87 |
88 |
89 |
90 |
91 | 92 | Developed by @nivandres 93 | 94 |
95 | 96 | ); 97 | } 98 | 99 | function Feature({ icon, title, desc }: { icon: React.ReactNode; title: string; desc: string }) { 100 | return ( 101 |
102 |
103 | {icon} 104 | {title} 105 |
106 |

{desc}

107 |
108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /docs/examples.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Examples & Strategies 3 | description: Practical patterns and edge cases 4 | --- 5 | 6 | ## Locales metadata 7 | 8 | You can include metadata in your translation files to help you with localization and translation management. 9 | 10 | For example 11 | 12 | ```jsonc 13 | { 14 | "meta": { 15 | // It is a normal node 16 | "code": "en", 17 | "name": "English", 18 | "dir": "ltr", 19 | }, 20 | // ... 21 | } 22 | ``` 23 | 24 | And then you can access it from your translations. 25 | 26 | ```tsx 27 | import { Translation, t } from "@/i18n/translation"; 28 | import { match } from "intl-t/tools"; 29 | 30 | interface Props { 31 | params: Promise<{ locale: typeof Translation.locale }>; 32 | } 33 | 34 | export default function RootLayout({ children, params }) { 35 | let { locale } = await params; 36 | locale = match(locale, t.allowedLocales); 37 | const { meta } = await t[locale]; // Preload if you are using dynamic import without TranslationProvider that will preload translations 38 | return ( 39 | 40 | 41 | {children} 42 | 43 | 44 | ); 45 | } 46 | ``` 47 | 48 | ## Fallbacks 49 | 50 | When a translation node is executed and the translation is not found, it will fall back to the input text with injected variables. This could be useful when you receive a string from an external API or server. You might get either a translation key or the direct text. 51 | 52 | ```ts 53 | t("Please try again"); // falls back to "Please try again" 54 | t("messages.try_again"); // outputs the translation 55 | t("Please try again, {name}", { name: "John" }); // falls back to "Please try again, John" 56 | // these fallbacks are also type safe 57 | typeof t("Please try again, {name}"); // `Please try again, ${string}` 58 | ``` 59 | 60 | ## String Methods 61 | 62 | ```tsx 63 | 64 | {t.description.split(" ").map((word, index, words) => { 65 | return ( 66 | 67 | {word + " "} 68 | 69 | ); 70 | })} 71 | 72 | ``` 73 | 74 | ## Template Strings 75 | 76 | ```tsx 77 | async function Greeting() { 78 | "use server"; 79 | return t`greeting`({ name: await getName() }); 80 | } 81 | ``` 82 | 83 | ## Namespaces 84 | 85 | Namespaces are a way to simulate isolated translation contexts in your application. While intl-t does not natively support namespaces as a built-in feature, you can achieve similar separation by organizing your translation files and configuration per feature or section. 86 | 87 | For example, you might have: 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | `app/protected/i18n/translation.ts` 118 | `i18n/translation.ts` 119 | `app/docs/i18n/translation.ts` 120 | 121 | Each of these files can export its own translation instance: 122 | 123 | ```ts title="protected/i18n/translation.ts" 124 | import { createTranslation } from "intl-t"; 125 | import en from "./locales/en.json"; 126 | 127 | export const { t: protectedT } = createTranslation({ locales: { en } }); 128 | ``` 129 | 130 | ```ts title="docs/i18n/translation.ts" 131 | import { createTranslation } from "intl-t"; 132 | import en from "./locales/en.json"; 133 | 134 | export const { t: docsT } = createTranslation({ locales: { en } }); 135 | ``` 136 | 137 | You can then import and use the appropriate translation object in each part of your app: 138 | 139 | ```ts 140 | import { docsT } from "../../docs/i18n/translation"; 141 | import { protectedT } from "../i18n/translation"; 142 | 143 | protectedT("dashboard.title"); 144 | docsT("guide.intro"); 145 | ``` 146 | 147 | Renaming is not required; this is just for demonstration purposes. Simply import from the appropriate folders. 148 | 149 | If you are sending translations dynamically to the client via React, you must use a [`TranslationProvider`](/docs/react#provider) for each isolated translation instance. 150 | 151 | You can also implement different strategies for each isolated translation, such as using only [dynamic](/docs/next/dynamic-rendering) translation loading or preloading at server-side and dynamically importing at client-side. 152 | 153 | **This approach keeps translations isolated. In the future, intl-t may support merging or extending translations dynamically, but for now, this pattern allows you to simulate namespaces effectively.** 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Intl-T 2 | 3 | ### A Fully-Typed Object-Based i18n Translation Library. 4 | 5 | [![npm version](https://img.shields.io/npm/v/intl-t.svg)](https://www.npmjs.com/package/intl-t) 6 | [![TypeScript](https://img.shields.io/badge/-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) 7 | [![React](https://img.shields.io/badge/-61DAFB?logo=react&logoColor=white)](https://reactjs.org/) 8 | [![Next.js](https://img.shields.io/badge/-000000?logo=next.js&logoColor=white)](https://nextjs.org/) 9 | [![Discord Chat](https://img.shields.io/discord/1063280542759526400?label=Chat&logo=discord&color=blue)](https://discord.gg/5EbCXKpdyw) 10 | [![Donate via PayPal](https://img.shields.io/badge/PayPal-Donate-blue?logo=paypal)](https://www.paypal.com/ncp/payment/PMH5ASCL7J8B6) 11 | [![Star on Github](https://img.shields.io/github/stars/nivandres/intl-t)](https://github.com/nivandres/intl-t) 12 | 13 | [![Banner](https://raw.githubusercontent.com/nivandres/intl-t/main/assets/banner.webp)](https://intl-t.dev/) 14 | 15 | > Fully-Typed Node-Based i18n Translation Library. 16 | 17 | `Intl T, 18 | International Tree, 19 | International Translations, 20 | International T Object, 21 | Internationalization for TypeScript, 22 | International T` 23 | 24 |

25 | → Visit Intl-T Web 💻 26 |

27 | 28 | ## Features 29 | 30 | - 🎯 **Fully-Typed** for TypeScript with autocomplete for translation variables 31 | - 🌲 **Node-based translations** for easy organization and management 32 | - ✨ **Type-safe** translation keys, values and all sub-nodes 33 | - 🚚 Supports **JSON files** and dynamic **remote** imports 34 | - 🪄 **Flexible syntax** integrating all the best parts of other i18n libraries 35 | - 🧩 **ICU message format** support and extended for complex and nested pluralization and formatting 36 | - ⚛️ **React components injections** out of the box with translation variables 37 | - 🚀 Supports **server-side rendering** and **static rendering** with [Next.js](https://nextjs.org/) and [React](https://reactjs.org/) 38 | - 🔄 **Dynamic importing of locales** for optimized bundle size and on-demand language loading 39 | - ⚙️ Modular and agnostic to **any framework** or **library** 40 | - 📦 **[4kb](https://bundlephobia.com/package/intl-t) Lightweight bundle** with no external dependencies and **Tree-Shakable** 41 | 42 | ## Demo 43 | 44 | ```jsx 45 | export default function Component() { 46 | const { t } = useTranslation("homepage"); 47 | 48 | return ( 49 | <> 50 |

{t("title")}

51 | {/* Get translations as an object or function */} 52 |

{t.title}

53 | 54 | {/* Use variables in your translations */} 55 | {t("welcome", { user: "Ivan" })} 56 | {t.summary(data)} 57 | {/* Flexible syntax */} 58 | 59 |

{t("main", { now: Date.now() })}

60 |
    61 | {/* Array of translations */} 62 | {t.features.map(t => ( 63 |
  • 64 | {t} 65 |
  • 66 | ))} 67 |
68 |
    69 |
  • {t.features[0]}
  • 70 |
  • {t("features.1", { name: "Ivan V" })}
  • 71 |
  • {t("features")[2]({ name: "Ivan V" })}
  • 72 |
  • {t({ name: "Ivan V" }).features("3")}
  • 73 |
74 | {/* Node-based translations */} 75 |

{t.page1.section1.article1.title}

76 |

{t("page1/section1").article1("title")}

77 | {/* Full typesafe with completion for variables */} 78 |

{t({ day: "Monday" }).account(UserVariables).options.change}

79 | 80 | ); 81 | } 82 | ``` 83 | 84 | ```jsonc 85 | // en.json 86 | { 87 | "title": "Homepage", 88 | "welcome": "Welcome, {user}!", // support ICU message format 89 | "summary": "{count, plural, =0 {no items} one {# item} other {# items}}", 90 | "main": "It is {now, date, sm}", 91 | "features": [ 92 | "Hi {name}. This is Feature 1", 93 | "Hi {name}. This is Feature 2", 94 | "Hi {name}. This is Feature 3", 95 | { 96 | "base": "Hi {name}. This is Feature 4 with html title", // base is default text for objects 97 | "title": "Feature 4", 98 | }, 99 | ], 100 | "page1": { 101 | "section1": { 102 | "article1": { 103 | "title": "Article 1", 104 | }, 105 | }, 106 | }, 107 | "account": { 108 | "options": { 109 | "change": "Change your account settings. Your account id is {accountId}", 110 | }, 111 | "values": { 112 | // default values for this node 113 | "accountId": 0, 114 | }, 115 | }, 116 | "values": { 117 | // default values 118 | "user": "World", 119 | "name": "{user}", 120 | "now": "{(Date.now())}", 121 | }, 122 | } 123 | ``` 124 | 125 | ### [**→ Read the full Intl-T documentation**](https://intl-t.dev/docs) 126 | 127 | ## Support 128 | 129 | If you find this project useful, [consider supporting its development ☕](https://www.paypal.com/ncp/payment/PMH5ASCL7J8B6) or [leave a ⭐ on the Github Repo.](https://github.com/nivandres/intl-t) Also, if you need direct support or help, please don't hesitate to contact me. 130 | 131 | [![Donate via PayPal](https://img.shields.io/badge/PayPal-Donate-blue?logo=paypal)](https://www.paypal.com/ncp/payment/PMH5ASCL7J8B6) [![Star on Github](https://img.shields.io/github/stars/nivandres/intl-t)](https://github.com/nivandres/intl-t) 132 | -------------------------------------------------------------------------------- /packages/format/test/inject.legacy.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "bun:test"; 2 | import { injectVariables as iv } from "../src"; 3 | 4 | describe("variable injection", () => { 5 | it("should work with simple variables", () => { 6 | expect(iv("{a}", { a: "b" })).toBe("b"); 7 | expect(iv("start {a} end", { a: "b" })).toBe("start b end"); 8 | expect(iv("start {a} mid {b} end", { a: "b", b: "c" })).toBe("start b mid c end"); 9 | expect(iv("start {a} mid {b} end", { a: "b" })).toBe("start b mid {b} end"); 10 | expect(iv("{a}a{a}y", { a: "b" })).toBe("baby"); 11 | }); 12 | it("should work with plurals", () => { 13 | expect(iv("{a, plural, one {# item} other {# items}}", { a: 1 }) as string).toBe("1 item"); 14 | expect(iv("{a, plural, one {# item} other {# items}}", { a: 8 }) as string).toBe("8 items"); 15 | expect(iv("{a, plural, one {# item} other {# items}}", { a: 0 }) as string).toBe("0 items"); 16 | }); 17 | it("should work with selection ", () => { 18 | expect(iv("{a, select, yes {Yes} no {No}}, sir", { a: true }) as string).toBe("Yes, sir"); 19 | expect(iv("{a, select, yes {Yes} no {No}}, sir", { a: 0 }) as string).toBe("No, sir"); 20 | }); 21 | it("should work with complex conditions", () => { 22 | expect(iv("{a, plural, one {# item} other {# items}}, {b, select, yes {Yes} no {No}}", { a: 1, b: true }) as string).toBe( 23 | "1 item, Yes", 24 | ); 25 | expect(iv("{a, plural, =0 {no items} =1 {# item} >1 {# items}}", { a: 0 }) as string).toBe("no items"); 26 | expect(iv("{a, plural, =0 {no items} =1 {# item} =7 {exactly seven items #} >1 {# items}}", { a: 7 }) as string).toBe( 27 | "exactly seven items 7", 28 | ); 29 | expect(iv("{a, =0 {no items} =1 {one item} >1&<10 {many items} >=10 {lots of items}}", { a: 10 }) as string).toBe("lots of items"); 30 | expect(iv("Hola {gender, select, ='male' 'señor' 'female' 'señorita'}", { gender: "female" })).toBe("Hola señorita"); 31 | expect(iv("{name, juan juanito sofia sofi laura lau other #}", { name: "laura" })).toBe("lau"); 32 | expect(iv("{name, juan juanito sofia sofi laura lau other #}", { name: "alex" })).toBe("alex"); 33 | expect(iv("{o, debt = 'negative'}", { o: -1 })).toBe("negative"); 34 | expect(iv("{o, 1 = 'one'}", { o: 1 })).toBe("one"); 35 | }); 36 | it("should work with actions", () => { 37 | expect(iv("{a, list, type:disjunction}", { a: ["a", "b", "c"] })).toBe("a, b, or c"); 38 | expect(iv("{a, currency}", { a: 100 })).toBe(new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(100)); 39 | expect(iv("{a, price, currency:COP}", { a: 100 })).toBe( 40 | new Intl.NumberFormat("en-US", { style: "currency", currency: "COP" }).format(100), 41 | ); 42 | }); 43 | it("should work nested", () => { 44 | expect( 45 | iv( 46 | "{a, plural, one {# item} !=1 {# items}}, {b, select, a>40 {a lot} Boolean(#)&&{a}>3 {Absolutely yes there is {a, =1 '1 item' >10 '# ITEMS' other {# items}}.} yes {Yes} no {No}}", 47 | { a: 11, b: 1 }, 48 | ) as string, 49 | ).toBe("11 items, Absolutely yes there is 11 ITEMS."); 50 | }); 51 | it("should work with operations", () => { 52 | expect(iv("{(a - 1)}", { a: 2 })).toBe("1"); 53 | expect(iv("{(2 - 1)}")).toBe("1"); 54 | expect(iv("{(600 *2)}")).toBe("1200"); 55 | }); 56 | it("examples", () => { 57 | expect( 58 | iv( 59 | "{guestCount, <=1 {{host} went alone} 2 {{host} and {guest} went to the party} other {{host}, {guest} and {(guestCount - 1), =1 'one person' other {# people}} went to the party}}", 60 | { 61 | guestCount: 4, 62 | host: "Ivan", 63 | guest: "Juan", 64 | }, 65 | ) as string, 66 | ).toBe("Ivan, Juan and 3 people went to the party"); 67 | expect( 68 | iv( 69 | `{gender_of_host, select, 70 | female {{num_guests, plural, offset:1 71 | =0 {{host} does not give a party.} 72 | =1 {{host} invites {guest} to her party.} 73 | =2 {{host} invites {guest} and one other person to her party.} 74 | other {{host} invites {guest} and # other people to her party.} 75 | }} 76 | male {{num_guests, plural, offset:1 77 | =0 {{host} does not give a party.} 78 | =1 {{host} invites {guest} to his party.} 79 | =2 {{host} invites {guest} and one other person to his party.} 80 | other {{host} invites {guest} and # other people to his party.} 81 | }} 82 | other {{num_guests, plural, offset:1 83 | =0 {{host} does not give a party.} 84 | =1 {{host} invites {guest} to their party.} 85 | =2 {{host} invites {guest} and one other person to their party.} 86 | other {{host} invites {guest} and # other people to their party.} 87 | }}}`, 88 | { 89 | gender_of_host: "male", 90 | num_guests: 4, 91 | host: "Ivan", 92 | guest: "Juan", 93 | }, 94 | ) as string, 95 | ).toBe("Ivan invites Juan and 3 other people to his party."); 96 | expect( 97 | iv(`On {takenDate, date, short} {name} took {numPhotos, plural, =0 {no photos} =1 {one photo} other {# photos}}.`, { 98 | name: "John", 99 | takenDate: new Date(0), 100 | numPhotos: 0, 101 | }) as string, 102 | ).toBe(`On ${new Intl.DateTimeFormat("en", { dateStyle: "short" }).format(new Date(0))} John took no photos.`); 103 | expect( 104 | iv( 105 | `{count, plural, 106 | =0 {No followers yer} 107 | =1 {One follower} 108 | other {# followers} 109 | }`, 110 | { count: 1 }, 111 | ) as string, 112 | ).toBe("One follower"); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /docs/typescript.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: TypeScript 3 | description: TypeScript Optimal Config for Intl-T 4 | --- 5 | 6 | ## Declarations 7 | 8 | TypeScript does not infer the literal strings directly from JSON, but you can generate them automatically using the `generateDeclarations` function or the `declarations` script. 9 | 10 | This will generate declarations files for JSON (.d.json.ts) including the literal strings and structured types. 11 | 12 | ```ts title="i18n/declarations.ts" 13 | import { generateDeclarations } from "intl-t/declarations"; 14 | 15 | generateDeclarations("./en.json"); // string | string[] 16 | ``` 17 | 18 | You can also generate declarations from a specific JSON folder, it will scan all JSON files in the folder and generate declarations for each one. 19 | 20 | ```ts 21 | generateDeclarations("./i18n/messages"); 22 | ``` 23 | 24 | This function is asynchronous and it will run once per process, for example when running build or dev mode. 25 | 26 | You can use it as a script in your `package.json` to generate declarations whenever needed, for example, by checking for updates to your locales or as part of a build script or initialization entry point. For example, you can import it in your `next.config.js` file in a Next.js project. 27 | 28 | ```jsonc title="package.json" 29 | { 30 | "scripts": { 31 | "declarations": "bun ./i18n/declarations.ts", 32 | }, 33 | } 34 | ``` 35 | 36 | Before using these declarations, it is recommended to enable `allowArbitraryExtensions` in your `tsconfig.json`: 37 | 38 | ```jsonc title="tsconfig.json" 39 | { 40 | "compilerOptions": { 41 | "allowArbitraryExtensions": true, 42 | }, 43 | } 44 | ``` 45 | 46 | Example in case you would like to generate declarations in Next.js from your next.config file: 47 | 48 | ```ts title="next.config.js" 49 | import { generateDeclarations } from "intl-t/declarations"; 50 | 51 | generateDeclarations("i18n/messages", { watchMode: true }); // translations folder 52 | ``` 53 | 54 | _Note: Running `generateDeclarations` in `next.config.js` may display ESM warnings in the console. You can safely ignore these warnings, or run the script separately to avoid them._ 55 | 56 | After running the script, declaration files will appear in your locales folder with the corresponding types. These types are not needed for production or development runtime, so you can ignore them in your git repository: 57 | 58 | `*.d.json.ts` 59 | 60 | ```ts title="i18n/translation.ts" 61 | import { createTranslation } from "intl-t"; 62 | import en from "./messages/en.json"; 63 | import es from "./messages/es.json"; 64 | 65 | export const t = createTranslation({ 66 | locales: { en, es }, 67 | }); 68 | ``` 69 | 70 | Alternatively, you can import the declarations and assert them in your translation settings file, but it is not recommended in order to use generated declarations. 71 | 72 | ```ts title="i18n/translation.ts" 73 | import { createTranslation } from "intl-t/core"; 74 | 75 | type Locale = typeof import("./messages/en.d.json.ts"); 76 | 77 | export const t = createTranslation({ 78 | locales: () => import("./messages/en.json") as Promise, 79 | allowedLocales: ["en", "es"], 80 | }); 81 | ``` 82 | 83 | ### Script 84 | 85 | You can generate TypeScript declarations for your translations using the `declarations` command from `@intl-t/declarations` package with binary script. 86 | 87 | ```bash title="declarations" 88 | Usage: ${cmdName} ... [options] 89 | 90 | Options: 91 | --out, --output Output file or folder 92 | --watch Watch files/folders for changes 93 | --format Output format: ts, d.ts, d.json.ts (default) 94 | --symbol Exported symbol name (default: data) 95 | --del, --remove Delete original JSON files 96 | --no-search Disable default JSON search 97 | --recursive Search recursively in specified folders 98 | --silent Silence logs 99 | -h, --help Show help 100 | ``` 101 | 102 | This converts your JSON files into TypeScript declarations, which can be used to type your translations and enhance the developer experience. 103 | 104 | If no files or folders are specified, the command will search for JSON files in the current directory and its subdirectories. 105 | 106 | There is also a watch mode that will automatically update the declarations when the JSON files change. 107 | 108 | ## Recommendations 109 | 110 | It is recommended to use TypeScript with intl-t. You may find the following configuration useful, especially when using [`declarations`](#declarations): 111 | 112 | ```jsonc title="tsconfig.json" 113 | { 114 | "compilerOptions": { 115 | "allowArbitraryExtensions": true, 116 | "paths": { 117 | "@i18n/*": ["./i18n/*"], 118 | }, 119 | }, 120 | } 121 | ``` 122 | 123 | ## Augmentation 124 | 125 | If you want to import functions, methods, etc. from the `intl-t/*` package directly instead of your custom i18n folder (`@/i18n/*`), you can declare `intl-t` module with TypeScript. However, this is not recommended, as the intended approach is for intl-t to infer everything from your created translations at `@/i18n/*`, which you then import with bound values and functions. If you import directly from the `intl-t` module, the value will be shared globally, but the types will not. If you want to enforce global type consistency, you can do so as follows: 126 | 127 | ```ts 128 | import { t } from "@/i18n/translation"; 129 | 130 | declare module "intl-t" { 131 | export interface Global { 132 | Translation: typeof t; 133 | } 134 | } 135 | ``` 136 | 137 | In this way you can then import from the `intl-t` module with inferred types. 138 | 139 | > This is not necessary. `intl-t` is designed to infer translations from your custom files in `@/i18n/*`, which you import with their bound values and functions. 140 | -------------------------------------------------------------------------------- /app/components/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { type ComponentProps, createContext, type ReactNode, useContext, useEffect, useId, useMemo, useState } from "react"; 5 | import { cn } from "../lib/cn"; 6 | import * as Unstyled from "./tabs.unstyled"; 7 | 8 | type CollectionKey = string | symbol; 9 | 10 | export interface TabsProps extends Omit, "value" | "onValueChange"> { 11 | /** 12 | * Use simple mode instead of advanced usage as documented in https://radix-ui.com/primitives/docs/components/tabs. 13 | */ 14 | items?: string[]; 15 | 16 | /** 17 | * Shortcut for `defaultValue` when `items` is provided. 18 | * 19 | * @default 0 20 | */ 21 | defaultIndex?: number; 22 | 23 | /** 24 | * Additional label in tabs list when `items` is provided. 25 | */ 26 | label?: ReactNode; 27 | } 28 | 29 | const TabsContext = createContext<{ 30 | items?: string[]; 31 | collection: CollectionKey[]; 32 | } | null>(null); 33 | 34 | function useTabContext() { 35 | const ctx = useContext(TabsContext); 36 | if (!ctx) throw new Error("You must wrap your component in "); 37 | return ctx; 38 | } 39 | 40 | export const TabsList = React.forwardRef< 41 | React.ComponentRef, 42 | React.ComponentPropsWithoutRef 43 | >((props, ref) => ( 44 | 49 | )); 50 | TabsList.displayName = "TabsList"; 51 | 52 | export const TabsTrigger = React.forwardRef< 53 | React.ComponentRef, 54 | React.ComponentPropsWithoutRef 55 | >((props, ref) => ( 56 | 64 | )); 65 | TabsTrigger.displayName = "TabsTrigger"; 66 | 67 | export function Tabs({ 68 | ref, 69 | className, 70 | items, 71 | label, 72 | defaultIndex = 0, 73 | defaultValue = items ? escapeValue(items[defaultIndex]) : undefined, 74 | ...props 75 | }: TabsProps) { 76 | const [value, setValue] = useState(defaultValue); 77 | const collection = useMemo(() => [], []); 78 | 79 | return ( 80 | { 85 | if (items && !items.some(item => escapeValue(item) === v)) return; 86 | setValue(v); 87 | }} 88 | {...props} 89 | > 90 | {items && ( 91 | 92 | {label && {label}} 93 | {items.map(item => ( 94 | 95 | {item} 96 | 97 | ))} 98 | 99 | )} 100 | ({ items, collection }), [collection, items])}>{props.children} 101 | 102 | ); 103 | } 104 | 105 | export interface TabProps extends Omit, "value"> { 106 | /** 107 | * Value of tab, detect from index if unspecified. 108 | */ 109 | value?: string; 110 | } 111 | 112 | export function Tab({ value, ...props }: TabProps) { 113 | const { items } = useTabContext(); 114 | const resolved = 115 | value ?? 116 | // eslint-disable-next-line react-hooks/rules-of-hooks -- `value` is not supposed to change 117 | items?.at(useCollectionIndex()); 118 | if (!resolved) throw new Error("Failed to resolve tab `value`, please pass a `value` prop to the Tab component."); 119 | 120 | return ( 121 | 122 | {props.children} 123 | 124 | ); 125 | } 126 | 127 | export function TabsContent({ value, className, ...props }: ComponentProps) { 128 | return ( 129 | figure:only-child]:-m-4 [&>figure:only-child]:border-none", 134 | className, 135 | )} 136 | {...props} 137 | > 138 | {props.children} 139 | 140 | ); 141 | } 142 | 143 | /** 144 | * Inspired by Headless UI. 145 | * 146 | * Return the index of children, this is made possible by registering the order of render from children using React context. 147 | * This is supposed by work with pre-rendering & pure client-side rendering. 148 | */ 149 | function useCollectionIndex() { 150 | const key = useId(); 151 | const { collection } = useTabContext(); 152 | 153 | useEffect(() => { 154 | return () => { 155 | const idx = collection.indexOf(key); 156 | if (idx !== -1) collection.splice(idx, 1); 157 | }; 158 | }, [key, collection]); 159 | 160 | if (!collection.includes(key)) collection.push(key); 161 | return collection.indexOf(key); 162 | } 163 | 164 | /** 165 | * only escape whitespaces in values in simple mode 166 | */ 167 | function escapeValue(v: string): string { 168 | return v.toLowerCase().replace(/\s/, "-"); 169 | } 170 | -------------------------------------------------------------------------------- /app/components/rate.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; 4 | import { cn } from "@/lib/utils"; 5 | import { cva } from "class-variance-authority"; 6 | import { buttonVariants } from "fumadocs-ui/components/ui/button"; 7 | import { ThumbsDown, ThumbsUp } from "lucide-react"; 8 | import { usePathname } from "next/navigation"; 9 | import { type SyntheticEvent, useEffect, useState, useTransition } from "react"; 10 | 11 | const rateButtonVariants = cva( 12 | "inline-flex items-center gap-2 px-3 py-2 rounded-full font-medium border text-sm [&_svg]:size-4 disabled:cursor-not-allowed", 13 | { 14 | variants: { 15 | active: { 16 | true: "bg-fd-accent text-fd-accent-foreground [&_svg]:fill-current", 17 | false: "text-fd-muted-foreground", 18 | }, 19 | }, 20 | }, 21 | ); 22 | 23 | export interface Feedback { 24 | opinion: "good" | "bad"; 25 | url?: string; 26 | message: string; 27 | } 28 | 29 | export interface ActionResponse { 30 | githubUrl: string; 31 | } 32 | 33 | interface Result extends Feedback { 34 | response?: ActionResponse; 35 | } 36 | 37 | export function Rate({ onRateAction }: { onRateAction: (url: string, feedback: Feedback) => Promise }) { 38 | const url = usePathname(); 39 | const [previous, setPrevious] = useState(null); 40 | const [opinion, setOpinion] = useState<"good" | "bad" | null>(null); 41 | const [message, setMessage] = useState(""); 42 | const [isPending, startTransition] = useTransition(); 43 | 44 | useEffect(() => { 45 | const item = localStorage.getItem(`docs-feedback-${url}`); 46 | 47 | if (item === null) return; 48 | setPrevious(JSON.parse(item) as Result); 49 | }, [url]); 50 | 51 | useEffect(() => { 52 | const key = `docs-feedback-${url}`; 53 | 54 | if (previous) localStorage.setItem(key, JSON.stringify(previous)); 55 | else localStorage.removeItem(key); 56 | }, [previous, url]); 57 | 58 | function submit(e?: SyntheticEvent) { 59 | if (opinion == null) return; 60 | 61 | startTransition(async () => { 62 | const feedback: Feedback = { 63 | opinion, 64 | message, 65 | }; 66 | 67 | void onRateAction(url, feedback).then(response => { 68 | setPrevious({ 69 | response, 70 | ...feedback, 71 | }); 72 | setMessage(""); 73 | setOpinion(null); 74 | }); 75 | }); 76 | 77 | e?.preventDefault(); 78 | } 79 | 80 | const activeOpinion = previous?.opinion ?? opinion; 81 | 82 | return ( 83 | { 86 | if (!v) setOpinion(null); 87 | }} 88 | className="border-y py-3" 89 | > 90 |
91 |

How is this guide?

92 | 106 | 120 |
121 | 122 | {previous ? ( 123 |
124 |

Thank you for your feedback!

125 |
126 | 137 | View on GitHub 138 | 139 | 140 | 154 |
155 |
156 | ) : ( 157 |
158 |