diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1649e2f..e9b8695 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/CONTEXT.md b/CONTEXT.md index 681ef88..462a665 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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.* @@ -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--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. diff --git a/README.md b/README.md index c1ed4b9..3a50143 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/apps/site/package.json b/apps/site/package.json index 9108209..b6ee81f 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -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", @@ -33,6 +35,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/katex": "^0.16.8", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/apps/site/public/llms-small.txt b/apps/site/public/llms-small.txt index 650dd0e..5f2eb66 100644 --- a/apps/site/public/llms-small.txt +++ b/apps/site/public/llms-small.txt @@ -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 ``` @@ -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 @@ -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`) @@ -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) diff --git a/apps/site/src/app/docs/page.tsx b/apps/site/src/app/docs/page.tsx index 4c1ea90..c73f208 100644 --- a/apps/site/src/app/docs/page.tsx +++ b/apps/site/src/app/docs/page.tsx @@ -8,14 +8,17 @@ import { IntegrationsTabs } from '@/app/integrations/integrations-tabs' import { AiCanonicalizerDemo } from '@/components/site/ai-canonicalizer-demo' import { AiEvalReadout } from '@/components/site/ai-eval-readout' import { SectionHeading, SubHeading } from '@/components/site/anchor-heading' +import { CalendarFieldDemo } from '@/components/site/calendar-field-demo' import { CodeBlock } from '@/components/site/code-block' import { CodeTabs } from '@/components/site/code-tabs' import { CommandBlock } from '@/components/site/command-block' import { CompletionsDemo } from '@/components/site/completions-demo' import { CoverageExplorer } from '@/components/site/coverage-explorer' +import { DataGridDemo } from '@/components/site/data-grid-demo' import { DocsNav } from '@/components/site/docs-nav' import { DocsPageActions } from '@/components/site/docs-page-actions' import { FormUxGallery } from '@/components/site/form-ux-gallery' +import { LatexUnitsDemo } from '@/components/site/latex-units-demo' import { ParsePlayground } from '@/components/site/parse-playground' import { PerformanceSection } from '@/components/site/performance-section' import { @@ -189,6 +192,23 @@ const formSchemaSnippet = `import { standardSchemaResolver } from '@hookform/res // user types "5 kg" / picks a date; canonical on submit useForm({ resolver: standardSchemaResolver(shipment) })` +const gridColumnSnippet = `// The column owns the unit; the cell owns nothing but text. +const columns = { + mass: quantityField({ kind: 'mass', unit: 'kg' }), + temp: quantityField({ kind: 'temperature', unit: 'C' }), + price: quantityField({ kind: 'currency', unit: 'USD' }), + shipBy: dateField({ now }), +} + +function cell(column: keyof typeof columns, text: string) { + const { value, warnings, issues } = columns[column].safeParse(text) + if (issues) { + return { state: 'refused', note: issues[0].message } + } + // "$12.50" resolved to USD, "half a ton" to a short ton — say so. + return { state: warnings ? 'assumed' : 'ok', value, warnings } +}` + // Sourced from the package so the badge wall can't drift from IssueCode. const issueCodes = Object.keys(ISSUE_CODES) @@ -832,6 +852,18 @@ export default async function Home() { {' '} for fields lingo doesn't own.)

+
+ A column is a schema +

+ The same idea scales past a single field. Give a table column a{' '} + quantityField and a cell can take any notation that column can resolve + — pounds and ounces, a comma decimal, Fahrenheit — normalizing into one canonical + unit, so the totals row can just add numbers. What it cannot resolve stays an issue + on the cell that caused it. +

+
+ +
format() emits re-parses to the same value.

+
+ Typeset the reading +

+ A canonical reading is structured enough to render as notation, not just text. The + unit id and the numeric value are separate fields, so m/s2 becomes a + real fraction with a superscript and ± tolerance becomes a proper + interval. This demo maps results to LaTeX in ~90 lines; lingo itself ships no + renderer. +

+
+
.

+
+ One field, three readings +

+ parseDateRange also reads date-to-date spans ( + Aug 3 - Aug 9) and whole calendar periods (next week,{' '} + this weekend, next month), each expanded to its real first + and last day. Because the reading says which shape it found, one input can decide + between a day picker, a two-month range picker, and a time slot — no mode toggle for + the person typing. +

+
+

- Locale packs are data-only subpath entries: @pascal-app/lingo/locales/es,{' '} - fr, pt, zh, ja, and{' '} - en-gb. Successful parses expose result.locale, which the + Locale packs are data-only subpath entries: @pascal-app/lingo/locales/en,{' '} + en-gb, es, fr, pt, zh + , and ja. Successful parses expose result.locale, which the playground above shows beside the parse state.

@@ -1014,7 +1069,7 @@ export default async function Home() {