npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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/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 | 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('ä ö') 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(' [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, 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.