@jimray/human-eyes
v0.2.0
Published
Format data for people. Human-friendly formatting functions and web components based on Django's humanize filters.
Maintainers
Readme
Human Eyes
Legible data for real people, JavaScript filtering based on Django's humanize filters.
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 — pure JS you call from your own code (
pluralize(2)→"s") - 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 so your JS can use clean bare specifiers — no bundler required:
<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 filter.
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 and timeuntil filters.
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 filter.
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 and truncatewords filters.
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 filter.
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 filter.
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 filter.
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 function. Not a Django built-in, but a natural complement to the other filters.
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 filter.
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 filter. Works best for numbers over 1 million.
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 filter.
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>
<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:
<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.
<human-eyes-timesince>
Live-updating relative time display. Refreshes every 60 seconds by default.
<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>
<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:
npm install
npm testTests use Vitest. No runtime dependencies are installed — node_modules is only needed for running tests.
License
MIT
