Text
Cyrillic ↔ Latin, Uzbek-aware search, slugify and truncation.
Uzbek text comes in many shapes: Latin and Cyrillic script, four or five apostrophe variants ('
ʻ ‘ ’), upper and lower case. These functions treat all of them as the same thing. They are
pure functions: no React, no dependencies, safe on the server.
import {
cyrToLat,
latToCyr,
normalizeUz,
searchMatch,
fuzzyMatch,
splitByRanges,
slugify,
truncate,
truncateMiddle,
capitalize,
} from '@akhatmirzo/core';Transliteration
Type some text or pick one of the samples below:
- cyrToLat
- G'uzor tumani
- latToCyr
- Ғузор тумани
- normalizeUz
- guzor tumani
'use client';
import { cyrToLat, latToCyr, normalizeUz } from '@akhatmirzo/core';
import { Badge, Field, Input } from '@akhatmirzo/ui';
import { useState } from 'react';
const SAMPLES = ['Ғузор тумани', "O'zbekiston", 'Gʻuzor', 'ер, поезд, маъно', 'SHAHAR'];
export default function CoreTranslit() {
const [text, setText] = useState('Ғузор тумани');
const rows = [
{ name: 'cyrToLat', value: cyrToLat(text) },
{ name: 'latToCyr', value: latToCyr(text) },
{ name: 'normalizeUz', value: normalizeUz(text) },
];
return (
<div className="flex w-full max-w-md flex-col gap-4">
<Field label="Matn">
<Input value={text} onChange={(e) => setText(e.target.value)} />
</Field>
<div className="flex flex-wrap gap-2">
{SAMPLES.map((s) => (
<button key={s} type="button" onClick={() => setText(s)} className="cursor-pointer">
<Badge variant="outline">{s}</Badge>
</button>
))}
</div>
<dl className="divide-y divide-border rounded-xl border border-border bg-surface text-sm">
{rows.map((row) => (
<div key={row.name} className="flex items-center justify-between gap-4 px-3 py-2">
<dt className="font-mono text-xs text-text-muted">{row.name}</dt>
<dd className="font-mono break-all text-text">{row.value || '—'}</dd>
</div>
))}
</dl>
</div>
);
}
cyrToLat
function cyrToLat(s: string, options?: TranslitOptions): string;Converts Uzbek Cyrillic to Latin script following the spelling rules: е at the start of a word
becomes ye, ц becomes ts, ъ becomes ' and ь is dropped. Case is preserved — Ш
becomes SH in an all-caps word and Sh otherwise. Non-Cyrillic characters pass through
untouched.
cyrToLat('Шаҳар'); // 'Shahar'
cyrToLat('ЎЗБЕКИСТОН'); // "O'ZBEKISTON"
cyrToLat('ер, поезд, маъно'); // "yer, poyezd, ma'no"
cyrToLat('центр'); // 'tsentr'
cyrToLat('Ғузор', { apostrophe: 'ʻ' }); // 'Gʻuzor'| Prop | Type | Default | Description |
|---|---|---|---|
srequired | string | — | Cyrillic text |
options.apostrophe | string | "'" | Character used for o', g' and ъ, e.g. 'ʻ' |
latToCyr
function latToCyr(s: string): string;Converts Uzbek Latin to Cyrillic. Any apostrophe variant works in o' and g'. An apostrophe
anywhere else becomes the hard sign ъ, and e at the start of a word becomes э. Split pairs
such as s'h are read correctly.
latToCyr("O'zbekiston"); // 'Ўзбекистон'
latToCyr('Gʻuzor'); // 'Ғузор'
latToCyr('SHAHAR'); // 'ШАҲАР'
latToCyr("ma'no"); // 'маъно'
latToCyr('ekran'); // 'экран'
latToCyr("Is'hoq"); // 'Исҳоқ'ts doesn't turn back into ц
In Latin script, ts can stand for ц or for т + с, so latToCyr('tsex') returns 'тсех'.
Double-check the output for words borrowed from Russian.
normalizeUz
function normalizeUz(s: string | null | undefined): string;Builds a search key: lower case, Cyrillic turned into Latin, every apostrophe removed. Use it to
check whether two strings are "the same", or to build your own search. null and undefined
return ''.
normalizeUz('Gʻuzor'); // 'guzor'
normalizeUz("G'uzor"); // 'guzor'
normalizeUz('Ғузор'); // 'guzor'
normalizeUz("O'zbekiston"); // 'ozbekiston'How it differs from cyrToLat
In normalizeUz, Cyrillic е is always e, even at the start of a word: normalizeUz('Ер юзи')
returns 'er yuzi', while cyrToLat returns 'Yer yuzi'. That way a partial query can still
match letters in the middle of a word.
Search
searchMatch
function searchMatch(
text: string | null | undefined,
query: string | null | undefined,
): SearchMatchResult; // { match: boolean; ranges: TextRange[] }Substring search that ignores case, apostrophe variants and script (Cyrillic vs Latin). ranges
lists every match in the original text ([start, end), compatible with slice), so you can
highlight a Latin query inside Cyrillic text. An empty query matches everything. The Combobox
search and the DataTable filter are built on it.
Sinab ko'ring: chir, ғуз, fargona, qo'qon
- Shahrisabz
- Шаҳрихон
'use client';
import { searchMatch, splitByRanges } from '@akhatmirzo/core';
import { Field, Input } from '@akhatmirzo/ui';
import { useState } from 'react';
const CITIES = [
'Toshkent',
'Samarqand',
'Buxoro',
"Farg'ona",
'Qo‘qon',
'Shahrisabz',
'Chirchiq',
'Ғузор',
'Ўрта Чирчиқ',
'Шаҳрихон',
];
export default function CoreSearch() {
const [query, setQuery] = useState('шаҳ');
const results = CITIES.map((city) => ({ city, ...searchMatch(city, query) })).filter(
(r) => r.match,
);
return (
<div className="flex w-full max-w-md flex-col gap-3">
<Field label="Shahar qidirish" description="Sinab ko'ring: chir, ғуз, fargona, qo'qon">
<Input value={query} onChange={(e) => setQuery(e.target.value)} clearable />
</Field>
<ul className="flex flex-wrap gap-2">
{results.map(({ city, ranges }) => (
<li key={city} className="rounded-lg border border-border bg-surface px-3 py-1.5 text-sm">
{splitByRanges(city, ranges).map((part, i) =>
part.match ? (
<mark key={i} className="rounded-sm bg-primary-soft text-primary-soft-foreground">
{part.text}
</mark>
) : (
<span key={i}>{part.text}</span>
),
)}
</li>
))}
{results.length === 0 && <li className="text-sm text-text-muted">Hech narsa topilmadi</li>}
</ul>
</div>
);
}
searchMatch('Toshkent', 'тош'); // { match: true, ranges: [[0, 4]] }
searchMatch('Ғузор тумани', "g'uzor"); // { match: true, ranges: [[0, 5]] }
searchMatch("Farg'ona", 'fargona'); // { match: true, ranges: [[0, 8]] }
searchMatch('Ota-ona, ota', 'ota'); // { match: true, ranges: [[0, 3], [9, 12]] }
searchMatch('Shahrisabz', 'xiva'); // { match: false, ranges: [] }
searchMatch('Toshkent', ''); // { match: true, ranges: [] }fuzzyMatch
function fuzzyMatch(
text: string | null | undefined,
query: string | null | undefined,
): FuzzyMatchResult | null; // { score: number; ranges: TextRange[] }Fuzzy search: the query letters must appear in the text in order, with anything in between.
Matches at the start of a word and consecutive matches score higher. Returns null when there's
no match. Normalization is the same as in searchMatch, and spaces in the query are ignored.
CommandPalette uses it.
Harflar tartibi muhim: mq, hsb, prof
- Yangi buyurtma23
'use client';
import { fuzzyMatch, splitByRanges } from '@akhatmirzo/core';
import { Field, Input } from '@akhatmirzo/ui';
import { useState } from 'react';
const COMMANDS = [
'Yangi buyurtma',
"Buyurtmalar ro'yxati",
"Mijoz qo'shish",
'Hisobotlar',
'Sozlamalar',
'Profilni tahrirlash',
'Mavzuni almashtirish',
];
export default function CoreFuzzy() {
const [query, setQuery] = useState('ybu');
const results = COMMANDS.flatMap((label) => {
const result = fuzzyMatch(label, query);
return result ? [{ label, ...result }] : [];
}).sort((a, b) => b.score - a.score);
return (
<div className="flex w-full max-w-md flex-col gap-3">
<Field label="Buyruq" description="Harflar tartibi muhim: mq, hsb, prof">
<Input value={query} onChange={(e) => setQuery(e.target.value)} clearable />
</Field>
<ul className="divide-y divide-border rounded-xl border border-border bg-surface text-sm">
{results.map(({ label, ranges, score }) => (
<li key={label} className="flex items-center justify-between gap-3 px-3 py-2">
<span>
{splitByRanges(label, ranges).map((part, i) =>
part.match ? (
<strong key={i} className="font-semibold text-primary-soft-foreground">
{part.text}
</strong>
) : (
<span key={i}>{part.text}</span>
),
)}
</span>
<span className="font-mono text-xs text-text-muted">{score}</span>
</li>
))}
{results.length === 0 && <li className="px-3 py-2 text-text-muted">Mos buyruq yo'q</li>}
</ul>
</div>
);
}
fuzzyMatch('Yangi buyurtma', 'ybu'); // { score: 23, ranges: [[0, 1], [6, 8]] }
fuzzyMatch('Yangi buyurtma', 'ybuy'); // { score: 29, ranges: [[0, 1], [6, 9]] }
fuzzyMatch('Sozlamalar', 'xyz'); // nullThe absolute score means nothing on its own — use it only to rank results against each other:
results.sort((a, b) => b.score - a.score).
splitByRanges
function splitByRanges(text: string, ranges: readonly TextRange[]): TextSegment[];
// TextSegment = { text: string; match: boolean }Splits text by the ranges that searchMatch or fuzzyMatch returned, so you can wrap the
matched part in <mark>.
splitByRanges('Toshkent', [[0, 4]]);
// [{ text: 'Tosh', match: true }, { text: 'kent', match: false }]import { searchMatch, splitByRanges } from '@akhatmirzo/core';
export function Highlight({ text, query }: { text: string; query: string }) {
const { ranges } = searchMatch(text, query);
return (
<>
{splitByRanges(text, ranges).map((part, i) =>
part.match ? <mark key={i}>{part.text}</mark> : <span key={i}>{part.text}</span>,
)}
</>
);
}Text helpers
slugify: fargona-viloyati-2026
- truncateMiddle
- shartnoma-mchj…ngan.docx
- truncate
- shartnoma-mchj-akhatmir…
'use client';
import { slugify, truncate, truncateMiddle } from '@akhatmirzo/core';
import { Field, Input } from '@akhatmirzo/ui';
import { useState } from 'react';
const FILE = 'shartnoma-mchj-akhatmirzo-imzolangan.docx';
export default function CoreTruncate() {
const [title, setTitle] = useState("Farg'ona viloyati — 2026");
const [max, setMax] = useState(24);
return (
<div className="flex w-full max-w-md flex-col gap-4">
<Field label="Sarlavha" description={`slugify: ${slugify(title) || '—'}`}>
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
</Field>
<label className="flex flex-col gap-2 text-sm font-medium text-text">
Maksimal uzunlik: {max}
<input
type="range"
min={6}
max={44}
value={max}
onChange={(e) => setMax(Number(e.target.value))}
className="w-full accent-primary"
/>
</label>
<dl className="divide-y divide-border rounded-xl border border-border bg-surface text-sm">
<div className="flex flex-col gap-1 px-3 py-2">
<dt className="font-mono text-xs text-text-muted">truncateMiddle</dt>
<dd className="font-mono text-text">{truncateMiddle(FILE, max)}</dd>
</div>
<div className="flex flex-col gap-1 px-3 py-2">
<dt className="font-mono text-xs text-text-muted">truncate</dt>
<dd className="font-mono text-text">{truncate(FILE, max)}</dd>
</div>
</dl>
</div>
);
}
slugify
function slugify(s: string | null | undefined): string;Makes a URL slug: Uzbek text (Cyrillic too) is converted to Latin, apostrophes are removed and
everything else becomes -.
slugify("Farg'ona viloyati"); // 'fargona-viloyati'
slugify('Тошкент шаҳри'); // 'toshkent-shahri'
slugify('Ер юзи'); // 'yer-yuzi'
slugify("Farg'ona viloyati — 2026"); // 'fargona-viloyati-2026'truncate
function truncate(s: string, max: number, ellipsis?: string): string;Shortens text from the end. The result, ellipsis included, never exceeds max characters, and a
trailing space is dropped. Characters are counted by code point, so emoji are never cut in half.
truncate('Salom dunyo', 7); // 'Salom…'
truncate('Salom dunyo', 8, '...'); // 'Salom...'
truncate('Salom', 10); // 'Salom'truncateMiddle
function truncateMiddle(s: string, max: number, ellipsis?: string): string;Shortens text in the middle. For file names it keeps the extension and the end of the name, so
files with the same prefix stay distinguishable. The FileUpload list uses it.
truncateMiddle('hisobot-oktabr-2026.pdf', 22); // 'hisobot-oktab…2026.pdf'
truncateMiddle('shartnoma-mchj-akhatmirzo-imzolangan.docx', 24); // 'shartnoma-mchj…ngan.docx'| Prop | Type | Default | Description |
|---|---|---|---|
srequired | string | — | Original text |
maxrequired | number | — | Maximum length of the result, ellipsis included |
ellipsis | string | '…' | Marks the cut |
capitalize
function capitalize(s: string): string;Upper-cases the first letter and leaves the rest alone.
capitalize("o'zbekiston"); // "O'zbekiston"
capitalize('toshkent shahri'); // 'Toshkent shahri'isApostrophe and UZ_APOSTROPHES
const UZ_APOSTROPHES: string; // "ʻʼ‘’`´'"
function isApostrophe(ch: string | undefined): boolean;Every character that shows up as an apostrophe in Uzbek text, plus a check for it.
isApostrophe('ʻ'); // true
isApostrophe('a'); // falseTypes
type TextRange = [start: number, end: number]; // UTF-16 indexes in the original text
interface TextSegment {
text: string;
match: boolean;
}
interface SearchMatchResult {
match: boolean;
ranges: TextRange[];
}
interface FuzzyMatchResult {
score: number;
ranges: TextRange[];
}
interface TranslitOptions {
apostrophe?: string;
}