Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ These instructions are for agents working on this repository. They are not shipp

## Packages

- This is a bun workspace. The core, `quanto`, is `packages/quanto`. It's the protocol: codec contracts, definition helpers (`defineCodec`, `quantity`, `defineRange`, …), context, issues, composition and shared parsing primitives. It holds strong opinions that every codec conforms to, and it has no codecs. The codecs are a bootstrap, to use, copy and modify: the common ones are `@quantojs/common` (`packages/common`), quantities, numbers, percent, ratio, text, money, odds and ready-made formatters. Dates are a separate package, `@quantojs/datetime` (`packages/datetime`), and so are music, `@quantojs/music` (`packages/music`), addresses, `@quantojs/libpostal` (`packages/libpostal`), an external codec over libpostal, phone numbers, `@quantojs/libphonenumber` (`packages/libphonenumber`), over libphonenumber-js, coordinates, `@quantojs/geo` (`packages/geo`), and ring and shoe sizes, `@quantojs/sizes` (`packages/sizes`). `@quantojs/anything` (`packages/anything`) merges them all into one codec. A codec never goes in the core. New first-party codecs go in `@quantojs/common` when their behaviour is complete and settled (a unit table, a small parser, or a finished domain like money), and in a dedicated package when they're intentionally incomplete or still evolving, so their parse results are expected to change between releases. What goes in the core is what codecs share and must agree on. The React input is `@quantojs/react` (`packages/react`). The site is `apps/site`. The root is private: shared tooling and the repo docs.
- This is a bun workspace. The core, `quanto`, is `packages/quanto`. It's the protocol: codec contracts, definition helpers (`defineCodec`, `quantity`, `defineRange`, …), context, issues, composition and shared parsing primitives. It holds strong opinions that every codec conforms to, and it has no codecs. The codecs are a bootstrap, to use, copy and modify: the common ones are `@quantojs/common` (`packages/common`), quantities, numbers, percent, ratio, text, money, odds, CSS colors and ready-made formatters. Dates are a separate package, `@quantojs/datetime` (`packages/datetime`), and so are music, `@quantojs/music` (`packages/music`), addresses, `@quantojs/libpostal` (`packages/libpostal`), an external codec over libpostal, phone numbers, `@quantojs/libphonenumber` (`packages/libphonenumber`), over libphonenumber-js, coordinates, `@quantojs/geo` (`packages/geo`), and ring and shoe sizes, `@quantojs/sizes` (`packages/sizes`). `@quantojs/anything` (`packages/anything`) merges them all into one codec. A codec never goes in the core. New first-party codecs go in `@quantojs/common` when their behaviour is complete and settled (a unit table, a small parser, or a finished domain like money), and in a dedicated package when they're intentionally incomplete or still evolving, so their parse results are expected to change between releases. What goes in the core is what codecs share and must agree on. The React input is `@quantojs/react` (`packages/react`). The site is `apps/site`. The root is private: shared tooling and the repo docs.
- Codec packages, `@quantojs/common` included, import quanto only by name, through its public API (`quanto`, `quanto/quantity`, `quanto/formats`, `quanto/testing`), never by relative path into `packages/quanto/src`. If it needs something that isn't public, raise it as an API question. Codec packages don't depend on each other; the core's own tests use `@quantojs/common` as a dev dependency. The exception is `@quantojs/anything` (`packages/anything`), which merges the others into one codec and so depends on them; it isn't a codec package of its own, and nothing depends on it.
- `bun run typecheck` and `bun run build` at the root cover every package.

Expand Down
22 changes: 17 additions & 5 deletions DESIGN.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ length().parse('70 kg'); // { ok: false, issues: [{ code: 'unknown_unit', … }
- **Round-trips.** Whatever `format` prints, `parse` reads back.
- **Yours to extend.** `quanto` is the protocol; the codecs are a bootstrap. Yours use the same API as the first-party ones, and a fixtures file is the spec.

[`@quantojs/common`](packages/common/README.md) has length, mass, duration, temperature, volume, area, speed, data size and rate, compute (FLOPs and FLOPS), energy, power, pressure, angle, frequency, fuel economy, pace, torque, force, acceleration, flow rate, density, electrical (voltage, current, resistance, capacitance, charge), light (lumens, lux, nits), radiation dose, sound level, plain numbers, percent, proportions (‰, basis points, ppm) and ratios, with money in `@quantojs/common/money` and betting odds in `@quantojs/common/odds`. [`@quantojs/datetime`](packages/datetime/README.md) adds dates, times and ranges like `Oct 3-5`, and separate packages add [phone numbers](packages/libphonenumber/README.md), [coordinates](packages/geo/README.md) (plus codes and geohashes too), [ring and shoe sizes](packages/sizes/README.md), [music](packages/music/README.md) and [addresses](packages/libpostal/README.md). Not sure which one you'll get? `merge` them and take the first that parses, or take [`@quantojs/anything`](packages/anything/README.md), which reads all of it.
[`@quantojs/common`](packages/common/README.md) has length, mass, duration, temperature, volume, area, speed, data size and rate, compute (FLOPs and FLOPS), energy, power, pressure, angle, frequency, fuel economy, pace, torque, force, acceleration, flow rate, density, electrical (voltage, current, resistance, capacitance, charge), light (lumens, lux, nits, color temperature), radiation dose, sound level, plain numbers, percent, proportions (‰, basis points, ppm) and ratios, with money in `@quantojs/common/money`, betting odds in `@quantojs/common/odds` and CSS colors (hex, names, `rgb()`, `oklch()`, `color()`…) in `@quantojs/common/css-color`. [`@quantojs/datetime`](packages/datetime/README.md) adds dates, times and ranges like `Oct 3-5`, and separate packages add [phone numbers](packages/libphonenumber/README.md), [coordinates](packages/geo/README.md) (plus codes and geohashes too), [ring and shoe sizes](packages/sizes/README.md), [music](packages/music/README.md) and [addresses](packages/libpostal/README.md). Not sure which one you'll get? `merge` them and take the first that parses, or take [`@quantojs/anything`](packages/anything/README.md), which reads all of it.

Pre-release, not on npm yet.

Expand Down
9 changes: 9 additions & 0 deletions apps/site/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Codec } from 'quanto';
import { anything } from '@quantojs/anything';
import * as q from '@quantojs/common';
import { feetInches, hoursMinutes, intlUnit, poundsOunces, stonesPounds } from '@quantojs/common/formats';
import { cssColor } from '@quantojs/common/css-color';
import { money } from '@quantojs/common/money';
import { odds } from '@quantojs/common/odds';
import { date, dateRange, dateTime, time } from '@quantojs/datetime';
Expand Down Expand Up @@ -93,6 +94,7 @@ const leaves: Entry[] = [
quantity('luminousFlux', ['800 lm', '3000 lumens']),
quantity('illuminance', ['500 lux', '50 fc']),
quantity('luminance', ['1000 nits', '500 cd/m²']),
quantity('colorTemperature', ['2700 K', '6500 kelvin', '153 mired']),
quantity('radiationDose', ['2.4 mSv', '500 mrem']),
quantity('absorbedDose', ['2 Gy', '180 cGy']),
quantity('soundLevel', ['85 dB', '70 dBA']),
Expand Down Expand Up @@ -162,6 +164,13 @@ const leaves: Entry[] = [
imports: { '@quantojs/sizes': ['shoeSize'] },
examples: ["men's 10", 'UK 9', 'EU 44', 'US 10'],
},
{
id: 'cssColor',
codec: cssColor(),
call: 'cssColor()',
imports: { '@quantojs/common/css-color': ['cssColor'] },
examples: ['#00aaff', 'rebeccapurple', 'rgb(0 170 255 / 50%)', 'hsl(200 100% 50%)', 'oklch(0.7 0.15 230)', 'color(display-p3 1 0 0)', 'currentcolor'],
},
{
id: 'date',
codec: date({ names }),
Expand Down
30 changes: 28 additions & 2 deletions apps/site/src/codecs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { approx, dimensions, merge, optional, range, type Codec, type Ctx, type
import { convert } from 'quanto/quantity';
import * as q from '@quantojs/common';
import { feetInches, hoursMinutes, intlUnit, poundsOunces, stonesPounds } from '@quantojs/common/formats';
import { cssColor } from '@quantojs/common/css-color';
import { money, moneyRange } from '@quantojs/common/money';
import { odds } from '@quantojs/common/odds';
import { date, dateRange, dateTime, localDateTime, time } from '@quantojs/datetime';
Expand Down Expand Up @@ -61,8 +62,8 @@ interface Doc {
readonly keys?: readonly string[];
}

type Group = 'Quantities' | 'Money' | 'Dates and times' | 'Music' | 'Addresses' | 'Phone numbers' | 'Coordinates' | 'Sizes' | 'Text and numbers' | 'Wrappers';
const groups: readonly Group[] = ['Quantities', 'Money', 'Dates and times', 'Music', 'Addresses', 'Phone numbers', 'Coordinates', 'Sizes', 'Text and numbers', 'Wrappers'];
type Group = 'Quantities' | 'Money' | 'Dates and times' | 'Music' | 'Addresses' | 'Phone numbers' | 'Coordinates' | 'Sizes' | 'Colors' | 'Text and numbers' | 'Wrappers';
const groups: readonly Group[] = ['Quantities', 'Money', 'Dates and times', 'Music', 'Addresses', 'Phone numbers', 'Coordinates', 'Sizes', 'Colors', 'Text and numbers', 'Wrappers'];

/** A quantity codec's entry: the codec with its defaults, and its table. */
function quantity(
Expand Down Expand Up @@ -236,6 +237,9 @@ const docs: readonly Doc[] = [
}),
quantity('illuminance', 'Rooms, plants, photography', ['500 lx', '50 fc', '100 klx'], { ref: 'lx' }),
quantity('luminance', 'Screens, displays', ['1000 nits', '500 cd/m²', '14 fL'], { ref: 'nit' }),
quantity('colorTemperature', 'Bulbs, white balance, smart lights', ['2700 K', '6500 kelvin', '153 mired', '370 mirek'], { ref: 'K' }, {
about: <>A light's color temperature. Mireds are a million over the kelvin, as smart-light APIs take them. Heat is <code>temperature</code>.</>,
}),
quantity('proportion', 'Rates, spreads, concentrations', ['25 bps', '5‰', '12.5%', '420 ppm', '12.5'], { ref: 'percent' }, {
about: (
<>
Expand Down Expand Up @@ -524,6 +528,28 @@ const docs: readonly Doc[] = [
['defaultUnit', <>What a bare number means, one system or one per measurement system: <code>{"{ us: 'usMen', uk: 'uk', metric: 'eu' }"}</code>.</>],
],
},
{
id: 'cssColor',
group: 'Colors',
summary: 'Every CSS color: hex, names, rgb(), hsl(), oklch(), color()',
from: '@quantojs/common/css-color',
name: 'cssColor',
call: 'cssColor()',
codec: cssColor(),
examples: ['#00aaff', '#0af8', 'rebeccapurple', 'rgb(0 170 255 / 50%)', 'rgba(0, 170, 255, 0.5)', 'hsl(200 100% 50%)', 'oklch(70% 0.15 230deg)', 'color(display-p3 1 0 0)', '0, 170, 255', 'currentcolor'],
play: 'cssColor',
about: (
<>
Stored as <code>{'{ space, coords, alpha }'}</code>, in the space it was written in: nothing is converted. Formats as hex when
the channels are whole bytes, and otherwise in the color's own function. Colors with no value of their own (
<code>currentcolor</code>, system colors, <code>light-dark()</code>) and computed ones (<code>color-mix()</code>, relative
colors) aren't read.
</>
),
options: [
['bare', <>Also read hex without <code>#</code> and three channels from 0 to 255 (<code>0, 170, 255</code>). On by default; turn it off where other values share the field.</>],
],
},
{
id: 'number',
group: 'Text and numbers',
Expand Down
4 changes: 2 additions & 2 deletions packages/anything/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @quantojs/anything

One [quanto](https://github.com/endograph/quanto/blob/main/packages/quanto/README.md) codec that reads anything the first-party packages can: dates, times, money, every quantity, dimensions, coordinates, ring and shoe sizes, pitches, numbers, ratios and odds, ranges of them, and all of it approximate. It's the big field on quanto's site, as a package.
One [quanto](https://github.com/endograph/quanto/blob/main/packages/quanto/README.md) codec that reads anything the first-party packages can: dates, times, money, every quantity, dimensions, coordinates, ring and shoe sizes, CSS colors, pitches, numbers, ratios and odds, ranges of them, and all of it approximate. It's the big field on quanto's site, as a package.

```ts
import { anything } from '@quantojs/anything';
Expand All @@ -16,7 +16,7 @@ withPhones.parse('(415) 555-2671'); // { value: { codec: 'phoneNumber', value:
```

- **The value is tagged** with the id of the codec that read it: switch on `value.codec` to know what `value.value` is. `format` prints it with that codec.
- **The order settles conflicts.** The first codec that reads the text wins, and the others' readings are its alternatives. Dates and times come first, then dimensions, length, mass, duration and temperature, money, pitch, the other quantities, coordinates and sizes, then plain numbers, ratios and odds, then ranges. So `24 × 36 in` is dimensions, `455hz` a pitch, `3 g` grams, `11/4` a date, `25 bps` a data rate, `US 10` a ring size and `70` a number. A trailing `odds` claims any of them for odds: `11/4 odds`, `70 odds`. The fixtures pin every conflict.
- **The order settles conflicts.** The first codec that reads the text wins, and the others' readings are its alternatives. Dates and times come first, then dimensions, length, mass, duration and temperature, money, pitch, the other quantities, coordinates, sizes and CSS colors, then plain numbers, ratios and odds, then ranges. So `24 × 36 in` is dimensions, `455hz` a pitch, `3 g` grams, `11/4` a date, `25 bps` a data rate, `US 10` a ring size, `2700 K` a temperature (with the color temperature as an alternative) and `70` a number. CSS colors read without `bare`, so `bad` and `0 170 255` aren't colors. A trailing `odds` claims any of them for odds: `11/4 odds`, `70 odds`. The fixtures pin every conflict.
- **Phone numbers are opt-in** with `include`, since libphonenumber-js's metadata is about 80 kB. `phoneNumber` is re-exported from `@quantojs/anything/phone` for convenience, on its own subpath so the main entry never brings the metadata, whatever the bundler. To load it later, `await import('@quantojs/anything/phone')` and build the codec again with it.
- **`include`** takes any codecs: they're read after the built-in ones and before the catch-alls (numbers, ratios, odds).
- **`names`** adds month and weekday names for dates, as `date({ names })` does.
Expand Down
2 changes: 1 addition & 1 deletion packages/anything/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@quantojs/anything",
"version": "0.4.0",
"description": "One quanto codec that reads anything: dates, money, every quantity, dimensions, coordinates, sizes, pitches, numbers, ratios and odds, with ranges and approximate values, in a curated order.",
"description": "One quanto codec that reads anything: dates, money, every quantity, dimensions, coordinates, sizes, CSS colors, pitches, numbers, ratios and odds, with ranges and approximate values, in a curated order.",
"license": "MIT",
"author": "rt2zz",
"homepage": "https://github.com/endograph/quanto/tree/main/packages/anything#readme",
Expand Down
9 changes: 9 additions & 0 deletions packages/anything/src/anything/fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
{"parse": "800 lm", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "luminousFlux", "value": {"value": 800, "unit": "lm"}}, "approximate": false}},
{"parse": "500 lux", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "illuminance", "value": {"value": 500, "unit": "lx"}}, "approximate": false}},
{"parse": "1000 nits", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "luminance", "value": {"value": 1000, "unit": "nit"}}, "approximate": false}},
{"parse": "2700 K", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "temperature", "value": {"value": 2700, "unit": "K"}}, "approximate": false}, "alternatives": [{"value": {"codec": "colorTemperature", "value": {"value": 2700, "unit": "K"}}, "approximate": false}]},
{"parse": "153 mired", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "colorTemperature", "value": {"value": 153, "unit": "mired"}}, "approximate": false}},
{"parse": "85 dBA", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "soundLevel", "value": {"value": 85, "unit": "dBA"}}, "approximate": false}},
{"parse": "2.4 mSv", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "radiationDose", "value": {"value": 2.4, "unit": "mSv"}}, "approximate": false}},
{"parse": "2 Gy", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "absorbedDose", "value": {"value": 2, "unit": "Gy"}}, "approximate": false}},
Expand All @@ -71,6 +73,12 @@
{"parse": "N½", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "ringSize", "value": {"value": 14.5, "unit": "uk"}}, "approximate": false}},
{"parse": "US 10", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "ringSize", "value": {"value": 10, "unit": "us"}}, "approximate": false}, "alternatives": [{"value": {"codec": "shoeSize", "value": {"value": 10, "unit": "usMen"}}, "approximate": false}, {"value": {"codec": "shoeSize", "value": {"value": 10, "unit": "usWomen"}}, "approximate": false}]},
{"parse": "men's 10", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "shoeSize", "value": {"value": 10, "unit": "usMen"}}, "approximate": false}},
{"parse": "#00aaff", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "cssColor", "value": {"space": "srgb", "coords": [0, 0.6666666666666666, 1], "alpha": 1}}, "approximate": false}},
{"parse": "rebeccapurple", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "cssColor", "value": {"space": "srgb", "coords": [0.4, 0.2, 0.6], "alpha": 1}}, "approximate": false}},
{"parse": "rgb(0 170 255 / 50%)", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "cssColor", "value": {"space": "srgb", "coords": [0, 0.6666666666666666, 1], "alpha": 0.5}}, "approximate": false}},
{"parse": "oklch(0.7 0.15 230)", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "cssColor", "value": {"space": "oklch", "coords": [0.7, 0.15, 230], "alpha": 1}}, "approximate": false}},
{"parse": "about #fff", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "cssColor", "value": {"space": "srgb", "coords": [1, 1, 1], "alpha": 1}}, "approximate": true}},
{"parse": "bad", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "issues": ["unparseable"]},

{"parse": "70", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "number", "value": 70}, "approximate": false}, "alternatives": [{"value": {"codec": "odds", "value": {"kind": "decimal", "value": 70}}, "approximate": false}]},
{"parse": "1,234.5", "ctx": {"now": "2026-10-01T12:00:00-04:00"}, "value": {"value": {"codec": "number", "value": 1234.5}, "approximate": false}, "alternatives": [{"value": {"codec": "odds", "value": {"kind": "decimal", "value": 1234.5}}, "approximate": false}]},
Expand Down Expand Up @@ -104,6 +112,7 @@
{"format": {"value": {"codec": "date", "value": "2026-10-02"}, "approximate": false}, "text": "Oct 2, 2026"},
{"format": {"value": {"codec": "money", "value": {"minorUnits": 1200, "currency": "USD"}}, "approximate": false}, "text": "$12.00"},
{"format": {"value": {"codec": "coordinates", "value": {"lat": 40.7128, "lng": -74.006}}, "approximate": false}, "text": "40.7128° N, 74.006° W"},
{"format": {"value": {"codec": "cssColor", "value": {"space": "srgb", "coords": [0, 0.6666666666666666, 1], "alpha": 1}}, "approximate": false}, "text": "#00aaff"},
{"format": {"value": {"codec": "range(length)", "value": {"start": {"value": 5, "unit": "ft"}, "end": {"value": 7, "unit": "ft"}}}, "approximate": false}, "text": "5 ft – 7 ft"},
{"format": {"value": {"codec": "number", "value": 1234.5}, "approximate": false}, "text": "1,234.5"}
]
Loading
Loading