npm package: @jimray/human-eyes
Description: Format data for people. Human-friendly formatting functions and web components based on Django's humanize filters.
Author: Jim Ray
Latest version: 0.2.0

# Human Eyes

**Legible data for real people**, JavaScript filtering based on [Django's humanize filters](https://docs.djangoproject.com/en/5.1/ref/contrib/humanize/).

Web Components make it possible, easy even, to build the kinds of complex modules we used to rely on backend framewoks for. It's nice to keep them easy to read.

Two ways to use it:

- **[Functions](#functions)** — pure JS you call from your own code (`pluralize(2)` → `"s"`)
- **[Web Components](#web-components)** — reactive custom elements that re-render when attributes change (`<human-eyes-pluralize count="2" word="item">` → "2 items")

Use the functions when you're building strings in JS that you want to make more human readable. 

Use the components when you want live-updating text on the page without writing any rendering logic.

Zero dependencies. Pure ES modules. Works in any browser or runtime — just drop the files in and import.

**Disclaimer:** This package, including this README but not this specific paragraph, is almost 100% prompt-engineered, machine generated, agent-assisted — however we're referring to code written by an LLM these days (in this case Claude Opus 4.6). I find the term "vibe coding" obscures more than it enlightens but if that's what you're feeling, go for it. Like almost everyone I know in tech right now, I've got some Big Feelings about agentic coding, this project is part of working that out. 

I've reviewed the code to the best of my (not at all exceptional or even very above average) abilities.

## Quick start

Copy the files you need into your project. Set up an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap) so your JS can use clean bare specifiers — no bundler required:

```html
<script type="importmap">
{
  "imports": {
    "human-eyes/pluralize": "./human-eyes/pluralize.js",
    "human-eyes/timesince": "./human-eyes/timesince.js",
    "human-eyes/ordinal":   "./human-eyes/ordinal.js"
  }
}
</script>
<script type="module">
  import { pluralize } from "human-eyes/pluralize";
  import { timesince } from "human-eyes/timesince";
  import { ordinal }   from "human-eyes/ordinal";

  `${count} file${pluralize(count)}`;       // "3 files"
  `joined ${timesince(user.createdAt)} ago`; // "joined 2 months, 3 days ago"
  `finished ${ordinal(position)}`;           // "finished 1st"
</script>
```

Each file is self-contained — grab one, grab all of them, no build step required. Map only the functions you use.

## Functions

### pluralize

Returns a plural suffix based on a numeric value. Mirrors Django's [`pluralize`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#pluralize) filter.

```js
import { pluralize } from "human-eyes/pluralize";

pluralize(1);              // ""
pluralize(2);              // "s"
pluralize(1, "es");        // ""
pluralize(2, "es");        // "es"
pluralize(1, "y,ies");     // "y"
pluralize(2, "y,ies");     // "ies"
pluralize(0, "person,people"); // "people" (full-word replacement)
```

---

### timesince / timeuntil

Formats the time difference between two dates as a human-readable string with up to two levels of granularity. Mirrors Django's [`timesince`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#timesince) and [`timeuntil`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#timeuntil) filters.

```js
import { timesince, timeuntil } from "human-eyes/timesince";

timesince("2024-01-01", "2024-01-05T06:30:00");
// "4 days, 6 hours"

timesince("2024-06-01", "2024-06-01T00:00:30");
// "0 minutes" (less than 60 seconds)

timeuntil("2024-12-25", "2024-12-20");
// "5 days"
```

The second argument defaults to `new Date()` if omitted.

---

### filesizeformat

Formats a number of bytes as a human-readable file size using 1024-based units. Mirrors Django's [`filesizeformat`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#filesizeformat) filter.

```js
import { filesizeformat } from "human-eyes/filesizeformat";

filesizeformat(0);           // "0 bytes"
filesizeformat(1);           // "1 byte"
filesizeformat(1024);        // "1 KB"
filesizeformat(123456789);   // "117.7 MB"
filesizeformat(1073741824);  // "1 GB"
```

---

### truncatechars / truncatewords

Truncates strings by character count or word count, appending "…" when truncated. Mirrors Django's [`truncatechars`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#truncatechars) and [`truncatewords`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#truncatewords) filters.

```js
import { truncatechars, truncatewords } from "human-eyes/truncate";

truncatechars("Joel is a slug", 7);   // "Joel i…"
truncatechars("short", 10);           // "short" (no truncation)

truncatewords("Joel is a slug", 2);   // "Joel is …"
truncatewords("hello\nworld foo", 2); // "hello world …" (newlines removed)
```

The character limit for `truncatechars` includes the ellipsis character. `truncatewords` removes newlines before splitting, matching Django's behavior.

---

### yesno

Maps truthy, falsy, and null/undefined values to custom strings. Mirrors Django's [`yesno`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#yesno) filter.

```js
import { yesno } from "human-eyes/yesno";

yesno(true, "yeah,no,maybe");   // "yeah"
yesno(false, "yeah,no,maybe");  // "no"
yesno(null, "yeah,no,maybe");   // "maybe"
yesno(null, "yeah,no");         // "no" (null falls back to false value)
yesno(true);                    // "yes" (defaults)
```

The argument is a comma-separated string: `"trueValue,falseValue[,nullValue]"`. If only two values are given, `null`/`undefined` maps to the false value.

---

### floatformat

Formats floating-point numbers with control over decimal places. Mirrors Django's [`floatformat`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#floatformat) filter.

```js
import { floatformat } from "human-eyes/floatformat";

// Default: 1 decimal, strip trailing zeros
floatformat(34.23234);          // "34.2"
floatformat(34.0);              // "34"

// Positive arg: always show N decimals
floatformat(34.23234, 3);       // "34.232"
floatformat(34.0, 3);           // "34.000"

// Zero: round to integer
floatformat(39.56, 0);          // "40"

// Negative arg: up to N decimals, strip trailing zeros
floatformat(34.232, -3);        // "34.232"
floatformat(34.0, -3);          // "34"

// "g" suffix: enable thousand-separator grouping
floatformat(34232.34, "2g");    // "34,232.34"
```

---

### slugify

Converts a string to a URL-friendly slug. Mirrors Django's [`slugify`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#slugify) filter.

```js
import { slugify } from "human-eyes/slugify";

slugify("Joel is a slug");    // "joel-is-a-slug"
slugify("Héllo Wörld");       // "hello-world"
slugify("  Hello, World!  "); // "hello-world"
slugify("hello_world");       // "hello_world" (underscores preserved)
```

Uses Unicode NFKD normalization to decompose accented characters, then strips combining marks.

---

### oxfordComma

Joins a list of items into a human-readable string with an Oxford comma. Inspired by [humanize-plus's `oxford`](https://github.hubspot.com/humanize/) function. Not a Django built-in, but a natural complement to the other filters.

```js
import { oxfordComma } from "human-eyes/oxford";

oxfordComma(["apple"]);                          // "apple"
oxfordComma(["apple", "banana"]);                // "apple and banana"
oxfordComma(["apple", "banana", "cherry"]);      // "apple, banana, and cherry"

// Limit the number of items shown
oxfordComma(["a", "b", "c", "d", "e"], 3);       // "a, b, c, and 2 others"
oxfordComma(["a", "b", "c", "d"], 3);            // "a, b, c, and 1 other"

// Custom suffix
oxfordComma(["a", "b", "c"], 2, "and more");     // "a, b, and more"
```

---

### ordinal

Converts an integer to its ordinal string representation. Mirrors Django's [`ordinal`](https://docs.djangoproject.com/en/5.1/ref/contrib/humanize/#ordinal) filter.

```js
import { ordinal } from "human-eyes/ordinal";

ordinal(1);    // "1st"
ordinal(2);    // "2nd"
ordinal(3);    // "3rd"
ordinal(4);    // "4th"
ordinal(11);   // "11th"
ordinal(12);   // "12th"
ordinal(13);   // "13th"
ordinal(22);   // "22nd"
ordinal(103);  // "103rd"
ordinal(111);  // "111th"
```

Correctly handles the 11th/12th/13th special cases and their hundreds equivalents (111th, 112th, 113th).

---

### intword

Converts large integers to friendly text representation. Mirrors Django's [`intword`](https://docs.djangoproject.com/en/5.1/ref/contrib/humanize/#intword) filter. Works best for numbers over 1 million.

```js
import { intword } from "human-eyes/intword";

intword(1000000);        // "1 million"
intword(1200000);        // "1.2 million"
intword(1200000000);     // "1.2 billion"
intword(1500000000000);  // "1.5 trillion"
intword(-3500000);       // "-3.5 million"
intword(500);            // "500"
intword(999999);         // "999,999"
```

Supports million, billion, trillion, quadrillion, and quintillion. Numbers below 1 million are returned with comma formatting.

---

### naturalday

Returns "today", "yesterday", or "tomorrow" when appropriate, otherwise returns a formatted date string. Mirrors Django's [`naturalday`](https://docs.djangoproject.com/en/5.1/ref/contrib/humanize/#naturalday) filter.

```js
import { naturalday } from "human-eyes/naturalday";

// Assuming today is June 15, 2024
naturalday("2024-06-15");  // "today"
naturalday("2024-06-14");  // "yesterday"
naturalday("2024-06-16");  // "tomorrow"
naturalday("2024-06-10");  // "Jun 10, 2024"

// Custom format
naturalday("2024-06-10", { month: "long", day: "numeric" });
// "June 10"
```

Compares calendar dates (ignoring time), so a Date at 11:59 PM and one at 12:01 AM on the same day both return "today". Accepts `Intl.DateTimeFormatOptions` for non-relative dates.

---

## Web Components

For values that need reactive rendering in the DOM, three web components are provided. Import the component module to register the custom element.

### `<human-eyes-pluralize>`

```html
<script type="module">
  import "human-eyes/components/pluralize";
</script>

<!-- Simple: "3 items" / "1 item" -->
<human-eyes-pluralize count="3" word="item"></human-eyes-pluralize>

<!-- Custom suffix: "2 walruses" / "1 walrus" -->
<human-eyes-pluralize count="2" word="walrus" suffix="es"></human-eyes-pluralize>

<!-- Irregular: "2 counties" / "1 county" -->
<human-eyes-pluralize count="2" word="count" suffix="y,ies"></human-eyes-pluralize>

<!-- Full-word replacement: "0 people" / "1 person" -->
<human-eyes-pluralize count="0" suffix="person,people"></human-eyes-pluralize>

<!-- Suffix-only (for surrounding text): -->
<p>You have 5 unread message<human-eyes-pluralize count="5"></human-eyes-pluralize>.</p>
```

| Attribute | Default | Description |
|-----------|---------|-------------|
| `count` | — | Numeric value to pluralize against (required) |
| `word` | — | Base word to prepend with count (renders `{count} {word}{suffix}`) |
| `suffix` | `"s"` | Suffix string using Django conventions — plain (`"es"`), comma-pair (`"y,ies"`), or full-word (`"person,people"`) |

When `word` is omitted, only the resolved suffix is rendered — useful for inline use within surrounding text.

Because it's a web component, `count` is a reactive attribute — update it from JS and the text re-renders automatically:

```html
<label>Count: <span id="n">2</span></label>
<input type="range" id="slider" min="0" max="20" value="2">

<p><human-eyes-pluralize id="demo" count="2" word="message"></human-eyes-pluralize></p>
<p><human-eyes-pluralize id="demo2" count="2" word="cherr" suffix="y,ies"></human-eyes-pluralize></p>

<script type="module">
  import "human-eyes/components/pluralize";

  slider.oninput = () => {
    document.getElementById("n").textContent = slider.value;
    demo.setAttribute("count", slider.value);
    demo2.setAttribute("count", slider.value);
  };
</script>
```

A full working version of this is in [`demo.html`](demo.html).

### `<human-eyes-timesince>`

Live-updating relative time display. Refreshes every 60 seconds by default.

```html
<script type="module">
  import "human-eyes/components/timesince";
</script>

<human-eyes-timesince datetime="2024-01-01T00:00:00Z"></human-eyes-timesince>
<!-- "5 months, 2 weeks" (updates live) -->

<human-eyes-timesince datetime="2025-12-25" mode="until"></human-eyes-timesince>
<!-- "1 year, 6 months" -->

<!-- Disable live updates -->
<human-eyes-timesince datetime="2024-01-01" live="false"></human-eyes-timesince>
```

| Attribute | Default | Description |
|-----------|---------|-------------|
| `datetime` | — | ISO 8601 date string or Unix timestamp (required) |
| `mode` | `"since"` | `"since"` or `"until"` |
| `live` | `"true"` | Auto-update every 60s |

### `<human-eyes-filesizeformat>`

```html
<script type="module">
  import "human-eyes/components/filesizeformat";
</script>

<human-eyes-filesizeformat bytes="123456789"></human-eyes-filesizeformat>
<!-- "117.7 MB" -->
```

| Attribute | Description |
|-----------|-------------|
| `bytes` | File size in bytes (required) |

---

## Why web components for only some filters?

Components are provided for filters whose output benefits from **reactive attribute watching** — values that change dynamically (live time, file upload sizes, item counts). The other filters (`truncate`, `yesno`, `floatformat`, `slugify`) are pure functions best called once during rendering.

## Development

If you want to run the test suite, npm is used for dev tooling only:

```bash
npm install
npm test
```

Tests use [Vitest](https://vitest.dev/). No runtime dependencies are installed — `node_modules` is only needed for running tests.

## License

MIT