@akhatmirzo/ui

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

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'
PropTypeDefaultDescription
srequiredstring—Cyrillic text
options.apostrophestring"'"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.

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
  • Шаҳрихон
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
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'); // null

The 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 }]
highlight.tsx
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…

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'
PropTypeDefaultDescription
srequiredstring—Original text
maxrequirednumber—Maximum length of the result, ellipsis included
ellipsisstring'…'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'); // false

Types

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;
}

On this page