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
26 changes: 0 additions & 26 deletions .github/ISSUE_TEMPLATE/bug_report.md

This file was deleted.

65 changes: 65 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: 🐛 Bug report
description: A parse, conversion, format, or validation result that is wrong
labels: ["bug", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Thanks for the report! A concrete input + the serialized result is
usually all we need to reproduce a parser bug.

- type: textarea
id: input
attributes:
label: Input and options
description: The exact call that misbehaves.
value: |
```ts
import { lingo } from '@pascal-app/lingo'
lingo('...', { /* options */ })
```
validations:
required: true

- type: textarea
id: expected
attributes:
label: Expected
description: What you expected the result (or issue codes) to be.
validations:
required: true

- type: textarea
id: actual
attributes:
label: Actual
description: >
The actual result. `JSON.stringify(result)` output is ideal — it
carries the schema version, spans, and issue codes.
validations:
required: true

- type: input
id: version
attributes:
label: "@pascal-app/lingo version"
placeholder: 0.2.1
validations:
required: true

- type: input
id: runtime
attributes:
label: Runtime
description: Node/bun/browser + version
placeholder: Node 22, bun 1.3, Chrome 126…
validations:
required: false

- type: textarea
id: additional
attributes:
label: Additional context
description: Locale packs loaded, related issues, anything else useful.
validations:
required: false
8 changes: 8 additions & 0 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 💬 Questions & Help
url: https://github.com/pascalorg/lingo/discussions/categories/q-a
about: Ask questions and get help from the community
- name: 💡 Ideas & Discussion
url: https://github.com/pascalorg/lingo/discussions/categories/ideas
about: Share ideas and discuss features before opening a formal request
22 changes: 0 additions & 22 deletions .github/ISSUE_TEMPLATE/feature_request.md

This file was deleted.

40 changes: 40 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: ✨ Feature request
description: A new input form, unit, kind, locale idiom, API, or integration
labels: ["enhancement", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Concrete inputs and expected outputs are the fastest path to a
decision. For locale idioms, one real sentence a native speaker would
type is worth a page of description.

- type: textarea
id: what
attributes:
label: What should lingo understand or do?
description: Concrete inputs → expected outputs.
placeholder: |
"2 stone 4 lb" → quantity, mass, 14.5 kg
"quinze jours" → duration, 2 weeks
validations:
required: true

- type: textarea
id: why
attributes:
label: Why
description: The use case — form field, LLM tool argument, MCP server, etc.
validations:
required: true

- type: textarea
id: scope
attributes:
label: Scope notes (optional)
description: >
Anything you already know: prior art, ambiguity hazards ("oz" is mass
and volume), locale concerns. See `plans/` for how specs are written
and `wiki/research/locale-idioms.md` for the idiom checklist.
validations:
required: false
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ new issue codes need tests for the code, its copy, and its typed `data`
payload. Hostile-input coverage (unicode, RTL, zero-width, 50k-char strings) is
expected for parser-facing changes.

### Locale corpora

Locale idioms are corpus-gated per pack. Add rows in
`packages/lingo/tests/corpus/locale-<id>-source.mjs`, regenerate checked-in
contracts with `node scripts/corpus-diff.mjs --write` from `packages/lingo/`,
and rely on `bun run check` to enforce them. When adding idioms to a locale
pack, use `plans/033-locale-idiom-coverage.md` and
`wiki/research/locale-idioms.md` as the checklist before touching data.

## Docs are part of done

A user-visible change updates, in the same PR: TSDoc `@example` on new public
Expand Down
34 changes: 28 additions & 6 deletions apps/site/public/llms-small.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ const range = parseRange("between 5 and 10 kg", { kind: "mass" })
- `quantity(value, unitRef, kind?)`, `convert(v, from, to)`, `tryConvert(v, from, to)`, `convertDelta(v, from, to)`.
- `createLingo({ registry?, kinds?, messages?, fuzzy?, locales? }) → instance` for SSR/multi-tenant isolation and loaded locale packs.
- `fromJSON(json)` rehydrates `toJSON()` output.
- Result guards: `isQuantity`, `isRange`, `isConversion`, `isNumber`; helpers: `firstError`, `candidateOf`, `formatIssue`.

Options `{ kind?, unit?, currency?, locale?, system?: 'us'|'imperial'|'metric', numberFormat?: 'auto'|'dot-decimal'|'comma-decimal', strictness?: 'forgiving'|'confirm'|'strict', accept?, tolerance?, escalate?, messages?, profile?, registry? }`.

Quantity: `.value`, `.base`, `.kind`, `.to(unitRef)`, `.valueIn(u)`, `.convertDelta(u)`, `.toMinor()`, `.format()`, `.toBest()`, `.toJSON()`.
QuantityRange: `.minBase/.maxBase/.min()/.max()/.plusMinus/.fuzzy/.contains(q)/.widthIn(u)/.to(u)/.format()`.
Quantity: `.value`, `.base`, `.kind`, `.to(unitRef)`, `.valueIn(u)`, `.convertDelta(u)`, `.toMinor()`, `.format()`, `.toBest()`, `.toJSON()`. Literal `.to()` targets are same-kind checked at compile time; dynamic strings still validate at runtime.
QuantityRange: `.minBase/.maxBase/.min()/.max()/.plusMinus/.fuzzy/.contains(q)/.widthIn(u)/.to(u)/.format()`. Literal `.to()` targets get the same check.

Kinds: length mass temperature duration volume area speed data data_rate flow_rate acceleration pressure energy force torque power frequency angle percent luminous_intensity luminous_flux illuminance luminance voltage current resistance charge substance concentration radiation_absorbed_dose radiation_equivalent_dose radioactivity currency. Bases: m, kg, K, s, m³, m², m/s, byte, bit/s, m³/s, m/s², Pa, J, N, N⋅m, W, Hz, rad, %, cd, lm, lx, cd/m², V, A, Ω, C, mol, mol/m³, Gy, Sv, Bq, and self-canonical ISO currency codes.

Expand All @@ -37,10 +38,10 @@ Kinds: length mass temperature duration volume area speed data data_rate flow_ra
import { completions } from "@pascal-app/lingo/complete"

const items = completions("10 kg to 16", { kind: "mass", limit: 8 })
// [{ text, result, confidence, source }] — source: parse|alternative|unit-ambiguity|unit-prefix|implied-unit
// [{ text, result, confidence, source }] — source: parse|alternative|unit-ambiguity|unit-prefix|implied-unit|range-implied|cross-kind|date
```

Distinct from success `alternatives`, failure `candidate`, and issue `suggestions`. Wire into `lingoInput({ complete, onComplete })` for autocomplete dropdowns.
Distinct from success `alternatives`, failure `candidate`, and issue `suggestions`. Wire into `lingoInput({ complete, onComplete })` for autocomplete dropdowns. Pass `date: (text) => parseDate(text, { now })` to opt into date completions without bundling `@pascal-app/lingo/date` into `@pascal-app/lingo/complete`.

## Locales (`@pascal-app/lingo/locales/*`)

Expand All @@ -58,6 +59,27 @@ lingo.parseQuantity("5公斤", { locale: "zh" })

Packs: `en`, `en-gb`, `es`, `fr`, `pt`, `zh`, `ja`. Omit `locale` to detect among loaded packs plus English; pass `locale` for known fields. English is built in and does not require a pack. `parseDate(text, { locale, localePacks, now })` can use loaded packs for relative date vocabulary.

Pack notes:
- `en`: built-in English quantity/date grammar; wave-1 adds spoken decimals and date idioms such as `two point five kg` and `quarter of five`.
- `en-gb`: English plus day-first numeric dates and UK weekday offsets (`Monday week`, `Tuesday fortnight`).
- `es`: Spanish number/range words, Romance composition, spoken decimals, approximants, and date clock/edge idioms.
- `fr`: French number/range words, exhaustive CLDR-style vigesimal compounds, spoken decimals, approximants, and weekday-offset/clock idioms.
- `pt`: Portuguese number/range words, compound hundreds/tens, spoken decimals, approximants, and clock/day-offset idioms.
- `zh`: Chinese CJK number walker, scale grouping, post-unit half, duration counters, period edges, and day offsets. CJK spoken clock (`下午3点半`) is deferred.
- `ja`: Japanese CJK number walker, scale grouping, post-unit half, duration counters, wave-dash ranges, and double-step period modifiers. CJK spoken clock (`午後3時半`) is deferred.

Verified locale examples (fixed `now = 2026-07-03 14:30` local time for date examples):

```txt
en: "two point five kg" → 2.5 kg; "quarter of five" → 2026-07-04 04:45
en-gb: "Monday week" → 2026-07-13; "Tuesday fortnight" → 2026-07-21
es: "treinta y cinco kilos" → 35 kg; "dos coma cinco kg" → 2.5 kg; "las tres menos cuarto" → 2026-07-04 02:45
fr: "quatre-vingt-dix kg" → 90 kg; "deux virgule cinq kg" → 2.5 kg; "deux heures et quart" → 2026-07-04 02:15; "lundi en huit" → 2026-07-13
pt: "vinte e cinco kg" → 25 kg; "dois virgula cinco kg" → 2.5 kg; "quinze para as tres" → 2026-07-04 02:45; "depois de amanha" → 2026-07-05
zh: "三十五公斤" → 35 kg; "两公斤半" → 2.5 kg; "七八天" → duration range 7..8 d; "月底" → 2026-07-31
ja: "三十五キロ" → 35 kg; "一時間半" → 1.5 h; "5〜10キロ" → 5..10 kg; "再来週" → week of 2026-07-13
```

## Dates (`@pascal-app/lingo/date`)

```ts
Expand Down Expand Up @@ -88,7 +110,7 @@ field.set("6ft")
field.commit() // hidden input submits canonical value
```

`field.value` · `.quantity` · `.result` · `.state` ('idle'|'incomplete'|'valid'|'invalid') · `.set()` · `.commit()` · `.update()` · `.destroy()`. Never rewrites while typing; canonicalizes on blur/Enter/submit. React: `useLingoInput(opts)` from `@pascal-app/lingo/react`.
`field.value` · `.quantity` · `.result` · `.state` ('idle'|'incomplete'|'valid'|'invalid') · `.set()` · `.commit()` · `.update()` · `.destroy()`. Never rewrites while typing; canonicalizes on blur/Enter/submit. With `complete`/`onComplete`, the input gets the headless WAI-ARIA combobox attributes (`role="combobox"`, `aria-autocomplete="list"`, `aria-expanded`); pass `listboxId` to set `aria-controls` for your rendered listbox. React: `useLingoInput(opts)` from `@pascal-app/lingo/react`.

## Element (`@pascal-app/lingo/element`)

Expand Down Expand Up @@ -201,7 +223,7 @@ The `/docs#forms-ux` section and `/docs/forms-ux.md` markdown mirror show the sa
| RANGE_MIN / RANGE_MAX | Value outside bounds | Re-emit within min/max advertised in field description |
| RATE_REQUIRED | Cross-currency without rates | Call `convertCurrency` with injected rates |

Full list: RANGE_KIND_MISMATCH, CONVERSION_KIND_MISMATCH, TRAILING_INPUT, SINGLE_VALUE_EXPECTED, APPROX_NOT_ALLOWED, NUMBER_FORMAT, NONFINITE, LOCALE_NOT_LOADED, RANGE_OPEN_BOUND_NOT_ALLOWED, REQUIRED, UNSUPPORTED_DATE, AMBIGUOUS_DATE, RANGE_REVERSED, COMPOUND_OVERFLOW, CIVIL_AVERAGE, UNIT_ASSUMED, WEEKDAY_ASSUMED_NEXT, SLANG_UNIT, AMBIGUOUS_TIMEZONE. Override copy via `messages` option map.
Full list: EMPTY, NO_VALUE, UNKNOWN_UNIT, KIND_MISMATCH, RANGE_KIND_MISMATCH, CONVERSION_KIND_MISMATCH, RATE_REQUIRED, TRAILING_INPUT, SINGLE_VALUE_EXPECTED, APPROX_NOT_ALLOWED, UNIT_REQUIRED, CONVERSION_NOT_ALLOWED, NUMBER_FORMAT, NONFINITE, LOCALE_NOT_LOADED, RANGE_MIN, RANGE_MAX, RANGE_OPEN_BOUND_NOT_ALLOWED, REQUIRED, UNSUPPORTED_DATE, NOW_REQUIRED, TYPO_CORRECTED, AMBIGUOUS_NUMBER, AMBIGUOUS_UNIT, AMBIGUOUS_DATE, RANGE_REVERSED, COMPOUND_OVERFLOW, CIVIL_AVERAGE, UNIT_ASSUMED, WEEKDAY_ASSUMED_NEXT, SLANG_UNIT, TZ_IGNORED, AMBIGUOUS_TIMEZONE. Override copy via `messages` option map.

## Canonical examples (input → essence)

Expand Down
80 changes: 80 additions & 0 deletions packages/lingo/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,86 @@ change**, even if the API is untouched.

## [Unreleased]

### Added

- **Locale idiom coverage wave 1** (plan 033, D68/D69): parse how people
actually write and speak quantities and dates across all shipped locales.
- CJK number engine: multi-character number words inside CJK tokens
(`三公斤`, `三十五キロ`), 万/亿/億 scale grouping (`三百五十万` = 3.5M),
elliptical shorthands (`一百五` = 150, `三万五` = 35 000), mixed
digit+scale (`3万5千`, `1億2千万`), wave-dash ranges (`5〜10キロ`),
adjacent-number ranges (`七八天` = 7–8 days), and post-unit 半
(`两公斤半` = 2.5 kg).
- Romance number composition via new pack fields: tens + and-word + ones
(`treinta y cinco`), `bareScales` (`cien gramos`, `mil metros`),
`composed` exact compounds (`quinientos`, exhaustive French vigesimal
`quatre-vingt-dix`), and spoken decimals via `decimalWords`
(`dos coma cinco`, `trois virgule quatorze`; English gains
`two point five` through the same table).
- Localized date grammar via new pack fields: spoken clock
(`las tres menos cuarto`, `quinze para as tres`, `deux heures et quart`,
US `quarter of five`), period edges (`fin juillet`, `a finales de mes`,
`月底`), weekday offsets (`lundi en huit`, `Monday week`,
`Tuesday fortnight`), after-next/before-last modifiers (`再来週`,
`先々週`, `the week after next`), day + day-part compounds
(`tomorrow morning`), and duration parsing that honors pack unit words
(`2 horas`).
- Multi-word leading approximants (`más o menos`, `à peu près`,
`por volta de`) and trailing approximants (`y pico`, `e pouco`).
- Deepened es/fr/pt/zh/ja packs (day offsets like `pasado mañana`,
`avant-hier`, `前天`, `一昨日`; fuzzy amounts `une vingtaine`;
duration words `个小时`, `時間`; `円` → JPY) — ~190 new locale corpus
rows.
- Per-locale corpus gates: `tests/corpus/locale-<id>-source.mjs` →
checked-in contracts, discovered generically and enforced by
`bun run check`, so locale behavior can no longer silently regress.
- `isNumber()` result guard, completing the `isQuantity`/`isRange`/`isConversion`
family for the bare-number branch of `LingoResult`.
- DOM completion fields now ship the headless half of the WAI-ARIA combobox
pattern: `role="combobox"`, `aria-autocomplete="list"`, `aria-expanded`
toggling, and a `listboxId` option wired to `aria-controls`. Author-set
attributes are respected and restored on `destroy()`.
- Compile-time unit checking on `Quantity.to()` / `QuantityRange.to()`:
cross-kind literal targets (`quantity(5, 'kg').to('cm')`) are now compile
errors while dynamic `string` refs still pass (plan 027 / D29 pattern).
- Seeded property-based round-trip suite (plan 010 layer 2): every built-in
kind × unit × format style across five magnitude regimes — 41k+ cases
asserting `parse(format(q)) ≈ q` and wire-JSON span integrity.
- Performance: `Intl.NumberFormat` instance caching for locale formatting,
binary-search token lookup, allocation-free unit-match dedupe, and
regex-free locale-detection scoring (+3–8% across parse suites).

### Changed

- `LOCALE_NOT_LOADED` and `NOW_REQUIRED` message copy now names the fix
(import the locale pack / pass an explicit reference time).
- Narrow-style formatting keeps the number–unit space for the five units
whose glued form re-parses differently (`K`, `M`, `ft²`, `ft³`, `kΩ`),
preserving the two-way guarantee.

### Fixed

- `/ai` output JSON Schemas for `output: 'quantity'`/`'range'` declared
`schemaVersion` enum `[2]` while the runtime emits `3`; strict providers
validating tool output against the declared schema would reject conformant
results.
- Scientific/engineering coefficients with three decimal digits
(`3.493e-4 m`, `1.234×10^5 kg`) now parse: an attached exponent
disambiguates the coefficient, so the European-thousands `AMBIGUOUS_NUMBER`
reading (and its stale alternative) no longer strands the exponent as
trailing input.
- Anchored duration ranges (`3 days starting tomorrow`): offset bookkeeping
now survives normalization shifts (unicode quotes, invisible characters),
absolute anchors (`3 days starting 2026-03-01`) no longer demand `now`,
and relative anchors without `now` fail with `NOW_REQUIRED` (D36).
- Time-grain anchored ranges now humanize to re-parseable phrasing
(`3 hours starting 2026-03-01 9:00 AM` instead of clock-only output that
re-parsed against `now`), and a trailing timezone on an anchored range is
applied/escalated with the same semantics as other date ranges (D67).
- `lingoTool()` MCP callbacks accept both bare arguments and the
`{ params: { arguments } }` request envelope, matching how
`@modelcontextprotocol/sdk` actually invokes `registerTool` callbacks.

## [0.2.0] - 2026-07-08

### Added
Expand Down
Loading
Loading