|
| 1 | +--- |
| 2 | +name: lingo |
| 3 | +description: Parse natural-language quantities, units, dates, and ranges ("5'11\"", "1.5 cups", "72 in to cm", "three days ago", "between 5 and 10 kg", "it's hot") into canonical, validated values — and humanize them back. Use when building form inputs that accept measurements/units/dates, making LLM tool calls safe at the boundary (AI SDK / MCP), or converting and validating any human- or model-entered measurement string. Zero-dependency TypeScript, two-way (round-trips), deterministic. |
| 4 | +metadata: |
| 5 | + author: pascalorg |
| 6 | + version: "1.0.0" |
| 7 | +--- |
| 8 | + |
| 9 | +# lingo — natural-language quantities, units, dates & ranges |
| 10 | + |
| 11 | +[`@pascal-app/lingo`](https://github.com/pascalorg/lingo) is a zero-dependency |
| 12 | +TypeScript library that turns the strings people type and models emit — |
| 13 | +`180cm`, `5ft 11`, `1.5 cups`, `90 min`, `next friday`, `1,5 kg`, `"5'11\""`, |
| 14 | +`"twenty-five kg"`, `"3pm EST"` — into canonical, validated values (one number |
| 15 | +in one unit, one ISO date), converts and range-checks them, then humanizes the |
| 16 | +value back. Every result carries a stable issue code and a `[start, end)` span |
| 17 | +into the original input. It's **two-way**: whatever `format()`/`humanize*()` |
| 18 | +emits re-parses to the same value. |
| 19 | + |
| 20 | +Tagline: **Make forms easier, LLM tools safer.** |
| 21 | + |
| 22 | +## When to use this skill |
| 23 | + |
| 24 | +Reach for lingo whenever a value arrives as free text and needs to become a |
| 25 | +trustworthy stored value: |
| 26 | + |
| 27 | +- **Web forms** — you're about to build a number box + unit dropdown. Use one |
| 28 | + text field instead: `180cm`, `2 lb 3 oz`, `an hour and a half`, typos with |
| 29 | + did-you-mean, fuzzy words (`it's hot`). |
| 30 | +- **LLM tool / MCP boundaries** — a model returns `"5'11\""` or `"1,234 kg"` and |
| 31 | + your handler needs a safe number. Models emit strings more reliably than |
| 32 | + floats; lingo makes the string the safe path and rejects risky readings. |
| 33 | +- **Data pipelines / imports** — normalizing messy measurement or date columns |
| 34 | + into canonical units without `Number()`/`new Date()` silently guessing wrong. |
| 35 | +- **Anything that converts** (`72 in to cm`), **ranges** (`between 5 and 10 kg`, |
| 36 | + `under 10 minutes`, `10 ± 0.5 mm`), or **relative dates** (`three days ago`). |
| 37 | + |
| 38 | +Do **not** reach for it for plain already-canonical numbers, or for currency |
| 39 | +conversion needing live FX (lingo returns `RATE_REQUIRED` — you inject rates). |
| 40 | + |
| 41 | +## Install & entry points |
| 42 | + |
| 43 | +```sh |
| 44 | +npm i @pascal-app/lingo # or bun / pnpm / yarn — zero runtime deps |
| 45 | +``` |
| 46 | + |
| 47 | +Import only what you need; each subpath is tree-shakeable. |
| 48 | + |
| 49 | +| Entry | Use for | |
| 50 | +|-------|---------| |
| 51 | +| `@pascal-app/lingo` | core parse/convert: `lingo`, `parseQuantity`, `parseRange`, `convert` | |
| 52 | +| `@pascal-app/lingo/date` | dates & durations: `parseDate`, `parseDuration`, `humanizeDate` | |
| 53 | +| `@pascal-app/lingo/ai` | Standard Schema fields for LLM tools: `quantityField`, `dateField`, `lingoObject` | |
| 54 | +| `@pascal-app/lingo/mcp` | `lingoTool()` — MCP tool with validation before the handler | |
| 55 | +| `@pascal-app/lingo/dom` | headless `lingoInput()` controller for any `<input>` | |
| 56 | +| `@pascal-app/lingo/react` | `useLingoInput()` hook | |
| 57 | +| `@pascal-app/lingo/element` | `<lingo-input>` form-associated custom element | |
| 58 | +| `@pascal-app/lingo/complete` | ranked autocomplete `completions()` | |
| 59 | +| `@pascal-app/lingo/locales/{en,en-gb,es,fr,pt,zh,ja}` | opt-in parsing language packs | |
| 60 | +| `@pascal-app/lingo/catalog` | query units/kinds/currencies | |
| 61 | + |
| 62 | +## Pattern 1 — parse, validate, convert (core) |
| 63 | + |
| 64 | +```ts |
| 65 | +import { lingo, parseQuantity, parseRange } from "@pascal-app/lingo" |
| 66 | + |
| 67 | +const height = parseQuantity("5'11\"", { kind: "length" }) |
| 68 | +if (height.ok) height.quantity.to("m").value // 1.8034 |
| 69 | + |
| 70 | +lingo("72 in to cm") // { type: "conversion", converted: 182.88 cm } |
| 71 | +parseRange("between 5 and 10 kg", { kind: "mass" }) // range 5..10 kg |
| 72 | +``` |
| 73 | + |
| 74 | +`lingo(text, opts?)` returns a versioned union on `.type` |
| 75 | +(`quantity | range | conversion | number | failure`), serialized as flat v3 JSON |
| 76 | +(`{ schemaVersion: 3, ok, type, ...value, text, span, issues, confidence }`). |
| 77 | +Always branch on `.ok` / `.type` before reading the value, and surface |
| 78 | +`.issues[]` (each has `code`, `severity`, `message`, `span`) to the user. |
| 79 | + |
| 80 | +Pass context to disambiguate: `{ kind, unit, currency, locale, system, strictness }`. |
| 81 | +`strictness: "confirm"` turns each assumption (typo fix, ambiguous number) into a |
| 82 | +failure carrying a `candidate`, so you can render a one-click confirmation. |
| 83 | + |
| 84 | +## Pattern 2 — a natural-language form field |
| 85 | + |
| 86 | +Headless controller — no styles shipped, canonicalizes on blur/Enter/submit, |
| 87 | +never rewrites while the user is mid-type (`2 f` is *incomplete*, not *invalid*): |
| 88 | + |
| 89 | +```ts |
| 90 | +import { lingoInput } from "@pascal-app/lingo/dom" |
| 91 | + |
| 92 | +const field = lingoInput(document.querySelector("#height"), { |
| 93 | + kind: "length", unit: "m", name: "height_m", |
| 94 | +}) |
| 95 | +field.set("6ft") |
| 96 | +field.commit() // hidden <input name="height_m"> submits the canonical value |
| 97 | +// field.state → 'idle' | 'incomplete' | 'valid' | 'invalid' |
| 98 | +``` |
| 99 | + |
| 100 | +React: `useLingoInput(opts)` from `@pascal-app/lingo/react`. Framework-agnostic |
| 101 | +element: `defineLingoInput()` then `<lingo-input kind="length" unit="m" name="height_m">`. |
| 102 | + |
| 103 | +## Pattern 3 — safe LLM tool / MCP boundary |
| 104 | + |
| 105 | +The fields expose a **`string`** input JSON Schema (models are better at emitting |
| 106 | +`"5'11\""` than `1.8034`) and hand your handler the **canonical** value. Risky |
| 107 | +readings fail loudly with `[CODE] … Did you mean …?` messages a model can |
| 108 | +self-correct from in one round trip. |
| 109 | + |
| 110 | +```ts |
| 111 | +import { tool, generateText } from "ai" |
| 112 | +import { lingoObject, quantityField, dateField } from "@pascal-app/lingo/ai" |
| 113 | + |
| 114 | +const shipment = lingoObject({ |
| 115 | + weight: quantityField({ kind: "mass", unit: "kg", min: 0, max: 500 }), |
| 116 | + deliverBy: dateField({ now: new Date() }), // relative dates REQUIRE now |
| 117 | +}) |
| 118 | + |
| 119 | +await generateText({ |
| 120 | + model: "claude-opus-4-8", |
| 121 | + tools: { create_shipment: tool({ inputSchema: shipment, execute: run }) }, |
| 122 | +}) |
| 123 | +``` |
| 124 | + |
| 125 | +MCP: wrap the same fields with `lingoTool({ name, description, input, handler })` |
| 126 | +from `@pascal-app/lingo/mcp`; validation runs before `handler`, and failures |
| 127 | +return `{ isError: true }` with `[CODE]`-prefixed text. |
| 128 | + |
| 129 | +## Rules that keep you out of trouble |
| 130 | + |
| 131 | +1. **Keep measurements as strings in tool/form schemas.** Let lingo convert, |
| 132 | + validate, surface spans, and handle ambiguity — don't ask the model or user |
| 133 | + for a float. |
| 134 | +2. **Store the canonical value, display the humanized one.** `format()` / |
| 135 | + `humanizeDate()` round-trip back to the same value (`1.9999 m` → `6′7″`, |
| 136 | + never `5′12″`). |
| 137 | +3. **Always pass an explicit `now`** to `parseDate`/`dateField` for relative |
| 138 | + dates — never rely on `Date.now()`, so a queued or retried call can't drift |
| 139 | + across midnight. Reference-dependent input without `now` → `NOW_REQUIRED`. |
| 140 | +4. **No silent guesses.** Ambiguous input returns a deterministic best reading |
| 141 | + plus ranked `alternatives`/`candidate` and a warning code — show it, or use |
| 142 | + `strictness: "confirm"` / `"strict"` to force confirmation. |
| 143 | +5. **Currency conversion needs injected rates** — `5 EUR to USD` returns |
| 144 | + `RATE_REQUIRED`; call `convertCurrency` with your own rates. |
| 145 | +6. **Locale packs are opt-in.** English is built in; for other languages |
| 146 | + `createLingo({ locales: [es, fr, …] })` or you'll get `LOCALE_NOT_LOADED`. |
| 147 | + |
| 148 | +Common issue codes to handle: `UNKNOWN_UNIT`, `KIND_MISMATCH`, `UNIT_REQUIRED`, |
| 149 | +`AMBIGUOUS_NUMBER`, `AMBIGUOUS_UNIT`, `TYPO_CORRECTED`, `RANGE_MIN`/`RANGE_MAX`, |
| 150 | +`NOW_REQUIRED`, `TZ_IGNORED`, `RATE_REQUIRED`. Override any message via the |
| 151 | +`messages` option. |
| 152 | + |
| 153 | +## Full reference |
| 154 | + |
| 155 | +This skill is the on-ramp; the exhaustive API, the complete kind/unit list, all |
| 156 | +issue codes, and per-input examples live in the agent docs: |
| 157 | + |
| 158 | +- **Offline** (after install): `node_modules/@pascal-app/lingo/llms.txt` — a |
| 159 | + compressed, self-contained reference. |
| 160 | +- **Online:** [`lingo.pascal.app/llms.txt`](https://lingo.pascal.app/llms.txt) |
| 161 | + (index) → [`/docs/<section>.md`](https://lingo.pascal.app/docs/parse.md) |
| 162 | + per-topic, or [`/llms-full.txt`](https://lingo.pascal.app/llms-full.txt) for |
| 163 | + the complete narrative. Human docs with live demos: |
| 164 | + [`lingo.pascal.app/docs`](https://lingo.pascal.app/docs). |
| 165 | +- **Repo & README:** [github.com/pascalorg/lingo](https://github.com/pascalorg/lingo). |
0 commit comments