Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
20 changes: 17 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,23 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add packages/lingo/package.json packages/lingo/CHANGELOG.md
git commit -m "chore(release): v${{ steps.bump.outputs.version }}"
git tag "v${{ steps.bump.outputs.version }}"
git commit -m "chore(release): v$VERSION"
git tag "v$VERSION"
git push origin HEAD --tags
gh release create "v${{ steps.bump.outputs.version }}" --generate-notes
# Release body = the curated changelog section, never an auto commit
# list. The notes file lives in RUNNER_TEMP so it can't be committed,
# and reaches gh via --notes-file: the changelog is full of backticks
# and quotes that would execute or break quoting if interpolated.
# npm has already published by this point, so a missing or empty
# section falls back to --generate-notes rather than failing the run.
NOTES="$RUNNER_TEMP/release-notes.md"
if node packages/lingo/scripts/changelog-section.mjs "$VERSION" \
--changelog packages/lingo/CHANGELOG.md --out "$NOTES"; then
gh release create "v$VERSION" --title "v$VERSION" --notes-file "$NOTES"
else
echo "::warning::no changelog section for $VERSION; using generated notes"
gh release create "v$VERSION" --title "v$VERSION" --generate-notes
fi
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.bump.outputs.version }}
14 changes: 12 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ kilograms, kelvin, seconds…). *Avoid: amount, measurement, normalized value.*
**Range:** a `QuantityRange` — min/max quantities, `plusMinus`, open bounds,
approximate/fuzzy flags. Never use "range" for text offsets — that's a **span**.

**Date range:** a `DateRange` from `parseDateRange()` — start/end endpoints,
either end optionally open. Unqualified "range" means the quantity kind; say
"date range" when you mean this one. It comes in three shapes, and these are
the names to use: a **time slot** from clock grammar (`2pm to 4pm`, `9-5`), a
**dated span** between two dates (`Aug 3 - Aug 9`; "date-to-date span" is the
long form), and a **calendar period** widened to its real first and last day
(`next week`, `August`, `2027`). The runtime-only `dated` flag is `true` on the
latter two and absent on a slot — test it truthy, never `=== false`.

**Span:** `{ start, end }` character offsets into the ORIGINAL input string
(the normalizer keeps an offset map, hard rule 3). *Avoid: range, position,
location.*
Expand Down Expand Up @@ -129,8 +138,9 @@ docs generate Zod/Valibot/TypeBox/ArkType/Effect adapters + a dictionary from it
`createLingo()` returns an isolated **instance** with its own registry,
messages, and fuzzy vocab.

**Corpus:** `packages/lingo/tests/corpus/contract-v1.json` — the behavior
contract. Drift is classified **ADDITIVE** (new inputs now parse) or
**Corpus:** the behavior contracts under `packages/lingo/tests/corpus/` —
`contract-v1.json` for English plus one `locale-<id>-contract-v1.json` per
locale pack. Drift is classified **ADDITIVE** (new inputs now parse) or
**BREAKING** (existing interpretations changed) by `scripts/corpus-diff.mjs`;
BREAKING requires a decision entry and a major version.

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ with the package: [`packages/lingo`](packages/lingo/README.md).**
|------|------------|
| [`packages/lingo`](packages/lingo) | `@pascal-app/lingo`, the published library (src, tests, bench, size/corpus/zero-deps gates) |
| [`apps/site`](apps/site) | Docs site with live parser demos (Next.js, port 3000 or next free) |
| [`skills/`](skills/README.md) | Agent skills shipped for coding agents (`skills/lingo` — the on-ramp to using the library) |
| [`plans/`](plans/README.md) | Forward-looking specs, one numbered living markdown file per topic |
| [`wiki/`](wiki/README.md) | As-built docs: architecture, decisions, conventions, credits, research |
| [`AGENTS.md`](AGENTS.md) | Canonical agent guide (hard rules, workflow, module map) |
Expand Down
3 changes: 3 additions & 0 deletions apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@
"dependencies": {
"@base-ui/react": "^1.6.0",
"@pascal-app/lingo": "workspace:*",
"@tanstack/react-table": "^8.21.3",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"geist": "^1.7.2",
"katex": "^0.18.1",
"lucide-react": "^1.23.0",
"marked": "^18.0.7",
"motion": "^12.42.2",
Expand All @@ -33,6 +35,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/katex": "^0.16.8",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
Expand Down
12 changes: 10 additions & 2 deletions apps/site/public/llms-small.txt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ import { parseDate, parseDateRange, humanizeDate, parseDuration } from "@pascal-
const now = new Date("2026-07-08T12:00:00Z")
parseDate("three days ago", { now }) // grain "day"; reference-dependent inputs need explicit now
parseDateRange("2pm to 4pm", { now }) // { start, end } civil endpoints
parseDateRange("Aug 3 - Aug 9", { now }) // dated span, day grain, dated: true
parseDateRange("August", { now }) // whole period: Aug 1 → Aug 31
parseDuration("1h30").duration.base // 5400 seconds
humanizeDate(d, { now }) // "3 days ago" — always re-parses within one grain
```
Expand All @@ -105,6 +107,10 @@ humanizeDate(d, { now }) // "3 days ago" — always re-parses within one grain
- Trailing timezones detected on `.zone`; pass `applyZone: true` to resolve the real UTC instant.
- `parseDateRange`, `humanizeDateRange`, `humanizeDuration`.

`parseDateRange` reads three shapes, in this order: a **time slot** (`2pm to 4pm`, `between 9am and 5pm`, `9-5`, `from 3pm`), a **date-to-date span** (`July 1 to July 5`, `Aug 3 - Aug 9`, `from tomorrow to friday`, `Mon-Fri`, `2026-08-01 to 2026-08-05`), and a **whole calendar period** expanded to its real first and last day (`next week` → Mon–Sun, `next month` → 1st–last, `2027` → Jan 1–Dec 31, `August`, `this weekend` → Sat–Sun). A coarse endpoint widens on the CLOSING side too: `July to August` ends Aug 31 and `until August` ends Aug 31, while `from August` opens on the 1st. `this weekend` on a Saturday or Sunday means the weekend in progress. Two absolute dates given backwards (`2026-08-09 to 2026-08-03`) are swapped with a `RANGE_REVERSED` warning; overnight clock slots (`9pm to 5am`) pass through untouched. A runtime-only `dated` flag says which grammar matched, so one field can drive a day picker, a range picker, or a slot picker; `humanizeDateRange` renders dated ranges as dates (`2026-08-01 to 2026-08-31`) and slots as clock phrases, both re-parseable.

Not supported: quarters (`Q3`, `next quarter`), elliptical right sides (`Aug 3-9`), and dash-joined ISO dates with no spaces (`2026-08-01-2026-08-05`) — all return `UNSUPPORTED_DATE`. Use a spaced dash or `to` between ISO dates.

## DOM (`@pascal-app/lingo/dom`)

```ts
Expand Down Expand Up @@ -249,7 +255,7 @@ Fields implement Standard Schema (validate + jsonSchema). Input JSON Schema is `
- `toJSONSchema(field, { io?, target? })`, `repairTextWith(spec)`, `repairToolCallWith(specsByTool)`.
- `canonicalizeValues(value, spec)`, `quantityMatch`, `dateMatch`.

Tool-boundary defaults: `AMBIGUOUS_NUMBER` → error + candidate; `dateField` escalates `TZ_IGNORED` and requires explicit `now` for reference-dependent dates; `lingoObject` is closed (`additionalProperties: false`; `{passthrough:true}` opts out).
Tool-boundary defaults: `AMBIGUOUS_NUMBER` → error + candidate; `dateField` escalates `TZ_IGNORED` and requires explicit `now` for reference-dependent dates; `lingoObject` is closed (`additionalProperties: false`; `{passthrough:true}` opts out). `dateRangeField` shares those guards and accepts every `parseDateRange` shape — slots, dated spans, and calendar periods — returning `{ start?, end?: ISO }`.

## MCP (`@pascal-app/lingo/mcp`)

Expand Down Expand Up @@ -325,13 +331,15 @@ The `/docs#forms-ux` section and `/docs/forms-ux.md` markdown mirror show the sa
| TZ_IGNORED | Timezone detected but not applied | Pass `applyZone:true` or remove zone from input |
| TYPO_CORRECTED | Typo auto-fixed | Use `strictness:'confirm'` to fail with candidate |
| RANGE_MIN / RANGE_MAX | Value outside bounds | Re-emit within min/max advertised in field description |
| RANGE_REVERSED | Bounds given high-to-low; parser swapped them | Emit low-to-high, or accept the swap |
| UNSUPPORTED_DATE | Date/range shape not in the grammar (`Q3`, `Aug 3-9`) | Re-emit a supported shape or an explicit `start to end` pair |
| RATE_REQUIRED | Cross-currency without rates | Call `convertCurrency` with injected rates |

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)

"2 ft" → quantity length base 0.6096 m · "5'11\"" → 1.8034 m (parts ft+in) · "72 in to cm" → conversion, converted 182.88 cm · "60 miles an hour" → speed in m/s · "5 cubic feet" → volume in m³ · "approx. 5 kg" → approximate mass · "1m80" → 1.8 m · "1h30" → 5400 s · "2 lb 3 oz" → 0.9922 kg · "$5" → currency USD value/base 5 baseUnit USD + AMBIGUOUS_UNIT; pass `{currency:'CAD'}` to read bare "$" as CAD · "50 cents" → 0.5 USD + AMBIGUOUS_UNIT; pass `{currency:'EUR'}` to read as EUR · "five dollars and fifty cents" → 5.5 USD · "50p" → 0.5 GBP · "3 quid 50" → 3.5 GBP · "€5-€10" → currency range baseUnit EUR · "5 EUR to USD" → ok:false RATE_REQUIRED (use convertCurrency with injected rates) · "between 5 and 10 kg" → range 5..10 kg · "under 10 minutes" → range max 600 s exclusive · "no greater than 5 kg" → range max 5 kg inclusive · "10 ± 0.5 mm" → plusMinus center 10 mm/base 0.01 and delta 0.5 mm/base 0.0005 · "a few minutes" → range 120..240 s approximate · "it's hot" (kind temperature) → range 300.15..308.15 K fuzzy 'hot' · "1,5 kg" → 1.5 kg · "1,234" → 1234 + AMBIGUOUS_NUMBER (alt 1.234) · "5 meterz" (kind length) → 5 m + TYPO_CORRECTED; with strictness confirm → ok:false + candidate 5 m · "72" (kind length, unit cm, accept.bareNumbers false) → UNIT_REQUIRED + candidate 72 cm · "72 in to cm" with accept.conversions false → CONVERSION_NOT_ALLOWED + candidate conversion · "5m" (kind duration) → 300 s + SLANG_UNIT · "in 2d" → date two days from now · "3min from tmrw" → tomorrow, same time-of-day +3 min · "17h30" → 17:30 · "quarter past 5" → 05:15 · "3pm EST" → 15:00 civil + zone {abbrev, -300, ambiguous} + TZ_IGNORED/AMBIGUOUS_TIMEZONE; `{applyZone:true}` → the 20:00Z instant · "2pm to 4pm" → date-range 14:00..16:00 · "9-5" → date-range 09:00..17:00 (workday shift) · "500 KB" → 500000 B · "5 Mb" → 625000 B (megabits) · "5 Mbps" → data_rate 5000000 bit/s; use "bit/s" for bits per second because bare "bps" stays basis points · "5 gpm" → flow_rate 0.000315451 m³/s · "250 mL/min" → flow_rate 0.000004167 m³/s · "10 inH₂O" → pressure 2490.8891 Pa · "1 kgf/cm²" → pressure 98.0665 kPa · "1 kg/cm²" → ok:false TRAILING_INPUT (kilogram-mass over area deferred; use kgf/cm²) · "5 psig" (kind pressure) → ok:false UNKNOWN_UNIT (gauge semantics deferred) · "9.8 m/s²" → acceleration 9.8 m/s² · "10 Nm" → torque 10 N⋅m (exact-case; lowercase "nm" remains nanometers) · "500 lux" → illuminance 500 lx · "100 nits" → luminance 100 cd/m² · "20 mSv" → radiation equivalent dose 0.02 Sv · "5 MBq" → radioactivity 5000000 Bq · "5 uM" → concentration 0.005 mol/m³ · "1 mol/L" and "1 mol per L" → concentration 1000 mol/m³; untyped glued "1M" fails, use "1 M" or `kind:'concentration'` · "-40°F" → 233.15 K · "3×10⁵ m" → 300000 m · "½ cup" → 118.29 mL · "15%" → percent 15 · "25 bps" → 0.25% (basis points; bare bps stays percent) · "500 mAh" → charge 1800 C · "4.7 kohm" → resistance 4700 Ω · "250 mmol" → substance 0.25 mol.
"2 ft" → quantity length base 0.6096 m · "5'11\"" → 1.8034 m (parts ft+in) · "72 in to cm" → conversion, converted 182.88 cm · "60 miles an hour" → speed in m/s · "5 cubic feet" → volume in m³ · "approx. 5 kg" → approximate mass · "1m80" → 1.8 m · "1h30" → 5400 s · "2 lb 3 oz" → 0.9922 kg · "$5" → currency USD value/base 5 baseUnit USD + AMBIGUOUS_UNIT; pass `{currency:'CAD'}` to read bare "$" as CAD · "50 cents" → 0.5 USD + AMBIGUOUS_UNIT; pass `{currency:'EUR'}` to read as EUR · "five dollars and fifty cents" → 5.5 USD · "50p" → 0.5 GBP · "3 quid 50" → 3.5 GBP · "€5-€10" → currency range baseUnit EUR · "5 EUR to USD" → ok:false RATE_REQUIRED (use convertCurrency with injected rates) · "between 5 and 10 kg" → range 5..10 kg · "under 10 minutes" → range max 600 s exclusive · "no greater than 5 kg" → range max 5 kg inclusive · "10 ± 0.5 mm" → plusMinus center 10 mm/base 0.01 and delta 0.5 mm/base 0.0005 · "a few minutes" → range 120..240 s approximate · "it's hot" (kind temperature) → range 300.15..308.15 K fuzzy 'hot' · "1,5 kg" → 1.5 kg · "1,234" → 1234 + AMBIGUOUS_NUMBER (alt 1.234) · "5 meterz" (kind length) → 5 m + TYPO_CORRECTED; with strictness confirm → ok:false + candidate 5 m · "72" (kind length, unit cm, accept.bareNumbers false) → UNIT_REQUIRED + candidate 72 cm · "72 in to cm" with accept.conversions false → CONVERSION_NOT_ALLOWED + candidate conversion · "5m" (kind duration) → 300 s + SLANG_UNIT · "in 2d" → date two days from now · "3min from tmrw" → tomorrow, same time-of-day +3 min · "17h30" → 17:30 · "quarter past 5" → 05:15 · "3pm EST" → 15:00 civil + zone {abbrev, -300, ambiguous} + TZ_IGNORED/AMBIGUOUS_TIMEZONE; `{applyZone:true}` → the 20:00Z instant · "2pm to 4pm" → date-range 14:00..16:00 · "9-5" → date-range 09:00..17:00 (workday shift) · "Aug 3 - Aug 9" → dated date-range 2026-08-03..2026-08-09 · "August" → dated date-range 2026-08-01..2026-08-31 (whole month) · "next week" → Mon..Sun · "this weekend" → Sat..Sun (the one in progress on Sat/Sun) · "2026-08-09 to 2026-08-03" → swapped 08-03..08-09 + RANGE_REVERSED · "Q3" → ok:false UNSUPPORTED_DATE (quarters need a fiscal-year anchor; not supported) · "500 KB" → 500000 B · "5 Mb" → 625000 B (megabits) · "5 Mbps" → data_rate 5000000 bit/s; use "bit/s" for bits per second because bare "bps" stays basis points · "5 gpm" → flow_rate 0.000315451 m³/s · "250 mL/min" → flow_rate 0.000004167 m³/s · "10 inH₂O" → pressure 2490.8891 Pa · "1 kgf/cm²" → pressure 98.0665 kPa · "1 kg/cm²" → ok:false TRAILING_INPUT (kilogram-mass over area deferred; use kgf/cm²) · "5 psig" (kind pressure) → ok:false UNKNOWN_UNIT (gauge semantics deferred) · "9.8 m/s²" → acceleration 9.8 m/s² · "10 Nm" → torque 10 N⋅m (exact-case; lowercase "nm" remains nanometers) · "500 lux" → illuminance 500 lx · "100 nits" → luminance 100 cd/m² · "20 mSv" → radiation equivalent dose 0.02 Sv · "5 MBq" → radioactivity 5000000 Bq · "5 uM" → concentration 0.005 mol/m³ · "1 mol/L" and "1 mol per L" → concentration 1000 mol/m³; untyped glued "1M" fails, use "1 M" or `kind:'concentration'` · "-40°F" → 233.15 K · "3×10⁵ m" → 300000 m · "½ cup" → 118.29 mL · "15%" → percent 15 · "25 bps" → 0.25% (basis points; bare bps stays percent) · "500 mAh" → charge 1800 C · "4.7 kohm" → resistance 4700 Ω · "250 mmol" → substance 0.25 mol.

## Docs (online)

Expand Down
Loading
Loading