- JavaScript 65%
- HTML 35%
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.
Note: Months and years are approximated with fixed averages (30 and 365 days) rather than real calendar arithmetic. Django walks the actual calendar, so spans crossing long months or leap years may differ by a day or so. The trade-off keeps the function dependency-free and timezone-agnostic.
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.0 KB"
filesizeformat(123456789); // "117.7 MB"
filesizeformat(1073741824); // "1.0 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. The visible text is wrapped in a nested <time datetime="…"> element, so the exact timestamp stays machine-readable for assistive technology and search engines while the text reads naturally.
<script type="module">
import "human-eyes/components/timesince";
</script>
<human-eyes-timesince datetime="2024-01-01T00:00:00Z"></human-eyes-timesince>
<!-- <time datetime="2024-01-01T00:00:00.000Z">5 months, 2 weeks</time> (updates live) -->
<human-eyes-timesince datetime="2025-12-25" mode="until"></human-eyes-timesince>
<!-- <time datetime="2025-12-25T00:00:00.000Z">1 year, 6 months</time> -->
<!-- Disable live updates -->
<human-eyes-timesince datetime="2024-01-01" live="false"></human-eyes-timesince>
The live refresh cadence scales to the size of the gap — minute-level deltas refresh every minute, hour-level deltas hourly, and anything a day or more out refreshes daily — so old timestamps don't re-render every 60 seconds for no reason.
| Attribute | Default | Description |
|---|---|---|
datetime |
— | ISO 8601 date string or Unix timestamp (required) |
mode |
"since" |
"since" or "until" |
live |
"true" |
Auto-update on a cadence scaled to the time gap; set "false" to disable |
<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 test
Tests use Vitest. No runtime dependencies are installed — node_modules is only needed for running tests.
License #
MIT