@arki/string
v0.5.0
Published
String toolkit — grapheme-aware slicing and truncation, case conversion, whitespace and word helpers, literal search and editing, HTML/RegExp escaping, templating, strict universal base64, multi-script slugs, and an immutable fluent wrapper.
Maintainers
Readme
@arki/string
A string toolkit with canonical encoding failures from @arki/failure. Positions count what a reader sees (graphemes), not UTF-16 units. Use plain functions by concern, or one immutable fluent wrapper.
Install
bun add @arki/string
# or
npm install @arki/stringQuick start
Turn a blog post into what an index page needs: a URL slug, a one-line summary that stops at a word, and a reading time.
import { squish, truncate } from '@arki/string/format';
import { readingTime } from '@arki/string/readability';
import { slugify } from '@arki/string/slug';
const title = 'Café Guide & Notes';
const body = 'We moved the ledger to a new database last week.\n\nThe move took two hours.';
const slug = slugify(title); // 'cafe-guide-and-notes'
const summary = truncate(squish(body), 40, { boundary: 'word-before' }); // never cuts a word or an emoji
const minutes = Math.max(1, Math.round(readingTime(body).minutes));
if (slug !== 'cafe-guide-and-notes') throw new Error(slug);
if (summary !== 'We moved the ledger to a new database…') throw new Error(summary);
if (minutes !== 1) throw new Error(String(minutes));The tested version is examples/describe-post.ts. Its tests pin the slug, the summary and the minimum of one minute.
/** What a blog index needs from a post: a URL slug, a one-line summary and a reading time. */
export function describePost(title: string, body: string): { slug: string; summary: string; minutes: number } {
return {
slug: slugify(title),
summary: truncate(squish(body), 60, { boundary: 'word-before' }),
minutes: Math.max(1, Math.round(readingTime(body).minutes)),
};
}When not to use this
- You need to check that a string is a URL or base64. Use a schema, such as Zod's
z.url()orz.base64(), withisorassertfrom@arki/schema. This package encodes and decodes base64 and does not validate URLs. - You need to parse, strip or sanitize HTML. Use a dedicated HTML parser or sanitizer.
escapeHtmlhere only escapes text. It does not sanitize. - You need dates, durations or relative time. Use
@arki/date. - You need hyphenation, dictionaries or embeddings. Use the
@arki/text-*packages. - You need English pluralization or number and byte formatting. They are out of scope on purpose.
- You need cryptographic hashing.
hashCodeis a fast 32-bit hash and is not secure.
Runtime support
Node 24+, Bun, Deno, every evergreen browser, and React Native (Hermes). The
library uses only ECMAScript 2022 plus Unicode property escapes: no Buffer,
atob, TextEncoder, node: imports, or ES2023 array-copy methods. The one
feature check is Intl.Segmenter, with an in-house fallback that follows
Unicode 18 (ICU engines through Unicode 17 still split a leading Indic
virama from the consonant after it). A lint rule and a
test (src/runtime.test.ts) fail the build if any of that changes.
Three more checks keep the claim honest:
bun run smokebuilds nothing itself; afterbun run buildit importsdist/under Node, Node withIntl.Segmenterdeleted (the Hermes path), Bun, and Deno, and runs one check per subpath on each.src/internal/graphemes.fuzz.test.tscompares the fallback scanner with the engine'sIntl.Segmenteron 3000 seeded random strings built from the hard cases (ZWJ sequences, flags, tags, Hangul jamo, Indic conjuncts, CR LF).src/performance.test.tsguards the paths that used to be quadratic by comparing a large input against a small one, so a regression fails the suite on any machine.
With Intl.Segmenter, the native path segments a growing prefix instead of
the whole string, so at(text, 5) costs the same on 10k and 100k characters.
| Runtime | Result | Version tested | Date (UTC) | | ------- | ------- | --------------------- | ---------- | | node | pass | v26.11.1 | 2026-10-17 | | bun | pass | 1.4.2 | 2026-10-17 | | deno | pass | 2.9.7 | 2026-10-17 | | browser | pass | Chromium 156.0.8078.4 | 2026-10-17 | | hermes | not run | - | 2026-10-17 |
Public imports
The root entry @arki/string re-exports everything. Each concern also has its own subpath, so a bundler keeps only what you import.
| Subpath | Functions |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| /unicode | graphemes, length, at, slice, take, reverse, splice, chunk, fold, graphemeBoundaries |
| /case | caseWords, camelCase, pascalCase, snakeCase, kebabCase, constantCase, dotCase, pathCase, titleCase, sentenceCase, capitalize, uncapitalize |
| /format | collapseWhitespace, squish, initials, truncate, excerpt, wrapText, dedent |
| /segment | lines, words, wordCount, sentences, sentenceCount, paragraphs, paragraphCount |
| /search | before, after, between, containsAny, indexOf |
| /edit | replaceFirst, replaceLast, replaceAll, replaceStart, replaceEnd, ensureStart, ensureEnd, trimPrefix, trimSuffix, swap, deduplicate, wrap, unwrap, mask, padStart, padEnd, padBoth |
| /escape | escapeHtml, escapeRegExp |
| /interpolate | interpolate, MissingValue |
| /slug | slugify, transliterate, rulesets (per-language rule sets) |
| /slug-cjk | chinese, korean opt-in rule sets |
| /base64 | toBase64, fromBase64, bytesToBase64, base64ToBytes, utf8Encode, utf8Decode, byteLength |
| /readability | syllableCount, fleschReadingEase, fleschKincaidGrade, gunningFog, smogIndex, colemanLiauIndex, automatedReadabilityIndex, lix, fleschReadingEaseLevel, readability, fernandezHuerta, szigrisztPazos, fleschAmstad, wienerSachtextformel, readingTime, speakingTime |
| /keywords | keywords, STOP_WORDS |
| /vocabulary | uniqueWordCount, wordFrequencies, lexicalDiversity, yulesK, simpsonsDiversityIndex, wordLengthStats |
| /metrics | textMetrics (everything above in one object), analyze |
| /distance | levenshtein, diceCoefficient, jaroWinkler |
| /hash | hashCode |
| /fluent | str, StringValue |
Failure behaviour
Interpolation and UTF-8/base64 APIs return canonical @arki/failure values with stable codes, status 400, and remediation. Successes remain plain numbers, bytes, or text. Narrow with isFailure before using them; fluent interpolation and codecs return StringValue or the same failures. No submitted input is included in failures.
Breaking: tryFromBase64 is removed; use fromBase64, which returns canonical failures instead of null. Replace null checks and encoding StringError catches with failure narrowing. Interpolation returns MissingValue for missing values; narrow before reading a fluent result. Replace missing: 'throw' with missing: 'fail'; E005 is removed. Other static misuse still throws StringError from @arki/string/errors; only E001 (invalid argument) and E004 (malformed template) remain. E002/E003 are removed; narrow codec outcomes with InvalidUtf8.is or InvalidBase64.is.
| Code | Raised by | Meaning |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| ARKI_STRING_E001 | padStart, mask, truncate, wrapText, indexOf, keywords, chunk, slugify, readingTime, search helpers, and others | An argument is out of range: empty needle, negative or non-integer length, bad glyph. |
| string.invalid_utf8 | toBase64, byteLength, utf8Encode, utf8Decode, fromBase64 | Text or bytes are not valid UTF-8. |
| string.invalid_base64 | base64ToBytes, fromBase64 | Input is not strict base64. |
| ARKI_STRING_E004 | interpolate | A placeholder is malformed. |
| string.missing_value | interpolate | A placeholder has no value and missing is 'fail' (the default). |
Handle bad user input without a try block:
import { isFailure } from '@arki/failure';
import { fromBase64, InvalidBase64 } from '@arki/string/base64';
const decoded = fromBase64('not base64!');
if (!isFailure(decoded) || !InvalidBase64.is(decoded)) throw new Error('expected base64 failure');Limits
- Segmentation follows Unicode 18 on the fallback path. ICU engines through Unicode 17 still split a leading Indic virama from the consonant after it. A string with that sequence can measure differently on Hermes than on V8.
slugify('ä ö')givesae-oe(German rules).slugify('ä ö', { locale: 'sv' })givesa-o. Passlocalewhen the language is not German.- Chinese and Korean transliteration is opt-in through
@arki/string/slug-cjk, because the tables are large. Without it, those characters are dropped from a slug. interpolatetakes flat identifier keys.{{ user.name }}is aStringError(ARKI_STRING_E004).- Base64 decoding is strict. A string with a newline, a mixed alphabet or bad padding returns
InvalidBase64. Handle the failure before using decoded text. escapeHtmlis not a sanitizer. It makes text safe inside HTML text and attribute values and does nothing for URLs, scripts or styles.syllableCountand the readability scores use a heuristic for English, Spanish and German. They suit grades and reading time. They do not suit hyphenation.
Two ways to use it
Named functions, tree-shakeable by concern:
import { toBase64 } from '@arki/string/base64';
import { camelCase } from '@arki/string/case';
import { squish, truncate } from '@arki/string/format';
camelCase('XMLHttpRequest v2Beta'); // 'xmlHttpRequestV2Beta'
truncate('The quick brown fox', 10, { boundary: 'word-before' }); // 'The quick…'
toBase64('crème brûlée 👋', { alphabet: 'url' });Or the immutable fluent wrapper:
import { str } from '@arki/string/fluent';
str(' XML_http_request ').squish().titleCase().truncate(24).value(); // 'Xml Http Request'
str('/articles/hello.md').after('/articles/').trimSuffix('.md').value(); // 'hello'
str(' [email protected] ').trim().lower().value(); // '[email protected]'Every method returns a new StringValue; nothing mutates. Literal searches
(includes, startsWith, endsWith, replaceAll, …) are grapheme-aligned
like the plain functions, and replacement strings are literal ($& stays $&). Extract with
.value() (or toString(), template literals, JSON.stringify). Note that
Boolean(str('')) is true like any object, so use .isEmpty().
The root entry @arki/string re-exports everything.
Unicode model
JavaScript strings are UTF-16: '👨👩👧👦'.length is 11 and .slice() can
split an emoji in half. Positional functions in this package count grapheme
clusters (user-perceived characters) instead:
import { at, chunk, length, reverse, slice, take } from '@arki/string/unicode';
length('👨👩👧👦'); // 1
at('A👩💻B', 1); // '👩💻'
take('A👩💻B', -2); // '👩💻B'
reverse('🇷🇴é'); // 'é🇷🇴'Segmentation uses Intl.Segmenter when the engine has it, and an in-house
UAX #29 scanner otherwise (Hermes). The scanner is driven by Unicode property
escapes, so the engine's own tables supply the data, and it is tested against
the official GraphemeBreakTest corpus. For UTF-16 units use native .length;
for bytes use utf8Encode.
Literal searches (before, after, replaceFirst, ensureStart, …) only
match on grapheme boundaries, so searching e never matches part of é in
decomposed form.
Reference by concern
Case conversion
All case functions share one tokenizer, caseWords, so they split identifiers
identically. Tokens are letters, numbers, and marks; digits stay attached to
the preceding token; an uppercase run ends before its last capital when a
lowercase letter follows.
| Input | Tokens | snakeCase |
| ---------------- | ------------------------ | ------------------ |
| XMLHttpRequest | XML, Http, Request | xml_http_request |
| v2Beta | v2, Beta | v2_beta |
| iPhone11Pro | i, Phone11, Pro | i_phone11_pro |
| crème brûlée | crème, brûlée | crème_brûlée |
| don't-stop | dont, stop | dont_stop |
Casing is locale-independent unless you pass { locale: 'tr' }; the machine
locale is never used. A malformed locale tag, such as a raw Accept-Language
header or cookie value, does not throw: the function falls back to
locale-independent casing and logs the swap at DEBUG (arki:string:locale). titleCase and sentenceCase build labels from tokens
and discard punctuation; capitalize only touches the first grapheme.
Format
truncate(text, max, { ellipsis, boundary })enforces a total grapheme budget that includes the ellipsis (…by default) and never leaves whitespace before it.boundary: 'word-before'backs up to the previous word;'sentence'backs up to the last sentence end that fits, or to a word when none does.excerpt(text, needle, { radius, boundary })returns the context around the first match.boundary: 'word' | 'sentence'pulls each edge inward to the nearest boundary without crossing the match, soradiusstays a hard maximum; a sentence edge out of reach degrades to a word edge on that side.initials('Ada King Lovelace')is'AL';{ pick: 'first' }gives'AK';{ singleWord: 2 }turns a lone'Alex'into'AL'.dedentstrips the shared indentation and one surrounding blank line, as a function or a template tag, for SQL and prompt templates.squishiscollapseWhitespaceplus a trim.
Unicode folding and similarity
import { jaroWinkler, levenshtein } from '@arki/string/distance';
import { fold } from '@arki/string/unicode';
fold('Émile Zola'); // 'emile zola' — lowercase, marks stripped, ß/ø/ł spelled out
levenshtein('kitten', 'sitting'); // 3, counted in graphemes
jaroWinkler(fold('Émile Zola'), fold('emile zola')); // 1foldis the search-key form: comparefold(a) === fold(b)or feed both sides to a distance. It does no script conversion; that istransliteratein/slug.levenshtein,diceCoefficient(grapheme bigrams) andjaroWinkler(name matching, prefix-boosted) are all measured in graphemes.
Segment
One definition of a word, sentence, line, and paragraph, shared by every counting and scoring function in the package. Regular expressions only, so results are identical on every runtime.
wordscounts prose words:don't,re-enter, and3.14are one word each. Scripts without word spacing yield one word per run.sentencessplits after.,!,?, or…followed by whitespace; a trailing fragment with a letter or digit counts.Mr.is not special-cased.paragraphsare non-blank lines, so single- and double-newline text agree.linessplits on CRLF, LF, CR, NEL, LS, and PS and keeps trailing empties.
Readability, keywords, vocabulary, metrics
import { keywords } from '@arki/string/keywords';
import { textMetrics } from '@arki/string/metrics';
import { readability, readingTime, syllableCount } from '@arki/string/readability';
import { lexicalDiversity, wordFrequencies } from '@arki/string/vocabulary';
declare const article: string;
declare const articulo: string;
readability(article).fleschKincaidGrade; // 8.4
readability(articulo, { language: 'es' }).fernandezHuerta; // Spanish score set
readingTime(article); // { words: 1200, seconds: 302.5, minutes: 5.04 }
keywords(article, { length: 2, limit: 5 }); // [{ keyword: 'machine learning', occurrences: 7, percentage: 1.9 }, …]
textMetrics(article); // every count, score, and statistic in one objectSyllables use an English heuristic (vowel groups with silent-
e,-ed,-es,-leand hiatus rules). It is meant for readability grades, not for hyphenation; words without Latin letters count 0.syllableCount(text, { language: 'es' | 'de' })switches to Spanish (diphthong/hiatus rules, silentuinque/gui) or German (one syllable per vowel group) counting.readability(text)is the English score set (Flesch Reading Ease clamped to 0–100, 100 for text without words; grade indexes return 0).readability(text, { language: 'es' })returnsfernandezHuertaandszigrisztPazos(0–100, higher is easier);'de'returnsfleschAmstad(0–100) andwienerSachtextformel(a school grade). Each language uses its own syllable counter, and every formula is also exported on its own.Every readability, vocabulary and metrics function accepts a string or a
TextAnalysisfromanalyze(text)(in/metrics). Analyze once, pass it to several functions, or write your own formula against itswords,sentences,syllablesand letter counts:import { analyze } from '@arki/string/metrics'; import { fleschKincaidGrade, lix } from '@arki/string/readability'; declare const text: string; const a = analyze(text); const grade = fleschKincaidGrade(a); const lixScore = lix(a); const longWordShare = a.words.filter(w => w.length > 6).length / a.words.length;keywordsbuilds 1-, 2-, or 3-word phrases that never cross clause punctuation and never contain a stop word or a word underminWordLengthgraphemes. The stop-word list followslanguage('en'default,'es','de','fr','ro'; all lists are inSTOP_WORDS); pass your ownstopWordsorstopWords: nullto disable.Vocabulary functions compare words lowercased; pass
{ locale }for locale-sensitive lowercasing.hashCode(in/hash) is the non-negative JavaString.hashCodeover UTF-16 units: stable, fast, and not cryptographic.
Base64 and UTF-8
No Buffer, atob, or btoa. Text functions are UTF-8; byte functions take
and return Uint8Array. Decoding is strict: whitespace, mixed alphabets, bad
padding, impossible lengths, and nonzero unused bits return InvalidBase64;
invalid UTF-8 returns InvalidUtf8. Use fromBase64 to decode text.
import { base64ToBytes, fromBase64, toBase64 } from '@arki/string/base64';
toBase64('f'); // 'Zg=='
toBase64('f', { alphabet: 'url' }); // 'Zg' (url defaults to no padding)
base64ToBytes('/w=='); // Uint8Array [255]
fromBase64('/w=='); // InvalidUtf8 failure: not UTF-8Slugs
import { slugify, transliterate } from '@arki/string/slug';
slugify('Привет мир'); // 'privet-mir'
slugify('Nguyễn Đăng Khoa'); // 'nguyen-dang-khoa'
slugify('Quattro Formaggi & Crème Fraîche'); // 'quattro-formaggi-and-creme-fraiche'
slugify('Hello World', { separator: '_', lowercase: false }); // 'Hello_World'
slugify('ä ö', { locale: 'sv' }); // 'a-o' (German default would give 'ae-oe')
transliterate('crème brûlée'); // 'creme brulee'Transliteration covers Latin, Cyrillic, Greek, Arabic, Armenian, Georgian,
Hindi, Burmese, Vietnamese, Yiddish, and more, with curated per-language rule
sets ported from cocur/slugify and the general table from
@sindresorhus/transliterate. Priority is: your replacements, then locale
overrides, then the curated sets, then the general table, then remaining
diacritics are stripped. Chinese (pinyin) and Korean rule sets are large, so
they are opt-in:
import { slugify } from '@arki/string/slug';
import { chinese } from '@arki/string/slug-cjk';
slugify('我是谁', { replacements: chinese }); // 'wo-shi-shui'Interpolation
import { interpolate } from '@arki/string/interpolate';
interpolate('Hi {{ name }}', { name: 'Ada' }); // 'Hi Ada'
interpolate('{{ missing }}', {}); // returned MissingValue, code string.missing_value
interpolate('{{ missing }}', {}, { missing: 'keep' }); // '{{ missing }}'Flat identifier keys only. null renders as '', undefined is missing.
\{{ emits a literal {{. Output is plain text; call escapeHtml separately.
Documentation
- Agent guide: tasks, rules that bite, errors.
- llms.txt: the index an agent reads first.
- Changelog.
@arki/string works on its own. With the @arki/dot application framework, see packages/dot/docs/.
Support
Report a bug or ask a question at github.com/arki-labs/arki/issues. Include the input string, the function and the result you expected.
Benchmarks
Speed of @arki/string against lodash-es, change-case, slugify and escape-html, the libraries most projects use for case conversion, truncation, slugs and escaping.
Node v22.13.1 on Apple M2 Max, 2026-10-13. Median time per operation, lower is better.
camelCase
| Library | Time / op | vs fastest | | ---------------------- | --------: | ---------: | | @arki/string camelCase | 45.23 µs | 21.84× | | lodash-es camelCase | 2.07 µs | fastest | | change-case camelCase | 2.90 µs | 1.40× |
kebabCase
| Library | Time / op | vs fastest | | ---------------------- | --------: | ---------: | | @arki/string kebabCase | 3.88 µs | 2.11× | | lodash-es kebabCase | 1.84 µs | fastest | | change-case kebabCase | 2.94 µs | 1.60× |
Truncate 228 characters to 80
Every library ends the result with … inside the 80-character limit. lodash cuts at a code unit; @arki/string cuts at a grapheme.
| Library | Time / op | vs fastest | | --------------------- | --------: | ---------: | | @arki/string truncate | 103.49 µs | 1671.23× | | lodash-es truncate | 61.9 ns | fastest |
Slugify an accented title
| Library | Time / op | vs fastest | | ----------------------- | --------: | ---------: | | @arki/string slugify | 9.29 µs | fastest | | slugify (lower, strict) | 13.78 µs | 1.48× |
Escape HTML (a 63-character tag with quotes and ampersands)
| Library | Time / op | vs fastest | | ----------------------- | --------: | ---------: | | @arki/string escapeHtml | 2.05 µs | 3.37× | | escape-html | 610.4 ns | fastest | | lodash-es escape | 992.4 ns | 1.63× |
Escape a string for use in a RegExp
@arki/string also escapes a leading letter or digit as \xNN, so the result is safe to paste after a backslash or inside a character class; lodash does not.
| Library | Time / op | vs fastest | | ------------------------- | --------: | ---------: | | @arki/string escapeRegExp | 2.65 µs | 2.53× | | lodash-es escapeRegExp | 1.05 µs | fastest |
Read the rows with the contracts in mind:
- Case inputs are plain ASCII so every library does the same work. With accents, lodash strips them (
cafébecomescafe) while@arki/stringand change-case keep them. @arki/stringcounts user-perceived characters (graphemes), so it never cuts an emoji or an accented letter in half. lodashtruncatecounts UTF-16 code units. Counting graphemes is more work, which shows in the truncate row.- Slugs differ by design:
@arki/stringtransliteratesütoueand&toand(German rules), slugify givesuand drops&; both are configurable. Outputs of the same input are not identical, so compare speed, not output. - escape-html, lodash
escapeand@arki/stringproduce the same output for HTML text and attribute values.
Reproduce with bun run bench in the package directory; regenerate this section with bun run bench --write-readme.
License
MIT. See LICENSE.
