[ Language & values ]
@sdxc/strings
English inflection, Chicago title case, slugs and grapheme-safe text
- Used by
- uptime, blog
- Source
- packages/strings
English inflection, Chicago title case, slugs and grapheme-safe text.
One implementation each for turning a class name into an identifier, a title into a slug, a
body into an excerpt and a field name into a label. Vocabulary is passed per instance, and
everything that measures or cuts text counts in grapheme clusters through
Intl.Segmenter,
so an emoji or an accented letter is never split.
Installation
npm add @sdxc/strings
Usage
Inflect English words and identifiers
import { camelize, dasherize, humanize, ordinalize, pluralize, underscore } from "@sdxc/strings";
pluralize("comment"); // "comments"
pluralize("comment", 1); // "comment"
camelize("blog_post_draft"); // "blogPostDraft"
underscore("createdAt"); // "created_at"
dasherize("blog_post_draft"); // "blog-post-draft"
humanize("author_id"); // "Author"
ordinalize(3); // "3rd"
Title-case headings and build slugs
import { sentenceCase, slugify, titleize } from "@sdxc/strings";
titleize("the state of javascript in 2026"); // "The State of JavaScript in 2026"
sentenceCase("Testing React Router Loaders", { special: ["React Router"] }); // "Testing React Router loaders"
slugify("Cómo usar Remix v3"); // "como-usar-remix-v3"
Cut and measure text in graphemes
import { capitalize, excerpt, initials, truncate, wordCount } from "@sdxc/strings";
truncate("a long sentence", { length: 10 }); // "a long se…"
truncate("a long sentence", { length: 10, words: true }); // "a long…"
excerpt("First paragraph.\n\nSecond one.", { length: 20 }); // "First paragraph.…"
wordCount("Hello, world!"); // 2
initials("Ada Lovelace"); // "AL"
capitalize("remix"); // "Remix"
Add your own vocabulary
import { createInflector, createTitleizer } from "@sdxc/strings";
let inflector = createInflector({ irregular: [["cactus", "cacti"]], uncountable: ["feedback"] });
inflector.pluralize("cactus"); // "cacti"
inflector.pluralize("feedback"); // "feedback"
let titleize = createTitleizer({ special: ["Remix"] });
titleize("getting started with remix"); // "Getting Started with Remix"
API
pluralize(word: string, count?: number): string
Plural form of an English word, or the singular form when count is exactly 1, so one call
site labels both cases. An uncountable word comes back unchanged.
singularize(word: string): string
Singular form of an English word: singularize("people") is "person".
camelize(value: string, options?: CamelizeOptions): string
camelCase form of an identifier written with underscores, dashes or spaces; upperFirst: true
produces PascalCase. Only the first letter of each part changes, so an acronym keeps its casing.
underscore(value: string): string
snake_case form of an identifier, splitting camelCase boundaries and keeping acronyms whole:
underscore("HTTPRequest") is "http_request".
dasherize(value: string): string
kebab-case form of an underscored identifier. It keeps the case of its input, so pass camelCase
through underscore() first.
humanize(value: string, options?: HumanizeOptions): string
Sentence-cased label for an identifier: a trailing _id is dropped, separators become spaces,
and only the first letter is uppercased (capitalize: false keeps it lowercase).
humanize("blogPostDraft") is "Blog post draft".
ordinalize(value: number): string
The number with its English ordinal suffix, where 11, 12 and 13 all take th:
ordinalize(112) is "112th".
createInflector(options?: InflectorOptions): Inflector
An inflector whose pluralize and singularize include your irregular pairs and uncountable
words, ahead of the English defaults. It also carries camelize, underscore, dasherize,
humanize and ordinalize, and each instance keeps its own vocabulary.
titleize(value: string, options?: TitleizeOptions): string
Headline-style capitalization following the Chicago Manual of Style, applied in this order:
| Rule | Example |
|---|---|
A special entry is rendered exactly as written | special: ["iOS"] renders ios as iOS |
| The first and last word are capitalized | "to be continued" → "To Be Continued" |
| The word after a colon is capitalized | "a history: the story" → "…: The Story" |
| Each element of a hyphenated compound follows | "state-of-the-art" → "State-of-the-Art" |
| Articles and coordinating conjunctions lowercase | a, an, the, and, but, or, nor, for, so, yet |
Prepositions of any length, as, infinitive to | "how to write" → "How to Write" |
Only a word's first letter is adjusted, and a word that reads as an identifier keeps its form:
inline code between backticks, a capital past the first letter, a digit, or code punctuation, so
GraphQL, useEffect, v3 and client_id survive as typed. A built-in special set covers names such as API, GitHub, iOS, JavaScript, macOS, npm and TypeScript;
your entries extend and override it.
createTitleizer(options?: TitleizeOptions): Titleizer
A titleize bound to a special vocabulary, so the names a product publishes are declared once.
sentenceCase(value: string, options?: SentenceCaseOptions): string
Capitalizes the first word and lowercases the rest, for a title read inside running text.
Words and phrases in special ("Remix", "React Router") render as written, possessives
included, alongside the built-in set and the pronoun I; identifiers and inline code keep
their form, as in titleize. createSentenceCaser(options) binds the list once.
slugify(value: string, options?: SlugifyOptions): string
A lowercase URL slug joined by separator (default "-", "" joins with nothing), or ""
when the input holds no letters or digits. Non-Latin letters stay letters. It stands in for:
value
.normalize("NFKD")
.replace(/\p{M}+/gu, "")
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "-"); // then trim "-" from both ends
truncate(text: string, options: TruncateOptions): string
Cuts text to at most length grapheme clusters, the omission marker included, and appends the
marker only when something was cut. words: true cuts at a word boundary.
excerpt(text: string, options: ExcerptOptions): string
Collapses every whitespace run into one space, then truncates at a word boundary, turning multi-paragraph text into a one-line summary.
wordCount(text: string, options?: LocaleOptions): number
Counts words by word-boundary segmentation, so a script written without spaces gets a count and punctuation or emoji stay out of the total.
initials(name: string, options?: InitialsOptions): string
The uppercased first grapheme of each word, keeping limit of them (default 2):
initials("Ada Byron King", { limit: 3 }) is "ABK".
capitalize(value: string, options?: LocaleOptions): string
Uppercases the first grapheme cluster and leaves the rest as written.
Types
type IrregularPair = readonly [singular: string, plural: string];
interface InflectorOptions {
irregular?: ReadonlyArray<IrregularPair>;
uncountable?: ReadonlyArray<string>;
}
interface CamelizeOptions {
upperFirst?: boolean; // PascalCase
}
interface HumanizeOptions {
capitalize?: boolean; // default true
}
interface TitleizeOptions {
special?: ReadonlyArray<string>; // matched case-insensitively
}
interface SlugifyOptions {
separator?: string; // default "-"
}
interface LocaleOptions {
locale?: Intl.LocalesArgument; // default: the runtime's locale
}
interface TruncateOptions extends LocaleOptions {
length: number; // grapheme clusters, omission included
words?: boolean; // default false
omission?: string; // default "…"
}
interface ExcerptOptions extends LocaleOptions {
length: number;
words?: boolean; // default true
omission?: string; // default "…"
}
interface InitialsOptions extends LocaleOptions {
limit?: number; // default 2
}
Inflector is the object createInflector() returns, and Titleizer is the
(value: string) => string function createTitleizer() returns.
Pattern: Stable Identifiers From Class Names
Job names, cache keys and event names stay stable across deploys when they are derived from the class name instead of written twice.
import { camelize, dasherize, underscore } from "@sdxc/strings";
function jobName(className: string) {
return dasherize(underscore(className));
}
jobName("SendWelcomeEmailJob"); // "send-welcome-email-job"
camelize("send_welcome_email_job", { upperFirst: true }); // "SendWelcomeEmailJob"
Pattern: One Slug For Forms And Jobs
A slug typed into a form and a slug a background job derives from the title agree when both go
through slugify().
import { slugify } from "@sdxc/strings";
interface PostInput {
title: string;
slug?: string;
}
function slugFor(input: PostInput) {
return slugify(input.slug || input.title);
}
slugFor({ title: "Hello, World!" }); // "hello-world"
slugFor({ title: "Hello", slug: "Mi Primer Artículo" }); // "mi-primer-articulo"
When slugs are already stored, check that slugify() reproduces them before switching, or the
published URLs move.
Pattern: Labels From Counts And Field Names
Form labels, table headers and counters come from the data's own names.
import { humanize, ordinalize, pluralize } from "@sdxc/strings";
function countLabel(total: number, noun: string) {
return `${total} ${pluralize(noun, total)}`;
}
countLabel(1, "reply"); // "1 reply"
countLabel(3, "reply"); // "3 replies"
let headers = ["author_id", "published_at"].map((field) => humanize(field)); // ["Author", "Published at"]
let place = `${ordinalize(2)} place`; // "2nd place"
Pattern: An Excerpt And Reading Time From Markdown
@sdxc/markdown turns a document into plain
text; this package summarizes and measures it.
import { Markdown } from "@sdxc/markdown";
import { toPlainText } from "@sdxc/markdown/plain";
import { isFailure } from "@sdxc/result";
import { excerpt, wordCount } from "@sdxc/strings";
let parsed = Markdown.parse(source);
if (isFailure(parsed)) throw parsed.error;
let text = toPlainText(parsed.data.document);
let summary = excerpt(text, { length: 200 });
let minutes = Math.ceil(wordCount(text) / 200);
Pattern: A Product's Published Vocabulary
Declare a product's names and nouns in one module and import the bound functions everywhere, so every heading and label is inflected the same way.
import { createInflector, createTitleizer } from "@sdxc/strings";
export let titleize = createTitleizer({ special: ["Remix", "SQLite", "WebSocket"] });
export let inflector = createInflector({
irregular: [["status-page", "status-pages"]],
uncountable: ["feedback", "metadata"],
});
titleize("syncing sqlite over a websocket"); // "Syncing SQLite over a WebSocket"
inflector.pluralize("feedback", 4); // "feedback"
Headline case and inflection are English rules: apply them to headings an author writes, and leave user input and translated copy as written.
Pattern: Avatars And Previews In Any Script
Names and previews are cut in graphemes, so emoji and combining marks stay whole; pass locale
when the text's language segments differently from the runtime's default.
import { initials, truncate, wordCount } from "@sdxc/strings";
initials("Zoë Ångström"); // "ZÅ"
truncate("Ship it 🚀🚀🚀 today", { length: 10 }); // "Ship it 🚀…"
truncate("Ship it today", { length: 12, omission: " [more]" }); // "Ship [more]"
wordCount("東京は晴れです", { locale: "ja" }); // counts words without spaces