Skip to content
arki-labsPublic

About

[Read-only mirror] Zero-dependency 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 wrappe

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

8 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Read-only mirror of @arki/string v0.5.1. Source and issues: arki-labs/arki. Docs: https://arki.dev/packages/string. Published 2026-11-09 with provenance https://www.npmjs.com/package/@arki/string/v/0.5.1#provenance.

@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/string

Quick 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() or z.base64(), with is or assert from @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. escapeHtml here 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. hashCode is 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 smoke builds nothing itself; after bun run build it imports dist/ under Node, Node with Intl.Segmenter deleted (the Hermes path), Bun, and Deno, and runs one check per subpath on each.
  • src/internal/graphemes.fuzz.test.ts compares the fallback scanner with the engine's Intl.Segmenter on 3000 seeded random strings built from the hard cases (ZWJ sequences, flags, tags, Hangul jamo, Indic conjuncts, CR LF).
  • src/performance.test.ts guards 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 v22.13.1 2026-11-05
bun pass 1.4.2 2026-11-05
deno pass 2.9.8 2026-11-05
browser pass Chromium 156.0.8078.4 2026-11-05
hermes pass V1 250829098.0.17 2026-11-05

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');

Observability

Pure computation: no I/O, so no spans or metrics.

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('ä ö') gives ae-oe (German rules). slugify('ä ö', { locale: 'sv' }) gives a-o. Pass locale when 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.
  • interpolate takes flat identifier keys. {{ user.name }} is a StringError (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.
  • escapeHtml is not a sanitizer. It makes text safe inside HTML text and attribute values and does nothing for URLs, scripts or styles.
  • syllableCount and 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(' Ada@Example.COM ').trim().lower().value(); // 'ada@example.com'

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, so radius stays 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'.
  • dedent strips the shared indentation and one surrounding blank line, as a function or a template tag, for SQL and prompt templates.
  • squish is collapseWhitespace plus 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')); // 1
  • fold is the search-key form: compare fold(a) === fold(b) or feed both sides to a distance. It does no script conversion; that is transliterate in /slug.
  • levenshtein, diceCoefficient (grapheme bigrams) and jaroWinkler (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.

  • words counts prose words: don't, re-enter, and 3.14 are one word each. Scripts without word spacing yield one word per run.
  • sentences splits after ., !, ?, or … followed by whitespace; a trailing fragment with a letter or digit counts. Mr. is not special-cased.
  • paragraphs are non-blank lines, so single- and double-newline text agree.
  • lines splits 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 object
  • Syllables use an English heuristic (vowel groups with silent-e, -ed, -es, -le and 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, silent u in que/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' }) returns fernandezHuerta and szigrisztPazos (0–100, higher is easier); 'de' returns fleschAmstad (0–100) and wienerSachtextformel (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 TextAnalysis from analyze(text) (in /metrics). Analyze once, pass it to several functions, or write your own formula against its words, sentences, syllables and 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;
  • keywords builds 1-, 2-, or 3-word phrases that never cross clause punctuation and never contain a stop word or a word under minWordLength graphemes. The stop-word list follows language ('en' default, 'es', 'de', 'fr', 'ro'; all lists are in STOP_WORDS); pass your own stopWords or stopWords: null to disable.

  • Vocabulary functions compare words lowercased; pass { locale } for locale-sensitive lowercasing.

  • hashCode (in /hash) is the non-negative Java String.hashCode over 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-8

Slugs

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

@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é becomes cafe) while @arki/string and change-case keep them.
  • @arki/string counts user-perceived characters (graphemes), so it never cuts an emoji or an accented letter in half. lodash truncate counts UTF-16 code units. Counting graphemes is more work, which shows in the truncate row.
  • Slugs differ by design: @arki/string transliterates ü to ue and & to and (German rules), slugify gives u and drops &; both are configurable. Outputs of the same input are not identical, so compare speed, not output.
  • escape-html, lodash escape and @arki/string produce 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.

About

[Read-only mirror] Zero-dependency 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 wrappe

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages