সরাসরি পর্বে যান
পর্ব ৫৯ · ৯৩-এর মধ্যে · মডিউল ৮

বাংলা সাইট — i18n, font, তারিখ, সংখ্যা আর locale routing

A Bangla Website — i18n, Fonts, Dates, Numbers and Locale Routing in Next.js

Taught in Bangla, this lesson builds a Bangla and English site in Next.js: locale routing with app/[lang], proxy, cookie and Accept-Language, server-side dictionaries and when to use next-intl, numbers, taka and plurals with Intl.NumberFormat, dates in Asia/Dhaka and the Bangla calendar, counting text with Intl.Segmenter, NFC and Intl.Collator sorting, Bangla-digit phone input, Bangla fonts with next/font, and avoiding hydration mismatches.

এই পর্বে যা শিখবেন

  • App Router-এ [lang] segment আর proxy দিয়ে ভাষা বাছাই, cookie-তে পছন্দ মনে রাখা
  • Server-এ dictionary load করা, আর নিজের বানানো পথ বনাম next-intl-এর সিদ্ধান্ত
  • ICU plural, বাংলায় টাকা, লাখ-কোটি, তারিখ, Asia/Dhaka আর আপেক্ষিক সময় ঠিকভাবে দেখানো
  • যুক্তাক্ষর গোনা, NFC, UTF-8-এ জমা, বাংলা নাম সাজানো আর খোঁজা
  • ব্যবহারকারীর টাইপ করা বাংলা অঙ্কের ফোন নম্বর যাচাইয়ের আগে ঠিক করা
  • বাংলা font, line-height, hydration mismatch, lang আর hreflang মিলিয়ে দুই ভাষার বাজারঘর

১ · বাংলা সাইট মানে শুধু অনুবাদ নয়

বাজারঘরের দুই ধরনের ক্রেতা। রংপুরের একজন মা বাংলায় পড়েন, দাম দেখতে চান “৪২৫৳”, তারিখ “৫ নভেম্বর”। দুবাইয়ে থাকা তাঁর ছেলে মায়ের জন্য চাল কেনেন, তাঁর ফোন ইংরেজিতে, তিনি চান “৳425”. একই পণ্য, একই দাম, একই database. বদলায় শুধু দেখানোর ধরন। এই পার্থক্য ঠিকভাবে সামলানোই এই পর্বের কাজ।

দর্জির দোকানের কথা ভাবুন। শার্টের নকশা একটিই, কাগজে কাটা প্যাটার্ন। প্রতিটি ক্রেতার জন্য দর্জি শুধু মাপ বদলান: গলা, হাতা, লম্বা। নকশা আবার আঁকেন না। ওয়েবেও তাই। কোড একটিই, এটি নকশা। ক্রেতার ভাষা আর অঞ্চল হলো মাপ। কোডকে এমনভাবে বানানো, যাতে মাপ বদলালেই কাজ হয়, তার নাম i18n. আর একটি নির্দিষ্ট মাপে কাপড় কাটা, অর্থাৎ বাংলার জন্য লেখা, অঙ্ক, তারিখ ঠিক করা, তার নাম l10n.

সেই “মাপ”-এর নাম locale. এটি লেখা হয় BCP 47 ট্যাগে: bn মানে বাংলা, bn-BD মানে বাংলাদেশের বাংলা, bn-IN ভারতের বাংলা, en-BD বাংলাদেশের ইংরেজি। ট্যাগের শেষে বাড়তি নির্দেশও জোড়া যায়, যেমন bn-BD-u-nu-latn: বাংলা নিয়ম, কিন্তু অঙ্ক ইংরেজি। এসব নিয়মের ভান্ডার CLDR, আর JavaScript-এর Intl সেই ভান্ডার পড়ে।

একটি বাংলা সাইটে মোট সাতটি জায়গায় locale কাজ করে। নিচের ছবিতে একটি request-এর পুরো পথ দেখুন। প্রতিটি ধাপ এই পর্বের একটি অংশ।

একটি request বাংলা পাতা হয়ে ফেরে

Where the locale is used, from request to hydrated page

বাংলা পাতার পথে locale-এর ব্যবহার ১ ক্রেতার browser Accept-Language · cookie NEXT_LOCALE ২ proxy.ts URL-এ ভাষা? → নইলে cookie → header 307 → /bn ৩ app/[lang]/layout.tsx html lang="bn-BD" · next/font · hreflang ৪ page.tsx (server) dictionary · plural Intl: ৪২৫৳ · ৫ নভেম্বর ৫ Database দাম পয়সায় NFC · UTF-8 ৬ HTML: বাংলা লেখা, বাংলা font ৭ Hydration: client-এও হুবহু একই লেখা
ছবি ৫৯.১ — Locale ঢোকে request-এ, বাছাই হয় proxy-তে, ব্যবহার হয় layout, page আর database-এ। শেষ ধাপে browser-কেও একই লেখা বানাতে হয়।

সব দাবি এখানে একটি আসল ল্যাবের run থেকে। লেখার সময়ের version: Next.js 16.4.4 (Turbopack), React 19.3.1, next-intl 4.14.9, TypeScript 6.0, Node 24.15.0 (ICU 78.2), আর Playwright দিয়ে চালানো Edge 156. পাতার ভেতরের চালানো যায় এমন Node ব্লক চলে Wandbox-এর Node 20.17-এ। দুই Node-এর ফল কোথায় আলাদা, তা-ও দেখাব। Project-এর পথ ছোট করে ~/bazarghar লেখা হয়েছে।

Config নিয়ে একটি স্পষ্ট কথা, কারণ “ডিফল্ট আচরণ” এর ওপর নির্ভর করে। এই ল্যাব হাতে বানানো, আর তার next.config.ts ফাঁকা: cacheComponents বা অন্য কোনো option নেই। পর্ব ৫১-এর run-এ create-next-app@16.4.4 --yes যে config লিখেছিল, তাতে cacheComponents: true আর partialPrefetching: true ছিল। সেই config-এ route table-এর চিহ্ন আর cache-এর আচরণ এখানকার থেকে আলাদা হতে পারে; এই পর্বের route table, header আর cache-এর সব বর্ণনা ফাঁকা config-এর। ভাষা বাছাইয়ের ফাইল proxy.ts, যা পর্ব ৫৭-এর run অনুযায়ী Node.js runtime-এ চলে; পুরোনো নাম middleware.ts দিলে deprecation-এর সতর্কবার্তা আসে আর সেটি Edge runtime-এ চলে।

পুরোনো tutorial-এ যা দেখবেন, Next.js 16-এ তা বদলেছে। (১) next.config.js-এর i18n: { locales } option ছিল Pages Router-এর; App Router এটি ব্যবহার করে না। (২) middleware.ts-এর নাম এখন proxy.ts, আর ফাংশনের নাম proxy. (৩) params এখন Promise, তাই await params লাগে। (৪) PageProps<'/[lang]'> আর LayoutProps type সব জায়গায় পাওয়া যায়। Proxy-র বিস্তারিত পর্ব ৫৭ (Route handler আর middleware)-এ।

২ · Locale routing — [lang] ফোল্ডার, proxy আর cookie

প্রথম প্রশ্ন: ভাষা কোথায় থাকবে? তিনটি পথ আছে। শুধু cookie-তে রাখলে একই URL দুই ভাষায় আসে; শেয়ার করা link-এ অন্যজন ভিন্ন ভাষা দেখেন, আর search engine একটিই রূপ দেখে। আলাদা domain (bazarghar.com.bd, bazarghar.com) বড় প্রতিষ্ঠানের জন্য। বেশিরভাগ সাইটের জন্য সহজ পথ হলো URL-এর শুরুতে ভাষা: /bn/offers, /en/offers. Next.js-এর docs-ও এই পথ দেখায়।

App Router-এ এর জন্য একটি dynamic segment লাগে। সব পাতা থাকে app/[lang]-এর ভেতরে, আর root layout-ও সেখানে। ল্যাবের গঠন:

~/bazarghar
├── proxy.ts                  ← ভাষা বাছাই
├── lib/i18n.ts               ← locale-এর তালিকা, Accept-Language পড়া
├── lib/format.ts             ← টাকা, সংখ্যা, তারিখ, plural
└── app/[lang]/
    ├── layout.tsx            ← <html lang>, font, generateStaticParams
    ├── page.tsx              ← বাজারঘরের header আর পণ্যের কার্ড
    ├── local-time.tsx        ← একটি Client Component
    ├── dictionaries.ts       ← import 'server-only'
    └── dictionaries/bn.json, en.json

Accept-Language header-এ browser পছন্দের ভাষা পাঠায়, প্রতিটির সাথে q মান (০ থেকে ১, বেশি মানে বেশি পছন্দ)। Docs-এর উদাহরণে negotiator আর @formatjs/intl-localematcher package আছে। দুটি ভাষার জন্য ছোট একটি ফাংশনই যথেষ্ট:

export const locales = ['bn', 'en'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'bn';

export const isLocale = (x: unknown): x is Locale =>
  typeof x === 'string' && (locales as readonly string[]).includes(x);

// "bn-BD,bn;q=0.9,en;q=0.8" -> 'bn'
export function pickLocale(header: string | null): Locale {
  const ranked = (header ?? '')
    .split(',')
    .map((part) => {
      const [tag, ...params] = part.trim().split(';');
      const q = params.find((p) => p.trim().startsWith('q='));
      return { lang: tag.toLowerCase().split('-')[0], q: q ? Number(q.split('=')[1]) : 1 };
    })
    .filter((x) => x.lang && x.q > 0)
    .sort((a, b) => b.q - a.q);
  return ranked.map((x) => x.lang).find(isLocale) ?? defaultLocale;
}

// BCP 47 tag for Intl formatters
export const intlTag = (l: Locale) => (l === 'bn' ? 'bn-BD' : 'en-BD');

এবার proxy. নিয়ম তিনটি, এই ক্রমে: URL-এ ভাষা থাকলে সেটিই চূড়ান্ত। না থাকলে আগে cookie-তে রাখা আগের পছন্দ। তা-ও না থাকলে header. ক্রেতা নিজে একবার ভাষা বদলালে সেই সিদ্ধান্ত browser-এর সেটিংয়ের চেয়ে বড়, তাই cookie আগে।

import { NextResponse, type NextRequest } from 'next/server';
import { isLocale, pickLocale } from './lib/i18n';

const COOKIE = 'NEXT_LOCALE';

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const first = pathname.split('/')[1];

  // URL-এ ভাষা আছে: সেটিই মানি, আর পছন্দটি cookie-তে মনে রাখি
  if (isLocale(first)) {
    const res = NextResponse.next();
    if (request.cookies.get(COOKIE)?.value !== first) {
      res.cookies.set(COOKIE, first, { path: '/', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' });
    }
    return res;
  }

  // URL-এ ভাষা নেই: আগে cookie, তারপর Accept-Language
  const saved = request.cookies.get(COOKIE)?.value;
  const locale = isLocale(saved) ? saved : pickLocale(request.headers.get('accept-language'));
  const url = request.nextUrl.clone();
  url.pathname = `/${locale}${pathname === '/' ? '' : pathname}`;
  return NextResponse.redirect(url);
}

export const config = {
  matcher: ['/((?!_next|api|.*\\..*).*)'],
};

Matcher বলছে, _next-এর ফাইল, /api আর বিন্দুওয়ালা path (যেমন favicon.ico) বাদ। একটি সত্যিকারের সতর্কতা: Bash heredoc দিয়ে এই ফাইল লেখার সময় ল্যাবে \\. হয়ে গিয়েছিল \., ফলে regex দাঁড়াল .*..*, আর দুই অক্ষরের /en-ও বাদ পড়ল। Proxy চলেইনি, কোনো error-ও আসেনি। Matcher বদলালে curl দিয়ে যাচাই করবেন। সত্যিকারের run (next start, port বদলানো):

$ curl -si localhost:47959/ -H 'Accept-Language: bn-BD,bn;q=0.9,en-US;q=0.8,en;q=0.7'
HTTP/1.1 307 Temporary Redirect
location: /bn

$ curl -si localhost:47959/ -H 'Accept-Language: en-US,en;q=0.9'
HTTP/1.1 307 Temporary Redirect
location: /en

$ curl -si localhost:47959/offers -H 'Accept-Language: en-US,en;q=0.9' -H 'Cookie: NEXT_LOCALE=bn'
HTTP/1.1 307 Temporary Redirect
location: /bn/offers

$ curl -si localhost:47959/en -H 'Cookie: NEXT_LOCALE=bn'
HTTP/1.1 200 OK
set-cookie: NEXT_LOCALE=en; Path=/; Expires=Tue, 02 Nov 2027 17:34:21 GMT; Max-Age=31536000; SameSite=lax
x-nextjs-cache: HIT
Cache-Control: s-maxage=31536000

$ curl -si localhost:47959/ -H 'Accept-Language: fr-FR,fr;q=0.9'
HTTP/1.1 307 Temporary Redirect
location: /bn

তৃতীয়টিতে header ইংরেজি চাইলেও cookie জিতেছে। চতুর্থটিতে URL জিতেছে, আর cookie নতুন পছন্দে বদলেছে। শেষটিতে ফরাসি সমর্থিত নয়, তাই ডিফল্ট বাংলা। নিচে একজন ক্রেতার দুই দিনের যাত্রা ধাপে ধাপে দেখুন।

  1. BrowserGET /header পাঠায়
  2. proxyfirst = ""URL-এ ভাষা নেই
  3. pickLocaleq মানসাজানো
  4. 307/enনতুন ঠিকানা
requestপ্রথম দিন, কোনো cookie নেই
GET / HTTP/1.1
Accept-Language: en-US,en;q=0.9,bn;q=0.8
Cookie: (নেই)
proxy.tsনিয়ম ১ আর ২
pathname "/" → first = ""
isLocale("")         → false
cookies NEXT_LOCALE  → undefined
→ Accept-Language পড়তে হবে
pickLocaleq অনুযায়ী সাজানো
en-US  → en   q = 1
en     → en   q = 0.9
bn     → bn   q = 0.8
প্রথম সমর্থিত: en
responseআসল run-এর মতো
HTTP/1.1 307 Temporary Redirect
location: /en
GET /enনিয়ম ১: URL-এ ভাষা
HTTP/1.1 200 OK
set-cookie: NEXT_LOCALE=en; Path=/; Max-Age=31536000; SameSite=lax
x-nextjs-cache: HIT     (build-এ বানানো ● /en)
পরের দিনক্রেতা আগে “বাংলা” চেপেছিলেন
GET /bn  → set-cookie: NEXT_LOCALE=bn
…
GET /   Accept-Language: en-US,en;q=0.9,bn;q=0.8
        Cookie: NEXT_LOCALE=bn
→ 307 location: /bn   (cookie জিতেছে)
  1. Request আসে। ক্রেতার ফোনের ভাষা ইংরেজি, তবে বাংলাও তালিকায় আছে, কম পছন্দে (q=0.8). কোনো cookie নেই, কারণ এটি প্রথম আসা।
  2. Proxy URL দেখে। Path /-এর প্রথম অংশ ফাঁকা, তাই URL-এ ভাষা নেই। Cookie-ও নেই। বাকি থাকল header.
  3. Header-এর ক্রম। তিনটি অংশ q মান অনুযায়ী সাজানো হলো: en (১), en (০.৯), bn (০.৮)। আমাদের তালিকায় থাকা প্রথম ভাষা en.
  4. Redirect. Proxy 307 দিয়ে /en-এ পাঠাল। 307 অস্থায়ী, তাই browser এটিকে স্থায়ী নিয়ম হিসেবে মনে রাখে না। ভাষা বদলাতে পারে, তাই 308 বা 301 এখানে ঠিক নয়।
  5. পাতা আসে, পছন্দ জমা হয়। /en-এ নিয়ম ১ খাটে। Build-এর সময় বানানো পাতা সরাসরি যায় (HIT), আর cookie-তে en লেখা হয় এক বছরের জন্য।
  6. পরের দিন cookie জেতে। ক্রেতা ভাষার link চেপে /bn-এ গিয়েছিলেন, তাই cookie এখন bn. Header না বদলালেও / এখন /bn-এ যায়। ক্রেতার নিজের সিদ্ধান্ত browser-এর ডিফল্টের চেয়ে বড়।

Build-এর আসল route table দেখুন। ● মানে generateStaticParams থেকে আগে বানানো HTML; প্রতিটি ভাষার একটি করে পাতা। Proxy আলাদা লাইনে, কারণ সেটি প্রতিটি request-এ চলে:

$ npx next build
▲ Next.js 16.4.4 (Turbopack)
✓ Compiled successfully in 2.6s
  Finished TypeScript in 1464ms ...
✓ Generating static pages using 4 workers (4/4) in 425ms

Route (app)
┌ ○ /_not-found
└   /[lang]
  ├ ● /bn
  └ ● /en

ƒ Proxy (Middleware)

○  (Static)  prerendered as static content
●  (SSG)     prerendered as static HTML (uses generateStaticParams)

Cache-এর দুটি ফাঁদ। (১) /-এর 307 উত্তর header আর cookie-র ওপর নির্ভর করে, কিন্তু ল্যাবের উত্তরে Vary ছিল না। সামনে কোনো shared cache বা CDN থাকলে একজনের redirect আরেকজন পেতে পারেন। এমন উত্তরে Cache-Control: private, no-store বা Vary: Accept-Language, Cookie দেওয়ার কথা ভাবুন, আর আপনার CDN-এর নিয়ম পড়ুন। (২) উপরের /en উত্তরে set-cookie আর s-maxage=31536000 একসাথে এসেছে। অনেক CDN cookie-ওয়ালা উত্তর cache করে না, কেউ কেউ নিয়ম অনুযায়ী করতে পারে। তাই cookie শুধু দরকার হলে লিখুন, যেমন next-intl করে (৩ নম্বর অংশে)।

৩ · Dictionary server-এ, আর library-র প্রশ্ন

অনুবাদের লেখা থাকে প্রতিটি ভাষার জন্য একটি JSON ফাইলে। চাবি একই, মান আলাদা। Next.js-এর docs-এর পথে এগুলো load হয় একটি ফাইল থেকে, যেটি শুধু server-এ চলে:

import 'server-only';
import type { Locale } from '@/lib/i18n';

const dictionaries = {
  bn: () => import('./dictionaries/bn.json').then((m) => m.default),
  en: () => import('./dictionaries/en.json').then((m) => m.default),
};

export type Dict = Awaited<ReturnType<(typeof dictionaries)['bn']>>;
export const getDictionary = async (locale: Locale) => dictionaries[locale]();
{
  "site": { "name": "বাজারঘর", "tagline": "ঘরে বসে বাজার" },
  "nav": { "home": "হোম", "offers": "অফার", "search": "খুঁজুন", "switch": "English" },
  "cart": { "one": "{count}টি পণ্য", "other": "{count}টি পণ্য" },
  "product": { "add": "ব্যাগে নিন", "stock": "স্টকে আছে {count}টি", "delivery": "ডেলিভারি {date}",
    "reviews": { "one": "{count}টি রিভিউ", "other": "{count}টি রিভিউ" }, "off": "{pct} ছাড়" }
}

তিনটি জিনিস লক্ষ করুন। server-only import থাকায় এই ফাইল ভুল করে Client Component-এ ঢুকলে build থেমে যায়। প্রতিটি ভাষা আলাদা import(), তাই বাংলা পাতায় ইংরেজি ফাইল লাগে না। আর page, layout সাধারণত Server Component, তাই পুরো dictionary browser-এ যায় না, যায় শুধু তৈরি HTML. Client Component-এর কোনো লেখা লাগলে শুধু সেই লেখাটুকু prop হিসেবে দিন। কোনটি কোথায় চলে, তা পর্ব ৫২ (Server Component আর Client Component)-এ।

Docs আরেকটি পথ দেখায়: next/root-params-এর lang(), যাতে প্রতিটি component-এ lang হাতে হাতে না পাঠিয়ে server-এ যেকোনো জায়গায় পড়া যায়। next-intl-এর docs অনুযায়ী এটি Next.js 16.3 থেকে ডিফল্টে পাওয়া যায়। আমাদের ছোট ল্যাবে param সরাসরি পাঠানো হয়েছে।

নিজের dictionary, নাকি next-intl?

উপরের bn.json-এ one আর other দুটি রূপ নিজে রাখতে হয়েছে, আর {count} নিজের ছোট ফাংশন দিয়ে বসাতে হয়েছে। দুই ভাষা আর বিশটি বাক্যের সাইটে এটি যথেষ্ট। কিন্তু অনুবাদক দল, ক্রমবাচক সংখ্যা (১ম, ২য়), লিঙ্গভেদ, Client Component-এ অনুবাদ, অনুবাদ ছুটে যাওয়ার সতর্কতা, এসব এলে library সময় বাঁচায়। এখানে বহুল ব্যবহৃত next-intl-এর আসল run. এটি একমাত্র পছন্দ নয়; Next.js-এর docs আরও কয়েকটি library-র নাম দেয়।

লেখার সময় npm-এ next-intl-এর latest হলো 4.14.9. সেটআপ docs থেকে, Next.js 16-এর জন্য proxy.ts-সহ:

// i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({ locales: ['bn', 'en'], defaultLocale: 'bn' });

// proxy.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = { matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)' };
import * as rootParams from 'next/root-params';
import { notFound } from 'next/navigation';
import { getRequestConfig } from 'next-intl/server';
import { hasLocale } from 'next-intl';
import { routing } from './routing';

export default getRequestConfig(async ({ locale }) => {
  if (!locale) {
    const value = await rootParams.locale();
    if (hasLocale(routing.locales, value)) locale = value;
    else notFound();
  }
  return {
    locale,
    timeZone: 'Asia/Dhaka',
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

সাথে next.config.ts-এ createNextIntlPlugin('./i18n/request.ts'), আর layout-এ NextIntlClientProvider. বার্তাগুলো লেখা ICU MessageFormat-এ, যেখানে plural-এর নিয়ম বার্তার ভেতরেই থাকে:

{
  "Header": { "cart": "{count, plural, one {#টি পণ্য} other {#টি পণ্য}}" },
  "Product": {
    "price": "দাম {price}",
    "reviews": "{count, plural, =0 {এখনো রিভিউ নেই} one {#টি রিভিউ} other {#টি রিভিউ}}",
    "rank": "বিক্রিতে {n, selectordinal, one {#ম} two {#য়} few {#র্থ} many {#ষ্ঠ} other {#তম}}"
  }
}

Page-এ getTranslations('Product') আর getFormatter(), তারপর t('reviews', { count: c }) আর t('rank', { n }). /bn-এর সত্যিকারের HTML থেকে লেখাগুলো:

৩টি পণ্য                    3 items
দাম ৪২৫.০০৳                  Price ৳425.00
এখনো রিভিউ নেই               No reviews yet
১টি রিভিউ                   1 review
২১টি রিভিউ                  21 reviews
বিক্রিতে ১ম, ২য়, ৪র্থ, ৬ষ্ঠ, ১১তম   1st, 2nd, 4th, 6th, 11th in sales
৫ নভেম্বর, ২০২৬               November 5, 2026

#-এর জায়গায় সংখ্যা বসেছে locale অনুযায়ী, বাংলা অঙ্কে। =0 একটি নির্দিষ্ট সংখ্যার জন্য আলাদা বাক্য। আর selectordinal CLDR-এর বাংলা ক্রমবাচক নিয়ম মেনে ১-কে one (ম), ২-কে two (য়), ৪-কে few (র্থ), ৬-কে many (ষ্ঠ), আর ১১-কে other (তম) ধরেছে। নিজের dictionary-তে এই নিয়ম হাতে লিখতে হতো।

Cookie-র আচরণেও পার্থক্য আছে। একই ধরনের curl, next-intl-এর proxy (সত্যিকারের run):

$ curl -si localhost:47961/en -H 'Accept-Language: bn-BD,bn;q=0.9'
HTTP/1.1 200 OK
link: <http://127.0.0.1:47961/bn>; rel="alternate"; hreflang="bn", <http://127.0.0.1:47961/en>; rel="alternate"; hreflang="en", <http://127.0.0.1:47961/>; rel="alternate"; hreflang="x-default"
set-cookie: NEXT_LOCALE=en; Path=/; SameSite=lax

$ curl -si localhost:47961/en -H 'Accept-Language: en-US,en;q=0.9'
HTTP/1.1 200 OK

$ curl -si localhost:47961/ -H 'Accept-Language: en-US' -H 'Cookie: NEXT_LOCALE=bn'
HTTP/1.1 307 Temporary Redirect
location: /bn

next-intl cookie লেখে শুধু তখন, যখন বেছে নেওয়া ভাষা browser-এর ভাষার সাথে মেলে না। দ্বিতীয় run-এ তাই কোনো cookie নেই। Cookie-তে Max-Age নেই, অর্থাৎ browser বন্ধ হলে মুছে যায়; docs অনুযায়ী localeCookie option দিয়ে মেয়াদ দেওয়া যায়। সাথে ভাষার বিকল্প পাতার একটি link header-ও এসেছে। ল্যাবে metadataBase দেওয়া ছিল না, তাই ঠিকানা localhost-এর।

বিষয়নিজের dictionarynext-intl 4.14
নতুন packageশুধু server-onlynext-intl আর তার নির্ভরতা
PluralIntl.PluralRules দিয়ে নিজেICU plural, =0, #
ক্রমবাচক (১ম, ২য়)নিজে লিখতে হয়selectordinal
Client Component-এ অনুবাদprop হিসেবে পাঠানোuseTranslations (Provider থেকে)
Cookieনিজের নিয়ম (ল্যাবে ১ বছর)দরকার হলে, session cookie
ভাষা-সচেতন link আর redirectনিজে /${lang} জোড়াcreateNavigation
বোঝার খরচসব কোড চোখের সামনেdocs আর version বদল মেলাতে হয়

সম্প্রদায়ে মত দুই রকম। একদল বলেন, ছোট সাইটে library মানে অপ্রয়োজনীয় নির্ভরতা। অন্যদল বলেন, plural আর format-এর হাজারো কোণা নিজে লিখলে ভুল হবেই। ব্যবহারিক নিয়ম: দুই ভাষা, অল্প বাক্য, শুধু Server Component হলে নিজের পথ; অনুবাদক দল, অনেক Client Component বা তিনের বেশি ভাষা হলে library. যেটিই নিন, অনুবাদের চাবিগুলো একই রাখবেন, যাতে পরে বদলানো যায়।

৪ · সংখ্যা, টাকা আর বহুবচন

বাংলাদেশে বড় সংখ্যা পড়া হয় লাখ আর কোটিতে: ১২,৩৪,৫৬৭ মানে বারো লাখ চৌত্রিশ হাজার পাঁচশো সাতষট্টি। প্রথম কমা তিন অঙ্ক পরে, তারপর দুই অঙ্ক পরপর। পশ্চিমা নিয়মে একই সংখ্যা 1,234,567. এই কাজ হাতে করবেন না; Intl.NumberFormat CLDR-এর নিয়ম জানে। নিচের ব্লক চালিয়ে দেখুন। এটি চলে Wandbox-এর Node 20-এ।

console.log("node", process.version, "| icu", process.versions.icu);

// দাম জমা থাকে পয়সায় (পূর্ণসংখ্যা), দেখানোর সময় টাকায়
const taka = new Intl.NumberFormat("bn-BD", {
  style: "currency", currency: "BDT", trailingZeroDisplay: "stripIfInteger",
});
for (const paisa of [42500, 9999, 123456750]) {
  console.log(paisa, "->", taka.format(paisa / 100));
}
console.log("en-BD:", new Intl.NumberFormat("en-BD", { style: "currency", currency: "BDT" }).format(1234567.5));
console.log("en-IN:", new Intl.NumberFormat("en-IN").format(1234567.5));
console.log("compact:", new Intl.NumberFormat("bn-BD", { notation: "compact", compactDisplay: "long" }).format(12500000));

const pr = new Intl.PluralRules("bn");
console.log("plural bn:", [0, 1, 2, 5].map((n) => `${n}=${pr.select(n)}`).join(" "));

const d = new Date("2026-04-14T03:30:00Z");
const opts = { dateStyle: "full", timeStyle: "short", timeZone: "Asia/Dhaka" };
console.log("Dhaka:", new Intl.DateTimeFormat("bn-BD", opts).format(d));
console.log("UTC:  ", new Intl.DateTimeFormat("bn-BD", { ...opts, timeZone: "UTC" }).format(d));
console.log("h23:  ", new Intl.DateTimeFormat("bn-BD", { timeStyle: "short", hourCycle: "h23", timeZone: "Asia/Dhaka" }).format(d));

const asked = new Intl.DateTimeFormat("bn-BD-u-ca-bengali", { dateStyle: "long", timeZone: "Asia/Dhaka" });
console.log("ca-bengali ->", asked.resolvedOptions().calendar, "|", asked.format(d));

const rtf = new Intl.RelativeTimeFormat("bn", { numeric: "auto" });
console.log("relative:", rtf.format(-1, "day"), "|", rtf.format(2, "day"), "|", rtf.format(-3, "hour"));

Wandbox-এর আসল আউটপুটের প্রথম অংশ: node v20.17.0 | icu 75.1, তারপর 42500 -> ৪২৫৳, 9999 -> ৯৯.৯৯৳ আর 123456750 -> ১২,৩৪,৫৬৭.৫০৳. বাংলা অঙ্ক, লাখের কমা, আর ৳ সংখ্যার পরে; এটি CLDR-এর বাংলা নিয়ম। trailingZeroDisplay: "stripIfInteger" থাকায় পূর্ণ টাকায় .০০ আসেনি।

en-BD: BDT 1,234,567.50: বাংলাদেশের ইংরেজিতে কমা পশ্চিমা নিয়মে, আর চিহ্নের বদলে BDT. en-IN: 12,34,567.5 লাখের নিয়মে। তাই ইংরেজি পাতায় লাখ চাইলে ভারতের ইংরেজি নয়, ভালো হয় en-BD-ই রাখা আর currencyDisplay: 'narrowSymbol' দিয়ে ৳ আনা (ল্যাবে ৳1,234,567.50). compact: ১.৩ কোটি: ছোট করে লেখা।

plural bn: 0=one 1=one 2=other 5=other: বাংলায় শূন্যও one, ইংরেজিতে শূন্য other (“0 items”). তারিখের লাইন আর শেষ লাইন ৫ নম্বর অংশে। আমার Node 24.15 (ICU 78.2) আর Edge 156-এ এই ব্লকের ফল হুবহু এক, শুধু একটি শব্দ ছাড়া: শেষ লাইনে Node 20 লিখেছে ৩ ঘন্টা আগে, নতুন দুটি ৩ ঘণ্টা আগে. ICU-র ভিন্ন version, ভিন্ন CLDR বানান। Server আর browser-এর ICU আলাদা হলে এটিই hydration-এর গোলমাল বাধাতে পারে (৯ নম্বর অংশ)।

দাম পয়সায় জমা, টাকায় দেখানো

Database-এ দাম কখনো দশমিক float-এ নয়। Node-এ 19.99 * 100 চালালে আসে 1998.9999999999998 (সত্যিকারের run). তাই বাজারঘরে দাম পূর্ণসংখ্যায়, পয়সায়: ৪২৫ টাকা মানে 42500. ভাগ হয় শুধু দেখানোর মুহূর্তে। ল্যাবের ফাংশনটি পূর্ণ টাকায় দশমিক বাদ দেয়:

export function formatPrice(paisa: number, locale: Locale) {
  return new Intl.NumberFormat(intlTag(locale), {
    style: 'currency',
    currency: 'BDT',
    currencyDisplay: 'narrowSymbol',
    maximumFractionDigits: paisa % 100 === 0 ? 0 : 2,
  }).format(paisa / 100);
}
1const paisa = 123456750;2const max = paisa % 100 === 0 ? 0 : 2;3const taka = paisa / 100;4new Intl.NumberFormat('bn-BD', {5  style: 'currency', currency: 'BDT',6  currencyDisplay: 'narrowSymbol',7  maximumFractionDigits: max,8}).format(taka);
মানগুলো (Watch)
paisa
max
taka
অঙ্ক
চিহ্ন
  1. Database থেকে পূর্ণসংখ্যা। একটি বড় পাইকারি order: 123456750 পয়সা। পূর্ণসংখ্যায় যোগ-বিয়োগে কোনো ভগ্নাংশের ভুল নেই।
  2. দশমিক লাগবে কি? 123456750 % 100 = 50, শূন্য নয়, অর্থাৎ ৫০ পয়সা বাকি। তাই দশমিকের পরে ২ অঙ্ক। 42500 হলে ভাগশেষ ০, তখন max = 0 আর ফল ৪২৫৳.
  3. এখন ভাগ। শুধু দেখানোর জন্য 123456750 ÷ 100 = 1234567.5. এই মান আর কোথাও জমা হয় না।
  4. অঙ্ক আর কমা। bn-BD-এর numbering system beng, তাই অঙ্ক ০-৯. কমা ডান থেকে প্রথমে তিন অঙ্কে (৫৬৭), তারপর দুই অঙ্কে (৩৪, ১২)। দশমিকের পরে ৫০.
  5. চিহ্ন বসে। বাংলা নিয়মে ৳ সংখ্যার পরে: ১২,৩৪,৫৬৭.৫০৳. একই কাজ en-এ দিলে ল্যাবে এসেছে ৳1,234,567.50. লেখা আলাদা, টাকা এক।

বহুবচনের কথা আবার বলি। বাংলায় “১টি পণ্য” আর “৩টি পণ্য” একই রূপ, কারণ বাংলা বিশেষ্য সংখ্যায় বদলায় না; বদলায় শব্দ-নির্দেশক (টি, জন, খানা)। তাহলে plural rules কেন? কারণ ইংরেজিতে “1 item”, “3 items”, আর ভবিষ্যতে আরবি বা রুশ যোগ হলে শ্রেণি ছয়টি পর্যন্ত। বাংলায় শ্রেণি দুটি (one, other), আর 0 পড়ে one-এ। তাই “শূন্য” বা “এখনো নেই” বোঝাতে =0 আলাদা রাখাই ভালো। চাইলে formatToParts দিয়ে অঙ্ক আর ৳ আলাদা ধরে ছোট-বড় করে সাজাতে পারেন।

৫ · তারিখ, সময়, Asia/Dhaka আর বঙ্গাব্দ

উপরের ব্লকের তারিখের লাইনগুলো আবার দেখুন। একই মুহূর্ত 2026-04-14T03:30:00Z (UTC, ISO 8601) দুই time zone-এ:

Dhaka: মঙ্গলবার, ১৪ এপ্রিল, ২০২৬ এ ৯:৩০ AM
UTC:   মঙ্গলবার, ১৪ এপ্রিল, ২০২৬ এ ৩:৩০ AM
h23:   ০৯:৩০

ঢাকা UTC-র ৬ ঘণ্টা আগে, তাই ৯:৩০. যদি timeZone না দেন, তাহলে যে যন্ত্রে কোড চলছে তার zone ব্যবহার হয়। আপনার ল্যাপটপ ঢাকায়, কিন্তু cloud-এর server প্রায়ই UTC-তে। তখন একই কোড ৬ ঘণ্টা পিছিয়ে দেখাবে, আর মাঝরাতের কাছের order ভুল দিনের হয়ে যাবে। নিয়ম: জমা করুন UTC-তে, দেখান timeZone: 'Asia/Dhaka' লিখে। বিস্তারিত আছে JavaScript-এর তারিখ আর Intl পর্বে।

আরেকটি সূক্ষ্ম জিনিস: CLDR-এর বাংলা সময়ে AM/PM ইংরেজিতেই থাকে। দুটি সমাধান। hourCycle: 'h23' দিলে ২৪ ঘণ্টার ঘড়ি, ০৯:৩০. অথবা dayPeriod: 'short' দিলে দিনের ভাগ বাংলায়; ল্যাবে রাত ৯টার জন্য এসেছে ৯:০৫ রাত্রি. দোকানের ভাষায় কোনটি মানায়, তা ব্যবসার সিদ্ধান্ত।

আপেক্ষিক সময়: Intl.RelativeTimeFormat('bn', { numeric: 'auto' }) দিয়েছে গতকাল, আগামী পরশু, ৩ ঘণ্টা আগে. numeric: 'auto' না দিলে আসে “১ দিন আগে”। কত সময় আগে, সেই হিসাব কিন্তু আপনার; Intl শুধু লেখে।

বঙ্গাব্দ: Intl-এ নেই, সাবধানে নিজে

পহেলা বৈশাখের অফারে বাজারঘর লিখতে চায় “১ বৈশাখ ১৪৩৩”। ব্লকের শেষ দিকের লাইনটি দেখুন: bn-BD-u-ca-bengali চাইলে resolvedOptions().calendar ফেরত দিয়েছে gregory, আর তারিখ এসেছে সাধারণ ১৪ এপ্রিল, ২০২৬. অর্থাৎ এই runtime-এ বঙ্গাব্দ নেই; Intl নিঃশব্দে গ্রেগরিয়ানে ফিরে গেছে, কোনো error ছাড়া। Node 24-এ Intl.supportedValuesOf('calendar')-এর তালিকায়ও bengali নেই। একটি ফাঁদ: u-ca-indian দিলে আসে ২৪ চৈত্র, ১৯৪৮ সাল; এটি ভারতের জাতীয় শকাব্দ, বঙ্গাব্দ নয়।

বাংলাদেশের সংশোধিত বঙ্গাব্দে ১৪ এপ্রিল পহেলা বৈশাখ, আর বছরের হিসাব মোটামুটি এমন: ১৪ এপ্রিল বা তার পরে হলে গ্রেগরিয়ান বছর − ৫৯৩, আগে হলে − ৫৯৪. যেমন ১৪ এপ্রিল ২০২৬ = ১ বৈশাখ ১৪৩৩. মাসের দিনসংখ্যা আর অধিবর্ষের নিয়ম সময়ের সাথে সংশোধিত হয়েছে, আর পশ্চিমবঙ্গের প্রচলিত পঞ্জিকা আলাদা নিয়মে চলে। তাই নিজের ফাংশন লিখলে বাংলা একাডেমি বা সরকারি ক্যালেন্ডারের কয়েকটি পরিচিত তারিখ দিয়ে test লিখবেন। ভবিষ্যতে কোনো runtime এই calendar যোগ করতে পারে; তাই resolvedOptions().calendar পরীক্ষা করেই নিজের ফাংশনে ফিরবেন।

৬ · লেখা গোনা, NFC, UTF-8 আর বাংলা নাম সাজানো

পণ্যের নামের সীমা “২০ অক্ষর”। কিন্তু অক্ষর মানে কী? JavaScript-এর .length গোনে UTF-16-এর টুকরো। চোখে যে একটি অক্ষর দেখি, যেমন যুক্তাক্ষর “ক্ষ”, তা ক + ্ + ষ, তিনটি code point. চোখের এককের নাম grapheme. চালিয়ে দেখুন:

console.log("icu", process.versions.icu, "| unicode", process.versions.unicode);

const seg = new Intl.Segmenter("bn", { granularity: "grapheme" });
const graphemes = (s) => [...seg.segment(s)].map((x) => x.segment);

for (const w of ["ক্ষমা", "কৃষ্ণ", "স্ত্রী", "বাংলা", "🇧🇩"]) {
  const g = graphemes(w);
  console.log(w, "| length", w.length, "| code points", [...w].length,
    "| graphemes", g.length, "|", g.join(" + "));
}

// ৫ অক্ষরের সীমায় নাম কাটা: .slice বনাম grapheme
const name = "ক্ষমা";
console.log("slice(0,2):", JSON.stringify(name.slice(0, 2)),
  "| first grapheme:", JSON.stringify(graphemes(name)[0]));

// NFC: একই দেখতে, ভিন্ন code point
const hex = (s) => [...s].map((c) => c.codePointAt(0).toString(16).toUpperCase()).join(" ");
const o1 = "\u09CB";          // ো এক code point
const o2 = "\u09C7\u09BE";    // ে + া
console.log("o:", o1 === o2, "| NFC(o2) =", hex(o2.normalize("NFC")));
const y1 = "\u09DF";          // য় এক code point
console.log("y: NFC(U+09DF) =", hex(y1.normalize("NFC")));

Wandbox-এর আসল আউটপুট: প্রথম লাইন icu 75.1 | unicode 15.1. তারপর ক্ষমা | length 5 | code points 5 | graphemes 2 | ক্ষ + মা: পাঁচটি টুকরো, চোখে দুটি অক্ষর। স্ত্রী-তে ৬টি code point মিলে একটিই grapheme. বাংলা-য় ং আগের অক্ষরের সাথে জুড়ে বাং + লা. পতাকা 🇧🇩-এর length 4, code point 2, grapheme 1.

slice(0,2): "ক্": নাম কাটতে .slice দিলে হসন্ত-ঝোলা ভাঙা অক্ষর আসে; প্রথম grapheme ঠিকভাবে "ক্ষ". NFC-র লাইন দুটি: o: false | NFC(o2) = 9CB, অর্থাৎ ে আর া আলাদা লিখলে ো-এর সমান নয়, NFC সেগুলো জুড়ে এক code point করে। আর y: NFC(U+09DF) = 9AF 9BC: য়-এর এক-code-point রূপ NFC-তে উল্টো ভেঙে য + নুক্তা হয়। আমার Node 24 (ICU 78.2, Unicode 17.0) আর Edge 156-এও grapheme-এর ফল হুবহু এক।

Unicode 15.1 grapheme-এর নিয়মে বাংলা-সহ কয়েকটি ভারতীয় লিপির যুক্তাক্ষরকে এক grapheme ধরার নিয়ম যোগ করেছে। তাই এর আগের ICU-তে (পুরোনো browser বা পুরোনো Node) “ক্ষ” ভেঙে দুই grapheme আসতে পারে। নিজের লক্ষ্য browser-এ test করুন, আর server-এ সীমা যাচাই করলে একই নিয়ম রাখুন। String-এর গভীর আলোচনা আছে string আর বাংলা Unicode পর্বে।

NFC আর UTF-8: জমা করার নিয়ম

একই দেখতে লেখা দুই রকম code point-এ আসে কেন? ভিন্ন কিবোর্ড, ভিন্ন ফোন, অন্য সাইট থেকে কপি। দেখতে এক, কিন্তু === মিথ্যা, তাই login-এর নাম, খোঁজ, unique constraint সব ভাঙে। সমাধান: ব্যবহারকারীর লেখা জমা করার আগে একবার .normalize('NFC'), server-এ, যাচাইয়ের সময়ই। Zod-এ এটি একটি transform; যাচাইয়ের নকশা পর্ব ২৭ (Request যাচাই)-এ।

জমার এনকোডিং UTF-8. বাংলার প্রতিটি code point UTF-8-এ ৩ byte. Node-এ মাপা: "বাজারঘর" ৭টি code point, ২১ byte; "মিনিকেট চাল ৫ কেজি" ১৮টি, ৪৮ byte (ফাঁকা ১ byte করে); 🇧🇩 ৮ byte. তাই MySQL-এ পুরোনো utf8 (যা আসলে ৩ byte পর্যন্ত) নয়, utf8mb4 দিন, নইলে emoji আটকে যাবে। PostgreSQL-এ database-এর encoding UTF8 রাখুন। PostgreSQL-এর varchar(20) গোনে অক্ষর (code point), byte নয়, আর grapheme-ও নয়। অর্থাৎ ২০ code point-এর সীমায় “ক্ষ”-এর মতো অক্ষর তিনটি জায়গা নেয়।

বাংলা নাম সাজানো আর খোঁজা

গ্রাহকের তালিকা নাম অনুযায়ী সাজাতে .sort() দিলে তা সাজায় code unit-এর সংখ্যা ধরে, ভাষা ধরে নয়। আর খোঁজে includes দিলে NFC-র সমস্যা ফিরে আসে। দেখুন:

// একই নাম, দুই কিবোর্ড: একটিতে "য়" এক code point, অন্যটিতে য + নুক্তা
const riya1 = "রি\u09DFা";  // U+09DF
const riya2 = "রি\u09AF\u09BCা";  // U+09AF U+09BC
console.log("same?", riya1 === riya2, "| NFC same?", riya1.normalize("NFC") === riya2.normalize("NFC"));

const names = ["রুবিনা", riya1, "রিফাত", riya2 + "দ", "অনিক", "Zara", "alif", "আনিকা"];
console.log("sort():  ", [...names].sort().join(", "));

const coll = new Intl.Collator("bn");
console.log("Collator:", [...names].sort(coll.compare).join(", "));
console.log("compare riya1 vs riya2:", coll.compare(riya1, riya2));

// খোঁজ: ডেটায় এক রূপ, ব্যবহারকারী টাইপ করলেন অন্য রূপ
const customers = [riya1 + " আক্তার", "রিফাত হাসান", "রুবিনা ইয়াসমিন"];
const query = riya2;
const naive = customers.filter((c) => c.includes(query));
const key = (s) => s.normalize("NFC").toLocaleLowerCase("bn");
const fixed = customers.filter((c) => key(c).includes(key(query)));
console.log("naive :", naive.length, "found");
console.log("NFC   :", fixed.length, "found ->", fixed.join(", "));

Edge-এ আসল আউটপুট: same? false | NFC same? true. sort(): দিয়েছে Zara, alif, অনিক, আনিকা, রিফাত, রিয়াদ, রিয়া, রুবিনা: বড় হাতের Z ছোট a-র আগে, ইংরেজি বাংলার আগে, আর ভুল ক্রম রিয়াদ, রিয়া, কারণ একটিতে য় এক code point (U+09DF), যার নম্বর বড়। Collator: দিয়েছে অনিক, আনিকা, রিফাত, রিয়া, রিয়াদ, রুবিনা, alif, Zara: বাংলা বর্ণমালার ক্রম, দুই রূপের য় একই, আর compare riya1 vs riya2: 0. শেষে naive : 0 found, কিন্তু NFC-র পরে NFC : 1 found -> রিয়া আক্তার. Node 24-এও একই ফল এসেছে।

Intl.Collator একবার বানিয়ে বারবার compare ব্যবহার করুন; প্রতিবার localeCompare ডাকলে বড় তালিকায় ধীর হতে পারে। Database-এ ORDER BY name-এর ফল নির্ভর করে column-এর collation-এর ওপর; PostgreSQL-এ ICU collation বেছে নেওয়া যায়। হাজার হাজার পণ্যে খোঁজ আর page ভাগ করার নকশা আছে পর্ব ৩৪ (Pagination, filter আর sort)-এ, আর বাংলা full-text search-এর সীমা DBMS কোর্সের full-text পর্বে। মূল কথা: খোঁজার চাবি আর জমার লেখা, দুটিই একই নিয়মে NFC করুন।

আরও গভীরে: Grapheme-এর নিয়ম Unicode-এর UAX #29-এ; বাংলার যুক্তাক্ষরের জন্য Unicode 15.1 (২০২৩) যে নিয়ম আনে, তা হসন্ত আর ব্যঞ্জনের একটি বিশেষ শ্রেণি দেখে কাজ করে। Normalization-এর নিয়ম UAX #15-এ। U+09DC, U+09DD, U+09DF (ড়, ঢ়, য়) “composition exclusion” তালিকায়, তাই NFC এগুলোকে জোড়া না দিয়ে ভেঙে রাখে; ল্যাবের 9AF 9BC সেটিই দেখাল। Collation-এর মূল নিয়ম Unicode Collation Algorithm (UTS #10), আর ভাষাভিত্তিক বদল CLDR-এ।

৭ · ব্যবহারকারীর ইনপুট — বাংলা অঙ্কের ফোন নম্বর

Checkout-এর form-এ একজন ক্রেতা বাংলা কিবোর্ডে লিখলেন “০১৭০০-০০০০০০”। আরেকজন লিখলেন “+৮৮০ ১৭০০ ০০০০০০”। আপনার যাচাই /^01[3-9]\d{8}$/ দুটিকেই বাতিল করবে, কারণ \d ডিফল্টে শুধু 0-9 ধরে। ক্রেতার কোনো দোষ নেই; ভুল আমাদের। নিয়ম: আগে ঠিক রূপে আনা, তারপর যাচাই। নমুনা নম্বরগুলো ইচ্ছা করেই নকল।

// বাংলা (০-৯) আর আরবি-ভারতীয় (٠-٩) অঙ্ককে ASCII অঙ্কে আনা
function toAsciiDigits(s) {
  return s.replace(/[\u09E6-\u09EF\u0660-\u0669]/g, (ch) => {
    const c = ch.charCodeAt(0);
    return String(c >= 0x09E6 ? c - 0x09E6 : c - 0x0660);
  });
}

function normalizePhone(raw) {
  let s = toAsciiDigits(raw.normalize('NFKC')); // পূর্ণ-প্রস্থ অঙ্কও ASCII হয়
  s = s.replace(/[\s\-().]/g, '');               // ফাঁকা, ড্যাশ, বন্ধনী বাদ
  if (s.startsWith('+880')) s = '0' + s.slice(4);
  else if (s.startsWith('880')) s = '0' + s.slice(3);
  return s;
}

const valid = (s) => /^01[3-9]\d{8}$/.test(s);

const inputs = [
  '০১৭০০-০০০০০০',
  '+৮৮০ ১৭০০ ০০০০০০',
  '8801700000000',
  '01700 000000',
  '০১২০০০০০০০০',
  '০১৭০০০০০০',
];
for (const raw of inputs) {
  const n = normalizePhone(raw);
  console.log(JSON.stringify(raw), '->', n, valid(n) ? 'OK' : 'INVALID');
}

Edge-এ আসল আউটপুট: প্রথম চারটি -> 01700000000 OK. বাংলা অঙ্ক, +৮৮০, পূর্ণ-প্রস্থ 88০ আর ফাঁকা, সব একই নম্বরে এসেছে। পূর্ণ-প্রস্থ অঙ্ক ঠিক করেছে NFKC; বাংলা অঙ্ক NFKC-তে বদলায় না, তাই আলাদা ফাংশন। শেষ দুটি INVALID: 01200000000-এর তৃতীয় অঙ্ক 2, যা [3-9]-এ পড়ে না, আর 017000000-এ অঙ্ক কম। আমার Node 24-এও ফল একই।

1// raw: ফর্ম থেকে আসা লেখা2let s = toAsciiDigits(raw.normalize('NFKC'));3s = s.replace(/[\s\-().]/g, '');4if (s.startsWith('+880')) s = '0' + s.slice(4);5else if (s.startsWith('880')) s = '0' + s.slice(3);6valid(s)  // /^01[3-9]\d{8}$/
মানগুলো (Watch)
s
দৈর্ঘ্য
valid
  1. যা এসেছে, তাই। ১৬ অক্ষরের লেখা, বাংলা অঙ্ক, ফাঁকা আর ড্যাশসহ। এই অবস্থায় regex চালালে false; অনেক সাইট এখানেই ক্রেতাকে “ভুল নম্বর” বলে ফিরিয়ে দেয়।
  2. অঙ্ক ASCII-তে। NFKC পূর্ণ-প্রস্থ চিহ্ন সরায়, তারপর ০-৯ থেকে 0-9. লেখা এখন +880 1700-000000, দৈর্ঘ্য একই ১৬।
  3. ফাঁকা আর ড্যাশ বাদ। ১৪ অক্ষরের +8801700000000. এখনো যাচাই ব্যর্থ, কারণ শুরুতে +880.
  4. দেশের কোড থেকে স্থানীয় রূপ। +880 সরিয়ে সামনে 0: 01700000000, ১১ অঙ্ক।
  5. এবার যাচাই। 01, তারপর 7 (৩ থেকে ৯-এর মধ্যে), তারপর আরও ৮ অঙ্ক: true. জমা হয় একটি নির্দিষ্ট রূপে। SMS বা আন্তর্জাতিক সেবায় পাঠাতে E.164 রূপ +8801700000000 কাজে লাগে।

কয়েকটি অভ্যাস। (১) Normalize আর যাচাই server-এ করবেন; browser-এর যাচাই শুধু সুবিধা, নিরাপত্তা নয় (দুই দিকের যাচাই পর্ব ২১, বড় form-এ)। (২) ইনপুটে inputmode="tel" আর autocomplete="tel" দিলে ফোনে সংখ্যার কিবোর্ড আসে। (৩) ক্রেতাকে ফেরত দেখানোর সময় চাইলে আবার বাংলা অঙ্কে দেখান, কিন্তু জমা থাকবে একটি রূপে। (৪) এই regex শুধু বাংলাদেশের মোবাইলের সাধারণ গঠন দেখে; নম্বরটি সত্যিই কারও কি না, তা জানতে OTP লাগে। মোবাইলের prefix-এর তালিকা সময়ের সাথে বদলাতে পারে, তাই নিয়মটি এক জায়গায় রাখুন। Regex-এর ভিত্তি JavaScript-এর regex পর্বে।

৮ · বাংলা font, আকার আর লাইনের ফাঁকা

System-এ বাংলা font না থাকলে ক্রেতা দেখবেন ভাঙা যুক্তাক্ষর, বা প্রতিটি যন্ত্রে ভিন্ন চেহারা। তাই ওয়েব font. next/font build-এর সময় Google Fonts থেকে ফাইল নামিয়ে আপনার সাইট থেকেই দেয়, অর্থাৎ browser-কে বাইরের font সার্ভারে যেতে হয় না। Next.js-এর font তালিকায় (font-data.json, Next.js 16.4.4) মিলিয়ে দেখেছি: Hind Siliguri (weight 300-700), Noto Sans Bengali (variable, 100-900), Noto Serif Bengali, Anek Bangla, Baloo Da 2, Tiro Bangla, সবগুলোতে bengali subset আছে।

import { Hind_Siliguri } from 'next/font/google';

const hind = Hind_Siliguri({
  subsets: ['bengali', 'latin'],
  weight: ['400', '600'],
  display: 'swap',
  variable: '--font-bn',
});

export default async function RootLayout({ children, params }: LayoutProps<'/[lang]'>) {
  const { lang } = await params;
  if (!isLocale(lang)) notFound();
  return (
    <html lang={lang === 'bn' ? 'bn-BD' : 'en'} className={hind.variable}>
      <body>{children}</body>
    </html>
  );
}

subsets হলো font subsetting: পুরো font নয়, ভাগ ভাগ ফাইল। Build-এর পরে .next/static/media-এ আসল ফাইল আর মাপ (byte):

$ ls -l .next/static/media
 7992  386dfd80ff50a4e5-s.p.0mbba7e7fye4a.woff2   latin      400  (preload)
40224  e131d9ecb0f018bc-s.p.0nu0d1-zq0f7n.woff2   bengali    400  (preload)
 8072  ebb9bd01525f6aff-s.p.2grpegx_myr9g.woff2   latin      600  (preload)
39644  fe077642404ed562-s.p.3eswb6t44uyhi.woff2   bengali    600  (preload)
 4236  54bc799868ede2bf-s.1hdl3ith7ac_n.woff2     latin-ext  600
 4156  c6cf74c16165ba32-s.316uk-vshaotl.woff2     latin-ext  400

# তৈরি CSS-এর একটি নিয়ম (ছোট করা)
@font-face{font-family:Hind Siliguri;font-weight:400;font-display:swap;
  src:url(../media/e131d9ecb0f018bc-s.p.0nu0d1-zq0f7n.woff2)format("woff2");
  unicode-range:U+951-952,U+964-965,U+980-9FE,U+200C-200D,U+20B9,U+25CC,…}
@font-face{font-family:Hind Siliguri Fallback;src:local(Arial);
  ascent-override:116.79%;descent-override:52.43%;size-adjust:95.56%}

পড়ার কৌশল: নামে .p. মানে preload হবে; HTML-এর <head>-এ ঠিক চারটি <link rel="preload" as="font"> এসেছে। চাওয়া হয়নি এমন latin-ext-ও তৈরি হয়েছে, কিন্তু preload হয়নি; unicode-range অনুযায়ী সেই অক্ষর পাতায় থাকলেই নামবে। বাংলা অংশ U+980-9FE, যার ভেতরে ৳ (U+09F3)-ও আছে। সব WOFF2.

হিসাবটি করে ফেলি। চারটি preload: 7,992 + 40,224 + 8,072 + 39,644 = 95,932 byte ≈ ৯৩.৭ KiB. একই ল্যাবে Noto_Sans_Bengali (weight না দিয়ে, অর্থাৎ variable font) build করলে preload দুটি: bengali 107,884 + latin 25,652 = 133,536 byte ≈ ১৩০.৪ KiB, তবে তাতে ১০০ থেকে ৯০০ সব weight. ধরা যাক ধীর মোবাইল সংযোগ ১.৬ Mbps (একটি অনুমান, মাপা নয়): 95,932 × 8 ÷ 1,600,000 ≈ ০.৪৮ সেকেন্ড, আর Noto-র জন্য ≈ ০.৬৭ সেকেন্ড। দুটি weight-এই কাজ চললে ছোট পথ সস্তা; অনেক weight লাগলে variable font. এগুলো এক ল্যাবের এক run-এর মাপ; font-এর নতুন version-এ বদলাতে পারে।

দুটি সূক্ষ্ম দিক। (১) ইংরেজি পাতাতেও এই চারটি ফাইল preload হয়েছে, কারণ layout একটিই। ইংরেজি পাতায় বাংলা শুধু “বাংলা” link-এ; চাইলে ভাষা অনুযায়ী আলাদা font বা preload: false বিবেচনা করুন, তবে মেপে সিদ্ধান্ত নিন। (২) Fallback-এর মাপ মেলানো হয়েছে Arial ধরে, আর Arial-এ বাংলা অক্ষর নেই। তাই font আসার আগে বাংলা লেখা যন্ত্রের অন্য কোনো বাংলা font-এ দেখা যায়, আর বদলের সময় লেখা একটু সরে যেতে পারে (CLS). Lighthouse বা DevTools-এ নিজের পাতায় মেপে দেখুন।

এবার চেহারা। বাংলা অক্ষরে ওপরে মাত্রা আর রেফ, নিচে উ-কার, ঊ-কার, ঋ-কার, আর যুক্তাক্ষর লম্বা। একই font-size-এ বাংলা লেখা ইংরেজির চেয়ে ছোট দেখায়, আর লাইন কাছাকাছি হলে ওপরের লাইনের উ-কার নিচের লাইনের মাত্রায় ছুঁয়ে যায়। ল্যাবের CSS ভাষা ধরে আলাদা নিয়ম দেয়:

html { font-family: var(--font-bn), system-ui, sans-serif; }
html:lang(bn-BD) body { font-size: 17px; line-height: 1.75; }
html:lang(en) body { font-size: 16px; line-height: 1.5; }

সংখ্যাগুলো শুরু করার একটি ধারণা, নিয়ম নয়: বাংলায় সাধারণত ইংরেজির চেয়ে একটু বড় আকার আর ১.৬ থেকে ১.৮ line-height আরামদায়ক লাগে। নিজের font আর পাঠকের ফোনে দেখে ঠিক করুন। এখানে :lang() selector কাজ করছে, কারণ <html lang> ঠিক বসানো। Typography-র পূর্ণ আলোচনা টাইপোগ্রাফি আর ওয়েব ফন্ট পর্বে, আর Tailwind-এ font token পর্ব ২২ (Styling)-এ।

৯ · Hydration, lang, hreflang আর পূর্ণ বাজারঘর

“শেষ হালনাগাদ” দেখাতে একটি Client Component লেখা হলো, ইচ্ছা করেই ভুল করে:

'use client';

// ভুল ইচ্ছাকৃত: locale আর time zone কোনোটিই দেওয়া হয়নি
export function LocalTime({ iso }: { iso: string }) {
  return <p id="updated">Updated: {new Date(iso).toLocaleString()}</p>;
}

Build-এর সময় server (Node, ডিফল্ট locale en-US) লিখল 11/2/2026, 9:05:00 PM. তারপর Playwright দিয়ে Edge খোলা হলো তিনভাবে। সত্যিকারের ফল, next dev-এ (লম্বা অংশ ছোট করা):

== Edge locale en-US, Asia/Dhaka
  text: Updated: 11/2/2026, 9:05:00 PM            (কোনো error নেই)

== Edge locale bn-BD, Asia/Dhaka
  text: Updated: ২/১১/২০২৬, ৯:০৫:০০ PM
  pageerror: Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
  …
  - Date formatting in a user's locale which doesn't match the server.
  …
                      <LocalTime iso="2026-11-02...">
                        <p id="updated">
+                           ২/১১/২০২৬, ৯:০৫:০০ PM
-                           11/2/2026, 9:05:00 PM

== Edge locale bn-BD, Europe/London
  text: Updated: ২/১১/২০২৬, ৩:০৫:০০ PM
  pageerror: Hydration failed because …

Production build (next start)-এ বার্তা ছোট: Minified React error #418. দুই ক্ষেত্রেই React ওই অংশ client-এ নতুন করে বানায়, তাই শেষে ক্রেতা নিজের locale-এর লেখা দেখেন, কিন্তু server-এর কাজ নষ্ট হয়, আর লেখা লাফ দেয়। এটিই hydration mismatch; hydration-এর পুরো ব্যাখ্যা পর্ব ৫৬ (Hydration — server থেকে client-এ)-এ। লন্ডনের সারিটি দেখায়, শুধু ভাষা নয়, time zone বদলালেও সময় বদলায় (৯:০৫ থেকে ৩:০৫)।

সমাধান: locale আর zone দুটিই স্থির করে দেওয়া। Server-এই format করে string পাঠানো সবচেয়ে নিরাপদ; Client Component-এ করতে হলে দুটিই হাতে দিন:

'use client';

// সারানো: locale আর time zone দুটিই স্থির, তাই server আর browser একই লেখা বানায়
export function LocalTime({ iso, tag }: { iso: string; tag: string }) {
  const text = new Intl.DateTimeFormat(tag, {
    dateStyle: 'medium', timeStyle: 'short', timeZone: 'Asia/Dhaka',
  }).format(new Date(iso));
  return <p id="updated">Updated: {text}</p>;
}

আবার তিনটি Edge, আসল ফল: তিনটিতেই Updated: ২ নভে, ২০২৬, ৯:০৫ PM, কোনো error নেই। তবু ঝুঁকি একটু থাকে: server-এর ICU আর browser-এর ICU ভিন্ন হলে (৪ নম্বর অংশের “ঘন্টা” বনাম “ঘণ্টা”) একই option-এও লেখা আলাদা হতে পারে। তাই যত পারেন, format server-এ করুন। ক্রেতার নিজের ঘড়ির সময় দেখাতেই হলে প্রথম render-এ server-এর লেখা রেখে useEffect-এর পরে বদলান, অথবা ওই ছোট অংশে suppressHydrationWarning দিন, জেনে-বুঝে।

lang আর hreflang

<html lang="bn-BD"> ছোট জিনিস, কাজ বড়: screen reader সঠিক উচ্চারণ বেছে নেয়, browser সঠিক font আর hyphen-এর নিয়ম দেখে, আর CSS-এর :lang() চলে। ভাষা বদলের link-এ lang="en" আর hrefLang="en" দিলে “English” শব্দটিও ঠিক উচ্চারণে পড়া হয়। Search engine-কে প্রতিটি ভাষার রূপ জানাতে hreflang; ল্যাবে generateMetadata-এর alternates থেকে build-এ এসেছে:

<html lang="bn-BD" class="edMohG_variable">
<title>বাজারঘর</title>
<link rel="canonical" href="https://bazarghar.example/bn"/>
<link rel="alternate" hrefLang="bn-BD" href="https://bazarghar.example/bn"/>
<link rel="alternate" hrefLang="en" href="https://bazarghar.example/en"/>
<link rel="alternate" hrefLang="x-default" href="https://bazarghar.example/bn"/>

প্রথম build-এ metadataBase ছিল না, তখন ঠিকানাগুলো এসেছিল আপেক্ষিক (/bn). পূর্ণ ঠিকানার জন্য metadataBase: new URL('https://bazarghar.example') যোগ করা হয়েছে। x-default মানে “ভাষা না মিললে এটি”। Metadata, sitemap আর structured data-র পুরো কাজ পর্ব ৫৮ (SEO)-এ।

পূর্ণ বাজারঘর: দুই ভাষার header আর পণ্যের কার্ড

এবার সব জোড়া লাগাই। Page একটি Server Component; সব format server-এ:

const product = {
  name: { bn: 'মিনিকেট চাল ৫ কেজি', en: 'Miniket rice 5 kg' },
  pricePaisa: 42500, oldPaisa: 50000, stock: 1250, reviews: 1, delivery: '2026-11-05T04:00:00Z',
};

export default async function Home({ params }: PageProps<'/[lang]'>) {
  const { lang } = await params;
  if (!isLocale(lang)) notFound();
  const t = await getDictionary(lang);
  const other = lang === 'bn' ? 'en' : 'bn';
  const off = 1 - product.pricePaisa / product.oldPaisa;
  return (
    <>
      <header>
        <strong>{t.site.name}</strong>
        <nav><Link href={`/${lang}`}>{t.nav.home}</Link> · <Link href={`/${lang}/offers`}>{t.nav.offers}</Link></nav>
        <span>🛒 {plural(t.cart, 3, lang)}</span>
        <Link href={`/${other}`} hrefLang={other} lang={other}>{t.nav.switch}</Link>
      </header>
      <article className="card">
        <h2>{product.name[lang]}</h2>
        <p className="price">{formatPrice(product.pricePaisa, lang)} <del>{formatPrice(product.oldPaisa, lang)}</del></p>
        <p>{fill(t.product.off, { pct: formatPercent(off, lang) })}</p>
        <p>{fill(t.product.stock, { count: formatNumber(product.stock, lang) })} · {plural(t.product.reviews, product.reviews, lang)}</p>
        <p>{fill(t.product.delivery, { date: formatDate(product.delivery, lang) })}</p>
        <button>{t.product.add}</button>
      </article>
    </>
  );
}

Build-এ বানানো দুই পাতার আসল HTML (<!-- --> চিহ্ন বাদ, লাইন ভাঙা আমার):

<header>
<strong>বাজারঘর</strong>
<nav><a href="/bn">হোম</a> · <a href="/bn/offers">অফার</a></nav>
<span>🛒 ৩টি পণ্য</span>
<a hrefLang="en" lang="en" href="/en">English</a>
</header>
<article class="card">
<h2>মিনিকেট চাল ৫ কেজি</h2>
<p class="price">৪২৫৳ <del>৫০০৳</del></p>
<p>১৫% ছাড়</p>
<p>স্টকে আছে ১,২৫০টি · ১টি রিভিউ</p>
<p>ডেলিভারি ৫ নভেম্বর, ২০২৬</p>
<button>ব্যাগে নিন</button>
</article>

একই ডেটা থেকে দুই ভাষা। ১২৫০ হয়েছে ১,২৫০ আর 1,250; ছাড় 1 − 42500/50000 = 0.15 থেকে ১৫%; ডেলিভারির UTC সময় 04:00Z ঢাকায় সকাল ১০টা, তাই তারিখ ৫ নভেম্বরই। বাংলার one আর other একই রূপ, ইংরেজির “1 review” আর “3 items” আলাদা। পণ্যের নামের অনুবাদ আসছে database-এর দুটি column (বা আলাদা translation table) থেকে, dictionary থেকে নয়; dictionary শুধু সাইটের নিজের লেখার জন্য।

পরীক্ষা ও ইন্টারভিউতে:

  • i18n আর l10n-এর পার্থক্য? i18n কোডকে ভাষা-নিরপেক্ষ করে; l10n একটি নির্দিষ্ট locale-এর লেখা আর format যোগ করে।
  • Next.js 16-এ ভাষা অনুযায়ী redirect কোথায় হয়? proxy.ts-এ, যা আগে middleware.ts ছিল; পাতা থাকে app/[lang]-এ।
  • দাম float-এ রাখা যায় না কেন? Binary-তে দশমিক হুবহু হয় না, যেমন 19.99 * 100 = 1998.9999999999998; পয়সায় পূর্ণসংখ্যা রাখুন।
  • "ক্ষ".length কত, আর চোখের অক্ষর গুনবেন কীভাবে? 3; Intl.Segmenter দিয়ে grapheme গুনে।
  • Hydration mismatch-এর একটি সাধারণ কারণ? Locale বা time zone না দিয়ে Client Component-এ তারিখ format করা।

১০ · AI-এর যুগে বাংলা i18n

AI-কে “Next.js-এ বাংলা আর ইংরেজি যোগ করো” বললে প্রায়ই পুরোনো Pages Router-এর i18n config, middleware.ts আর params.lang (await ছাড়া) পাওয়া যায়। কোড দেখতে ঠিক, কিন্তু Next.js 16-এ হয় চলে না, নয়তো সতর্কবার্তা দেয়। একটি মজার তথ্য: ল্যাবে next dev চালাতেই Next.js 16.4.4 নিজে project-এ একটি AGENTS.md লিখেছে, যার প্রথম লাইন “This is NOT the Next.js you know”, আর AI-কে node_modules/next/dist/docs/-এর docs পড়তে বলে। অর্থাৎ framework নিজেই জানে, AI-এর স্মৃতি পুরোনো।

AI-এর ভুলকীভাবে ধরবেন
App Router-এ next.config-এর i18n optionNext.js-এর internationalization guide পড়ুন; App Router-এ [lang]
middleware.ts আর params.lang সরাসরিnext build-এর বার্তা দেখুন; proxy.ts আর await params
লাখ-কোটি বা বাংলা অঙ্কের নিজের ফাংশনIntl.NumberFormat('bn-BD')-এর সাথে কয়েকটি সংখ্যায় তুলনা
toLocaleString() locale আর zone ছাড়াEdge-এ bn-BD আর অন্য zone দিয়ে খুলে console দেখুন
নামের সীমায় .length বা .slice“ক্ষমা”, “স্ত্রী” দিয়ে test
ফোনে /^\d{11}$/ কাঁচা ইনপুটেবাংলা অঙ্ক আর +৮৮০ দিয়ে test
নেই এমন package বা optionnpm view <pkg> version আর docs-এ নাম মেলান
পুরো dictionary Client Component-এ importBuild-এর পরে client chunk-এ অনুবাদের একটি বাক্য grep করুন

AI-কে কাজ দিন সীমা আর version বলে, আর ফলাফলের প্রমাণ নিজে নিন। আসল ক্রেতার নাম বা ফোন নম্বর prompt-এ দেবেন না; নকল নমুনা দিন।

আমার প্রকল্প Next.js 16.4 (App Router, proxy.ts), React 19, TypeScript. বাংলা (bn-BD, ডিফল্ট) আর ইংরেজি (en) লাগবে। শর্ত: ১. app/[lang] কাঠামো দাও, next.config-এর i18n option ব্যবহার করবে না। params আর cookies() await করবে। ২. ভাষা বাছাইয়ের ক্রম: URL, তারপর NEXT_LOCALE cookie, তারপর Accept-Language. proxy.ts-এর matcher কোন path বাদ দেয়, এক লাইনে বলো। ৩. টাকা, সংখ্যা, তারিখ শুধু Intl দিয়ে, timeZone 'Asia/Dhaka' সবখানে লিখবে। দাম পয়সায় পূর্ণসংখ্যা। ৪. কোনো library প্রস্তাব করলে npm-এ তার নাম, সর্বশেষ version আর কেন দরকার লেখো; আমি নিজে মিলিয়ে নেব। ৫. প্রতিটি অংশের জন্য একটি test দাও: "ক্ষমা"-র grapheme 2, "০১৭০০-০০০০০০" যাচাইয়ে পাস, 123456750 পয়সা থেকে "১২,৩৪,৫৬৭.৫০৳". নিজের কোড চালিয়েছ বলবে না, কারণ তুমি চালাওনি। নিশ্চিত না হলে "অনিশ্চিত" লেখো।

AI-এর লেখা i18n কোড মেলানোর চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১একই দেখতে বাংলা লেখা কেন দুই রকম code point-এ আসে? এর ইতিহাস কী, আর একটি অ্যাপ কী নিয়ম মানবে?

কম্পিউটারে বাংলার প্রথম বড় যুগ ছিল Unicode-এর বাইরে। ১৯৮০-র দশকের শেষে মোস্তফা জব্বারের বিজয় কিবোর্ড আর তার font ছড়িয়ে পড়ে, যেখানে ইংরেজি অক্ষরের জায়গায় বাংলা আকৃতি বসানো থাকত। পর্দায় বাংলা, কিন্তু ফাইলের ভেতরে ইংরেজি অক্ষরের সংকেত। সেই font না থাকলে লেখা হয়ে যেত অর্থহীন ইংরেজি। খোঁজা, সাজানো, ওয়েবে দেওয়া, প্রায় অসম্ভব ছিল।

Unicode-এ বাংলার নিজস্ব block (U+0980–U+09FF) শুরু থেকেই ছিল, কিন্তু ব্যাপক ব্যবহার আসে ২০০০-এর দশকে, অভ্র-র মতো Unicode-ভিত্তিক কিবোর্ড আর অপারেটিং সিস্টেমে বাংলা font আসার পরে। Unicode-এর নকশায় যুক্তাক্ষর আলাদা চিহ্ন নয়; ক + ্ + ষ লিখলে font সেটিকে ক্ষ আঁকে। আর কিছু অক্ষরের দুই রকম লেখার পথ রাখা হয়েছিল: ড়, ঢ়, য়-এর এক-code-point রূপ, আবার মূল অক্ষর + নুক্তা। ো-কারও এক চিহ্নে বা ে + া দুই চিহ্নে লেখা যায়।

দুই রূপকে “সমান” ধরার নিয়মই normalization. NFC সাধারণত জোড়া দেয়, যেমন ে + া থেকে ো। কিন্তু ড়, ঢ়, য় “composition exclusion” তালিকায়, যাতে normalization-এর ফল পরের Unicode version-এ বদলে না যায় (Unicode-এর স্থিতিশীলতার নীতি)। তাই NFC এগুলোকে ভেঙে রাখে, যা আমাদের ল্যাবে 9AF 9BC দেখাল। অর্থাৎ “NFC মানে সব জোড়া” ধারণাটিও ভুল।

অ্যাপের নিয়ম তাই সরল: সীমানায় একবার। ব্যবহারকারীর লেখা server-এ ঢোকার মুহূর্তে NFC, জমার আগে। খোঁজার শব্দও একই NFC. Database-এ unique নাম বা email থাকলে সেই column-এ NFC-করা মান। আর পুরোনো ডেটা থাকলে একবারের migration-এ সব সারি NFC করে নিন, নইলে পুরোনো আর নতুন সারি মিলবে না। Display-এ কিছু বদলানোর দরকার নেই, কারণ font দুই রূপই একইভাবে আঁকে।

মূল উপলব্ধি: চোখে এক হলেই কম্পিউটারে এক নয়। লেখা ঢোকার দরজায় একটি নির্দিষ্ট রূপ ঠিক করে দিলে তুলনা, খোঁজ আর unique নিয়ম নির্ভরযোগ্য হয়।

প্র ০২অনুবাদের বার্তায় "{count} items"-এর মতো জোড়াতালি কেন যথেষ্ট নয়? ICU MessageFormat আর CLDR কোন সমস্যা সমাধান করে?

শুরুর দিকের সফটওয়্যারে অনুবাদ ছিল লেখা জোড়া দেওয়া: count + " item" + (count != 1 ? "s" : ""). ইংরেজিতে চলে। কিন্তু প্রতিটি ভাষায় সংখ্যার সাথে শব্দের রূপ বদলের নিয়ম আলাদা। রুশ, পোলিশে তিন-চার রকম রূপ, আরবিতে ছয়টি শ্রেণি, জাপানিতে একটিই। এই ভিন্নতা কোডের if-এ লেখা হলে প্রতিটি নতুন ভাষার জন্য কোড বদলাতে হয়, যা i18n-এর মূল লক্ষ্যের উল্টো।

১৯৯০-এর দশকে GNU gettext ngettext দিয়ে এক ধাপ এগোয়: অনুবাদ ফাইলেই থাকে plural-এর সূত্র। পরে IBM-এর ICU library-তে MessageFormat আসে, যেখানে বার্তার ভেতরেই {count, plural, one {…} other {…}}, লিঙ্গ বা অন্য বিকল্পের জন্য select, আর ক্রমবাচকের জন্য selectordinal. কোন সংখ্যা কোন শ্রেণিতে, সেই তথ্য আসে Unicode Consortium-এর CLDR থেকে, যা ২০০০-এর দশকের শুরু থেকে বহু ভাষার নিয়ম জমা করছে। JavaScript-এর Intl.PluralRules সেই CLDR-ই পড়ে।

বাংলার উদাহরণ ল্যাবেই দেখেছেন। সংখ্যাবাচকে শ্রেণি দুটি, আর ০ পড়ে one-এ, ইংরেজির উল্টো। ক্রমবাচকে শ্রেণি পাঁচটি: ১ম, ২য়, ৪র্থ, ৬ষ্ঠ, আর বাকি -তম। কোনো ডেভেলপার হাতে লিখলে এটি ভুল হওয়ার সম্ভাবনা বেশি; CLDR-এ এটি একবার সঠিকভাবে লেখা, সবাই ব্যবহার করে।

এখনো কাজ চলছে। ICU MessageFormat-এর কিছু সীমা, যেমন জটিল নেস্টিং আর অনুবাদকের জন্য কঠিন গঠন, মেটাতে Unicode MessageFormat 2.0 নামে নতুন মান তৈরি হয়েছে, যা সাম্প্রতিক CLDR-এ স্থিতিশীল বলে ঘোষিত; JavaScript-এ Intl.MessageFormat আনার প্রস্তাবও আলোচনায় আছে। আপনার কাজের জন্য শিক্ষা হলো, বার্তাকে পুরো বাক্য হিসেবে অনুবাদকের হাতে দিন, টুকরো জোড়া নয়, কারণ ভাষাভেদে শব্দের ক্রমও বদলায়।

মূল উপলব্ধি: ভাষার নিয়ম কোডে নয়, ডেটায় থাকা উচিত। CLDR সেই ডেটা, ICU MessageFormat সেই ডেটা ব্যবহারের ভাষা; দুটির ওপর দাঁড়ালে নতুন ভাষা যোগ মানে নতুন ফাইল, নতুন কোড নয়।

প্র ০৩বাংলা সাইটে কি সব সংখ্যা বাংলা অঙ্কে দেখানো উচিত? দাম, ফোন, OTP, order নম্বর, কোনটি কোন অঙ্কে?

বাংলা অঙ্ক (০-৯) Unicode-এ U+09E6 থেকে U+09EF, আর টাকার চিহ্ন ৳ হলো U+09F3, যার নাম Unicode-এ “Bengali rupee sign”, কারণ ঐতিহাসিকভাবে পুরো অঞ্চলে ব্যবহৃত। মুদ্রার আন্তর্জাতিক কোড BDT (ISO 4217). CLDR-এর bn locale ডিফল্টে beng numbering system দেয়, তাই Intl নিজে থেকেই বাংলা অঙ্ক লেখে।

পাঠকের দিক থেকে বাংলা পাতায় দাম, তারিখ, পরিমাণ বাংলা অঙ্কে স্বাভাবিক লাগে, যেমন সংবাদপত্রে। কিন্তু কিছু সংখ্যা আসলে “পরিচয়”, পরিমাণ নয়: order নম্বর, ফোন নম্বর, OTP, tracking code. ক্রেতা এগুলো কপি করবেন, অন্য অ্যাপে বসাবেন, কল সেন্টারে পড়ে শোনাবেন। অনেক ফোনে OTP স্বয়ংক্রিয়ভাবে বসানোর সুবিধা ASCII অঙ্ক আশা করে; কল সেন্টারের সিস্টেমে বাংলা অঙ্ক না-ও মিলতে পারে।

তাই একটি ব্যবহারিক নিয়ম: পরিমাণ (দাম, স্টক, তারিখ) locale অনুযায়ী; পরিচয় (order id, OTP) ASCII-তে, অথবা দুই রূপই গ্রহণযোগ্য রেখে। আর ইনপুটে সব সময় দুই রূপই মেনে নিন, যেমন ফোন নম্বরের ল্যাবে করলেন। Intl-এ একটি ট্যাগেই এটি সম্ভব: bn-BD-u-nu-latn দিলে বাংলা নিয়ম, ইংরেজি অঙ্ক, আর কমা তবুও লাখের নিয়মে (12,34,56,789.5, ল্যাবের run).

Accessibility-ও ভাবুন। Screen reader বাংলা অঙ্ক বাংলায় পড়ে কি না, তা নির্ভর করে যন্ত্র আর ভাষার সেটিংয়ের ওপর। lang ঠিক থাকলে সম্ভাবনা বাড়ে। আর search engine-এর জন্য দাম structured data-তে মেশিনের পড়ার মতো সংখ্যায় (425.00, BDT) দিন, পর্দায় যা-ই থাকুক।

মূল উপলব্ধি: সংখ্যার ভাষা ঠিক হয় তার কাজ দেখে। মানুষের পড়ার পরিমাণ locale-এ, মেশিন বা অন্য সিস্টেমে যাওয়া পরিচয় ASCII-তে, আর ইনপুটে সব রূপ গ্রহণ।

প্র ০৪ভাষা বাছাইয়ে IP দেখে দেশ ধরে নেওয়া কেন দুর্বল? Accept-Language, cookie আর URL-এর তুলনা করুন।

সবচেয়ে লোভনীয় পথ মনে হয় IP: বাংলাদেশ থেকে এলে বাংলা। কিন্তু দেশ আর ভাষা এক জিনিস নয়। বিপুল সংখ্যক বাংলাদেশি বিদেশে থাকেন, আর দেশেও অনেকে ইংরেজিতে কাজ করেন। VPN, অফিসের proxy, মোবাইল অপারেটরের ঠিকানা বিন্যাস IP-র দেশকে ভুল দেখাতে পারে। আর search engine-এর crawler সাধারণত একটি নির্দিষ্ট দেশ থেকে আসে; IP দেখে redirect করলে সে হয়তো কখনো বাংলা রূপই দেখবে না। Google-এর নির্দেশনাও ভাষার স্বয়ংক্রিয় redirect-এর চেয়ে hreflang আর ব্যবহারকারীর বাছাইয়ের ওপর জোর দেয়।

Accept-Language HTTP-র পুরোনো header; এর বর্তমান সংজ্ঞা HTTP Semantics, RFC 9110 (২০২২)-এ, আর ভাষার ট্যাগের মান BCP 47 (RFC 5646, ২০০৯)। এটি ব্যবহারকারীর নিজের যন্ত্রের সেটিং, তাই IP-র চেয়ে ভালো সংকেত। তবু এটি একটি অনুমান: অনেকে ফোন কেনার পরে ভাষা কখনো বদলান না। তাই একে প্রথম অনুমান হিসেবে নিন, চূড়ান্ত নয়।

Cookie হলো ব্যবহারকারীর সচেতন সিদ্ধান্তের স্মৃতি। তিনি একবার “বাংলা” চাপলে সেটি browser-এর সেটিংয়ের চেয়ে বেশি অর্থবহ। ল্যাবে তাই cookie header-এর আগে। কিন্তু cookie যন্ত্রভিত্তিক, আর তা মুছে যেতে পারে। Login করা ক্রেতার পছন্দ তাই profile-এ রাখাও ভালো।

শেষ কথা বলে URL. /bn/offers link যেই খুলুক, বাংলাই দেখবেন; শেয়ার, bookmark, search engine, সব নির্ভরযোগ্য। এ কারণেই URL-এ ভাষা থাকলে অন্য কোনো সংকেত তাকে উল্টাতে পারে না, আর cookie শুধু ভাষাহীন URL (/)-এর জন্য। ভাষা বদলের link সব পাতায় চোখে পড়ার মতো রাখুন, আর ভাষার নাম সেই ভাষাতেই লিখুন (“English”, “বাংলা”), পতাকা দিয়ে নয়, কারণ পতাকা দেশ বোঝায়, ভাষা নয়।

মূল উপলব্ধি: সংকেতের ক্রম হলো URL, তারপর ব্যবহারকারীর বাছাই, তারপর browser-এর অনুমান। IP দিয়ে বড়জোর মুদ্রা বা ডেলিভারি অঞ্চলের প্রস্তাব দেওয়া যায়, ভাষা চাপিয়ে দেওয়া নয়।

প্র ০৫বঙ্গাব্দ কেন Intl-এ নেই, আর দুই দেশের পহেলা বৈশাখ কেন কখনো আলাদা দিনে পড়ে? একটি অ্যাপ কী করবে?

বঙ্গাব্দের উৎস নিয়ে ইতিহাসবিদদের মধ্যে একাধিক মত আছে; প্রচলিত মত মুঘল আমলে খাজনা আদায়ের সুবিধায় সন চালুর কথা বলে। দীর্ঘদিন এটি চলেছে সূর্য সিদ্ধান্তের মতো জ্যোতির্বিদ্যার হিসাবের পঞ্জিকা ধরে, যেখানে মাসের দৈর্ঘ্য বছর বছর বদলায়। তাই ইংরেজি তারিখের সাথে এর মিল একটি সরল সূত্রে আসে না।

বাংলাদেশে ১৯৬০-এর দশকে বাংলা একাডেমির উদ্যোগে মুহম্মদ শহীদুল্লাহর নেতৃত্বে একটি কমিটি সংস্কারের প্রস্তাব দেয়: স্থির মাসের দৈর্ঘ্য আর গ্রেগরিয়ান অধিবর্ষের সাথে মিল। পরে সরকারিভাবে এটি গৃহীত হয়, আর ২০১৯ সালে আবার সংশোধন হয়, যাতে জাতীয় দিবসগুলো বাংলা তারিখেও প্রতিবছর একই দিনে পড়ে। ফলে বাংলাদেশে পহেলা বৈশাখ প্রতিবছর ১৪ এপ্রিল। পশ্চিমবঙ্গে প্রচলিত পঞ্জিকা অনুযায়ী এটি সাধারণত ১৪ বা ১৫ এপ্রিল। একই নাম, দুই নিয়ম। (সাল আর খুঁটিনাটি বিভিন্ন সূত্রে সামান্য আলাদা পাওয়া যায়; সরকারি সূত্রে মিলিয়ে নিন।)

CLDR আর ICU কোন calendar রাখবে, তা নির্ভর করে স্থিতিশীল, সর্বসম্মত নিয়ম আর কারও এগিয়ে এসে ডেটা দেওয়ার ওপর। দুই দেশে দুই নিয়ম আর ঐতিহ্যবাহী রূপের জ্যোতির্বিদ্যার হিসাব, দুটিই কাজটিকে কঠিন করে। ল্যাবের runtime-এ তাই bengali calendar চাইলে নিঃশব্দে gregory এসেছে। সবচেয়ে বিপজ্জনক ভুল হলো indian calendar-কে বঙ্গাব্দ ভেবে বসা; সেটি শকাব্দ, “২৪ চৈত্র ১৯৪৮”।

অ্যাপের করণীয়: কোন নিয়ম (বাংলাদেশের সংশোধিত) মানছেন, তা কোডের নামেই লিখুন, যেমন toBangladeshBongabdo(). প্রতিটি মাসের সীমা আর অধিবর্ষের জন্য পরিচিত তারিখ দিয়ে test লিখুন। Time zone Asia/Dhaka ধরে দিন বদলান, নইলে মাঝরাতের কাছে এক দিন ভুল হবে। আর ভবিষ্যতে runtime সমর্থন যোগ করলে resolvedOptions().calendar দেখে সেটিতে ফিরতে পারেন।

মূল উপলব্ধি: ক্যালেন্ডার শুধু গণিত নয়, সমাজের সিদ্ধান্ত। Library-তে না থাকলে নিজে লিখুন, কিন্তু কোন সিদ্ধান্ত মানছেন তা স্পষ্ট লিখে আর test দিয়ে বেঁধে।

অনুশীলন

হিসাব, কমান্ড, সমালোচনা আর আউটপুট অনুমান, চারটিই আছে।

  1. Font আর byte-এর হিসাব: (ক) Hind_Siliguri-এর চারটি preload ফাইল 7,992, 40,224, 8,072 আর 39,644 byte. মোট কত KiB? ১.৬ Mbps সংযোগে (অনুমান) কত সেকেন্ড? (খ) শুধু bengali subset রাখলে (দুই weight) কত byte বাঁচে? (গ) "মিনিকেট চাল ৫ কেজি" UTF-8-এ কত byte, যদি ১৫টি বাংলা code point আর ৩টি ফাঁকা থাকে?

    (ক) 7,992 + 40,224 + 8,072 + 39,644 = ৯৫,৯৩২ byte; ÷ 1024 ≈ ৯৩.৭ KiB. সময়: 95,932 × 8 ÷ 1,600,000 ≈ ০.৪৮ সেকেন্ড। (খ) Latin ফাইল দুটি বাদ: 7,992 + 8,072 = ১৬,০৬৪ byte বাঁচে, বাকি থাকে ৭৯,৮৬৮ byte. কিন্তু তখন ইংরেজি অক্ষর আর অঙ্ক fallback font-এ আসবে, তাই সাধারণত latin রাখাই ঠিক। (গ) 15 × 3 + 3 × 1 = ৪৮ byte; ল্যাবে Buffer.byteLength-ও ৪৮ দিয়েছে। JavaScript-এর .length সেখানে ১৮।

  2. Proxy যাচাই (কমান্ড): আপনার next start চলছে localhost:47959-এ। এমন তিনটি curl লিখুন, যা প্রমাণ করবে: (১) ইংরেজি browser /en-এ যায়, (২) NEXT_LOCALE=bn cookie থাকলে header-কে হারায়, (৩) /en-এ গেলে cookie en হয়। প্রতিটিতে কোন লাইন দেখবেন?

    (১) curl -si localhost:47959/ -H 'Accept-Language: en-US,en;q=0.9': দেখুন 307 আর location: /en. (২) একই কমান্ডে -H 'Cookie: NEXT_LOCALE=bn' যোগ: location: /bn. (৩) curl -si localhost:47959/en -H 'Cookie: NEXT_LOCALE=bn': 200 আর set-cookie: NEXT_LOCALE=en. ল্যাবের আসল run-এ ঠিক এই তিনটি ফলই এসেছে। -s নীরব রাখে, -i header দেখায়; চাইলে | grep -iE 'location|set-cookie'.

  3. কোড সমালোচনা (নকশা): অন্তত পাঁচটি সমস্যা ধরুন: 'use client'; import bn from './bn.json'; export function Card({p}) { const ok = /^\d{11}$/.test(p.phone); return <html lang="en"><p>{p.name.slice(0,10)}</p><p>৳{(p.price * 1.15).toFixed(2)}</p><p>{new Date(p.at).toLocaleString()}</p></html> }, আর তালিকা সাজানো হয় names.sort() দিয়ে।
    • পুরো bn.json Client Component-এ import: পুরো dictionary browser-এর bundle-এ যায়। Server-এ load করে শুধু দরকারি লেখা prop-এ দিন।
    • /^\d{11}$/ কাঁচা ইনপুটে: বাংলা অঙ্ক, +৮৮০, ফাঁকা সব বাতিল। আগে normalize, তারপর ^01[3-9]\d{8}$, আর server-এ।
    • lang="en" বাংলা পাতায়, আর component-এ <html>: ভুল। lang layout-এ bn-BD.
    • .slice(0,10): যুক্তাক্ষর ভেঙে যেতে পারে; Intl.Segmenter.
    • price * 1.15 আর toFixed: float-এর হিসাব, ইংরেজি অঙ্ক, ৳ ভুল জায়গায়। পয়সায় পূর্ণসংখ্যার হিসাব আর Intl.NumberFormat('bn-BD', { style: 'currency', currency: 'BDT' }).
    • toLocaleString() locale আর zone ছাড়া: hydration mismatch আর ভুল সময়।
    • names.sort(): code point-এর ক্রম; new Intl.Collator('bn').compare, আর আগে NFC.
  4. আউটপুট অনুমান: Node 24-এ নিচের চারটি লাইন কী ছাপবে? new Intl.PluralRules('bn').select(0); new Intl.NumberFormat('bn-BD').format(1234567); "ক্ষমা".length; [...new Intl.Segmenter('bn', { granularity: 'grapheme' }).segment("ক্ষমা")].length.

    one (বাংলায় ০ one শ্রেণিতে); ১২,৩৪,৫৬৭ (বাংলা অঙ্ক, লাখের কমা); 5 (ক, ্, ষ, ম, া); 2 (ক্ষ, মা)। প্রথম তিনটি পুরোনো engine-এও একই আসার কথা, কিন্তু শেষটি Unicode 15.1-এর আগের ICU-তে ৩ হতে পারে, কারণ সেখানে “ক্ষ” ভেঙে “ক্” আর “ষ” হতো।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমNext.js 16-এর একটি দুই ভাষার সাইটে /-এর request থেকে বাংলা পাতা পর্যন্ত ধাপগুলো সাজান (▲▼ চেপে সরান)

  1. Browser Accept-Language আর cookie পাঠায়
  2. proxy.ts দেখে URL-এ ভাষা নেই
  3. Cookie, না থাকলে header থেকে bn বাছাই হয়
  4. 307 দিয়ে /bn-এ পাঠানো হয়
  5. app/[lang] layout আর page server-এ dictionary পড়ে
  6. lang="bn-BD"-সহ HTML browser-এ যায় আর hydrate হয়

new Intl.PluralRules('bn').select(0) কী ফেরত দেয়?

  • "other"
  • "one"
  • "zero"
  • 0

Next.js 16-এর App Router-এ URL-এ ভাষা না থাকলে Accept-Language দেখে /bn-এ redirect করার কোড সাধারণত কোন ফাইলে থাকে?

  • next.config.js-এর i18n option
  • app/page.tsx-এর ভেতরে
  • proxy.ts
  • lib/i18n.ts

একটি form-এ নাম সর্বোচ্চ ২০টি “চোখে দেখা অক্ষর” রাখতে হবে, যেখানে “ক্ষ”-এর মতো যুক্তাক্ষর একটি অক্ষর ধরা হবে। JavaScript-এ কোনটি ঠিক উপায়?

  • Intl.Segmenter দিয়ে grapheme গোনা
  • name.length
  • [...name].length
  • UTF-8-এ byte গোনা

একটি দোকান দাম জমা রাখে পয়সায় পূর্ণসংখ্যা হিসেবে, যেমন ৪২৫ টাকা মানে 42500. এর প্রধান কারণ কী?

  • Database-এ জায়গা বাঁচানো
  • Intl দশমিক format করতে পারে না
  • বাংলা অঙ্কে দেখানোর সুবিধা
  • Float-এর দশমিক হিসাবের ভুল এড়ানো

দুটি string দেখতে একই “য়”, কিন্তু একটি U+09DF আর অন্যটি U+09AF U+09BC. a === b আর a.normalize('NFC') === b.normalize('NFC')-এর ফল কী?

  • দুটিই true
  • প্রথমটি false, দ্বিতীয়টি true
  • দুটিই false
  • দ্বিতীয়টি error দেয়

একটি Client Component সময় দেখায় new Date(iso).toLocaleString() দিয়ে। Server-এর ডিফল্ট locale en-US, আর ক্রেতার browser bn-BD. কী হওয়ার সম্ভাবনা বেশি?

  • দুই জায়গায় একই লেখা আসে
  • next build ব্যর্থ হয়
  • Hydration mismatch error হয়
  • React নিজে থেকে server-এর locale ব্যবহার করে

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. URL-এ ভাষা (app/[lang]); proxy-র ক্রম URL, cookie, তারপর Accept-Language; matcher আর cache curl দিয়ে যাচাই।
  2. Dictionary server-এ, server-only-সহ; plural, ক্রমবাচক আর দল বড় হলে next-intl-এর মতো library.
  3. দাম পয়সায় পূর্ণসংখ্যা, দেখানো Intl.NumberFormat('bn-BD')-এ: ১২,৩৪,৫৬৭.৫০৳; তারিখে সব সময় timeZone: 'Asia/Dhaka'; বঙ্গাব্দ Intl-এ নেই।
  4. লেখা ঢোকার দরজায় NFC, গোনায় Intl.Segmenter, সাজানোয় Intl.Collator, ফোনে আগে বাংলা অঙ্ক ঠিক, পরে যাচাই।
  5. Server আর browser একই লেখা বানাবে; বাংলা font subset করে, line-height বেশি, আর lang, hreflang ঠিক।

পরের পর্বে: Next.js মডিউল শেষ। এবার real-time: WebSocket আর Socket.IO দিয়ে বাজারঘরে ক্রেতার সাথে লাইভ chat, room আর বার্তার ক্রম।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান