String helpers
Seven helpers that count graphemes rather than UTF-16 units, and follow the reader's language rather than English.
<script setup lang="ts">
import { capitalize, initials, maskString, slugify, titleCase, truncate } from '@shared/utils/string'
import { Button } from '@shared/components/ui/button'
import { Input } from '@shared/components/ui/input'
import { Label } from '@shared/components/ui/label'
import { computed, ref } from 'vue'
const LOCALES = ['tr', 'en', 'de', 'ar']
const text = ref('istanbul şehri')
const locale = ref('tr')
const maxLength = ref(10)
const card = ref('4242424242424242')
const rows = computed(() => [
{ fn: `capitalize(value, { locale: '${locale.value}' })`, out: capitalize(text.value, { locale: locale.value }) },
{ fn: `titleCase(value, { locale: '${locale.value}' })`, out: titleCase(text.value, { locale: locale.value }) },
{ fn: 'slugify(value)', out: slugify(text.value) },
{ fn: 'slugify(value, { ascii: true })', out: slugify(text.value, { ascii: true }) || '(empty — ascii is Latin-only)' },
{ fn: `truncate(value, ${maxLength.value})`, out: truncate(text.value, maxLength.value) },
{ fn: `initials(value, { locale: '${locale.value}' })`, out: initials(text.value, { locale: locale.value }) },
])
</script>
<template>
<div class="flex flex-col gap-4">
<div class="grid gap-3 sm:grid-cols-3">
<div class="grid gap-1 sm:col-span-2">
<Label for="str-value">Value</Label>
<Input id="str-value" v-model="text" />
</div>
<div class="grid gap-1">
<span class="sa-text-sm font-medium">Locale</span>
<div class="flex flex-wrap items-center gap-2">
<Button
v-for="code in LOCALES"
:key="code"
size="sm"
variant="outline"
:aria-pressed="locale === code"
@click="locale = code"
>
{{ code }}
</Button>
</div>
</div>
</div>
<div class="grid gap-1 max-w-[12rem]">
<Label for="str-max">truncate length</Label>
<Input id="str-max" v-model.number="maxLength" type="number" min="1" max="40" />
</div>
<table class="w-full border-collapse sa-text-sm">
<tbody>
<tr v-for="row in rows" :key="row.fn" class="border-b border-border">
<td class="py-2 pe-4 align-top">
<code>{{ row.fn }}</code>
</td>
<td class="py-2 align-top font-medium">
{{ row.out }}
</td>
</tr>
</tbody>
</table>
<div class="grid gap-1 max-w-sm">
<Label for="str-card">maskString</Label>
<Input id="str-card" v-model="card" />
<p class="sa-text-sm text-muted-foreground">
{{ maskString(card) }}
</p>
</div>
</div>
</template>Import
import { capitalize, initials, maskString, slugify, titleCase, truncate } from '@shared/utils/string'
import { stripHtml } from '@shared/utils/html'capitalize
function capitalize(value: string, options?: CaseOptions): string
interface CaseOptions {
locale?: string
lowercaseRest?: boolean
}Uppercases the first grapheme, locale-aware.
capitalize('istanbul', { locale: 'tr' }) // 'İstanbul'
capitalize('istanbul', { locale: 'en' }) // 'Istanbul'
capitalize('iPhone') // 'IPhone'lowercaseRest is off by default, because turning it on destroys iPhone.
locale goes straight to toLocaleUpperCase, which throws RangeError: Invalid language tag on anything malformed. See localeTag.
titleCase
function titleCase(value: string, options?: CaseOptions): stringtitleCase('istanbul şehri', { locale: 'tr' }) // 'İstanbul Şehri'Boundaries are whitespace and hyphens only. An apostrophe boundary would fix o'brien and break every contraction, which is the worse trade.
slugify
function slugify(value: string, options?: SlugifyOptions): string
interface SlugifyOptions {
separator?: string
ascii?: boolean
locale?: string
}slugify('Hello, World!') // 'hello-world'
slugify('مرحبا بالعالم') // 'مرحبا-بالعالم'
slugify('Crème Brûlée', { ascii: true }) // 'creme-brulee'It keeps letters of any script. The character class is \p{L}\p{N}; the usual [^a-z0-9] would return an empty string for the Arabic example.
ascii: true reduces to [a-z0-9] and is Latin-only — Arabic, Chinese and Hebrew reduce to an empty slug, so leave it off for anything multilingual.
Lowercasing is locale-independent by default, so a slug cannot change because the reader switched language. Uncomposable combining marks are dropped, which is right for Arabic harakat and wrong for a script whose standalone marks carry vowels.
truncate
function truncate(value: string, maxLength: number, options?: TruncateOptions): stringtruncate('The quick brown fox', 10) // 'The quick…'
truncate('Family 👨👩👧👦 time', 9) // 'Family 👨👩👧👦…'Graphemes, not code units. A ZWJ family emoji is one character to a reader but eleven UTF-16 units, so .slice() prints a replacement box. The ellipsis is inside the budget, so the result is never longer than maxLength.
The suffix defaults to '…', one character.
initials
function initials(value: string, options?: InitialsOptions): string
interface InitialsOptions {
max?: number
locale?: string
}initials('Ada Lovelace') // 'AL'
initials('محمد المعراج') // 'ما'
initials('ismail kaya', { locale: 'tr' }) // 'İK'The first grapheme of each word, so an astral character yields a whole glyph rather than half a surrogate pair. max defaults to 2.
maskString
function maskString(value: string, options?: MaskOptions): string
interface MaskOptions {
keepStart?: number
keepEnd?: number
maskChar?: string
locale?: string
}maskString('4242424242424242') // '••••••••••••4242'
maskString('ada@example.com', { keepStart: 2, keepEnd: 4 }) // 'ad•••••••••.com'Defaults: keepStart 0, keepEnd 4, maskChar '•'.
It always masks at least one character. keepStart and keepEnd are clamped rather than allowed to no-op — which is how a card number ends up rendered in full.
stripHtml
function stripHtml(value: string): stringimport { stripHtml } from '@shared/utils/html'Removes tags, for a plain-text preview of rich-text content.
Related
- Intl helpers — the grapheme segmenter these share.
useSaEditor— wherestripHtmlearns its place.- Color helpers —
colorFromStringpairs withinitialsfor avatars.