Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,77 @@ function example(s: string) {
}
```

### Localization

`ms` ships with built-in locales for **French** (`fr`), **Arabic** (`ar`), **German** (`de`), **Spanish** (`es`), and **Chinese** (`zh`). Pass a locale to `format` or `ms` via the `locale` option:

```ts
import { ms, format, fr, de, zh } from 'ms';

// Short format
format(60000, { locale: fr }) // "1min"
format(3600000, { locale: de }) // "1Std"
format(86400000, { locale: zh }) // "1天"

// Long format
ms(1000, { locale: fr, long: true }) // "1 seconde"
ms(10000, { locale: fr, long: true }) // "10 secondes"
ms(3600000, { locale: de, long: true }) // "1 Stunde"
ms(7200000, { locale: de, long: true }) // "2 Stunden"
ms(86400000, { locale: zh, long: true }) // "1 天"
```

> [!NOTE]
> Parsing (`ms('1s')`, `parse('1h')`) is always English-only regardless of locale. Only formatting is localized.

#### Adding a Custom Locale

Implement the `LocaleDefinition` interface exported from `ms` and pass it as `locale`:

```ts
import { format, type LocaleDefinition } from 'ms';

const pt: LocaleDefinition = {
shortUnits: {
ms: 'ms',
s: 's',
m: 'min',
h: 'h',
d: 'd',
w: 'sem',
mo: 'mês',
y: 'ano',
},
longUnits: {
millisecond: ['milissegundo', 'milissegundos'],
second: ['segundo', 'segundos'],
minute: ['minuto', 'minutos'],
hour: ['hora', 'horas'],
day: ['dia', 'dias'],
week: ['semana', 'semanas'],
month: ['mês', 'meses'],
year: ['ano', 'anos'],
},
// Return true when the plural form should be used.
// Omit to use the English default: value >= unitMs * 1.5.
isPlural: (value) => value !== 1,
};

format(1000, { locale: pt, long: true }); // "1 segundo"
format(10000, { locale: pt, long: true }); // "10 segundos"
format(60000, { locale: pt }); // "1min"
```

The `LocaleDefinition` fields:

| Field | Type | Description |
|---|---|---|
| `shortUnits` | `object` | Abbreviated unit labels appended directly after the number (e.g. `1min`, `2h`) |
| `longUnits` | `object` | `[singular, plural]` pair for each unit, used in long format |
| `isPlural` | `(value: number) => boolean` | Optional. Receives the rounded absolute display value; return `true` to use the plural form. Defaults to `value >= unitMs * 1.5` (English-style threshold). |

For languages without grammatical number (like Chinese), set `isPlural: () => false` and use the same string for both entries in each `longUnits` pair.

## Edge Runtime Support

`ms` is compatible with the [Edge Runtime](https://edge-runtime.vercel.app/). It can be used inside environments like [Vercel Edge Functions](https://vercel.com/edge) as follows:
Expand Down
118 changes: 93 additions & 25 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,74 @@ export type StringValue =
| `${number}${UnitAnyCase}`
| `${number} ${UnitAnyCase}`;

interface Options {
export interface LocaleDefinition {
shortUnits: {
ms: string;
s: string;
m: string;
h: string;
d: string;
w: string;
mo: string;
y: string;
};
longUnits: {
millisecond: [string, string];
second: [string, string];
minute: [string, string];
hour: [string, string];
day: [string, string];
week: [string, string];
month: [string, string];
year: [string, string];
};
/**
* Receives rounded abs display value, returns true if plural form should be used.
* Defaults to English-style: `msAbs >= unit * 1.5`.
*/
isPlural?: (value: number) => boolean;
}

export interface Options {
/**
* Set to `true` to use verbose formatting. Defaults to `false`.
*/
long?: boolean;
/**
* Locale definition for formatting. Defaults to English.
*/
locale?: LocaleDefinition;
}

export const en: LocaleDefinition = {
shortUnits: {
ms: 'ms',
s: 's',
m: 'm',
h: 'h',
d: 'd',
w: 'w',
mo: 'mo',
y: 'y',
},
longUnits: {
millisecond: ['ms', 'ms'],
second: ['second', 'seconds'],
minute: ['minute', 'minutes'],
hour: ['hour', 'hours'],
day: ['day', 'days'],
week: ['week', 'weeks'],
month: ['month', 'months'],
year: ['year', 'years'],
},
};

export { ar } from './locales/ar';
export { de } from './locales/de';
export { es } from './locales/es';
export { fr } from './locales/fr';
export { zh } from './locales/zh';

/**
* Parse or format the given value.
*
Expand Down Expand Up @@ -160,59 +221,62 @@ export function parseStrict(value: StringValue): number {
/**
* Short format for `ms`.
*/
function fmtShort(ms: number): StringValue {
function fmtShort(ms: number, locale: LocaleDefinition): string {
const msAbs = Math.abs(ms);
const u = locale.shortUnits;
if (msAbs >= y) {
return `${Math.round(ms / y)}y`;
return `${Math.round(ms / y)}${u.y}`;
}
if (msAbs >= mo) {
return `${Math.round(ms / mo)}mo`;
return `${Math.round(ms / mo)}${u.mo}`;
}
if (msAbs >= w) {
return `${Math.round(ms / w)}w`;
return `${Math.round(ms / w)}${u.w}`;
}
if (msAbs >= d) {
return `${Math.round(ms / d)}d`;
return `${Math.round(ms / d)}${u.d}`;
}
if (msAbs >= h) {
return `${Math.round(ms / h)}h`;
return `${Math.round(ms / h)}${u.h}`;
}
if (msAbs >= m) {
return `${Math.round(ms / m)}m`;
return `${Math.round(ms / m)}${u.m}`;
}
if (msAbs >= s) {
return `${Math.round(ms / s)}s`;
return `${Math.round(ms / s)}${u.s}`;
}
return `${ms}ms`;
return `${ms}${u.ms}`;
}

/**
* Long format for `ms`.
*/
function fmtLong(ms: number): StringValue {
function fmtLong(ms: number, locale: LocaleDefinition): string {
const msAbs = Math.abs(ms);
const { longUnits, isPlural } = locale;
if (msAbs >= y) {
return plural(ms, msAbs, y, 'year');
return fmtPlural(ms, msAbs, y, longUnits.year, isPlural);
}
if (msAbs >= mo) {
return plural(ms, msAbs, mo, 'month');
return fmtPlural(ms, msAbs, mo, longUnits.month, isPlural);
}
if (msAbs >= w) {
return plural(ms, msAbs, w, 'week');
return fmtPlural(ms, msAbs, w, longUnits.week, isPlural);
}
if (msAbs >= d) {
return plural(ms, msAbs, d, 'day');
return fmtPlural(ms, msAbs, d, longUnits.day, isPlural);
}
if (msAbs >= h) {
return plural(ms, msAbs, h, 'hour');
return fmtPlural(ms, msAbs, h, longUnits.hour, isPlural);
}
if (msAbs >= m) {
return plural(ms, msAbs, m, 'minute');
return fmtPlural(ms, msAbs, m, longUnits.minute, isPlural);
}
if (msAbs >= s) {
return plural(ms, msAbs, s, 'second');
return fmtPlural(ms, msAbs, s, longUnits.second, isPlural);
}
return `${ms} ms`;
const shouldBePlural = isPlural ? isPlural(msAbs) : msAbs >= 1.5;
return `${ms} ${shouldBePlural ? longUnits.millisecond[1] : longUnits.millisecond[0]}`;
}

/**
Expand All @@ -227,18 +291,22 @@ export function format(ms: number, options?: Options): string {
throw new Error('Value provided to ms.format() must be of type number.');
}

return options?.long ? fmtLong(ms) : fmtShort(ms);
const locale = options?.locale ?? en;
return options?.long ? fmtLong(ms, locale) : fmtShort(ms, locale);
}

/**
* Pluralization helper.
*/
function plural(
function fmtPlural(
ms: number,
msAbs: number,
n: number,
name: string,
): StringValue {
const isPlural = msAbs >= n * 1.5;
return `${Math.round(ms / n)} ${name}${isPlural ? 's' : ''}` as StringValue;
units: [string, string],
isPlural?: (value: number) => boolean,
): string {
const value = Math.round(ms / n);
const absValue = Math.abs(value);
const shouldBePlural = isPlural ? isPlural(absValue) : msAbs >= n * 1.5;
return `${value} ${shouldBePlural ? units[1] : units[0]}`;
}
47 changes: 47 additions & 0 deletions src/locales/ar.parse-strict.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from '@jest/globals';
import { parseStrict } from '../index';

// parseStrict() is typed to only accept StringValue (English units).
// All Arabic locale output uses Arabic script — none are valid StringValue.

describe('parseStrict — ar short format', () => {
it('should return NaN for all Arabic short units (type errors)', () => {
// @ts-expect-error — Arabic script, not a StringValue
expect(Number.isNaN(parseStrict('500مللي ث'))).toBe(true);
// @ts-expect-error — Arabic script, not a StringValue
expect(Number.isNaN(parseStrict('1ث'))).toBe(true);
// @ts-expect-error — Arabic script, not a StringValue
expect(Number.isNaN(parseStrict('1د'))).toBe(true);
// @ts-expect-error — Arabic script, not a StringValue
expect(Number.isNaN(parseStrict('1س'))).toBe(true);
// @ts-expect-error — Arabic script, not a StringValue
expect(Number.isNaN(parseStrict('1ي'))).toBe(true);
// @ts-expect-error — Arabic script, not a StringValue
expect(Number.isNaN(parseStrict('1أ'))).toBe(true);
// @ts-expect-error — Arabic script, not a StringValue
expect(Number.isNaN(parseStrict('1شه'))).toBe(true);
// @ts-expect-error — Arabic script, not a StringValue
expect(Number.isNaN(parseStrict('1سن'))).toBe(true);
});
});

describe('parseStrict — ar long format', () => {
it('should return NaN for all Arabic long unit words (type errors)', () => {
// @ts-expect-error — not a StringValue
expect(Number.isNaN(parseStrict('1 مللي ثانية'))).toBe(true);
// @ts-expect-error — not a StringValue
expect(Number.isNaN(parseStrict('1 ثانية'))).toBe(true);
// @ts-expect-error — not a StringValue
expect(Number.isNaN(parseStrict('1 دقيقة'))).toBe(true);
// @ts-expect-error — not a StringValue
expect(Number.isNaN(parseStrict('1 ساعة'))).toBe(true);
// @ts-expect-error — not a StringValue
expect(Number.isNaN(parseStrict('1 يوم'))).toBe(true);
// @ts-expect-error — not a StringValue
expect(Number.isNaN(parseStrict('1 أسبوع'))).toBe(true);
// @ts-expect-error — not a StringValue
expect(Number.isNaN(parseStrict('1 شهر'))).toBe(true);
// @ts-expect-error — not a StringValue
expect(Number.isNaN(parseStrict('1 سنة'))).toBe(true);
});
});
45 changes: 45 additions & 0 deletions src/locales/ar.parse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from '@jest/globals';
import { ar, format, parse } from '../index';

// parse() is English-only. All Arabic locale output uses Arabic script,
// so no locale-formatted strings can be round-tripped through parse().

describe('parse — ar short format', () => {
it('should return NaN for all Arabic short units', () => {
expect(Number.isNaN(parse(format(500, { locale: ar })))).toBe(true); // '500مللي ث'
expect(Number.isNaN(parse(format(1000, { locale: ar })))).toBe(true); // '1ث'
expect(Number.isNaN(parse(format(60 * 1000, { locale: ar })))).toBe(true); // '1د'
expect(Number.isNaN(parse(format(60 * 60 * 1000, { locale: ar })))).toBe(
true,
); // '1س'
expect(
Number.isNaN(parse(format(24 * 60 * 60 * 1000, { locale: ar }))),
).toBe(true); // '1ي'
expect(
Number.isNaN(parse(format(7 * 24 * 60 * 60 * 1000, { locale: ar }))),
).toBe(true); // '1أ'
expect(
Number.isNaN(
parse(format(30.4375 * 24 * 60 * 60 * 1000, { locale: ar })),
),
).toBe(true); // '1شه'
expect(
Number.isNaN(
parse(format(365.25 * 24 * 60 * 60 * 1000 + 1, { locale: ar })),
),
).toBe(true); // '1سن'
});
});

describe('parse — ar long format', () => {
it('should return NaN for all Arabic long unit words', () => {
expect(Number.isNaN(parse('1 مللي ثانية'))).toBe(true);
expect(Number.isNaN(parse('1 ثانية'))).toBe(true);
expect(Number.isNaN(parse('1 دقيقة'))).toBe(true);
expect(Number.isNaN(parse('1 ساعة'))).toBe(true);
expect(Number.isNaN(parse('1 يوم'))).toBe(true);
expect(Number.isNaN(parse('1 أسبوع'))).toBe(true);
expect(Number.isNaN(parse('1 شهر'))).toBe(true);
expect(Number.isNaN(parse('1 سنة'))).toBe(true);
});
});
Loading