@akhatmirzo/ui

Text

Cyrillic ↔ Latin, script detection, apostrophes, 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,
  detectScript,
  toScript,
  fixApostrophes,
  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.

Script and apostrophes

detectScript: latin
toScript(s, 'latin')
O'zbekiston g'alabasi, ma'no
toScript(s, 'cyrillic')
Ўзбекистон ғалабаси, маъно
fixApostrophes
Oʻzbekiston gʻalabasi, maʼno

detectScript

function detectScript(s: string | null | undefined): DetectedScript;
// DetectedScript = 'latin' | 'cyrillic' | 'mixed' | 'none'

Tells which script a text is written in. Only letters count: digits, punctuation, apostrophes and other scripts (Greek, Arabic ...) are ignored. The rule is simple and predictable: at least one letter from each script gives 'mixed' (a single stray letter is enough), letters from only one script give that script, and no letters at all give 'none'.

detectScript('Salom dunyo'); // 'latin'
detectScript('Салом'); // 'cyrillic'
detectScript('Salom Салом'); // 'mixed'
detectScript('iPhone 15 — янги'); // 'mixed'
detectScript('+998 90 123-45-67'); // 'none'

toScript

function toScript(
  s: string | null | undefined,
  target: 'latin' | 'cyrillic',
  options?: ToScriptOptions, // { apostrophe?: 'typographic' | 'ascii' }
): string;

Converts text to the target script. The source script is detected automatically and only the parts written in the other script are converted: in mixed text, Latin parts stay as they are when converting to Latin (and vice versa). If the text is already in the target script it comes back untouched — apostrophes included (use fixApostrophes for those).

Latin output uses typographic characters by default: oʻ, gʻ (U+02BB) and the ʼ sign (U+02BC). Pass apostrophe: 'ascii' for a plain '.

toScript('Салом, дунё', 'latin'); // 'Salom, dunyo'
toScript('Ўзбекистон', 'latin'); // 'Oʻzbekiston'
toScript('маъно', 'latin', { apostrophe: 'ascii' }); // "ma'no"
toScript("Salom, O'zbekiston", 'cyrillic'); // 'Салом, Ўзбекистон'
toScript('Toshkent — Тошкент', 'cyrillic'); // 'Тошкент — Тошкент'
toScript("O'zbekiston", 'latin'); // "O'zbekiston" — unchanged

fixApostrophes

function fixApostrophes(
  s: string | null | undefined,
  options?: FixApostrophesOptions, // { style?: 'typographic' | 'ascii' }
): string;

Fixes apostrophes in Latin Uzbek text, whatever character the user actually typed:

  • any variant after o / g (' ‘ ’ ` ´ ʼ) becomes ʻ: oʻ, gʻ;
  • any other apostrophe inside a word (the glottal stop sign) becomes ʼ: maʼno, sanʼat;
  • apostrophes used as quotation marks are left alone: one at the start of a word opens a quote, and one at the end of a word closes it while a quote is open.

style: 'ascii' goes the other way: ʻ and ʼ become a plain '.

fixApostrophes("O'zbekiston g'alabasi, ma'no"); // 'Oʻzbekiston gʻalabasi, maʼno'
fixApostrophes("o'n, tog' va bog'"); // 'oʻn, togʻ va bogʻ'
fixApostrophes("'Salom' dedi"); // "'Salom' dedi"
fixApostrophes("'O'zbekiston' kitobi"); // "'Oʻzbekiston' kitobi"
fixApostrophes("mo''tabar"); // 'moʻʼtabar'
fixApostrophes('Oʻzbekiston', { style: 'ascii' }); // "O'zbekiston"

Word endings inside quotes

While a quote is open, o' / g' at the end of a word is read as the closing quote: "'Kino'" stays correct, but in "'tog'" the g' apostrophe is also kept as a quote. Check such ambiguous text by hand.

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;
}
type UzScript = 'latin' | 'cyrillic';
type UzApostropheStyle = 'typographic' | 'ascii';
type DetectedScript = 'latin' | 'cyrillic' | 'mixed' | 'none';
interface ToScriptOptions {
  apostrophe?: UzApostropheStyle; // default: 'typographic'
}
interface FixApostrophesOptions {
  style?: UzApostropheStyle; // default: 'typographic'
}

On this page