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
'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.
Script and apostrophes
- toScript(s, 'latin')
- O'zbekiston g'alabasi, ma'no
- toScript(s, 'cyrillic')
- Ўзбекистон ғалабаси, маъно
- fixApostrophes
- Oʻzbekiston gʻalabasi, maʼno
'use client';
import { detectScript, fixApostrophes, toScript } from '@akhatmirzo/core';
import { Badge, Field, Input } from '@akhatmirzo/ui';
import { useState } from 'react';
const SAMPLES = [
"O'zbekiston g'alabasi, ma'no",
'Салом, дунё',
'Toshkent — Тошкент',
"'Salom' dedi",
];
export default function CoreScript() {
const [text, setText] = useState("O'zbekiston g'alabasi, ma'no");
const rows = [
{ name: "toScript(s, 'latin')", value: toScript(text, 'latin') },
{ name: "toScript(s, 'cyrillic')", value: toScript(text, 'cyrillic') },
{ name: 'fixApostrophes', value: fixApostrophes(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 items-center gap-2">
<Badge color="info">detectScript: {detectScript(text)}</Badge>
{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>
);
}
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" — unchangedfixApostrophes
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.
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;
}
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'
}