diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index fc12450..9ac984d 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -28,6 +28,17 @@ body:
- Text to Binary / Hex
- Number Converter
- Unicode Inspector
+ - YAML / JSON / TOML
+ - SQL Formatter
+ - CSS Formatter
+ - CSV to Table
+ - .env Converter
+ - Cron Explainer
+ - String Escaper
+ - Color Converter
+ - Contrast Checker
+ - QR Code
+ - Lorem Ipsum
- The site itself (⌘K palette, sidebar, shortcuts, saved state)
validations:
required: true
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 15308a7..62f00b5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -62,7 +62,7 @@ jobs:
set -euo pipefail
pnpm pack --pack-destination "$RUNNER_TEMP" >/dev/null
LIST=$(tar -tzf "$RUNNER_TEMP"/fadeltd-devtools-core-*.tgz)
- for sub in base64 diff hash html-text id-gen json jwt list number random slug text text-bytes text-stats timestamp unicode url; do
+ for sub in base64 color cron css csv diff env escape hash html-text id-gen json jwt list lorem number qr random slug sql text text-bytes text-stats timestamp unicode url yaml-toml; do
# A here-string, not `echo | grep -q`: grep -q stops reading at the
# first match, and under pipefail the writer's SIGPIPE fails the step.
grep -qx "package/dist/$sub/index.d.ts" <<< "$LIST" || { echo "::error::Missing dist/$sub/index.d.ts"; exit 1; }
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ff02449..a298e60 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
## [Unreleased]
-Nothing yet.
+### Added
+- YAML / JSON / TOML: convert between all three formats in any direction, with the input format detected for you. Every parse error shows its line and column with a caret; YAML follows the 1.2 rules and points out where a 1.1 reader would differ (`no`, `on`, `0755`); YAML-to-YAML keeps comments; anchors and merge keys are expanded; multi-document YAML converts as an array or one document at a time; integers beyond 2^53 stay exact, and a `null` going to TOML is reported with its key path.
+- SQL Formatter: format SQL for PostgreSQL, MySQL, MariaDB, SQLite, BigQuery, Snowflake, SQL Server, Oracle PL/SQL, Redshift, Spark, Trino or standard SQL, with keyword case, indent and blank lines between statements. Comments are kept, a query that won't parse shows where it stopped, and Minify squeezes a query onto one line without touching strings, quoted names or comments.
+- CSS Formatter: pretty-print CSS, SCSS or Less with Prettier, choosing indent, line width and quote style, or minify plain CSS. Syntax errors show their line and column, and Minify keeps `/*! licence */` comments, the spaces `calc()` needs, and strings and `url(data:…)` values exactly as written.
+- CSV to Table: paste CSV, TSV, semicolon- or pipe-separated data and see it as a table you can scroll both ways, with a sticky header, row numbers and a type hint per column. The delimiter is detected for you (European decimal commas included), quoted fields with commas, quotes and line breaks are read correctly, rows with too few or too many cells are listed by line, and the data converts to JSON, a Markdown table, CSV or TSV. Files of 10 MB stay responsive.
+- .env Converter: convert between a .env file, JSON, shell `export` lines and Docker's `--env-file` format. Comments, `export`, quotes, `\n` escapes and multiline values like private keys are read the way `dotenv` reads them, and each value is written with the safest quoting for the format you pick. Duplicate keys, nested JSON and keys a shell can't accept are listed by line number instead of silently dropped. Mask values hides values in the preview while you share your screen (Copy still copies the real ones). Nothing you paste is ever saved.
+- Cron Explainer: paste a cron expression and read it in plain English, field by field, with its next 10 run times in any time zone. It handles 5- and 6-field cron, month and weekday names and `@daily`-style macros; errors point at the exact field and character, Quartz-only syntax (`?`, `L`, `#`) is called out by name, day-of-month and day-of-week combine with OR as real cron does, daylight-saving changes are handled the way Vixie cron does, and a date that never comes, like `0 0 30 2 *`, is reported as never firing.
+- String Escaper: escape and unescape text for JSON strings, JavaScript strings (any quote style), SQL literals (standard or MySQL), shell arguments, regexes and HTML entities. When unescaping fails it says what is wrong, where, and how to fix it, and it warns about things that would surprise you, such as a backslash MySQL would read as an escape.
+- Color Converter: paste any CSS color (hex, `rgb()`, `hsl()`, `oklch()` or one of the 148 color names) or use the picker, and get it as hex, RGB, HSL and OKLCH, each with its own Copy button. Bad input is pointed out by position, and an OKLCH color outside sRGB is flagged and shown alongside the nearest sRGB color with the same lightness and hue.
+- Contrast Checker: check two colors against WCAG 2 for AA and AAA, normal and large text, and UI components (3:1), with a live preview. The ratio is never rounded up, so a near miss can't look like a pass; a translucent text color is blended over the background first; and when a check fails you get the nearest passing text color, with Copy and Apply.
+- QR Code: make a QR code from text, a URL or a Wi-Fi network and download it as SVG or PNG, with your choice of error correction, quiet zone, size and colors, and a live preview. You're warned about non-web links, low-contrast or inverted colors and codes too dense to scan easily, and content too long for any QR code says how many bytes it is and what the limit is. The Wi-Fi password is never saved.
+- Lorem Ipsum: generate an exact number of words, sentences or paragraphs of placeholder text as plain text, HTML `
` or Markdown, optionally starting with "Lorem ipsum dolor sit amet…", with Regenerate on ⌘⏎.
## [0.11.0] — 2026-10-29
diff --git a/README.md b/README.md
index 03a43bd..63388e9 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# devtools.fadeltd.dev
-**Free, open-source developer tools that run entirely in your browser.** A JSON formatter, diff checker, base64 encoder/decoder, JWT decoder, timestamp converter, URL encoder, hash and HMAC calculator, UUID, ID and password generator, Unicode inspector, word counter, HTML-to-text extractor and list sorter — with no backend, no accounts, no ads and no tracking.
+**Free, open-source developer tools that run entirely in your browser.** A JSON formatter, diff checker, YAML/TOML converter, SQL and CSS formatter, CSV viewer, base64 encoder/decoder, JWT decoder, timestamp and cron tools, URL encoder, string escaper, hash and HMAC calculator, UUID, ID and password generator, color and contrast checker, QR code generator, Unicode inspector, word counter, HTML-to-text extractor and list sorter — with no backend, no accounts, no ads and no tracking.
[](https://devtools.fadeltd.dev)
[](LICENSE)
@@ -41,6 +41,17 @@ Three other things it does that the free sites generally do not:
| **[Text to Binary / Hex](https://devtools.fadeltd.dev/text-bytes)** | UTF-8 text ↔ binary, hex, decimal or octal; `0x` / `\x` prefixes and C-string output; tolerant paste parsing with exact error positions; format auto-detect; invalid UTF-8 located, with a labelled fallback |
| **[Number Converter](https://devtools.fadeltd.dev/number)** | Bases 2–36 with no precision loss, two's complement, byte sizes in SI vs IEC, chmod octal ↔ symbolic with checkboxes |
| **[Unicode Inspector](https://devtools.fadeltd.dev/unicode)** | Every code point with line:column, UTF-8/UTF-16 and category; flags zero-width, bidi (Trojan Source), exotic spaces, tags, lone surrogates; flagged-only view; one-click clean with optional NFC/NFKC |
+| **[YAML / JSON / TOML](https://devtools.fadeltd.dev/yaml-toml)** | Convert between YAML, JSON and TOML in any direction with auto-detection; positioned errors with a caret; YAML 1.1 trap notes (`no`, `on`, `0755`); comments kept YAML → YAML; anchors, merge keys and multi-document YAML; exact big integers |
+| **[SQL Formatter](https://devtools.fadeltd.dev/sql)** | Twelve dialects, keyword case, indent and statement spacing; comments kept; parse errors with line and column; minify that leaves strings, quoted identifiers and comments untouched |
+| **[CSS Formatter](https://devtools.fadeltd.dev/css)** | Prettier for CSS, SCSS and Less with width, indent and quote options; syntax errors with line and column; a careful CSS minifier that keeps `calc()` spacing, licence comments and data URIs intact |
+| **[CSV to Table](https://devtools.fadeltd.dev/csv)** | RFC 4180 parsing with quoted newlines, delimiter detection (comma, tab, semicolon, pipe; European decimal commas handled), virtualised table with sticky header and column type hints, ragged-row and unclosed-quote warnings by line, convert to JSON, Markdown, CSV or TSV |
+| **[.env Converter](https://devtools.fadeltd.dev/env)** | .env ↔ JSON ↔ shell `export` ↔ docker `--env-file`; dotenv-accurate parsing (quotes, escapes, multiline, inline comments, CRLF); per-value safe quoting; problems reported by line; flatten nested JSON with `__`; mask values for screen sharing; never saved |
+| **[Cron Explainer](https://devtools.fadeltd.dev/cron)** | Cron in plain English with a per-field breakdown; next 10 runs in any time zone with DST handled the way Vixie cron does; 6-field seconds, macros, day-of-month/day-of-week OR, Quartz syntax called out by name |
+| **[String Escaper](https://devtools.fadeltd.dev/escape)** | Escape and unescape for JSON, JavaScript (`"` `'` or template), SQL (standard `''` or MySQL backslashes), POSIX shell (`'\''` quoting), regex and HTML entities; positioned unescape errors with the fix |
+| **[Color Converter](https://devtools.fadeltd.dev/color)** | Hex, RGB, HSL and OKLCH from any CSS color or a picker; all 148 named colors; out-of-sRGB-gamut warning with a chroma-reduced fallback; alpha; positioned errors |
+| **[Contrast Checker](https://devtools.fadeltd.dev/contrast)** | WCAG 2 ratio with AA/AAA for normal and large text and 3:1 for UI; live preview; alpha blended over the background; nearest passing text color by OKLCH lightness, with Apply |
+| **[QR Code](https://devtools.fadeltd.dev/qr)** | QR codes for text, URLs and Wi-Fi with a chosen error correction level, downloadable as a single-path SVG or a crisp PNG; scan-risk warnings; the Wi-Fi password is never saved |
+| **[Lorem Ipsum](https://devtools.fadeltd.dev/lorem)** | Exact counts of words, sentences or paragraphs, optional classic opening, plain text / HTML `
` / Markdown output, one-key Regenerate |
Press `⌘K` anywhere to jump to a tool, `⌘1`–`⌘9` for pinned ones, and `?` for the shortcut list. Your last input is restored when you come back.
diff --git a/package.json b/package.json
index c5fb827..3b70f22 100644
--- a/package.json
+++ b/package.json
@@ -32,9 +32,11 @@
"comlink": "4.4.2",
"idb-keyval": "6.3.0",
"lucide-react": "1.47.0",
+ "prettier": "3.9.10",
"react": "19.3.0",
"react-dom": "19.3.0",
- "react-router": "8.4.0"
+ "react-router": "8.4.0",
+ "sql-formatter": "15.9.0"
},
"devDependencies": {
"@tailwindcss/node": "4.3.3",
diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index a8a482d..9b5203b 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -6,7 +6,17 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
## [Unreleased]
-Nothing yet.
+### Added
+- `/yaml-toml`: `convert(input, from, to, options)` between JSON, YAML and TOML with `from: 'auto'`, returning errors, warnings and notes with line, column and a caret snippet; never throws. Also `guessFormat` and `stringifyJson` (JSON with exact bigint digits).
+- `/sql`: `formatSql` over twelve dialects via `sql-formatter`, with errors as line and column, and a dialect-aware `minifySql` that never touches strings, quoted identifiers or comments. `sql-formatter` is an optional peer dependency, loaded on first use.
+- `/css`: `formatCss` for CSS, SCSS and Less via Prettier standalone, with errors as line and column, and a hand-written plain-CSS `minifyCss`. `prettier` is an optional peer dependency, loaded on first use.
+- `/csv`: an RFC 4180 parser in linear time that never throws, with positioned issues (unclosed quotes, ragged rows, repeated headers), delimiter sniffing, column type hints, and JSON / Markdown / CSV / TSV writers.
+- `/env`: .env ↔ JSON ↔ shell export ↔ docker env-file conversion with dotenv-accurate parsing, per-value safe quoting, positioned issues and JSON flattening.
+- `/cron`: 5- and 6-field cron and `@` macros with field-and-position errors, Vixie day-of-month/day-of-week OR, `nextRuns` in any IANA zone with Vixie DST handling and never-fires detection, and `describeCron` (English, via `cronstrue`).
+- `/escape`: JSON, JavaScript, SQL (standard/MySQL), POSIX shell, regex and HTML-entity escaping and strict unescaping, with positioned errors.
+- `/color`: CSS color parsing (hex, rgb, hsl, oklch, named) with positioned errors; sRGB ↔ HSL ↔ OKLCH via OKLab; gamut detection and chroma clipping; WCAG contrast ratio, verdicts and the nearest passing foreground.
+- `/qr`: QR payload builders for text, URLs and Wi-Fi, encoding to a module matrix (via `uqr`), and a single-path SVG.
+- `/lorem`: lorem ipsum by exact word, sentence or paragraph count, as text, HTML or Markdown, seedable through any `RandomSource`.
## [0.4.0] — 2026-10-29
diff --git a/packages/core/README.md b/packages/core/README.md
index a1b9c7f..26f596e 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -1,6 +1,6 @@
# @fadeltd/devtools-core
-The engines behind [devtools.fadeltd.dev](https://devtools.fadeltd.dev): list pipelines, JSON errors and fixes, diff, base64, prefixed IDs and UUIDs, timestamps, JWT decoding, URL encoding, hashes and HMAC, slugs, text bytes, number bases, Unicode inspection, text statistics and HTML to text.
+The engines behind [devtools.fadeltd.dev](https://devtools.fadeltd.dev): list pipelines, JSON errors and fixes, diff, base64, prefixed IDs and UUIDs, timestamps, JWT decoding, URL encoding, hashes and HMAC, slugs, text bytes, number bases, Unicode inspection, YAML/JSON/TOML conversion, SQL and CSS formatting, CSV parsing, .env conversion, cron, string escaping, colors and contrast, QR codes, lorem ipsum, text statistics and HTML to text.
Every export is a pure function: strings and bytes in, data out. No DOM, no network, no storage. It runs the same in Node, in a browser and in a worker, which is how the site itself uses it. Each tool also handles the edge cases that free online tools get wrong:
@@ -18,6 +18,8 @@ pnpm add @fadeltd/devtools-core
ESM only. Requires Node 22.12+ or a current browser.
+`/sql` and `/css` use `sql-formatter` and `prettier`, which are optional peer dependencies so the other subpaths don't install them. Add the one you need: `pnpm add sql-formatter` or `pnpm add prettier`.
+
## Use
Import from the subpath for the tool you need. Each subpath is independent, so you only pay for what you import.
@@ -87,6 +89,30 @@ summary('{"key\u200b": 1}').positions[0] // { line: 1, column: 6, hex: 'U+200B',
clean('a\u00a0b\u200bc').text // 'a bc'
```
+```js
+import { convert } from '@fadeltd/devtools-core/yaml-toml'
+
+convert('country: no\n', 'auto', 'json').notes[0] // { code: 'yaml11-bool', … } — "no" stays a string under YAML 1.2
+convert('{"a": null}', 'json', 'toml').errors[0] // { code: 'toml-null', path: '.a', line: 1, column: 2, … }
+```
+
+```js
+import { describeCron, nextRuns, parseCron } from '@fadeltd/devtools-core/cron'
+
+const r = parseCron('30 2 * * *')
+if (r.ok && r.value.kind === 'schedule') {
+ describeCron(r.value, { use24HourTimeFormat: true }) // { ok: true, text: 'At 02:30' }
+ nextRuns(r.value, { now: Date.now(), timeZone: 'America/New_York', count: 3 }).runs
+}
+```
+
+```js
+import { formatSql } from '@fadeltd/devtools-core/sql'
+
+await formatSql('select a from t where b = 1', { dialect: 'postgresql', keywordCase: 'upper' })
+// { ok: true, output: 'SELECT\n a\nFROM\n t\nWHERE\n b = 1' }
+```
+
| Subpath | What it does |
|---|---|
| `/list` | Sort, natural sort, dedupe, shuffle, case, prefix/suffix, filter, number, wrap, join, split, trim, normalise, set operations, slice; chained with per-step line counts |
@@ -104,6 +130,16 @@ clean('a\u00a0b\u200bc').text // 'a bc'
| `/text-bytes` | UTF-8 text ↔ binary, hex, decimal or octal, with format auto-detection and invalid-UTF-8 offsets |
| `/number` | BigInt base conversion 2–36 with two's complement, SI/IEC byte sizes, Unix permissions and chmod commands |
| `/unicode` | Per-code-point inspection, invisible/bidi/tag/space flags with names, and an emoji-safe `clean()` |
+| `/yaml-toml` | JSON ↔ YAML ↔ TOML with auto-detection, positioned errors, YAML 1.2 with 1.1-gotcha notes, multi-document streams, exact big integers and TOML dates |
+| `/sql` | SQL formatting in twelve dialects via `sql-formatter` (optional peer, loaded on first use), errors with line and column, and a dialect-aware minifier |
+| `/css` | CSS/SCSS/Less formatting via Prettier standalone (optional peer, loaded on first use), errors with line and column, and a plain-CSS minifier |
+| `/csv` | RFC 4180 parsing in linear time with positioned issues, delimiter sniffing, column type hints, and JSON / Markdown / CSV / TSV output |
+| `/env` | .env ↔ JSON ↔ shell export ↔ docker env-file: dotenv-accurate parsing, safe quoting, positioned errors, JSON flattening |
+| `/cron` | 5- and 6-field cron and `@` macros, Vixie day OR semantics, next runs in any IANA zone with Vixie DST handling, never-fires detection, English via `cronstrue` |
+| `/escape` | JSON, JavaScript, SQL (standard/MySQL), POSIX shell, regex and HTML-entity escaping and strict unescaping, with positioned errors |
+| `/color` | CSS color parsing with positioned errors; sRGB ↔ HSL ↔ OKLCH; gamut detection and clipping; WCAG contrast and nearest passing foreground |
+| `/qr` | QR payloads for text, URLs and Wi-Fi, encoded to a module matrix, plus a single-path SVG |
+| `/lorem` | Lorem ipsum by exact word, sentence or paragraph count, as text, HTML or Markdown, seedable |
| `/text` | Line/column offsets and character counts shared by the tools |
| `/random` | Unbiased crypto random source and a seeded PRNG |
diff --git a/packages/core/package.json b/packages/core/package.json
index 570cca3..21280bf 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,7 +1,7 @@
{
"name": "@fadeltd/devtools-core",
"version": "0.4.0",
- "description": "The engines behind devtools.fadeltd.dev: list pipelines, JSON errors and fixes, diff, base64, IDs and UUIDs, timestamps, JWT decoding and verification, URL encoding, hashes and HMAC, slugs, text bytes, number bases and Unicode inspection. Pure functions, no DOM, no network.",
+ "description": "The engines behind devtools.fadeltd.dev: list pipelines, JSON errors and fixes, diff, base64, IDs and UUIDs, timestamps, JWT decoding and verification, URL encoding, hashes and HMAC, slugs, text bytes, number bases, Unicode inspection, YAML/JSON/TOML, SQL and CSS formatting, CSV, .env, cron, string escaping, colors and contrast, QR codes and lorem ipsum. Pure functions, no DOM, no network.",
"license": "MIT",
"type": "module",
"sideEffects": false,
@@ -10,10 +10,34 @@
"types": "./dist/base64/index.d.ts",
"default": "./dist/base64/index.js"
},
+ "./color": {
+ "types": "./dist/color/index.d.ts",
+ "default": "./dist/color/index.js"
+ },
+ "./cron": {
+ "types": "./dist/cron/index.d.ts",
+ "default": "./dist/cron/index.js"
+ },
+ "./css": {
+ "types": "./dist/css/index.d.ts",
+ "default": "./dist/css/index.js"
+ },
+ "./csv": {
+ "types": "./dist/csv/index.d.ts",
+ "default": "./dist/csv/index.js"
+ },
"./diff": {
"types": "./dist/diff/index.d.ts",
"default": "./dist/diff/index.js"
},
+ "./env": {
+ "types": "./dist/env/index.d.ts",
+ "default": "./dist/env/index.js"
+ },
+ "./escape": {
+ "types": "./dist/escape/index.d.ts",
+ "default": "./dist/escape/index.js"
+ },
"./hash": {
"types": "./dist/hash/index.d.ts",
"default": "./dist/hash/index.js"
@@ -38,10 +62,18 @@
"types": "./dist/list/index.d.ts",
"default": "./dist/list/index.js"
},
+ "./lorem": {
+ "types": "./dist/lorem/index.d.ts",
+ "default": "./dist/lorem/index.js"
+ },
"./number": {
"types": "./dist/number/index.d.ts",
"default": "./dist/number/index.js"
},
+ "./qr": {
+ "types": "./dist/qr/index.d.ts",
+ "default": "./dist/qr/index.js"
+ },
"./random": {
"types": "./dist/random/index.d.ts",
"default": "./dist/random/index.js"
@@ -50,6 +82,10 @@
"types": "./dist/slug/index.d.ts",
"default": "./dist/slug/index.js"
},
+ "./sql": {
+ "types": "./dist/sql/index.d.ts",
+ "default": "./dist/sql/index.js"
+ },
"./text": {
"types": "./dist/text/index.d.ts",
"default": "./dist/text/index.js"
@@ -74,6 +110,10 @@
"types": "./dist/url/index.d.ts",
"default": "./dist/url/index.js"
},
+ "./yaml-toml": {
+ "types": "./dist/yaml-toml/index.d.ts",
+ "default": "./dist/yaml-toml/index.js"
+ },
"./package.json": "./package.json"
},
"files": [
@@ -96,12 +136,30 @@
},
"dependencies": {
"change-case": "5.4.4",
+ "cronstrue": "3.32.0",
"diff": "9.0.0",
- "jsonc-parser": "3.3.1"
+ "jsonc-parser": "3.3.1",
+ "smol-toml": "1.9.1",
+ "uqr": "0.1.3",
+ "yaml": "2.9.1"
+ },
+ "peerDependencies": {
+ "prettier": "3.9.10",
+ "sql-formatter": "15.9.0"
+ },
+ "peerDependenciesMeta": {
+ "prettier": {
+ "optional": true
+ },
+ "sql-formatter": {
+ "optional": true
+ }
},
"devDependencies": {
"@types/node": "24.9.2",
"fast-check": "4.10.2",
+ "prettier": "3.9.10",
+ "sql-formatter": "15.9.0",
"typescript": "7.0.2",
"vitest": "5.0.1"
},
@@ -131,7 +189,20 @@
"slugify",
"unicode",
"jwt verify",
- "jwks"
+ "jwks",
+ "yaml",
+ "toml",
+ "sql formatter",
+ "css formatter",
+ "csv",
+ "dotenv",
+ "cron",
+ "escape",
+ "color",
+ "oklch",
+ "wcag contrast",
+ "qr code",
+ "lorem ipsum"
],
"publishConfig": {
"access": "public"
diff --git a/packages/core/src/color/contrast.test.ts b/packages/core/src/color/contrast.test.ts
new file mode 100644
index 0000000..650e2cc
--- /dev/null
+++ b/packages/core/src/color/contrast.test.ts
@@ -0,0 +1,130 @@
+import fc from 'fast-check'
+import { describe, expect, it } from 'vitest'
+import { checkContrast, contrastRatio, formatRatio, relativeLuminance, suggestForeground, WCAG } from './contrast.ts'
+import type { Color } from './convert.ts'
+import { parseColor } from './parse.ts'
+
+function color(input: string): Color {
+ const r = parseColor(input)
+ if (!r.ok) throw new Error(r.error.message)
+ return r.color
+}
+
+const byteArb = fc.integer({ min: 0, max: 255 })
+const colorArb = fc
+ .tuple(byteArb, byteArb, byteArb)
+ .map(([r, g, b]): Color => ({ space: 'srgb', r: r / 255, g: g / 255, b: b / 255, alpha: 1 }))
+
+describe('ratio', () => {
+ it('is 21 for black on white and 1 for a color on itself', () => {
+ expect(checkContrast(color('#000'), color('#fff')).ratio).toBeCloseTo(21, 10)
+ expect(checkContrast(color('#fff'), color('#000')).ratio).toBeCloseTo(21, 10)
+ expect(checkContrast(color('red'), color('red')).ratio).toBe(1)
+ })
+
+ it('gives #777 on white about 4.48, which fails AA', () => {
+ const r = checkContrast(color('#777'), color('#fff'))
+ expect(r.ratio).toBeCloseTo(4.48, 2)
+ expect(r.pass).toEqual({ aaNormal: false, aaLarge: true, aaaNormal: false, aaaLarge: false, ui: true })
+ })
+
+ it('uses the WCAG luminance weights', () => {
+ expect(relativeLuminance({ r: 1, g: 0, b: 0 })).toBeCloseTo(0.2126, 10)
+ expect(relativeLuminance({ r: 0, g: 1, b: 0 })).toBeCloseTo(0.7152, 10)
+ })
+
+ it('is symmetric and between 1 and 21', () => {
+ fc.assert(
+ fc.property(colorArb, colorArb, (a, b) => {
+ const ra = checkContrast(a, b).ratio
+ expect(ra).toBeCloseTo(checkContrast(b, a).ratio, 10)
+ expect(ra).toBeGreaterThanOrEqual(1)
+ expect(ra).toBeLessThanOrEqual(21 + 1e-9)
+ }),
+ )
+ })
+
+ it('rounds the printed ratio down, so a failing ratio never prints as the threshold', () => {
+ expect(formatRatio(4.4999)).toBe('4.49:1')
+ expect(formatRatio(4.478)).toBe('4.47:1')
+ expect(formatRatio(21)).toBe('21:1')
+ expect(formatRatio(3)).toBe('3:1')
+ expect(formatRatio(4.5)).toBe('4.5:1')
+ })
+})
+
+describe('alpha', () => {
+ it('blends a translucent foreground over the background first, and says so', () => {
+ const r = checkContrast(color('rgb(0 0 0 / 0.5)'), color('#fff'))
+ // 50% black on white is #808080 (rounded), not black.
+ expect(r.ratio).toBeCloseTo(contrastRatio({ r: 0.5, g: 0.5, b: 0.5 }, { r: 1, g: 1, b: 1 }), 10)
+ expect(r.ratio).toBeLessThan(5)
+ expect(r.notes.join(' ')).toMatch(/50% opaque.*blended/)
+ })
+
+ it('measures a translucent background over white, and says so', () => {
+ const r = checkContrast(color('#000'), color('rgb(0 0 0 / 0)'))
+ expect(r.ratio).toBeCloseTo(21, 10)
+ expect(r.notes.join(' ')).toMatch(/over white/)
+ })
+
+ it('notes an out-of-gamut color', () => {
+ expect(checkContrast(color('oklch(70% 0.4 150)'), color('#fff')).notes.join(' ')).toMatch(/outside sRGB/)
+ })
+})
+
+describe('suggestForeground', () => {
+ it('darkens #777 on white just enough for AA, and keeps it gray', () => {
+ const s = suggestForeground(color('#777'), color('#fff'), WCAG.aaNormal)!
+ expect(s.hex).toBe('#767676')
+ expect(s.ratio).toBeGreaterThanOrEqual(4.5)
+ expect(s.deltaL).toBeLessThan(0)
+ })
+
+ it('lightens on a dark background', () => {
+ const s = suggestForeground(color('#555'), color('#000'), WCAG.aaaNormal)!
+ expect(s.deltaL).toBeGreaterThan(0)
+ expect(s.ratio).toBeGreaterThanOrEqual(7)
+ })
+
+ it('keeps the hue of a colored foreground', () => {
+ const s = suggestForeground(color('#3b82f6'), color('#fff'), WCAG.aaNormal)!
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(s.hex.slice(i, i + 2), 16))
+ expect(b).toBeGreaterThan(r!)
+ expect(b).toBeGreaterThan(g!)
+ })
+
+ it('returns the color itself when it already passes', () => {
+ expect(suggestForeground(color('#000'), color('#fff'), 7)).toMatchObject({ hex: '#000000', deltaL: 0 })
+ })
+
+ it('returns null when no lightness reaches the target', () => {
+ // Mid-gray: black gives ~4.7 and white ~4.5, so 7 is out of reach.
+ expect(suggestForeground(color('#777'), color('#777'), 7)).toBeNull()
+ })
+
+ it('always meets the target it returns, measured on the hex itself', () => {
+ fc.assert(
+ fc.property(colorArb, colorArb, fc.constantFrom(WCAG.aaLarge, WCAG.aaNormal, WCAG.aaaNormal), (fg, bg, target) => {
+ const s = suggestForeground(fg, bg, target)
+ if (s === null) {
+ // Only allowed when neither black nor white gets there.
+ const best = Math.max(checkContrast(color('#000'), bg).ratio, checkContrast(color('#fff'), bg).ratio)
+ expect(best).toBeLessThan(target)
+ return
+ }
+ expect(checkContrast(color(s.hex), bg).ratio).toBeGreaterThanOrEqual(target)
+ }),
+ { numRuns: 300 },
+ )
+ })
+
+ it('always finds AA, since black or white always reaches 4.5', () => {
+ fc.assert(
+ fc.property(colorArb, colorArb, (fg, bg) => {
+ expect(suggestForeground(fg, bg, WCAG.aaNormal)).not.toBeNull()
+ }),
+ { numRuns: 200 },
+ )
+ })
+})
diff --git a/packages/core/src/color/contrast.ts b/packages/core/src/color/contrast.ts
new file mode 100644
index 0000000..29f8d6c
--- /dev/null
+++ b/packages/core/src/color/contrast.ts
@@ -0,0 +1,177 @@
+import { clipToSrgb, rgbToOklch, srgbToLinear, type Color, type Rgb } from './convert.ts'
+import { describeColor, formatHex, formatNumber } from './format.ts'
+
+/** WCAG 2.x minimum contrast ratios (SC 1.4.3, 1.4.6 and 1.4.11). */
+export const WCAG = {
+ aaNormal: 4.5,
+ aaLarge: 3,
+ aaaNormal: 7,
+ aaaLarge: 4.5,
+ /** Non-text: UI components, focus rings, icons and graph lines. */
+ ui: 3,
+} as const
+
+export type WcagCheck = keyof typeof WCAG
+
+/**
+ * WCAG relative luminance of a gamma-encoded sRGB color.
+ *
+ * WCAG 2.0 printed the linearisation threshold as 0.03928, a typo for the sRGB
+ * standard's 0.04045 that WCAG 2.2 corrected. No 8-bit value falls between the
+ * two, so either gives the same answer; this uses the standard's.
+ */
+export function relativeLuminance({ r, g, b }: Rgb): number {
+ return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b)
+}
+
+/** (lighter + 0.05) / (darker + 0.05): 1 for identical colors, 21 for black on white. */
+export function contrastRatio(a: Rgb, b: Rgb): number {
+ const la = relativeLuminance(a)
+ const lb = relativeLuminance(b)
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)
+}
+
+/**
+ * The ratio as people quote it, to two decimals and rounded *down*. WCAG has
+ * no rounding: 4.499 fails 4.5, so printing it as "4.50" would show a passing
+ * number next to a failing verdict.
+ */
+export function formatRatio(ratio: number): string {
+ const floored = Math.floor(ratio * 100 + 1e-9) / 100
+ return `${floored.toFixed(2).replace(/\.?0+$/, '')}:1`
+}
+
+/** Source-over blending of a translucent color onto an opaque one, in gamma-encoded sRGB as browsers do. */
+export function composite(top: Rgb, alpha: number, under: Rgb): Rgb {
+ const mix = (t: number, u: number) => t * alpha + u * (1 - alpha)
+ return { r: mix(top.r, under.r), g: mix(top.g, under.g), b: mix(top.b, under.b) }
+}
+
+const WHITE: Rgb = { r: 1, g: 1, b: 1 }
+
+export interface ContrastReport {
+ ratio: number
+ /** The opaque foreground actually measured (after blending, if it had alpha). */
+ fg: Rgb
+ /** The opaque background actually measured. */
+ bg: Rgb
+ pass: Record
+ /** Assumptions made to get a single number, phrased for the user. */
+ notes: string[]
+}
+
+/**
+ * Contrast of text in `fg` on `bg`. A ratio is only defined between opaque
+ * colors, so a translucent foreground is first blended over the background
+ * (what the reader actually sees), and a translucent background over white,
+ * the browser's default canvas. Out-of-gamut OKLCH is measured as the sRGB
+ * color a screen will actually show.
+ */
+export function checkContrast(fg: Color, bg: Color): ContrastReport {
+ const notes: string[] = []
+ const f = describeColor(fg)
+ const b = describeColor(bg)
+
+ let bgRgb = b.rgb
+ if (b.alpha < 1) {
+ bgRgb = composite(b.rgb, b.alpha, WHITE)
+ notes.push(
+ `The background is ${percent(b.alpha * 100)}% opaque. It is measured over white, as ${formatHex(bgRgb)}; over anything else the ratio changes.`,
+ )
+ }
+ let fgRgb = f.rgb
+ if (f.alpha < 1) {
+ fgRgb = composite(f.rgb, f.alpha, bgRgb)
+ notes.push(
+ `The foreground is ${percent(f.alpha * 100)}% opaque, so it is blended over the background first and measured as ${formatHex(fgRgb)}.`,
+ )
+ }
+ if (!f.inGamut) notes.push('The foreground is outside sRGB; measured as its in-gamut version.')
+ if (!b.inGamut) notes.push('The background is outside sRGB; measured as its in-gamut version.')
+
+ const ratio = contrastRatio(fgRgb, bgRgb)
+ const pass = Object.fromEntries(
+ (Object.keys(WCAG) as WcagCheck[]).map((k) => [k, ratio >= WCAG[k]]),
+ ) as Record
+ return { ratio, fg: fgRgb, bg: bgRgb, pass, notes }
+}
+
+function percent(n: number): string {
+ // Whole percent, unless that would round 99.6% up to a misleading "100%".
+ return formatNumber(n, (n > 99 && n < 100) || (n > 0 && n < 1) ? 1 : 0)
+}
+
+export interface Suggestion {
+ /** Opaque, 8-bit: what to paste. Its ratio is measured on this exact hex. */
+ hex: string
+ rgb: Rgb
+ ratio: number
+ /** Signed change in OKLCH lightness from the measured foreground, 0–1 scale. */
+ deltaL: number
+}
+
+/**
+ * The foreground nearest the current one that reaches `target` on `bg`.
+ *
+ * Only OKLCH lightness moves; chroma and hue are kept (chroma is lowered only
+ * where sRGB runs out of room near black and white), so the suggestion is
+ * still recognisably "the same color, lighter" or "darker". Both directions
+ * are searched and the smaller change wins. Returns null when no lightness
+ * reaches the target, which happens only above about 4.58:1, for mid-tone
+ * backgrounds where even black and white fall short.
+ *
+ * Every candidate is judged after rounding to 8-bit hex, so the returned hex
+ * meets the target as pasted, not just in floating point.
+ */
+export function suggestForeground(fg: Color, bg: Color, target: number): Suggestion | null {
+ const report = checkContrast(fg, bg)
+ const bgRgb = report.bg
+ const measured = rgbToOklch(report.fg)
+ // A gray's chroma comes out as float noise (~1e-8) with a random hue. Left
+ // in, that noise splits R, G and B across a rounding boundary and "suggests"
+ // a faintly tinted gray like #777676 for #777777.
+ const base = measured.c < 1e-4 ? { l: measured.l, c: 0, h: 0 } : measured
+
+ const at = (l: number): { hex: string; rgb: Rgb; ratio: number } => {
+ const { rgb } = clipToSrgb({ l, c: base.c, h: base.h })
+ const hex = formatHex(rgb)
+ const rounded = hexToRgb(hex)
+ return { hex, rgb: rounded, ratio: contrastRatio(rounded, bgRgb) }
+ }
+
+ const search = (towards: 0 | 1): { l: number; ratio: number; hex: string; rgb: Rgb } | null => {
+ const step = towards === 1 ? 0.01 : -0.01
+ let prev = base.l
+ if (at(prev).ratio >= target) return { l: prev, ...at(prev) }
+ // Scan first, then bisect the first crossing: contrast is not strictly
+ // monotonic in L once the gamut clip kicks in, so bisection alone could
+ // skip past the nearest passing band.
+ for (let l = base.l + step; towards === 1 ? l < 1 + step : l > -step; l += step) {
+ const cur = Math.min(1, Math.max(0, l))
+ if (at(cur).ratio >= target) {
+ let fail = prev
+ let ok = cur
+ for (let i = 0; i < 20; i++) {
+ const mid = (fail + ok) / 2
+ if (at(mid).ratio >= target) ok = mid
+ else fail = mid
+ }
+ return { l: ok, ...at(ok) }
+ }
+ prev = cur
+ }
+ return null
+ }
+
+ const up = search(1)
+ const down = search(0)
+ let best = up
+ if (down !== null && (best === null || Math.abs(down.l - base.l) < Math.abs(best.l - base.l))) best = down
+ if (best === null) return null
+ return { hex: best.hex, rgb: best.rgb, ratio: best.ratio, deltaL: best.l - base.l }
+}
+
+function hexToRgb(hex: string): Rgb {
+ const n = (k: number) => parseInt(hex.slice(1 + k * 2, 3 + k * 2), 16) / 255
+ return { r: n(0), g: n(1), b: n(2) }
+}
diff --git a/packages/core/src/color/convert.test.ts b/packages/core/src/color/convert.test.ts
new file mode 100644
index 0000000..89da48f
--- /dev/null
+++ b/packages/core/src/color/convert.test.ts
@@ -0,0 +1,141 @@
+import fc from 'fast-check'
+import { describe, expect, it } from 'vitest'
+import { clipToSrgb, hslToRgb, isInGamut, oklchToRgb, rgbToHsl, rgbToOklch, type Rgb } from './convert.ts'
+import { describeColor, formatHex, formatHsl, formatNumber, formatOklch, formatRgb } from './format.ts'
+import { parseColor } from './parse.ts'
+
+const byteArb = fc.integer({ min: 0, max: 255 })
+const rgbArb = fc.tuple(byteArb, byteArb, byteArb)
+const toRgb = ([r, g, b]: [number, number, number]): Rgb => ({ r: r / 255, g: g / 255, b: b / 255 })
+const bytesOf = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16))
+
+function info(input: string) {
+ const r = parseColor(input)
+ if (!r.ok) throw new Error(r.error.message)
+ return describeColor(r.color)
+}
+
+describe('OKLab', () => {
+ // Reference values from Björn Ottosson's post and the CSS Color 4 spec.
+ it('matches published values', () => {
+ const red = rgbToOklch({ r: 1, g: 0, b: 0 })
+ expect(red.l).toBeCloseTo(0.62796, 4)
+ expect(red.c).toBeCloseTo(0.25768, 4)
+ expect(red.h).toBeCloseTo(29.2339, 2)
+ const white = rgbToOklch({ r: 1, g: 1, b: 1 })
+ expect(white.l).toBeCloseTo(1, 6)
+ expect(white.c).toBeCloseTo(0, 6)
+ })
+
+ it('round-trips hex → OKLCH → hex within ±1 per channel', () => {
+ fc.assert(
+ fc.property(rgbArb, (bytes) => {
+ const back = formatHex(oklchToRgb(rgbToOklch(toRgb(bytes))))
+ bytesOf(back).forEach((v, i) => expect(Math.abs(v - bytes[i]!)).toBeLessThanOrEqual(1))
+ }),
+ )
+ })
+
+ it('round-trips through the printed oklch() string within ±1 and stays in gamut', () => {
+ fc.assert(
+ fc.property(rgbArb, (bytes) => {
+ const hex = formatHex(toRgb(bytes))
+ const back = info(info(hex).css.oklch)
+ expect(back.inGamut).toBe(true)
+ bytesOf(back.css.hex).forEach((v, i) => expect(Math.abs(v - bytes[i]!)).toBeLessThanOrEqual(1))
+ }),
+ { numRuns: 2000 },
+ )
+ })
+})
+
+describe('HSL', () => {
+ it('converts the primaries', () => {
+ expect(rgbToHsl({ r: 0, g: 0, b: 1 })).toEqual({ h: 240, s: 100, l: 50 })
+ expect(hslToRgb({ h: 240, s: 100, l: 50 })).toEqual({ r: 0, g: 0, b: 1 })
+ })
+
+ it('round-trips through the printed hsl() string exactly', () => {
+ fc.assert(
+ fc.property(rgbArb, (bytes) => {
+ const hex = formatHex(toRgb(bytes))
+ expect(info(info(hex).css.hsl).css.hex).toBe(hex)
+ }),
+ { numRuns: 2000 },
+ )
+ })
+})
+
+describe('gamut', () => {
+ it('detects an out-of-gamut OKLCH color and clips chroma only', () => {
+ const c = info('oklch(70% 0.4 150)')
+ expect(c.inGamut).toBe(false)
+ expect(c.clipped).not.toBeNull()
+ expect(c.clipped!.l).toBe(0.7)
+ expect(c.clipped!.h).toBe(150)
+ expect(c.clipped!.c).toBeLessThan(0.4)
+ expect(c.clipped!.c).toBeGreaterThan(0.15)
+ // The clip lands on the gamut boundary, not well inside it.
+ expect(isInGamut(oklchToRgb({ ...c.clipped!, c: c.clipped!.c + 0.005 }), 0)).toBe(false)
+ // The original is shown as written; the sRGB rows show the clipped color.
+ expect(c.css.oklch).toBe('oklch(70% 0.4 150)')
+ expect(c.css.clippedOklch).toMatch(/^oklch\(70% 0\.\d+ 150\)$/)
+ expect(info(c.css.clippedOklch!).inGamut).toBe(true)
+ })
+
+ it('treats every sRGB color as in gamut', () => {
+ expect(info('#ff0000').inGamut).toBe(true)
+ expect(info('oklch(62.8% 0.2577 29.23)').inGamut).toBe(true)
+ })
+
+ it('clips toward a gray at the extremes', () => {
+ expect(formatHex(clipToSrgb({ l: 1, c: 0.3, h: 40 }).rgb)).toBe('#ffffff')
+ expect(formatHex(clipToSrgb({ l: 0, c: 0.3, h: 40 }).rgb)).toBe('#000000')
+ })
+})
+
+describe('formatting', () => {
+ it('prints canonical CSS', () => {
+ const c = info('#3b82f6')
+ expect(c.css).toMatchObject({
+ hex: '#3b82f6',
+ rgb: 'rgb(59 130 246)',
+ hsl: 'hsl(217.2 91.2% 59.8%)',
+ oklch: 'oklch(62.31% 0.188 259.81)',
+ })
+ })
+
+ it('prints alpha only when present', () => {
+ const c = info('rgb(59 130 246 / 50%)')
+ expect(c.alpha).toBe(0.5)
+ expect(c.css.hex).toBe('#3b82f680')
+ expect(c.css.rgb).toBe('rgb(59 130 246 / 0.5)')
+ expect(c.css.hsl).toBe('hsl(217.2 91.2% 59.8% / 0.5)')
+ expect(c.css.oklch).toBe('oklch(62.31% 0.188 259.81 / 0.5)')
+ })
+
+ it('prints a gray with hue 0, not float noise', () => {
+ expect(info('#777').css.oklch).toBe('oklch(56.93% 0 0)')
+ expect(info('#777').css.hsl).toBe('hsl(0 0% 46.7%)')
+ expect(formatOklch({ l: 0.5, c: 0.00001, h: 123.4 })).toBe('oklch(50% 0 0)')
+ expect(formatHsl({ h: 99, s: 0.01, l: 50 })).toBe('hsl(0 0% 50%)')
+ })
+
+ it('clamps when printing sRGB', () => {
+ expect(formatRgb({ r: 1.2, g: -0.1, b: 0.5 })).toBe('rgb(255 0 128)')
+ })
+
+ it('trims trailing zeros and never prints -0', () => {
+ expect(formatNumber(1.5, 3)).toBe('1.5')
+ expect(formatNumber(100, 2)).toBe('100')
+ expect(formatNumber(-0.0001, 2)).toBe('0')
+ })
+
+ it('names exact matches, both names when there are two', () => {
+ expect(info('#0ff').names).toEqual(['aqua', 'cyan'])
+ expect(info('#663399').names).toEqual(['rebeccapurple'])
+ expect(info('#663398').names).toEqual([])
+ expect(info('#66339980').names).toEqual([])
+ expect(info('transparent').names).toEqual(['transparent'])
+ })
+})
diff --git a/packages/core/src/color/convert.ts b/packages/core/src/color/convert.ts
new file mode 100644
index 0000000..cb6c4e8
--- /dev/null
+++ b/packages/core/src/color/convert.ts
@@ -0,0 +1,170 @@
+/** sRGB, gamma-encoded, each channel 0–1. Out-of-gamut values may exceed that range. */
+export interface Rgb {
+ r: number
+ g: number
+ b: number
+}
+
+/** Hue in degrees [0, 360); saturation and lightness 0–100. */
+export interface Hsl {
+ h: number
+ s: number
+ l: number
+}
+
+/** Lightness 0–1, chroma ≥ 0 (about 0.37 at most inside sRGB), hue in degrees [0, 360). */
+export interface Oklch {
+ l: number
+ c: number
+ h: number
+}
+
+export interface Oklab {
+ l: number
+ a: number
+ b: number
+}
+
+/**
+ * A parsed color. sRGB and OKLCH are kept apart because OKLCH can name colors
+ * sRGB cannot show; converting at parse time would lose the "out of gamut"
+ * fact the converter has to report. HSL is always inside sRGB, so it is
+ * converted to sRGB as soon as it is read.
+ */
+export type Color =
+ | ({ space: 'srgb'; alpha: number } & Rgb)
+ | ({ space: 'oklch'; alpha: number } & Oklch)
+
+export function normalizeHue(h: number): number {
+ const r = h % 360
+ // `+ 0` folds -0 into 0 so a hue never prints as "-0".
+ return (r < 0 ? r + 360 : r) + 0
+}
+
+const clamp01 = (x: number) => Math.min(1, Math.max(0, x))
+
+// sRGB transfer function (IEC 61966-2-1). Extended to negative values by
+// mirroring, so out-of-gamut colors survive the round trip instead of being
+// flattened before the gamut check has seen them.
+export function srgbToLinear(c: number): number {
+ const a = Math.abs(c)
+ const v = a <= 0.04045 ? a / 12.92 : ((a + 0.055) / 1.055) ** 2.4
+ return Math.sign(c) * v
+}
+
+export function linearToSrgb(c: number): number {
+ const a = Math.abs(c)
+ const v = a <= 0.0031308 ? a * 12.92 : 1.055 * a ** (1 / 2.4) - 0.055
+ return Math.sign(c) * v
+}
+
+// Björn Ottosson's published OKLab matrices (bottosson.github.io/posts/oklab):
+// linear sRGB → LMS → cube root → OKLab, and the exact inverse.
+export function linearRgbToOklab(r: number, g: number, b: number): Oklab {
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
+ return {
+ l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
+ a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
+ b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
+ }
+}
+
+export function oklabToLinearRgb({ l: L, a, b }: Oklab): Rgb {
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3
+ return {
+ r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
+ g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
+ b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
+ }
+}
+
+export function rgbToOklch({ r, g, b }: Rgb): Oklch {
+ const lab = linearRgbToOklab(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
+ const c = Math.hypot(lab.a, lab.b)
+ return { l: lab.l, c, h: normalizeHue((Math.atan2(lab.b, lab.a) * 180) / Math.PI) }
+}
+
+/** Unclamped: an out-of-gamut OKLCH color comes back with channels outside 0–1. */
+export function oklchToRgb({ l, c, h }: Oklch): Rgb {
+ const rad = (h * Math.PI) / 180
+ const lin = oklabToLinearRgb({ l, a: c * Math.cos(rad), b: c * Math.sin(rad) })
+ return { r: linearToSrgb(lin.r), g: linearToSrgb(lin.g), b: linearToSrgb(lin.b) }
+}
+
+export function rgbToHsl({ r, g, b }: Rgb): Hsl {
+ const max = Math.max(r, g, b)
+ const min = Math.min(r, g, b)
+ const l = (max + min) / 2
+ const d = max - min
+ if (d === 0) return { h: 0, s: 0, l: l * 100 }
+ const s = d / (1 - Math.abs(2 * l - 1))
+ let h: number
+ if (max === r) h = ((g - b) / d) % 6
+ else if (max === g) h = (b - r) / d + 2
+ else h = (r - g) / d + 4
+ return { h: normalizeHue(h * 60), s: s * 100, l: l * 100 }
+}
+
+export function hslToRgb({ h, s, l }: Hsl): Rgb {
+ // The CSS Color 4 reference algorithm (§7.1).
+ const sat = s / 100
+ const light = l / 100
+ const f = (n: number) => {
+ const k = (n + h / 30) % 12
+ const a = sat * Math.min(light, 1 - light)
+ return light - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
+ }
+ return { r: f(0), g: f(8), b: f(4) }
+}
+
+/**
+ * How far outside 0–1 a channel may stray and still count as inside sRGB.
+ *
+ * One 8-bit step. The oklch() this module prints for a hex color is rounded,
+ * and reading it back can land up to ~0.8 of a step outside the sRGB cube
+ * (measured over the whole cube). Without this slack, pasting our own output
+ * back in would raise an out-of-gamut warning about a difference no hex,
+ * rgb() or hsl() could show anyway.
+ */
+export const GAMUT_EPSILON = 1 / 255
+
+export function isInGamut({ r, g, b }: Rgb, epsilon = GAMUT_EPSILON): boolean {
+ const ok = (x: number) => x >= -epsilon && x <= 1 + epsilon
+ return ok(r) && ok(g) && ok(b)
+}
+
+export function clampRgb({ r, g, b }: Rgb): Rgb {
+ return { r: clamp01(r), g: clamp01(g), b: clamp01(b) }
+}
+
+/**
+ * Bring an OKLCH color inside sRGB by lowering chroma alone, so lightness and
+ * hue (what a designer picked) stay put and only the saturation gives. A
+ * binary search on chroma: the in-gamut region along a line of constant L and
+ * h runs from c = 0 (a gray, always inside for 0 ≤ L ≤ 1) up to the boundary.
+ */
+export function clipToSrgb(color: Oklch): { oklch: Oklch; rgb: Rgb } {
+ const l = clamp01(color.l)
+ const h = color.h
+ const direct = oklchToRgb({ l, c: color.c, h })
+ if (isInGamut(direct)) return { oklch: { l, c: color.c, h }, rgb: clampRgb(direct) }
+
+ let lo = 0
+ let hi = color.c
+ // 24 halvings take 0.4 of chroma below 1e-7, far under a printable digit.
+ for (let i = 0; i < 24; i++) {
+ const mid = (lo + hi) / 2
+ if (isInGamut(oklchToRgb({ l, c: mid, h }), 0)) lo = mid
+ else hi = mid
+ }
+ return { oklch: { l, c: lo, h }, rgb: clampRgb(oklchToRgb({ l, c: lo, h })) }
+}
+
+/** Any parsed color as sRGB, with no gamut handling: channels may fall outside 0–1. */
+export function toRgbUnclamped(color: Color): Rgb {
+ return color.space === 'srgb' ? { r: color.r, g: color.g, b: color.b } : oklchToRgb(color)
+}
diff --git a/packages/core/src/color/format.ts b/packages/core/src/color/format.ts
new file mode 100644
index 0000000..9f6fdc5
--- /dev/null
+++ b/packages/core/src/color/format.ts
@@ -0,0 +1,126 @@
+import {
+ clipToSrgb,
+ isInGamut,
+ oklchToRgb,
+ rgbToHsl,
+ rgbToOklch,
+ clampRgb,
+ type Color,
+ type Hsl,
+ type Oklch,
+ type Rgb,
+} from './convert.ts'
+import { namesForHex } from './named.ts'
+
+/** Round to `digits` decimals and drop trailing zeros; never prints "-0". */
+export function formatNumber(n: number, digits: number): string {
+ const s = n.toFixed(digits)
+ const trimmed = s.includes('.') ? s.replace(/\.?0+$/, '') : s
+ return trimmed === '-0' ? '0' : trimmed
+}
+
+const byte = (x: number) => Math.round(Math.min(1, Math.max(0, x)) * 255)
+const hex2 = (n: number) => n.toString(16).padStart(2, '0')
+
+// Alpha keeps three decimals: enough to tell 8-bit alpha steps apart (1/255 ≈
+// 0.004) without printing float noise like 0.50196.
+const alphaSuffix = (alpha: number) => (alpha >= 1 ? '' : ` / ${formatNumber(alpha, 3)}`)
+
+/** `#rrggbb`, or `#rrggbbaa` when not opaque. Lowercase, never shortened. */
+export function formatHex(rgb: Rgb, alpha = 1): string {
+ const base = `#${hex2(byte(rgb.r))}${hex2(byte(rgb.g))}${hex2(byte(rgb.b))}`
+ return alpha >= 1 ? base : base + hex2(byte(alpha))
+}
+
+/** `rgb(59 130 246)` / `rgb(59 130 246 / 0.5)`: the CSS Color 4 space syntax, integer channels. */
+export function formatRgb(rgb: Rgb, alpha = 1): string {
+ return `rgb(${byte(rgb.r)} ${byte(rgb.g)} ${byte(rgb.b)}${alphaSuffix(alpha)})`
+}
+
+/**
+ * `hsl(217.2 91.2% 59.8%)`. One decimal: integer HSL cannot reach every 8-bit
+ * color, and a second decimal is noise. A gray prints hue 0 rather than an
+ * arbitrary angle left over from float error.
+ */
+export function formatHsl(hsl: Hsl, alpha = 1): string {
+ const s = formatNumber(hsl.s, 1)
+ const h = s === '0' ? '0' : formatNumber(hsl.h, 1)
+ return `hsl(${h} ${s}% ${formatNumber(hsl.l, 1)}%${alphaSuffix(alpha)})`
+}
+
+/**
+ * `oklch(62.37% 0.1882 259.81)`. Lightness as a percentage, as Tailwind and
+ * most design tools write it. Two decimals of L, four of C and two of H keep
+ * the printed value within one 8-bit step of the hex it came from. Below that
+ * chroma the hue is meaningless, so a gray prints hue 0.
+ */
+export function formatOklch(oklch: Oklch, alpha = 1): string {
+ const c = formatNumber(oklch.c, 4)
+ const h = c === '0' ? '0' : formatNumber(oklch.h, 2)
+ return `oklch(${formatNumber(oklch.l * 100, 2)}% ${c} ${h}${alphaSuffix(alpha)})`
+}
+
+export interface ColorInfo {
+ alpha: number
+ /** False only for an OKLCH color that sRGB (hex, rgb(), hsl()) cannot show. */
+ inGamut: boolean
+ /** The sRGB color the hex / rgb / hsl outputs show: the color itself, or its chroma-reduced version. */
+ rgb: Rgb
+ hsl: Hsl
+ /** The color as given (an OKLCH input is not altered), or the OKLCH of an sRGB input. */
+ oklch: Oklch
+ /** When out of gamut: the same lightness and hue with chroma lowered until sRGB can show it. */
+ clipped: Oklch | null
+ css: {
+ hex: string
+ rgb: string
+ hsl: string
+ oklch: string
+ /** `formatOklch(clipped)`, when out of gamut. */
+ clippedOklch: string | null
+ }
+ /** CSS names for this exact color (`aqua` and `cyan` share one). Empty unless opaque. */
+ names: readonly string[]
+}
+
+/** Every output the converter shows, from one parsed color. */
+export function describeColor(color: Color): ColorInfo {
+ const alpha = color.alpha
+ let rgb: Rgb
+ let oklch: Oklch
+ let clipped: Oklch | null = null
+
+ if (color.space === 'srgb') {
+ rgb = clampRgb(color)
+ oklch = rgbToOklch(rgb)
+ } else {
+ oklch = { l: color.l, c: color.c, h: color.h }
+ const direct = oklchToRgb(oklch)
+ if (isInGamut(direct)) {
+ rgb = clampRgb(direct)
+ } else {
+ const clip = clipToSrgb(oklch)
+ clipped = clip.oklch
+ rgb = clip.rgb
+ }
+ }
+
+ const hex = formatHex(rgb, alpha)
+ const names = alpha >= 1 ? namesForHex(hex.slice(1)) : alpha === 0 && hex === '#00000000' ? ['transparent'] : []
+ return {
+ alpha,
+ inGamut: clipped === null,
+ rgb,
+ hsl: rgbToHsl(rgb),
+ oklch,
+ clipped,
+ css: {
+ hex,
+ rgb: formatRgb(rgb, alpha),
+ hsl: formatHsl(rgbToHsl(rgb), alpha),
+ oklch: formatOklch(oklch, alpha),
+ clippedOklch: clipped === null ? null : formatOklch(clipped, alpha),
+ },
+ names,
+ }
+}
diff --git a/packages/core/src/color/index.ts b/packages/core/src/color/index.ts
new file mode 100644
index 0000000..164cb76
--- /dev/null
+++ b/packages/core/src/color/index.ts
@@ -0,0 +1,6 @@
+export type { InputError } from '../number/error.ts'
+export * from './convert.ts'
+export * from './named.ts'
+export * from './parse.ts'
+export * from './format.ts'
+export * from './contrast.ts'
diff --git a/packages/core/src/color/named.ts b/packages/core/src/color/named.ts
new file mode 100644
index 0000000..9522084
--- /dev/null
+++ b/packages/core/src/color/named.ts
@@ -0,0 +1,175 @@
+/**
+ * The 148 CSS named colors (CSS Color Module Level 4, §6.1), as 6-digit hex.
+ *
+ * Written out by hand rather than read from the DOM, so it works in Node and a
+ * worker, and so a typo is caught by the test that pins the count and a few
+ * values. `transparent` is not in this table: it is the only keyword with an
+ * alpha, and the parser handles it on its own.
+ */
+export const NAMED_COLORS: Readonly> = {
+ aliceblue: 'f0f8ff',
+ antiquewhite: 'faebd7',
+ aqua: '00ffff',
+ aquamarine: '7fffd4',
+ azure: 'f0ffff',
+ beige: 'f5f5dc',
+ bisque: 'ffe4c4',
+ black: '000000',
+ blanchedalmond: 'ffebcd',
+ blue: '0000ff',
+ blueviolet: '8a2be2',
+ brown: 'a52a2a',
+ burlywood: 'deb887',
+ cadetblue: '5f9ea0',
+ chartreuse: '7fff00',
+ chocolate: 'd2691e',
+ coral: 'ff7f50',
+ cornflowerblue: '6495ed',
+ cornsilk: 'fff8dc',
+ crimson: 'dc143c',
+ cyan: '00ffff',
+ darkblue: '00008b',
+ darkcyan: '008b8b',
+ darkgoldenrod: 'b8860b',
+ darkgray: 'a9a9a9',
+ darkgreen: '006400',
+ darkgrey: 'a9a9a9',
+ darkkhaki: 'bdb76b',
+ darkmagenta: '8b008b',
+ darkolivegreen: '556b2f',
+ darkorange: 'ff8c00',
+ darkorchid: '9932cc',
+ darkred: '8b0000',
+ darksalmon: 'e9967a',
+ darkseagreen: '8fbc8f',
+ darkslateblue: '483d8b',
+ darkslategray: '2f4f4f',
+ darkslategrey: '2f4f4f',
+ darkturquoise: '00ced1',
+ darkviolet: '9400d3',
+ deeppink: 'ff1493',
+ deepskyblue: '00bfff',
+ dimgray: '696969',
+ dimgrey: '696969',
+ dodgerblue: '1e90ff',
+ firebrick: 'b22222',
+ floralwhite: 'fffaf0',
+ forestgreen: '228b22',
+ fuchsia: 'ff00ff',
+ gainsboro: 'dcdcdc',
+ ghostwhite: 'f8f8ff',
+ gold: 'ffd700',
+ goldenrod: 'daa520',
+ gray: '808080',
+ green: '008000',
+ greenyellow: 'adff2f',
+ grey: '808080',
+ honeydew: 'f0fff0',
+ hotpink: 'ff69b4',
+ indianred: 'cd5c5c',
+ indigo: '4b0082',
+ ivory: 'fffff0',
+ khaki: 'f0e68c',
+ lavender: 'e6e6fa',
+ lavenderblush: 'fff0f5',
+ lawngreen: '7cfc00',
+ lemonchiffon: 'fffacd',
+ lightblue: 'add8e6',
+ lightcoral: 'f08080',
+ lightcyan: 'e0ffff',
+ lightgoldenrodyellow: 'fafad2',
+ lightgray: 'd3d3d3',
+ lightgreen: '90ee90',
+ lightgrey: 'd3d3d3',
+ lightpink: 'ffb6c1',
+ lightsalmon: 'ffa07a',
+ lightseagreen: '20b2aa',
+ lightskyblue: '87cefa',
+ lightslategray: '778899',
+ lightslategrey: '778899',
+ lightsteelblue: 'b0c4de',
+ lightyellow: 'ffffe0',
+ lime: '00ff00',
+ limegreen: '32cd32',
+ linen: 'faf0e6',
+ magenta: 'ff00ff',
+ maroon: '800000',
+ mediumaquamarine: '66cdaa',
+ mediumblue: '0000cd',
+ mediumorchid: 'ba55d3',
+ mediumpurple: '9370db',
+ mediumseagreen: '3cb371',
+ mediumslateblue: '7b68ee',
+ mediumspringgreen: '00fa9a',
+ mediumturquoise: '48d1cc',
+ mediumvioletred: 'c71585',
+ midnightblue: '191970',
+ mintcream: 'f5fffa',
+ mistyrose: 'ffe4e1',
+ moccasin: 'ffe4b5',
+ navajowhite: 'ffdead',
+ navy: '000080',
+ oldlace: 'fdf5e6',
+ olive: '808000',
+ olivedrab: '6b8e23',
+ orange: 'ffa500',
+ orangered: 'ff4500',
+ orchid: 'da70d6',
+ palegoldenrod: 'eee8aa',
+ palegreen: '98fb98',
+ paleturquoise: 'afeeee',
+ palevioletred: 'db7093',
+ papayawhip: 'ffefd5',
+ peachpuff: 'ffdab9',
+ peru: 'cd853f',
+ pink: 'ffc0cb',
+ plum: 'dda0dd',
+ powderblue: 'b0e0e6',
+ purple: '800080',
+ rebeccapurple: '663399',
+ red: 'ff0000',
+ rosybrown: 'bc8f8f',
+ royalblue: '4169e1',
+ saddlebrown: '8b4513',
+ salmon: 'fa8072',
+ sandybrown: 'f4a460',
+ seagreen: '2e8b57',
+ seashell: 'fff5ee',
+ sienna: 'a0522d',
+ silver: 'c0c0c0',
+ skyblue: '87ceeb',
+ slateblue: '6a5acd',
+ slategray: '708090',
+ slategrey: '708090',
+ snow: 'fffafa',
+ springgreen: '00ff7f',
+ steelblue: '4682b4',
+ tan: 'd2b48c',
+ teal: '008080',
+ thistle: 'd8bfd8',
+ tomato: 'ff6347',
+ turquoise: '40e0d0',
+ violet: 'ee82ee',
+ wheat: 'f5deb3',
+ white: 'ffffff',
+ whitesmoke: 'f5f5f5',
+ yellow: 'ffff00',
+ yellowgreen: '9acd32',
+}
+
+let byHex: Map | null = null
+
+/**
+ * Every name for an opaque 6-digit hex (lowercase, no `#`), in table order.
+ * Several colors have two (`aqua` / `cyan`, `gray` / `grey`), and naming only
+ * one would suggest the other is wrong.
+ */
+export function namesForHex(hex6: string): readonly string[] {
+ if (byHex === null) {
+ byHex = new Map()
+ for (const [name, hex] of Object.entries(NAMED_COLORS)) {
+ byHex.set(hex, [...(byHex.get(hex) ?? []), name])
+ }
+ }
+ return byHex.get(hex6.toLowerCase()) ?? []
+}
diff --git a/packages/core/src/color/parse.test.ts b/packages/core/src/color/parse.test.ts
new file mode 100644
index 0000000..71d9c3c
--- /dev/null
+++ b/packages/core/src/color/parse.test.ts
@@ -0,0 +1,201 @@
+import { describe, expect, it } from 'vitest'
+import { describeColor } from './format.ts'
+import { NAMED_COLORS } from './named.ts'
+import { parseColor } from './parse.ts'
+
+/** Parse and return the hex the converter would show, or the error code. */
+function hex(input: string): string {
+ const r = parseColor(input)
+ return r.ok ? describeColor(r.color).css.hex : `error:${r.error.code}`
+}
+
+function error(input: string) {
+ const r = parseColor(input)
+ if (r.ok) throw new Error(`expected ${input} to fail`)
+ return r.error
+}
+
+describe('hex', () => {
+ it('reads all four lengths', () => {
+ expect(hex('#f80')).toBe('#ff8800')
+ expect(hex('#f808')).toBe('#ff880088')
+ expect(hex('#FF8800')).toBe('#ff8800')
+ expect(hex('#ff880080')).toBe('#ff880080')
+ })
+
+ it('ignores surrounding whitespace', () => {
+ expect(hex(' #ff8800\n')).toBe('#ff8800')
+ })
+
+ it('accepts 6 and 8 digits without a #, and says so', () => {
+ const r = parseColor('3b82f6')
+ expect(r.ok && r.notes).toEqual(['Read as #3b82f6.'])
+ expect(hex('3b82f680')).toBe('#3b82f680')
+ // Three letters is a word, not a color.
+ expect(hex('add')).toBe('error:unknown-name')
+ })
+
+ it('points at the bad digit', () => {
+ expect(error('#ff88zz')).toMatchObject({ code: 'hex-digit', index: 5, char: 'z' })
+ expect(error(' #12g')).toMatchObject({ code: 'hex-digit', index: 5 })
+ })
+
+ it('rejects other lengths', () => {
+ expect(error('#12345')).toMatchObject({ code: 'hex-length', index: 1 })
+ expect(error('#')).toMatchObject({ code: 'hex-length' })
+ })
+})
+
+describe('rgb()', () => {
+ it('reads comma and space syntax, with and without alpha', () => {
+ expect(hex('rgb(255, 136, 0)')).toBe('#ff8800')
+ expect(hex('rgb(255 136 0)')).toBe('#ff8800')
+ expect(hex('rgba(255, 136, 0, 0.5)')).toBe('#ff880080')
+ expect(hex('rgb(255 136 0 / 0.5)')).toBe('#ff880080')
+ expect(hex('rgb(255 136 0 / 50%)')).toBe('#ff880080')
+ // rgba is an alias, and rgb takes a legacy alpha too.
+ expect(hex('rgba(255 136 0)')).toBe('#ff8800')
+ expect(hex('rgb(255, 136, 0, 1)')).toBe('#ff8800')
+ })
+
+ it('reads percentages, fractions and exponents', () => {
+ expect(hex('rgb(100%, 0%, 50%)')).toBe('#ff0080')
+ expect(hex('rgb(127.5 0 1e2)')).toBe('#800064')
+ expect(hex('RGB(.5e1 +0 -0)')).toBe('#050000')
+ })
+
+ it('treats none as zero', () => {
+ expect(hex('rgb(none 255 none)')).toBe('#00ff00')
+ expect(hex('rgb(255 0 0 / none)')).toBe('#ff000000')
+ })
+
+ it('clamps out-of-range values as a browser does, and notes it', () => {
+ const r = parseColor('rgb(300 -5 0 / 2)')
+ expect(r.ok && describeColor(r.color).css.hex).toBe('#ff0000')
+ expect(r.ok && r.notes).toHaveLength(3)
+ })
+
+ it('rejects a mix of commas and spaces', () => {
+ expect(error('rgb(255, 136 0)')).toMatchObject({ code: 'syntax', index: 13 })
+ expect(error('rgb(255 136, 0)')).toMatchObject({ code: 'syntax' })
+ })
+
+ it('rejects a mix of numbers and percentages in comma syntax only', () => {
+ expect(error('rgb(255, 50%, 0)')).toMatchObject({ code: 'syntax', index: 9 })
+ expect(hex('rgb(255 50% 0)')).toBe('#ff8000')
+ })
+
+ it('rejects none and slash in comma syntax', () => {
+ expect(error('rgb(255, none, 0)').code).toBe('syntax')
+ expect(error('rgb(255, 0, 0 / 0.5)').code).toBe('syntax')
+ })
+
+ it('counts values', () => {
+ expect(error('rgb(1 2)')).toMatchObject({ code: 'count', index: 7 })
+ expect(error('rgb(1 2 3 4)')).toMatchObject({ code: 'count', index: 10 })
+ expect(error('rgb(1, 2, 3, 4, 5)').code).toBe('count')
+ expect(error('rgb(1 2 3 /)').code).toBe('syntax')
+ expect(error('rgb(1, 2, 3,)').code).toBe('syntax')
+ })
+
+ it('reports a missing paren, a bad unit and trailing text by position', () => {
+ expect(error('rgb(1 2 3')).toMatchObject({ code: 'unclosed', index: 9 })
+ expect(error('rgb(1px 2 3)')).toMatchObject({ code: 'unit', index: 5 })
+ expect(error('rgb(1 2 3) red')).toMatchObject({ code: 'trailing', index: 11 })
+ expect(error('rgb(1 2 3deg)')).toMatchObject({ code: 'unit', index: 8 })
+ expect(error('rgb(1 x 3)')).toMatchObject({ code: 'unexpected', index: 6 })
+ })
+})
+
+describe('hsl()', () => {
+ it('reads comma and space syntax', () => {
+ expect(hex('hsl(120, 100%, 50%)')).toBe('#00ff00')
+ expect(hex('hsl(120 100% 50%)')).toBe('#00ff00')
+ expect(hex('hsla(120, 100%, 50%, 0.5)')).toBe('#00ff0080')
+ expect(hex('hsl(120 100% 50% / 50%)')).toBe('#00ff0080')
+ })
+
+ it('reads every angle unit and a bare number', () => {
+ for (const h of ['240', '240deg', '266.6667grad', `${(4 * Math.PI) / 3}rad`, '0.6667turn', '-120deg', '600']) {
+ expect(hex(`hsl(${h} 100% 50%)`), h).toBe('#0000ff')
+ }
+ })
+
+ it('takes bare numbers for s and l only in space syntax', () => {
+ expect(hex('hsl(0 100 50)')).toBe('#ff0000')
+ expect(error('hsl(0, 100, 50%)')).toMatchObject({ code: 'unit', index: 7 })
+ })
+
+ it('treats a none hue as 0', () => {
+ expect(hex('hsl(none 100% 50%)')).toBe('#ff0000')
+ })
+
+ it('rejects a percentage hue', () => {
+ expect(error('hsl(50% 100% 50%)')).toMatchObject({ code: 'unit', index: 4 })
+ })
+})
+
+describe('oklch()', () => {
+ it('reads lightness as a number or a percentage', () => {
+ expect(hex('oklch(62.8% 0.2577 29.23)')).toBe('#ff0000')
+ expect(hex('oklch(0.628 0.2577 29.23)')).toBe('#ff0000')
+ expect(hex('oklch(1 0 0)')).toBe('#ffffff')
+ })
+
+ it('reads chroma as a percentage of 0.4', () => {
+ expect(hex('oklch(62.8% 64.425% 29.23)')).toBe('#ff0000')
+ })
+
+ it('reads alpha, angle units and none', () => {
+ expect(hex('oklch(62.8% 0.2577 29.23 / 0.5)')).toBe('#ff000080')
+ expect(hex('oklch(62.8% 0.2577 0.0812turn)')).toBe('#ff0000')
+ expect(hex('oklch(100% none none)')).toBe('#ffffff')
+ })
+
+ it('keeps an out-of-gamut color as written', () => {
+ const r = parseColor('oklch(70% 0.4 150)')
+ expect(r.ok && r.color).toEqual({ space: 'oklch', l: 0.7, c: 0.4, h: 150, alpha: 1 })
+ })
+
+ it('clamps lightness and negative chroma', () => {
+ const r = parseColor('oklch(120% -0.1 0)')
+ expect(r.ok && r.color).toMatchObject({ l: 1, c: 0 })
+ expect(r.ok && r.notes).toHaveLength(2)
+ })
+
+ it('has no comma syntax', () => {
+ expect(error('oklch(0.5, 0.1, 30)')).toMatchObject({ code: 'syntax', index: 9 })
+ })
+})
+
+describe('named colors', () => {
+ it('has all 148', () => {
+ expect(Object.keys(NAMED_COLORS)).toHaveLength(148)
+ })
+
+ it('pins values that are easy to typo', () => {
+ expect(hex('rebeccapurple')).toBe('#663399')
+ expect(hex('lightgoldenrodyellow')).toBe('#fafad2')
+ expect(hex('navajowhite')).toBe('#ffdead')
+ expect(hex('darkgrey')).toBe(hex('darkgray'))
+ expect(hex('CornflowerBlue')).toBe('#6495ed')
+ })
+
+ it('reads transparent', () => {
+ expect(hex('transparent')).toBe('#00000000')
+ })
+
+ it('reports unknown names and functions', () => {
+ expect(error('reddish')).toMatchObject({ code: 'unknown-name', index: 0 })
+ expect(error('currentColor').code).toBe('context')
+ expect(error('lab(50% 40 59)').code).toBe('unsupported')
+ expect(error('rgbx(1 2 3)').code).toBe('unknown-function')
+ expect(error('red blue')).toMatchObject({ code: 'trailing', index: 3 })
+ expect(error('red;')).toMatchObject({ code: 'unexpected', index: 3 })
+ })
+})
+
+it('reports empty input without a position', () => {
+ expect(error(' ')).toEqual({ code: 'empty', message: 'Enter a color.' })
+ expect(error('(1 2 3)')).toMatchObject({ code: 'unexpected', index: 0 })
+})
diff --git a/packages/core/src/color/parse.ts b/packages/core/src/color/parse.ts
new file mode 100644
index 0000000..1b3c951
--- /dev/null
+++ b/packages/core/src/color/parse.ts
@@ -0,0 +1,358 @@
+import { fail, quoteChar, trimBounds, type Parsed } from '../number/error.ts'
+import { hslToRgb, normalizeHue, type Color } from './convert.ts'
+import { NAMED_COLORS } from './named.ts'
+
+export type Notation = 'hex' | 'rgb' | 'hsl' | 'oklch' | 'named'
+
+export type ColorParse = Parsed<{
+ color: Color
+ notation: Notation
+ /**
+ * Things the browser would also do silently, said out loud: a value clamped
+ * into range, or a hex read without its `#`.
+ */
+ notes: string[]
+}>
+
+type Unit = '' | '%' | 'deg' | 'rad' | 'grad' | 'turn'
+
+type Token =
+ | { kind: 'num'; value: number; unit: Unit; index: number; text: string }
+ | { kind: 'none'; index: number; text: string }
+ | { kind: 'comma'; index: number; text: string }
+ | { kind: 'slash'; index: number; text: string }
+
+type Value = Extract
+
+const SUPPORTED = ['rgb', 'rgba', 'hsl', 'hsla', 'oklch'] as const
+type Fn = (typeof SUPPORTED)[number]
+
+/** Functions people will paste that are valid CSS but out of scope here. */
+const KNOWN_UNSUPPORTED = new Set(['hwb', 'lab', 'lch', 'oklab', 'color', 'color-mix', 'light-dark', 'var', 'device-cmyk'])
+
+const HEX = /^[0-9a-f]$/i
+const WORD = /[A-Za-z0-9_-]/
+// CSS : an optional sign, digits with an optional fraction (or a bare
+// fraction), and an optional exponent. Sticky, so it matches only at lastIndex.
+const NUMBER = /[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?/y
+const UNIT = /[A-Za-z%]+/y
+const ANGLE_TO_DEG: Record = { deg: 1, rad: 180 / Math.PI, grad: 0.9, turn: 360 }
+
+/**
+ * Read one CSS color: hex (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`), `rgb()` /
+ * `rgba()`, `hsl()` / `hsla()`, `oklch()`, a named color or `transparent`.
+ *
+ * Follows CSS Color 4: both the comma (legacy) and space (modern) syntaxes,
+ * `/ alpha`, percentages, `none`, and angle units. Like a browser, values out
+ * of range are clamped rather than rejected, but each clamp is noted. What a
+ * browser would reject is rejected here too, with the position of the culprit.
+ */
+export function parseColor(input: string): ColorParse {
+ const [start, end] = trimBounds(input)
+ if (start === end) return fail('empty', 'Enter a color.')
+
+ if (input[start] === '#') return parseHex(input, start + 1, end, false)
+
+ let i = start
+ while (i < end && WORD.test(input[i] ?? '')) i++
+ if (i === start) {
+ return fail(
+ 'unexpected',
+ `${quoteChar(input[start] ?? '')} can't start a color. Use #hex, rgb(…), hsl(…), oklch(…) or a color name.`,
+ start,
+ input[start],
+ )
+ }
+ const word = input.slice(start, i)
+ const lower = word.toLowerCase()
+
+ if (input[i] === '(') {
+ if ((SUPPORTED as readonly string[]).includes(lower)) return parseFunction(input, lower as Fn, i + 1, end)
+ if (KNOWN_UNSUPPORTED.has(lower)) {
+ return fail(
+ 'unsupported',
+ `${lower}() is valid CSS but isn't supported here. Use hex, rgb(), hsl(), oklch() or a color name.`,
+ start,
+ )
+ }
+ return fail('unknown-function', `${word}() isn't a CSS color function.`, start)
+ }
+
+ if (i < end) {
+ const ch = input[i] ?? ''
+ if (/\s/.test(ch)) {
+ return fail('trailing', `Unexpected text after “${word}”. Enter one color at a time.`, i)
+ }
+ return fail('unexpected', `Unexpected ${quoteChar(ch)}.`, i, ch)
+ }
+
+ if (lower === 'transparent') {
+ return { ok: true, color: { space: 'srgb', r: 0, g: 0, b: 0, alpha: 0 }, notation: 'named', notes: [] }
+ }
+ const named = NAMED_COLORS[lower]
+ if (named !== undefined) {
+ const hex = parseHex(named, 0, named.length, false)
+ return hex.ok ? { ...hex, notation: 'named' } : hex
+ }
+ // A hex code pasted from a design tool often arrives without its `#`. Only
+ // 6 and 8 digits are taken this way: a 3-letter word like "bad" or "add" is
+ // far more likely a typo than a color.
+ if ((word.length === 6 || word.length === 8) && [...word].every((c) => HEX.test(c))) {
+ return parseHex(input, start, end, true)
+ }
+ if (lower === 'currentcolor') {
+ return fail('context', 'currentColor depends on where it is used, so it has no value on its own.', start)
+ }
+ return fail('unknown-name', `“${word}” isn't a CSS color name.`, start)
+}
+
+function parseHex(input: string, from: number, end: number, assumedHash: boolean): ColorParse {
+ for (let i = from; i < end; i++) {
+ const ch = input[i] ?? ''
+ if (!HEX.test(ch)) return fail('hex-digit', `${quoteChar(ch)} isn't a hex digit (0–9, a–f).`, i, ch)
+ }
+ const digits = input.slice(from, end)
+ const n = digits.length
+ if (n !== 3 && n !== 4 && n !== 6 && n !== 8) {
+ return fail('hex-length', `A hex color has 3, 4, 6 or 8 digits, not ${n}.`, n === 0 ? from - 1 : from)
+ }
+ const short = n <= 4
+ const at = (k: number) => {
+ const pair = short ? (digits[k] ?? '0').repeat(2) : digits.slice(k * 2, k * 2 + 2)
+ return parseInt(pair, 16) / 255
+ }
+ const alpha = n === 4 || n === 8 ? at(3) : 1
+ return {
+ ok: true,
+ color: { space: 'srgb', r: at(0), g: at(1), b: at(2), alpha },
+ notation: 'hex',
+ notes: assumedHash ? [`Read as #${digits}.`] : [],
+ }
+}
+
+function tokenize(input: string, from: number, end: number): Parsed<{ tokens: Token[]; close: number }> {
+ const tokens: Token[] = []
+ let i = from
+ while (i < end) {
+ const ch = input[i] ?? ''
+ if (/\s/.test(ch)) {
+ i++
+ continue
+ }
+ if (ch === ')') {
+ for (let j = i + 1; j < end; j++) {
+ if (!/\s/.test(input[j] ?? '')) {
+ return fail('trailing', 'Unexpected text after “)”. Enter one color at a time.', j)
+ }
+ }
+ return { ok: true, tokens, close: i }
+ }
+ if (ch === ',' || ch === '/') {
+ tokens.push({ kind: ch === ',' ? 'comma' : 'slash', index: i, text: ch })
+ i++
+ continue
+ }
+ NUMBER.lastIndex = i
+ const num = NUMBER.exec(input)
+ if (num !== null && num.index === i) {
+ let j = i + num[0].length
+ UNIT.lastIndex = j
+ const unitMatch = UNIT.exec(input)
+ let unit: Unit = ''
+ if (unitMatch !== null && unitMatch.index === j) {
+ const u = unitMatch[0].toLowerCase()
+ if (u !== '%' && !(u in ANGLE_TO_DEG)) {
+ return fail('unit', `“${unitMatch[0]}” isn't a unit a color takes. Use %, deg, rad, grad or turn.`, j)
+ }
+ unit = u as Unit
+ j += unitMatch[0].length
+ }
+ tokens.push({ kind: 'num', value: Number(num[0]), unit, index: i, text: input.slice(i, j) })
+ i = j
+ continue
+ }
+ if (/[A-Za-z]/.test(ch)) {
+ let j = i
+ while (j < end && /[A-Za-z-]/.test(input[j] ?? '')) j++
+ const word = input.slice(i, j)
+ if (word.toLowerCase() === 'none') {
+ tokens.push({ kind: 'none', index: i, text: word })
+ i = j
+ continue
+ }
+ return fail('unexpected', `Unexpected “${word}”. Expected a number, a percentage or none.`, i)
+ }
+ return fail('unexpected', `Unexpected ${quoteChar(ch)}.`, i, ch)
+ }
+ return fail('unclosed', 'Missing the closing “)”.', end)
+}
+
+interface Args {
+ channels: [Value, Value, Value]
+ alpha: Value | null
+ legacy: boolean
+}
+
+/**
+ * Split tokens into three channels and an optional alpha, enforcing one
+ * syntax or the other: `rgb(1, 2, 3, 0.5)` or `rgb(1 2 3 / 0.5)`, never a mix.
+ */
+function splitArgs(tokens: Token[], close: number, fn: Fn): Parsed<{ args: Args }> {
+ const legacy = tokens.some((t) => t.kind === 'comma')
+ const values: Value[] = []
+ let alpha: Value | null = null
+
+ if (legacy) {
+ for (let k = 0; k < tokens.length; k++) {
+ const t = tokens[k]!
+ const wantComma = k % 2 === 1
+ if (t.kind === 'slash') {
+ return fail('syntax', 'With commas, alpha is the fourth value after a comma, not after “/”.', t.index)
+ }
+ if (wantComma && t.kind !== 'comma') {
+ return fail('syntax', 'Use commas between every value, or spaces between all of them, not a mix.', t.index)
+ }
+ if (!wantComma) {
+ if (t.kind === 'comma') return fail('syntax', 'Expected a value before this comma.', t.index)
+ if (t.kind === 'none') {
+ return fail('syntax', '“none” only works in the space syntax, without commas.', t.index)
+ }
+ values.push(t)
+ }
+ }
+ const last = tokens[tokens.length - 1]
+ if (last?.kind === 'comma') return fail('syntax', 'Expected a value after this comma.', last.index)
+ if (values.length > 4) return fail('count', `${fn}() takes 3 values and an optional alpha.`, values[4]!.index)
+ if (values.length === 4) alpha = values.pop()!
+ } else {
+ const slash = tokens.findIndex((t) => t.kind === 'slash')
+ const before = slash < 0 ? tokens : tokens.slice(0, slash)
+ for (const t of before) if (t.kind === 'num' || t.kind === 'none') values.push(t)
+ if (values.length > 3) {
+ return fail('count', `${fn}() takes 3 values, then “/” and alpha.`, values[3]!.index)
+ }
+ if (slash >= 0) {
+ const after = tokens.slice(slash + 1)
+ const first = after[0]
+ if (first === undefined) return fail('syntax', 'Expected an alpha value after “/”.', close)
+ if (first.kind !== 'num' && first.kind !== 'none') {
+ return fail('syntax', 'Expected an alpha value after “/”.', first.index)
+ }
+ if (after.length > 1) return fail('count', 'Only one alpha value goes after “/”.', after[1]!.index)
+ alpha = first
+ }
+ }
+
+ if (values.length < 3) {
+ return fail('count', `${fn}() needs 3 values; found ${values.length}.`, close)
+ }
+ return { ok: true, args: { channels: [values[0]!, values[1]!, values[2]!], legacy, alpha } }
+}
+
+/** A hue: a bare number is degrees; `none` is 0. */
+function angle(t: Value): Parsed<{ deg: number }> {
+ if (t.kind === 'none') return { ok: true, deg: 0 }
+ if (t.unit === '%') return fail('unit', 'Hue is an angle or a number of degrees, not a percentage.', t.index)
+ return { ok: true, deg: normalizeHue(t.value * (ANGLE_TO_DEG[t.unit] ?? 1)) }
+}
+
+function parseFunction(input: string, fn: Fn, from: number, end: number): ColorParse {
+ const tok = tokenize(input, from, end)
+ if (!tok.ok) return tok
+ const split = splitArgs(tok.tokens, tok.close, fn)
+ if (!split.ok) return split
+ const { channels, alpha: alphaTok, legacy } = split.args
+ const notes: string[] = []
+
+ /** Clamp into [lo, hi], noting the clamp against the token as written. */
+ const clamp = (v: number, lo: number, hi: number, t: Value, what: string): number => {
+ if (v < lo || v > hi) {
+ const to = v < lo ? lo : hi
+ notes.push(`${what} ${t.text} is out of range; clamped as a browser would.`)
+ return to
+ }
+ return v
+ }
+
+ let alpha = 1
+ if (alphaTok !== null) {
+ if (alphaTok.kind === 'none') alpha = 0
+ else if (alphaTok.unit === '%') alpha = clamp(alphaTok.value / 100, 0, 1, alphaTok, 'Alpha')
+ else if (alphaTok.unit === '') alpha = clamp(alphaTok.value, 0, 1, alphaTok, 'Alpha')
+ else return fail('unit', 'Alpha is a number or a percentage, not an angle.', alphaTok.index)
+ }
+
+ if (fn === 'rgb' || fn === 'rgba') {
+ // Legacy rgb() must be all numbers or all percentages; browsers reject a mix.
+ if (legacy) {
+ const pct = channels[0].kind === 'num' && channels[0].unit === '%'
+ for (const t of channels) {
+ if (t.kind === 'num' && (t.unit === '%') !== pct) {
+ return fail(
+ 'syntax',
+ 'With commas, rgb() values must be all numbers or all percentages.',
+ t.index,
+ )
+ }
+ }
+ }
+ const rgb: number[] = []
+ for (const [k, t] of channels.entries()) {
+ const label = ['Red', 'Green', 'Blue'][k]!
+ if (t.kind === 'none') rgb.push(0)
+ else if (t.unit === '%') rgb.push(clamp(t.value, 0, 100, t, label) / 100)
+ else if (t.unit === '') rgb.push(clamp(t.value, 0, 255, t, label) / 255)
+ else return fail('unit', `${label} is a number 0–255 or a percentage, not an angle.`, t.index)
+ }
+ return {
+ ok: true,
+ color: { space: 'srgb', r: rgb[0]!, g: rgb[1]!, b: rgb[2]!, alpha },
+ notation: 'rgb',
+ notes,
+ }
+ }
+
+ if (fn === 'hsl' || fn === 'hsla') {
+ const h = angle(channels[0])
+ if (!h.ok) return h
+ const pct: number[] = []
+ for (const [k, t] of [channels[1], channels[2]].entries()) {
+ const label = k === 0 ? 'Saturation' : 'Lightness'
+ if (t.kind === 'none') {
+ pct.push(0)
+ continue
+ }
+ if (t.unit !== '%' && t.unit !== '') {
+ return fail('unit', `${label} is a percentage, not an angle.`, t.index)
+ }
+ // CSS Color 4 lets the space syntax drop the %; the legacy syntax may not.
+ if (t.unit === '' && legacy) return fail('unit', `With commas, ${label.toLowerCase()} needs a %.`, t.index)
+ pct.push(clamp(t.value, 0, 100, t, label))
+ }
+ const rgb = hslToRgb({ h: h.deg, s: pct[0]!, l: pct[1]! })
+ return { ok: true, color: { space: 'srgb', ...rgb, alpha }, notation: 'hsl', notes }
+ }
+
+ // oklch() has no legacy syntax.
+ if (legacy) {
+ const comma = tok.tokens.find((t) => t.kind === 'comma')!
+ return fail('syntax', 'oklch() takes spaces between values, not commas.', comma.index)
+ }
+ const [lt, ct, ht] = channels
+ let l = 0
+ if (lt.kind === 'num') {
+ if (lt.unit !== '' && lt.unit !== '%') return fail('unit', 'Lightness is a number 0–1 or a percentage.', lt.index)
+ l = clamp(lt.unit === '%' ? lt.value / 100 : lt.value, 0, 1, lt, 'Lightness')
+ }
+ let c = 0
+ if (ct.kind === 'num') {
+ if (ct.unit !== '' && ct.unit !== '%') return fail('unit', 'Chroma is a number or a percentage.', ct.index)
+ // In oklch(), 100% chroma means 0.4 (CSS Color 4 §9.3).
+ const raw = ct.unit === '%' ? (ct.value / 100) * 0.4 : ct.value
+ // Chroma has no upper bound in CSS; only a negative one is clamped.
+ c = clamp(raw, 0, Infinity, ct, 'Chroma')
+ }
+ const h = angle(ht)
+ if (!h.ok) return h
+ return { ok: true, color: { space: 'oklch', l, c, h: h.deg, alpha }, notation: 'oklch', notes }
+}
diff --git a/packages/core/src/cron/describe.test.ts b/packages/core/src/cron/describe.test.ts
new file mode 100644
index 0000000..e083940
--- /dev/null
+++ b/packages/core/src/cron/describe.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest'
+import { describeCron } from './describe.ts'
+import { parseCron, type CronSchedule, type RebootSchedule } from './parse.ts'
+
+function parsed(input: string): CronSchedule | RebootSchedule {
+ const r = parseCron(input)
+ if (!r.ok) throw new Error(r.error.message)
+ return r.value
+}
+
+const text = (input: string, use24HourTimeFormat = false) => {
+ const r = describeCron(parsed(input), { use24HourTimeFormat })
+ if (!r.ok) throw new Error(r.error.message)
+ return r.text
+}
+
+describe('describeCron', () => {
+ it('phrases common schedules', () => {
+ expect(text('*/15 * * * *')).toBe('Every 15 minutes')
+ expect(text('* * * * *')).toBe('Every minute')
+ expect(text('0 9 * * MON-FRI')).toBe('At 09:00 AM, Monday through Friday')
+ })
+
+ it('uses a 24-hour clock on request', () => {
+ expect(text('30 14 * * *')).toBe('At 02:30 PM')
+ expect(text('30 14 * * *', true)).toBe('At 14:30')
+ })
+
+ it('describes the expansion of a macro', () => {
+ expect(text('@daily', true)).toBe('At 00:00')
+ expect(text('@weekly', true)).toBe('At 00:00, only on Sunday')
+ })
+
+ it('reads day-of-week 7 as Sunday', () => {
+ expect(text('0 0 * * 7', true)).toBe('At 00:00, only on Sunday')
+ })
+
+ it('describes seconds in a 6-field schedule', () => {
+ expect(text('*/10 * * * * *')).toBe('Every 10 seconds')
+ })
+
+ it('describes @reboot without a time', () => {
+ expect(text('@reboot')).toMatch(/when the cron daemon starts/)
+ })
+
+ it('returns a cronstrue failure as data', () => {
+ const s = parsed('* * * * *') as CronSchedule
+ const r = describeCron({ ...s, expression: 'not a cron expression at all' })
+ expect(r.ok).toBe(false)
+ if (!r.ok) expect(r.error.code).toBe('describe')
+ })
+})
diff --git a/packages/core/src/cron/describe.ts b/packages/core/src/cron/describe.ts
new file mode 100644
index 0000000..7d76e5e
--- /dev/null
+++ b/packages/core/src/cron/describe.ts
@@ -0,0 +1,36 @@
+import { toString as cronstrueToString } from 'cronstrue'
+import type { CronSchedule, RebootSchedule } from './parse.ts'
+
+export interface DescribeOptions {
+ /** `14:30` rather than `02:30 PM`. Default false. */
+ use24HourTimeFormat?: boolean
+}
+
+export type DescribeResult = { ok: true; text: string } | { ok: false; error: { code: 'describe'; message: string } }
+
+/**
+ * The schedule as an English sentence, by cronstrue.
+ *
+ * It is given the parsed 5- or 6-field form, never the raw input: our parser
+ * has already rejected what crond would, and expanded macros, so cronstrue
+ * only has to phrase it. cronstrue still throws on input it dislikes, and that
+ * comes back as data rather than reaching the UI.
+ */
+export function describeCron(s: CronSchedule | RebootSchedule, opts: DescribeOptions = {}): DescribeResult {
+ if (s.kind === 'reboot') return { ok: true, text: 'Once, when the cron daemon starts (usually at boot)' }
+ try {
+ const text = cronstrueToString(s.expression, {
+ use24HourTimeFormat: opts.use24HourTimeFormat === true,
+ throwExceptionOnParseError: true,
+ locale: 'en',
+ // Standard cron numbers weekdays from 0 (Sunday) and months from 1, which
+ // are cronstrue's defaults; say so rather than rely on them.
+ dayOfWeekStartIndexZero: true,
+ monthStartIndexZero: false,
+ })
+ return { ok: true, text }
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e)
+ return { ok: false, error: { code: 'describe', message: `Couldn't describe this expression: ${message}` } }
+ }
+}
diff --git a/packages/core/src/cron/index.ts b/packages/core/src/cron/index.ts
new file mode 100644
index 0000000..8aab2e7
--- /dev/null
+++ b/packages/core/src/cron/index.ts
@@ -0,0 +1,3 @@
+export * from './describe.ts'
+export * from './next.ts'
+export * from './parse.ts'
diff --git a/packages/core/src/cron/next.test.ts b/packages/core/src/cron/next.test.ts
new file mode 100644
index 0000000..0c23c8d
--- /dev/null
+++ b/packages/core/src/cron/next.test.ts
@@ -0,0 +1,300 @@
+import fc from 'fast-check'
+import { describe, expect, it } from 'vitest'
+import { nextRuns, neverFiresReason, type CronRun, type NextRunsOptions, type NextRunsResult } from './next.ts'
+import { parseCron, type CronSchedule } from './parse.ts'
+
+function schedule(input: string): CronSchedule {
+ const r = parseCron(input)
+ if (!r.ok || r.value.kind !== 'schedule') throw new Error(`expected ${input} to parse`)
+ return r.value
+}
+
+function result(input: string, opts: NextRunsOptions): Extract {
+ const r = nextRuns(schedule(input), opts)
+ if (!r.ok) throw new Error(r.error.message)
+ return r
+}
+
+function runs(input: string, now: string, timeZone: string, count = 10): CronRun[] {
+ return result(input, { now: Date.parse(now), timeZone, count }).runs
+}
+
+const isos = (input: string, now: string, timeZone: string, count = 10) =>
+ runs(input, now, timeZone, count).map((r) => r.iso)
+
+const NY = 'America/New_York'
+
+describe('nextRuns: basics', () => {
+ it('lists the next runs strictly after now, in the zone', () => {
+ expect(isos('0 9 * * *', '2026-10-30T12:00:00Z', 'UTC', 3)).toEqual([
+ '2026-10-31T09:00:00+00:00',
+ '2026-11-01T09:00:00+00:00',
+ '2026-11-02T09:00:00+00:00',
+ ])
+ expect(isos('0 9 * * *', '2026-10-30T12:00:00Z', 'Asia/Tokyo', 2)).toEqual([
+ '2026-10-31T09:00:00+09:00',
+ '2026-11-01T09:00:00+09:00',
+ ])
+ })
+
+ it('defaults to 10 runs', () => {
+ expect(result('* * * * *', { now: 0, timeZone: 'UTC' }).runs).toHaveLength(10)
+ })
+
+ it('names the weekday and carries the offset', () => {
+ const [run] = runs('0 0 * * *', '2026-10-30T12:00:00Z', 'Asia/Kolkata', 1)
+ expect(run).toMatchObject({ weekday: 'Saturday', offsetSeconds: 19800, kind: 'exact', note: null })
+ })
+
+ it('handles */15 exactly on a minute boundary', () => {
+ expect(isos('*/15 * * * *', '2026-10-30T12:00:00.000Z', 'UTC', 2)).toEqual([
+ '2026-10-30T12:15:00+00:00',
+ '2026-10-30T12:30:00+00:00',
+ ])
+ expect(isos('*/15 * * * *', '2026-10-30T12:14:59.999Z', 'UTC', 1)).toEqual(['2026-10-30T12:15:00+00:00'])
+ expect(isos('*/15 * * * *', '2026-10-30T12:15:00.000Z', 'UTC', 1)).toEqual(['2026-10-30T12:30:00+00:00'])
+ expect(isos('*/15 * * * *', '2026-10-30T12:15:00.001Z', 'UTC', 1)).toEqual(['2026-10-30T12:30:00+00:00'])
+ })
+
+ it('rolls over the end of an hour, day, month and year', () => {
+ expect(isos('*/20 * * * *', '2026-12-31T23:50:00Z', 'UTC', 2)).toEqual([
+ '2027-01-01T00:00:00+00:00',
+ '2027-01-01T00:20:00+00:00',
+ ])
+ })
+
+ it('runs a 6-field schedule to the second', () => {
+ expect(isos('*/10 * * * * *', '2026-10-30T12:00:05Z', 'UTC', 3)).toEqual([
+ '2026-10-30T12:00:10+00:00',
+ '2026-10-30T12:00:20+00:00',
+ '2026-10-30T12:00:30+00:00',
+ ])
+ expect(isos('30 0 0 * * *', '2026-10-30T12:00:00Z', 'UTC', 1)).toEqual(['2026-10-31T00:00:30+00:00'])
+ expect(isos('59 59 23 31 12 *', '2026-10-30T12:00:00Z', 'UTC', 1)).toEqual(['2026-12-31T23:59:59+00:00'])
+ })
+
+ it('reads day-of-week 7 as Sunday', () => {
+ expect(runs('0 0 * * 7', '2026-10-30T12:00:00Z', 'UTC', 3).map((r) => r.weekday)).toEqual([
+ 'Sunday',
+ 'Sunday',
+ 'Sunday',
+ ])
+ })
+
+ it('refuses an unknown zone as data', () => {
+ expect(nextRuns(schedule('* * * * *'), { now: 0, timeZone: 'Mars/Olympus' })).toEqual({
+ ok: false,
+ error: { code: 'time-zone', message: 'Unknown time zone "Mars/Olympus".' },
+ })
+ })
+})
+
+describe('nextRuns: Vixie day-of-month OR day-of-week', () => {
+ it('fires on the 13th and on every Friday when both are restricted', () => {
+ const list = runs('0 0 13 * FRI', '2026-01-01T00:00:00Z', 'UTC', 40)
+ for (const run of list) expect(run.weekday === 'Friday' || run.iso.slice(8, 10) === '13').toBe(true)
+ // Both halves of the OR show up, not just their intersection.
+ expect(list.some((r) => r.weekday === 'Friday' && r.iso.slice(8, 10) !== '13')).toBe(true)
+ expect(list.some((r) => r.weekday !== 'Friday' && r.iso.slice(8, 10) === '13')).toBe(true)
+ expect(isos('0 0 13 * FRI', '2026-01-01T00:00:00Z', 'UTC', 4)).toEqual([
+ '2026-01-02T00:00:00+00:00', // Friday
+ '2026-01-09T00:00:00+00:00', // Friday
+ '2026-01-13T00:00:00+00:00', // Tuesday the 13th
+ '2026-01-16T00:00:00+00:00', // Friday
+ ])
+ })
+
+ it('needs both when day of week is *', () => {
+ const list = runs('0 0 13 * *', '2026-01-01T00:00:00Z', 'UTC', 12)
+ expect(list.every((r) => r.iso.slice(8, 10) === '13')).toBe(true)
+ })
+
+ it('needs both when a day field merely starts with * (*/2)', () => {
+ const list = runs('0 0 */2 * FRI', '2026-01-01T00:00:00Z', 'UTC', 20)
+ for (const run of list) {
+ expect(run.weekday).toBe('Friday')
+ expect(Number(run.iso.slice(8, 10)) % 2).toBe(1)
+ }
+ })
+})
+
+describe('nextRuns: daylight saving in America/New_York', () => {
+ // 2026: clocks spring forward 2026-03-08 02:00 EST -> 03:00 EDT and fall back
+ // 2026-11-01 02:00 EDT -> 01:00 EST.
+
+ it('moves a fixed-time run in the spring gap to the first instant after it', () => {
+ const list = runs('30 2 * * *', '2026-03-07T12:00:00-05:00', NY, 3)
+ expect(list.map((r) => r.iso)).toEqual([
+ '2026-03-08T03:00:00-04:00',
+ '2026-03-09T02:30:00-04:00',
+ '2026-03-10T02:30:00-04:00',
+ ])
+ expect(list[0]).toMatchObject({ kind: 'gap' })
+ expect(list[0]?.note).toMatch(/02:30 doesn't exist on 2026-03-08.*first instant after the gap/)
+ })
+
+ it('collapses several fixed-time runs in the gap into one', () => {
+ expect(isos('0,15,30,45 2 * * *', '2026-03-08T01:00:00-05:00', NY, 3)).toEqual([
+ '2026-03-08T03:00:00-04:00',
+ '2026-03-09T02:00:00-04:00',
+ '2026-03-09T02:15:00-04:00',
+ ])
+ })
+
+ it('skips wildcard runs in the gap, and says so', () => {
+ const r = result('30 * * * *', { now: Date.parse('2026-03-08T00:45:00-05:00'), timeZone: NY, count: 3 })
+ expect(r.runs.map((x) => x.iso)).toEqual([
+ '2026-03-08T01:30:00-05:00',
+ '2026-03-08T03:30:00-04:00',
+ '2026-03-08T04:30:00-04:00',
+ ])
+ expect(r.notes).toHaveLength(1)
+ expect(r.notes[0]).toMatch(/2026-03-08.*a run at 02:30.*skipped/)
+ })
+
+ it('runs a fixed-time job once in the autumn overlap, at the first occurrence', () => {
+ const list = runs('30 1 * * *', '2026-10-31T12:00:00-04:00', NY, 2)
+ expect(list.map((r) => r.iso)).toEqual(['2026-11-01T01:30:00-04:00', '2026-11-02T01:30:00-05:00'])
+ expect(list[0]).toMatchObject({ kind: 'overlap-first' })
+ expect(list[0]?.note).toMatch(/happens twice on 2026-11-01.*once, at the first occurrence/)
+ })
+
+ it('does not rerun a fixed-time job when now is in the second pass', () => {
+ expect(isos('45 1 * * *', '2026-11-01T01:10:00-05:00', NY, 1)).toEqual(['2026-11-02T01:45:00-05:00'])
+ })
+
+ it('runs a wildcard job through both passes of the repeated hour', () => {
+ const list = runs('*/30 1 * * *', '2026-11-01T00:45:00-04:00', NY, 5)
+ expect(list.map((r) => r.iso)).toEqual([
+ '2026-11-01T01:00:00-04:00',
+ '2026-11-01T01:30:00-04:00',
+ '2026-11-01T01:00:00-05:00',
+ '2026-11-01T01:30:00-05:00',
+ '2026-11-02T01:00:00-05:00',
+ ])
+ expect(list.map((r) => r.kind)).toEqual(['overlap-first', 'overlap-first', 'overlap-repeat', 'overlap-repeat', 'exact'])
+ })
+
+ it('catches the repeated hour when now is late in the first pass', () => {
+ expect(isos('*/15 * * * *', '2026-11-01T01:50:00-04:00', NY, 6)).toEqual([
+ '2026-11-01T01:00:00-05:00',
+ '2026-11-01T01:15:00-05:00',
+ '2026-11-01T01:30:00-05:00',
+ '2026-11-01T01:45:00-05:00',
+ '2026-11-01T02:00:00-05:00',
+ '2026-11-01T02:15:00-05:00',
+ ])
+ })
+
+ it('keeps every list strictly increasing across both changes', () => {
+ for (const now of ['2026-03-07T23:00:00-05:00', '2026-10-31T23:00:00-04:00']) {
+ const list = runs('*/7 * * * *', now, NY, 60)
+ for (let i = 1; i < list.length; i++) expect((list[i] as CronRun).ms).toBeGreaterThan((list[i - 1] as CronRun).ms)
+ }
+ })
+})
+
+describe('nextRuns: half-hour zones', () => {
+ it('reads wall time in a fixed +05:30 zone', () => {
+ expect(isos('30 9 * * *', '2026-10-30T00:00:00Z', 'Asia/Kolkata', 2)).toEqual([
+ '2026-10-30T09:30:00+05:30',
+ '2026-10-31T09:30:00+05:30',
+ ])
+ })
+
+ it('handles the spring gap in Adelaide (+09:30 -> +10:30)', () => {
+ // 2026-10-04 02:00 ACST -> 03:00 ACDT.
+ expect(isos('30 2 * * *', '2026-10-03T12:00:00+09:30', 'Australia/Adelaide', 2)).toEqual([
+ '2026-10-04T03:00:00+10:30',
+ '2026-10-05T02:30:00+10:30',
+ ])
+ })
+
+ it('handles a half-hour DST shift on Lord Howe Island (+10:30 -> +11:00)', () => {
+ // 2026-10-04 02:00 -> 02:30: only half an hour goes missing.
+ expect(isos('15 2 * * *', '2026-10-03T12:00:00+10:30', 'Australia/Lord_Howe', 2)).toEqual([
+ '2026-10-04T02:30:00+11:00',
+ '2026-10-05T02:15:00+11:00',
+ ])
+ expect(isos('45 2 * * *', '2026-10-03T12:00:00+10:30', 'Australia/Lord_Howe', 1)).toEqual([
+ '2026-10-04T02:45:00+11:00',
+ ])
+ })
+})
+
+describe('nextRuns: rare and impossible schedules', () => {
+ it('finds Feb 29 every four years', () => {
+ expect(isos('0 0 29 2 *', '2026-10-30T00:00:00Z', 'UTC', 3)).toEqual([
+ '2028-02-29T00:00:00+00:00',
+ '2032-02-29T00:00:00+00:00',
+ '2036-02-29T00:00:00+00:00',
+ ])
+ })
+
+ it('skips 2100, which is not a leap year', () => {
+ expect(isos('0 0 29 2 *', '2097-01-01T00:00:00Z', 'UTC', 1)).toEqual(['2104-02-29T00:00:00+00:00'])
+ })
+
+ it('says "never fires" for a date that does not exist, quickly', () => {
+ const start = performance.now()
+ const r = result('0 0 30 2 *', { now: Date.parse('2026-10-30T00:00:00Z'), timeZone: 'UTC' })
+ expect(performance.now() - start).toBeLessThan(50)
+ expect(r).toMatchObject({ runs: [], status: 'never' })
+ expect(r.reason).toBe('Day 30 never occurs in February, so this schedule never fires.')
+ expect(neverFiresReason(schedule('0 0 31 4,6,9,11 *'))).toBe(
+ 'Day 31 never occurs in April, June, September or November, so this schedule never fires.',
+ )
+ })
+
+ it('does not call a day-of-week OR schedule impossible', () => {
+ expect(neverFiresReason(schedule('0 0 30 2 MON'))).toBeNull()
+ expect(runs('0 0 30 2 MON', '2026-10-30T00:00:00Z', 'UTC', 1)[0]?.weekday).toBe('Monday')
+ })
+
+ it('stops after the search horizon and says the list is short', () => {
+ const r = result('0 0 29 2 *', { now: Date.parse('2026-10-30T00:00:00Z'), timeZone: 'UTC', count: 5, searchYears: 5 })
+ expect(r.status).toBe('complete')
+ const short = result('0 0 29 2 *', { now: Date.parse('2026-10-30T00:00:00Z'), timeZone: 'UTC', count: 5, searchYears: 1 })
+ expect(short).toMatchObject({ runs: [], status: 'never', reason: 'No run in the next 1 years.' })
+ })
+})
+
+const list = (min: number, max: number) =>
+ fc.uniqueArray(fc.integer({ min, max }), { minLength: 1, maxLength: 4 }).map((xs) => xs.join(','))
+
+describe('nextRuns: agrees with a brute-force minute walk (UTC)', () => {
+ const expr = fc.tuple(
+ fc.oneof(list(0, 59), fc.constantFrom('*', '*/5', '*/7')),
+ fc.oneof(list(0, 23), fc.constantFrom('*', '*/3')),
+ fc.oneof(list(1, 28), fc.constantFrom('*', '*/2')),
+ fc.constant('*'),
+ fc.oneof(list(0, 6), fc.constant('*')),
+ )
+
+ it('matches minute by minute', () => {
+ fc.assert(
+ fc.property(expr, fc.integer({ min: 0, max: 4_000_000_000 }), (fields, minutes) => {
+ const s = schedule(fields.join(' '))
+ const now = minutes * 60_000 + 12_345
+ const got = result(fields.join(' '), { now, timeZone: 'UTC', count: 3 }).runs.map((r) => r.ms)
+
+ const want: number[] = []
+ const [mins, hours, doms, , dows] = fields.map((f, i) => s.fields[i]?.values ?? [])
+ const dayOr = s.dayOr
+ for (let t = Math.ceil((now + 1) / 60_000) * 60_000; want.length < 3; t += 60_000) {
+ const d = new Date(t)
+ const dom = doms?.includes(d.getUTCDate()) === true
+ const dow = dows?.includes(d.getUTCDay()) === true
+ if (!(dayOr ? dom || dow : dom && dow)) {
+ // Jump to the last minute of the day; the loop step lands on midnight.
+ t = Math.floor(t / 86_400_000) * 86_400_000 + 86_400_000 - 60_000
+ continue
+ }
+ if (mins?.includes(d.getUTCMinutes()) && hours?.includes(d.getUTCHours())) want.push(t)
+ }
+ expect(got).toEqual(want)
+ }),
+ { numRuns: 200 },
+ )
+ })
+})
diff --git a/packages/core/src/cron/next.ts b/packages/core/src/cron/next.ts
new file mode 100644
index 0000000..60267c3
--- /dev/null
+++ b/packages/core/src/cron/next.ts
@@ -0,0 +1,304 @@
+import {
+ civilFromDays,
+ daysFromCivil,
+ fieldsOf,
+ formatOffset,
+ isValidTimeZone,
+ MS_PER_DAY,
+ offsetSeconds,
+ utcMs,
+ wallTime,
+ wallToInstant,
+ type WallTime,
+} from '../timestamp/zone.ts'
+import { MONTH_NAMES, WEEKDAY_NAMES, type CronField, type CronSchedule, type FieldName } from './parse.ts'
+
+/**
+ * How a run relates to the zone's daylight-saving changes.
+ *
+ * - `exact`: the wall time exists once.
+ * - `gap`: the wall time was skipped when clocks went forward; the run is moved
+ * to the first instant after the gap.
+ * - `overlap-first`: the wall time happens twice because clocks went back; this
+ * is the first occurrence.
+ * - `overlap-repeat`: the second occurrence, which cron only runs for
+ * schedules with `*` in the minute or hour field.
+ */
+export type CronRunKind = 'exact' | 'gap' | 'overlap-first' | 'overlap-repeat'
+
+export interface CronRun {
+ /** Epoch milliseconds. */
+ ms: number
+ /** ISO 8601 with the zone's offset, to the second: `2027-03-14T03:00:00-04:00`. */
+ iso: string
+ /** UTC offset at the run, in seconds east. */
+ offsetSeconds: number
+ weekday: string
+ kind: CronRunKind
+ /** What a DST change did to this run, in plain English; null for an ordinary run. */
+ note: string | null
+}
+
+export interface NextRunsOptions {
+ /** Epoch ms. Runs are strictly after it. */
+ now: number
+ /** IANA zone the schedule's wall-clock fields are read in. */
+ timeZone: string
+ /** Default 10. */
+ count?: number
+ /**
+ * Give up after this many years without a run. Default 50: a fixed date on a
+ * fixed weekday, Feb 29 included, recurs within 40 years even across a
+ * skipped leap year like 2100, so only a schedule that can never fire hits it.
+ */
+ searchYears?: number
+}
+
+export type NextRunsStatus = 'complete' | 'sparse' | 'never'
+
+export type NextRunsResult =
+ | {
+ ok: true
+ runs: CronRun[]
+ /** `sparse`: fewer than `count` runs before the search gave up. */
+ status: NextRunsStatus
+ /** Why there are no (more) runs; null when complete. */
+ reason: string | null
+ /** DST effects that have no run to hang off, such as runs skipped in a gap. */
+ notes: string[]
+ }
+ | { ok: false; error: { code: 'time-zone' | 'now'; message: string } }
+
+interface WallMatch {
+ day: number
+ hour: number
+ minute: number
+ second: number
+}
+
+const DAYS_IN_MONTH = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
+
+function field(s: CronSchedule, name: FieldName): CronField | undefined {
+ return s.fields.find((f) => f.name === name)
+}
+
+/** 0 = Sunday, from days since the epoch (a Thursday). */
+function weekdayOf(day: number): number {
+ return (((day + 4) % 7) + 7) % 7
+}
+
+function dayMatches(s: CronSchedule, dom: number, dow: number): boolean {
+ const a = s.match.dayOfMonth[dom] === true
+ const b = s.match.dayOfWeek[dow] === true
+ // Vixie: restrict both and either will do; leave either one as `*` and both must.
+ return s.dayOr ? a || b : a && b
+}
+
+function firstTime(s: CronSchedule, h0: number, m0: number, s0: number): Omit | null {
+ for (let hour = h0; hour < 24; hour++) {
+ if (s.match.hour[hour] !== true) continue
+ for (let minute = hour === h0 ? m0 : 0; minute < 60; minute++) {
+ if (s.match.minute[minute] !== true) continue
+ for (let second = hour === h0 && minute === m0 ? s0 : 0; second < 60; second++) {
+ if (s.match.second[second] === true) return { hour, minute, second }
+ }
+ }
+ }
+ return null
+}
+
+/**
+ * The first wall-clock time at or after `from` (a UTC-style ms reading of the
+ * wall clock) that the schedule matches, searching no later than `lastDay`.
+ * Pure calendar arithmetic: no time zone is involved until a match is found.
+ */
+function nextWallMatch(s: CronSchedule, from: number, lastDay: number): WallMatch | null {
+ const start = fieldsOf(from)
+ let day = Math.floor(from / MS_PER_DAY)
+ let [h0, m0, s0] = [start.hour, start.minute, start.second]
+ while (day <= lastDay) {
+ const { year, month, day: dom } = civilFromDays(day)
+ if (s.match.month[month] !== true) {
+ // Skip the rest of the month in one step; month 13 rolls into January.
+ day = daysFromCivil(year, month + 1, 1)
+ ;[h0, m0, s0] = [0, 0, 0]
+ continue
+ }
+ if (dayMatches(s, dom, weekdayOf(day))) {
+ const t = firstTime(s, h0, m0, s0)
+ if (t !== null) return { day, ...t }
+ }
+ day++
+ ;[h0, m0, s0] = [0, 0, 0]
+ }
+ return null
+}
+
+/**
+ * Why a schedule can never fire, when that can be seen without searching:
+ * the day fields must both match and no chosen day of month exists in any
+ * chosen month (`0 0 30 2 *`).
+ */
+export function neverFiresReason(s: CronSchedule): string | null {
+ if (s.dayOr) return null
+ const doms = field(s, 'dayOfMonth')?.values ?? []
+ const months = field(s, 'month')?.values ?? []
+ if (months.some((m) => doms.some((d) => d <= (DAYS_IN_MONTH[m] ?? 0)))) return null
+ return `Day ${orList(doms.map(String))} never occurs in ${orList(months.map((m) => MONTH_NAMES[m - 1] ?? String(m)))}, so this schedule never fires.`
+}
+
+function orList(items: readonly string[]): string {
+ if (items.length <= 1) return items.join('')
+ return `${items.slice(0, -1).join(', ')} or ${items[items.length - 1]}`
+}
+
+function pad(n: number, width = 2): string {
+ return String(n).padStart(width, '0')
+}
+
+function isoAt(ms: number, offset: number): string {
+ const f = fieldsOf(ms + offset * 1000)
+ return `${pad(f.year, 4)}-${pad(f.month)}-${pad(f.day)}T${pad(f.hour)}:${pad(f.minute)}:${pad(f.second)}${formatOffset(offset)}`
+}
+
+function dateText(day: number): string {
+ const { year, month, day: d } = civilFromDays(day)
+ return `${year}-${pad(month)}-${pad(d)}`
+}
+
+/**
+ * The instant clocks jump forward across the gap containing local reading
+ * `local`: the first instant after a wall time that doesn't exist. Bisects
+ * between the instants the two surrounding offsets would give.
+ */
+function gapEnd(local: number, timeZone: string): number {
+ const before = offsetSeconds(local - MS_PER_DAY, timeZone)
+ let lo = local - offsetSeconds(local + MS_PER_DAY, timeZone) * 1000
+ let hi = local - before * 1000
+ while (hi - lo > 1000) {
+ const mid = lo + Math.floor((hi - lo) / 2000) * 1000
+ if (offsetSeconds(mid, timeZone) === before) lo = mid
+ else hi = mid
+ }
+ return hi
+}
+
+/**
+ * The next `count` runs of `s` strictly after `now`, reading the schedule's
+ * fields as wall-clock time in `timeZone`.
+ *
+ * Daylight-saving changes follow Vixie cron, which separates "wildcard" jobs
+ * (minute or hour field starting with `*`) from fixed-time ones:
+ * - Spring forward: a fixed-time run whose wall time is skipped fires at the
+ * first instant after the gap; wildcard runs in the gap are skipped.
+ * - Fall back: a fixed-time run fires once, at the first occurrence; wildcard
+ * runs carry on through the repeated hour, so they fire in both passes.
+ */
+export function nextRuns(s: CronSchedule, opts: NextRunsOptions): NextRunsResult {
+ const { now, timeZone, count = 10, searchYears = 50 } = opts
+ if (!isValidTimeZone(timeZone)) {
+ return { ok: false, error: { code: 'time-zone', message: `Unknown time zone "${timeZone}".` } }
+ }
+ if (!Number.isFinite(now)) return { ok: false, error: { code: 'now', message: 'The reference time is not a valid date.' } }
+
+ const impossible = neverFiresReason(s)
+ if (impossible !== null) return { ok: true, runs: [], status: 'never', reason: impossible, notes: [] }
+
+ const stepMs = s.hasSeconds ? 1000 : 60_000
+ const wild = field(s, 'minute')?.star === true || field(s, 'hour')?.star === true
+ const capDays = Math.ceil(searchYears * 365.25)
+
+ // Start from the wall clock just after `now`. If clocks fall back within the
+ // next day, wall times a little *before* now's reading recur after `now`, so
+ // start that much earlier and let the `> now` filter drop what has passed.
+ const offsetNow = offsetSeconds(now, timeZone)
+ const minOffset = Math.min(offsetNow, offsetSeconds(now + MS_PER_DAY, timeZone))
+ const nowWall = utcMs(wallTime(now, timeZone))
+ let cursor = Math.floor((nowWall - (offsetNow - minOffset) * 1000) / stepMs) * stepMs
+ if (cursor === Math.floor(nowWall / stepMs) * stepMs) cursor += stepMs
+
+ const runs: CronRun[] = []
+ const pending: CronRun[] = []
+ const skipped = new Map()
+ let last = now
+ const emit = (run: CronRun) => {
+ // Several gap times collapse onto one instant; cron runs the job once.
+ if (run.ms <= last) return
+ runs.push(run)
+ last = run.ms
+ }
+ const make = (ms: number, kind: CronRunKind, note: string | null): CronRun => {
+ const offset = offsetSeconds(ms, timeZone)
+ const f = fieldsOf(ms + offset * 1000)
+ return { ms, iso: isoAt(ms, offset), offsetSeconds: offset, weekday: WEEKDAY_NAMES[f.weekday % 7] as string, kind, note }
+ }
+
+ let lastDay = Math.floor(cursor / MS_PER_DAY) + capDays
+ while (runs.length < count) {
+ const m = nextWallMatch(s, cursor, lastDay)
+ if (m === null) break
+ const local = m.day * MS_PER_DAY + ((m.hour * 60 + m.minute) * 60 + m.second) * 1000
+ cursor = local + stepMs
+ // Every instant for this reading is at most `local - minOffset`; if even
+ // that has passed, skip the Intl work. This only bites near `now`.
+ if (local - minOffset * 1000 <= now) continue
+
+ const civil = civilFromDays(m.day)
+ const wall: WallTime = { year: civil.year, month: civil.month, day: civil.day, hour: m.hour, minute: m.minute, second: m.second }
+ const clock = s.hasSeconds ? `${pad(m.hour)}:${pad(m.minute)}:${pad(m.second)}` : `${pad(m.hour)}:${pad(m.minute)}`
+ const date = dateText(m.day)
+ const r = wallToInstant(wall, timeZone)
+ let primary: CronRun
+ if (r.kind === 'exact') {
+ primary = make(r.ms, 'exact', null)
+ } else if (r.kind === 'ambiguous') {
+ primary = make(
+ r.ms,
+ 'overlap-first',
+ wild
+ ? `${clock} happens twice on ${date} (clocks fall back). This is the first pass; cron runs this schedule in both, because its minute or hour field starts with *.`
+ : `${clock} happens twice on ${date} (clocks fall back). Cron runs a fixed-time job once, at the first occurrence.`,
+ )
+ if (wild) {
+ pending.push(make(r.later, 'overlap-repeat', `${clock} again on ${date}, after clocks fall back. Cron repeats runs whose minute or hour field starts with *.`))
+ }
+ } else if (wild) {
+ const list = skipped.get(m.day) ?? []
+ list.push(clock)
+ skipped.set(m.day, list)
+ continue
+ } else {
+ primary = make(
+ gapEnd(local, timeZone),
+ 'gap',
+ `${clock} doesn't exist on ${date} (clocks spring forward), so cron runs it at the first instant after the gap.`,
+ )
+ }
+ while (pending.length > 0 && (pending[0] as CronRun).ms < primary.ms) emit(pending.shift() as CronRun)
+ emit(primary)
+ lastDay = m.day + capDays
+ }
+ for (const run of pending) emit(run)
+ runs.splice(count)
+
+ const notes = [...skipped].map(([day, clocks]) => {
+ const span = clocks.length === 1 ? clocks[0] : `${clocks[0]}–${clocks[clocks.length - 1]}`
+ const n = clocks.length === 1 ? 'a run' : `${clocks.length} runs`
+ return `Clocks spring forward on ${dateText(day)}, so ${n} at ${span} fall in the gap and are skipped. Cron skips them rather than catching up, because the minute or hour field starts with *.`
+ })
+
+ if (runs.length === 0) {
+ return { ok: true, runs, status: 'never', reason: `No run in the next ${searchYears} years.`, notes }
+ }
+ if (runs.length < count) {
+ const plural = runs.length === 1 ? 'run' : 'runs'
+ return {
+ ok: true,
+ runs,
+ status: 'sparse',
+ reason: `Only ${runs.length} ${plural} found: none for ${searchYears} years after the last one.`,
+ notes,
+ }
+ }
+ return { ok: true, runs, status: 'complete', reason: null, notes }
+}
diff --git a/packages/core/src/cron/parse.test.ts b/packages/core/src/cron/parse.test.ts
new file mode 100644
index 0000000..443caf3
--- /dev/null
+++ b/packages/core/src/cron/parse.test.ts
@@ -0,0 +1,243 @@
+import { describe, expect, it } from 'vitest'
+import { parseCron, type CronError, type CronSchedule, type FieldName } from './parse.ts'
+
+function schedule(input: string): CronSchedule {
+ const r = parseCron(input)
+ if (!r.ok) throw new Error(`expected ${input} to parse: ${r.error.message}`)
+ if (r.value.kind !== 'schedule') throw new Error(`expected ${input} to be a schedule`)
+ return r.value
+}
+
+function values(input: string, name: FieldName): number[] {
+ const f = schedule(input).fields.find((x) => x.name === name)
+ if (f === undefined) throw new Error(`no ${name} field`)
+ return f.values
+}
+
+function error(input: string): CronError {
+ const r = parseCron(input)
+ if (r.ok) throw new Error(`expected ${input} to fail`)
+ return r.error
+}
+
+const range = (from: number, to: number, step = 1) => {
+ const out: number[] = []
+ for (let v = from; v <= to; v += step) out.push(v)
+ return out
+}
+
+describe('parseCron: syntax forms', () => {
+ it('reads five fields in order', () => {
+ const s = schedule('5 4 3 2 1')
+ expect(s.fields.map((f) => [f.name, f.values])).toEqual([
+ ['minute', [5]],
+ ['hour', [4]],
+ ['dayOfMonth', [3]],
+ ['month', [2]],
+ ['dayOfWeek', [1]],
+ ])
+ expect(s.hasSeconds).toBe(false)
+ expect(s.expression).toBe('5 4 3 2 1')
+ })
+
+ it('expands * to the whole field', () => {
+ expect(values('* * * * *', 'minute')).toEqual(range(0, 59))
+ expect(values('* * * * *', 'hour')).toEqual(range(0, 23))
+ expect(values('* * * * *', 'dayOfMonth')).toEqual(range(1, 31))
+ expect(values('* * * * *', 'month')).toEqual(range(1, 12))
+ expect(values('* * * * *', 'dayOfWeek')).toEqual(range(0, 6))
+ })
+
+ it('reads lists, ranges and steps', () => {
+ expect(values('1,5,30 * * * *', 'minute')).toEqual([1, 5, 30])
+ expect(values('10-15 * * * *', 'minute')).toEqual(range(10, 15))
+ expect(values('*/15 * * * *', 'minute')).toEqual([0, 15, 30, 45])
+ expect(values('1-10/2 * * * *', 'minute')).toEqual([1, 3, 5, 7, 9])
+ expect(values('0 */6 * * *', 'hour')).toEqual([0, 6, 12, 18])
+ expect(values('0 0 */10 * *', 'dayOfMonth')).toEqual([1, 11, 21, 31])
+ expect(values('0,30,1-3,*/20 * * * *', 'minute')).toEqual([0, 1, 2, 3, 20, 30, 40])
+ })
+
+ it('reads a/step as "from a to the end of the field"', () => {
+ expect(values('5/15 * * * *', 'minute')).toEqual([5, 20, 35, 50])
+ })
+
+ it('tolerates extra whitespace, including tabs', () => {
+ expect(schedule(' */5\t* * * * ').expression).toBe('*/5 * * * *')
+ })
+
+ it('reads month and weekday names in any case', () => {
+ expect(values('0 0 * JAN,mar,Dec *', 'month')).toEqual([1, 3, 12])
+ expect(values('0 0 * * MON-FRI', 'dayOfWeek')).toEqual([1, 2, 3, 4, 5])
+ expect(values('0 0 * * mon-fri', 'dayOfWeek')).toEqual([1, 2, 3, 4, 5])
+ expect(values('0 0 * jan-jun/2 *', 'month')).toEqual([1, 3, 5])
+ })
+
+ it('treats day-of-week 7 as Sunday', () => {
+ expect(values('0 0 * * 7', 'dayOfWeek')).toEqual([0])
+ expect(values('0 0 * * 5-7', 'dayOfWeek')).toEqual([0, 5, 6])
+ expect(values('0 0 * * 0-7', 'dayOfWeek')).toEqual(range(0, 6))
+ })
+
+ it('reads MON-SUN as the whole week rather than a backwards range', () => {
+ expect(values('0 0 * * MON-SUN', 'dayOfWeek')).toEqual(range(0, 6))
+ expect(values('0 0 * * FRI-SUN', 'dayOfWeek')).toEqual([0, 5, 6])
+ })
+
+ it('reads six fields as seconds first', () => {
+ const s = schedule('*/10 0 12 * * MON')
+ expect(s.hasSeconds).toBe(true)
+ expect(s.fields.map((f) => f.name)).toEqual(['second', 'minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek'])
+ expect(values('*/10 0 12 * * MON', 'second')).toEqual([0, 10, 20, 30, 40, 50])
+ })
+
+ it('records where each field starts', () => {
+ expect(schedule(' 0 12 * * *').fields.map((f) => f.start)).toEqual([1, 4, 7, 9, 11])
+ })
+
+ it('describes each field on its own', () => {
+ const meanings = schedule('*/15 9-17 1,15 JAN-MAR MON-FRI').fields.map((f) => f.meaning)
+ expect(meanings).toEqual([
+ 'every 15 minutes',
+ 'hours 9 through 17',
+ 'day 1, day 15',
+ 'January through March',
+ 'Monday through Friday',
+ ])
+ expect(schedule('0 0 * * 7').fields[4]?.meaning).toBe('Sunday')
+ expect(schedule('0 1-11/2 * * *').fields[1]?.meaning).toBe('every 2 hours from 1 through 11')
+ expect(schedule('5/15 * * * *').fields[0]?.meaning).toBe('every 15 minutes starting at 5')
+ })
+})
+
+describe('parseCron: macros', () => {
+ it.each([
+ ['@yearly', '0 0 1 1 *'],
+ ['@annually', '0 0 1 1 *'],
+ ['@monthly', '0 0 1 * *'],
+ ['@weekly', '0 0 * * 0'],
+ ['@daily', '0 0 * * *'],
+ ['@midnight', '0 0 * * *'],
+ ['@hourly', '0 * * * *'],
+ ['@HOURLY', '0 * * * *'],
+ ])('expands %s to %s', (macro, expansion) => {
+ const s = schedule(macro)
+ expect(s.expression).toBe(expansion)
+ expect(s.macro).toBe(macro.toLowerCase())
+ expect(s.fields.every((f) => f.start === -1)).toBe(true)
+ })
+
+ it('reports @reboot as having no schedule', () => {
+ const r = parseCron('@reboot')
+ expect(r).toEqual({ ok: true, value: { kind: 'reboot', source: '@reboot', macro: '@reboot' } })
+ })
+
+ it('rejects an unknown macro', () => {
+ expect(error('@fortnightly')).toMatchObject({ code: 'macro', field: null, position: 0 })
+ })
+
+ it('rejects anything after a macro', () => {
+ expect(error('@daily /usr/bin/backup')).toMatchObject({ code: 'field-count', position: 7 })
+ })
+})
+
+describe('parseCron: day-of-month and day-of-week', () => {
+ it('ORs the day fields when both are restricted (Vixie)', () => {
+ const s = schedule('0 0 13 * FRI')
+ expect(s.dayOr).toBe(true)
+ expect(s.notes.join(' ')).toMatch(/either one/)
+ })
+
+ it('ANDs them when either is *', () => {
+ expect(schedule('0 0 13 * *').dayOr).toBe(false)
+ expect(schedule('0 0 * * FRI').dayOr).toBe(false)
+ expect(schedule('0 0 * * *').notes).toEqual([])
+ })
+
+ it('counts a field starting with * as unrestricted even when stepped, and says so', () => {
+ const s = schedule('0 0 */2 * MON')
+ expect(s.dayOr).toBe(false)
+ expect(s.notes.join(' ')).toMatch(/Day of month starts with "\*".*both/)
+ })
+})
+
+describe('parseCron: notes', () => {
+ it('flags a step that only ever matches once', () => {
+ expect(schedule('*/90 * * * *').notes.join(' ')).toMatch(/A step of 90 only matches minute 0.*restart every hour/)
+ expect(schedule('0 */30 * * *').notes.join(' ')).toMatch(/hour 0/)
+ expect(schedule('*/15 * * * *').notes).toEqual([])
+ })
+})
+
+describe('parseCron: errors carry their field and position', () => {
+ it.each<[string, Partial]>([
+ ['', { code: 'empty', field: null, position: 0 }],
+ [' ', { code: 'empty', field: null, position: 0 }],
+ ['* * * *', { code: 'field-count', field: null, position: 7 }],
+ ['* * * * * * * *', { code: 'field-count', field: null, position: 12 }],
+ ['60 * * * *', { code: 'out-of-range', field: 'minute', position: 0 }],
+ ['* 24 * * *', { code: 'out-of-range', field: 'hour', position: 2 }],
+ ['* * 0 * *', { code: 'out-of-range', field: 'dayOfMonth', position: 4 }],
+ ['* * 32 * *', { code: 'out-of-range', field: 'dayOfMonth', position: 4 }],
+ ['* * * 13 *', { code: 'out-of-range', field: 'month', position: 6 }],
+ ['* * * * 8', { code: 'out-of-range', field: 'dayOfWeek', position: 8 }],
+ ['60 * * * * *', { code: 'out-of-range', field: 'second', position: 0 }],
+ ['0 0-99 * * *', { code: 'out-of-range', field: 'hour', position: 4 }],
+ ['*/0 * * * *', { code: 'step', field: 'minute', position: 2 }],
+ ['*/ * * * *', { code: 'step', field: 'minute', position: 2 }],
+ ['*/x * * * *', { code: 'step', field: 'minute', position: 2 }],
+ ['/5 * * * *', { code: 'syntax', field: 'minute', position: 0 }],
+ ['*/5/2 * * * *', { code: 'syntax', field: 'minute', position: 3 }],
+ ['30-10 * * * *', { code: 'range', field: 'minute', position: 0 }],
+ ['0 0 * * FRI-MON', { code: 'range', field: 'dayOfWeek', position: 8 }],
+ ['1,,2 * * * *', { code: 'syntax', field: 'minute', position: 2 }],
+ ['1, * * * *', { code: 'syntax', field: 'minute', position: 2 }],
+ ['1.5 * * * *', { code: 'syntax', field: 'minute', position: 1 }],
+ ['-5 * * * *', { code: 'syntax', field: 'minute', position: 0 }],
+ ['1- * * * *', { code: 'syntax', field: 'minute', position: 2 }],
+ ['0 0 * FOO *', { code: 'name', field: 'month', position: 6 }],
+ ['0 0 * JANUARY *', { code: 'name', field: 'month', position: 6 }],
+ ['0 0 * * FUNDAY', { code: 'name', field: 'dayOfWeek', position: 8 }],
+ ['MON * * * *', { code: 'name', field: 'minute', position: 0 }],
+ ['0 0 * * JAN', { code: 'name', field: 'dayOfWeek', position: 8 }],
+ ])('%j', (input, expected) => {
+ expect(error(input)).toMatchObject(expected)
+ })
+
+ it('names the field in the message', () => {
+ expect(error('* 24 * * *').message).toBe('Hour 24 is out of range (0–23).')
+ expect(error('*/0 * * * *').message).toMatch(/minute field/)
+ })
+
+ it('says a pasted crontab line has a command on the end', () => {
+ expect(error('0 5 * * * /usr/bin/backup')).toMatchObject({ code: 'field-count', position: 10 })
+ expect(error('0 5 * * * backup.sh --full')).toMatchObject({ code: 'field-count', position: 10 })
+ expect(error('0 5 * * * echo hi').message).toMatch(/crontab line with a command/)
+ })
+})
+
+describe('parseCron: Quartz syntax is refused by name', () => {
+ it.each<[string, FieldName | null, number]>([
+ ['0 0 ? * MON', 'dayOfMonth', 4],
+ ['0 0 1 * ?', 'dayOfWeek', 8],
+ ['0 0 L * *', 'dayOfMonth', 4],
+ ['0 0 LW * *', 'dayOfMonth', 4],
+ ['0 0 L-3 * *', 'dayOfMonth', 4],
+ ['0 0 15W * *', 'dayOfMonth', 6],
+ ['0 0 * * 5L', 'dayOfWeek', 9],
+ ['0 0 * * FRIL', 'dayOfWeek', 11],
+ ['0 0 * * 5#3', 'dayOfWeek', 9],
+ ['0 0 * * MON#1', 'dayOfWeek', 11],
+ ['0 0 12 ? * MON', 'dayOfMonth', 7],
+ ['0 0 12 * * MON 2027', null, 15],
+ ])('%j', (input, field, position) => {
+ const e = error(input)
+ expect(e).toMatchObject({ code: 'quartz', field, position })
+ expect(e.message).toMatch(/^Quartz syntax isn't standard cron/)
+ })
+
+ it('still accepts names that contain L or W', () => {
+ expect(values('0 0 * JUL *', 'month')).toEqual([7])
+ expect(values('0 0 * * WED', 'dayOfWeek')).toEqual([3])
+ })
+})
diff --git a/packages/core/src/cron/parse.ts b/packages/core/src/cron/parse.ts
new file mode 100644
index 0000000..dce80c1
--- /dev/null
+++ b/packages/core/src/cron/parse.ts
@@ -0,0 +1,478 @@
+/**
+ * A hand-written parser for standard (Vixie) cron expressions: five fields,
+ * or six with seconds first, plus the `@` macros.
+ *
+ * Hand-written rather than borrowed because the point of the tool is to say
+ * exactly where an expression is wrong, and to refuse the Quartz dialect by
+ * name instead of half-accepting it: `?`, `L`, `W` and `#` mean something to
+ * Quartz and Spring, and nothing to crond.
+ */
+
+export type FieldName = 'second' | 'minute' | 'hour' | 'dayOfMonth' | 'month' | 'dayOfWeek'
+
+export type CronErrorCode =
+ | 'empty'
+ | 'field-count'
+ | 'quartz'
+ | 'syntax'
+ | 'out-of-range'
+ | 'range'
+ | 'step'
+ | 'name'
+ | 'macro'
+
+export interface CronError {
+ code: CronErrorCode
+ message: string
+ /** The field the problem is in, or null when it is about the whole expression. */
+ field: FieldName | null
+ /** 0-based UTF-16 offset into the original input. */
+ position: number
+}
+
+interface FieldSpec {
+ name: FieldName
+ label: string
+ min: number
+ max: number
+ /** Upper bound for `*` and open-ended `a/step`, which differs from `max` only for day of week (7 = Sunday again). */
+ top: number
+ singular: string
+ plural: string
+ names?: readonly string[]
+ /** Offset of `names[0]`: months count from 1, weekdays from 0. */
+ nameBase?: number
+}
+
+export const MONTH_NAMES = [
+ 'January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December',
+] as const
+
+export const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const
+
+const SPECS: Record = {
+ second: { name: 'second', label: 'Second', min: 0, max: 59, top: 59, singular: 'second', plural: 'seconds' },
+ minute: { name: 'minute', label: 'Minute', min: 0, max: 59, top: 59, singular: 'minute', plural: 'minutes' },
+ hour: { name: 'hour', label: 'Hour', min: 0, max: 23, top: 23, singular: 'hour', plural: 'hours' },
+ dayOfMonth: { name: 'dayOfMonth', label: 'Day of month', min: 1, max: 31, top: 31, singular: 'day', plural: 'days' },
+ month: {
+ name: 'month', label: 'Month', min: 1, max: 12, top: 12, singular: 'month', plural: 'months',
+ names: MONTH_NAMES, nameBase: 1,
+ },
+ dayOfWeek: {
+ name: 'dayOfWeek', label: 'Day of week', min: 0, max: 7, top: 6, singular: 'day of the week', plural: 'days of the week',
+ names: WEEKDAY_NAMES, nameBase: 0,
+ },
+}
+
+export function fieldLabel(name: FieldName): string {
+ return SPECS[name].label
+}
+
+const FIVE: readonly FieldName[] = ['minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek']
+const SIX: readonly FieldName[] = ['second', ...FIVE]
+
+export type CronItem =
+ | { kind: 'star'; step: number }
+ | { kind: 'single'; value: number }
+ | { kind: 'range'; from: number; to: number; step: number }
+ /** `a/step`: from `a` to the top of the field. Vixie rejects it; cronie and every describer accept it. */
+ | { kind: 'start'; from: number; step: number }
+
+export interface CronField {
+ name: FieldName
+ label: string
+ /** The field exactly as written. */
+ text: string
+ /** 0-based offset of the field in the input; -1 for a field expanded from a macro. */
+ start: number
+ items: CronItem[]
+ /** Sorted, distinct; day of week 7 is folded to 0. */
+ values: number[]
+ /**
+ * Vixie's `*_STAR` flag: the field text starts with `*`. Note this is true
+ * for `*\/2` as well, which matters for the day-of-month/day-of-week rule.
+ */
+ star: boolean
+ /** Plain-English reading of this field alone. */
+ meaning: string
+}
+
+export interface CronSchedule {
+ kind: 'schedule'
+ /** The trimmed input. */
+ source: string
+ /** `@daily` and friends, lowercased, or null. */
+ macro: string | null
+ /** The 5- or 6-field form: the macro's expansion, or the input with whitespace normalised. */
+ expression: string
+ hasSeconds: boolean
+ fields: CronField[]
+ /** Lookup tables for the matcher, indexed by value. */
+ match: Record
+ /** Day of month and day of week are both restricted, so either one matching is enough. */
+ dayOr: boolean
+ /** Readings that are valid cron but easy to misjudge, in plain English. */
+ notes: string[]
+}
+
+export interface RebootSchedule {
+ kind: 'reboot'
+ source: string
+ macro: '@reboot'
+}
+
+export type CronParseResult =
+ | { ok: true; value: CronSchedule | RebootSchedule }
+ | { ok: false; error: CronError }
+
+export const MACROS: Readonly> = {
+ '@yearly': '0 0 1 1 *',
+ '@annually': '0 0 1 1 *',
+ '@monthly': '0 0 1 * *',
+ '@weekly': '0 0 * * 0',
+ '@daily': '0 0 * * *',
+ '@midnight': '0 0 * * *',
+ '@hourly': '0 * * * *',
+}
+
+class Fail {
+ constructor(readonly error: CronError) {}
+}
+
+function fail(code: CronErrorCode, field: FieldName | null, position: number, message: string): never {
+ // Thrown only inside this module and caught in parseCron: the public API
+ // returns errors as data.
+ throw new Fail({ code, field, position, message })
+}
+
+const QUARTZ_HINTS: Readonly> = {
+ '?': '"?" means "no specific value"; use * instead',
+ L: '"L" means "last"; standard cron has no last-day syntax',
+ W: '"W" means "nearest weekday"; standard cron has no equivalent',
+ '#': '"#" means "nth weekday of the month"; standard cron has no equivalent',
+}
+
+function quartz(spec: FieldSpec, position: number, symbol: string): never {
+ fail(
+ 'quartz',
+ spec.name,
+ position,
+ `Quartz syntax isn't standard cron: ${QUARTZ_HINTS[symbol] ?? `"${symbol}"`} (${spec.label.toLowerCase()} field).`,
+ )
+}
+
+// `L`, `LW`, `L-3`, `15W`, `5L`, `FRIL`: the Quartz forms built from letters,
+// which would otherwise fall through to a confusing "unknown name" error.
+const QUARTZ_LETTERS = /^(?:L|LW|L-\d+|\d+[LW]|\d*L|[A-Z]{3}L)$/i
+
+function parseAtom(spec: FieldSpec, text: string, at: number): number {
+ if (text === '') fail('syntax', spec.name, at, `Expected a number in the ${spec.label.toLowerCase()} field.`)
+ if (/^\d+$/.test(text)) {
+ const n = Number(text)
+ if (n < spec.min || n > spec.max) {
+ fail('out-of-range', spec.name, at, `${spec.label} ${text} is out of range (${spec.min}–${spec.max}).`)
+ }
+ return n
+ }
+ if (/^[A-Za-z]+$/.test(text)) {
+ const upper = text.toUpperCase()
+ if (spec.names !== undefined && spec.nameBase !== undefined) {
+ const i = spec.names.findIndex((n) => n.slice(0, 3).toUpperCase() === upper)
+ if (i >= 0) return i + spec.nameBase
+ }
+ if (QUARTZ_LETTERS.test(text)) quartz(spec, at + upper.search(/[LW]/), upper.includes('W') ? 'W' : 'L')
+ if (spec.names === undefined) {
+ fail('name', spec.name, at, `Names like "${text}" are only allowed in the month and day-of-week fields.`)
+ }
+ const examples = spec.name === 'month' ? 'JAN–DEC' : 'SUN–SAT'
+ fail('name', spec.name, at, `Unknown ${spec.label.toLowerCase()} name "${text}"; use three letters (${examples}).`)
+ }
+ if (QUARTZ_LETTERS.test(text)) quartz(spec, at + text.toUpperCase().search(/[LW]/), /W/i.test(text) ? 'W' : 'L')
+ const bad = text.search(/[^0-9A-Za-z]/)
+ const offset = bad < 0 ? 0 : bad
+ fail('syntax', spec.name, at + offset, `Unexpected "${text[offset] ?? text}" in the ${spec.label.toLowerCase()} field.`)
+}
+
+function parseStep(spec: FieldSpec, text: string, at: number): number {
+ if (!/^\d+$/.test(text)) {
+ fail('step', spec.name, at, `A step must be a whole number, as in */5 (${spec.label.toLowerCase()} field).`)
+ }
+ const n = Number(text)
+ if (n === 0) fail('step', spec.name, at, `A step of 0 never advances (${spec.label.toLowerCase()} field).`)
+ return n
+}
+
+function parseItem(spec: FieldSpec, text: string, at: number): CronItem {
+ const slash = text.indexOf('/')
+ const base = slash < 0 ? text : text.slice(0, slash)
+ let step = 1
+ if (slash >= 0) {
+ const rest = text.slice(slash + 1)
+ if (rest.includes('/')) fail('syntax', spec.name, at + slash + 1 + rest.indexOf('/'), `Only one "/" is allowed per list item.`)
+ step = parseStep(spec, rest, at + slash + 1)
+ if (base === '') fail('syntax', spec.name, at, `A step needs something to step through, as in */${rest} or 0-30/${rest}.`)
+ }
+ if (base === '*') return { kind: 'star', step }
+
+ const dash = base.indexOf('-', 1)
+ // `L-3` is Quartz; catch it before the dash splits it.
+ if (QUARTZ_LETTERS.test(base)) quartz(spec, at + base.toUpperCase().search(/[LW]/), /W/i.test(base) ? 'W' : 'L')
+ if (dash < 0) {
+ const value = parseAtom(spec, base, at)
+ return slash < 0 ? { kind: 'single', value } : { kind: 'start', from: value, step }
+ }
+ const from = parseAtom(spec, base.slice(0, dash), at)
+ const toText = base.slice(dash + 1)
+ let to = parseAtom(spec, toText, at + dash + 1)
+ // MON-SUN reads naturally, but SUN is 0; treat a named Sunday at the end of a
+ // range as 7, which cron already accepts as Sunday.
+ if (spec.name === 'dayOfWeek' && to === 0 && from > 0 && /^[A-Za-z]+$/.test(toText)) to = 7
+ if (to < from) {
+ fail('range', spec.name, at, `The range ${base} runs backwards; cron ranges don't wrap around.`)
+ }
+ return { kind: 'range', from, to, step }
+}
+
+function expand(spec: FieldSpec, item: CronItem): number[] {
+ let from: number
+ let to: number
+ let step = 1
+ switch (item.kind) {
+ case 'star':
+ ;[from, to, step] = [spec.min, spec.top, item.step]
+ break
+ case 'single':
+ ;[from, to] = [item.value, item.value]
+ break
+ case 'range':
+ ;[from, to, step] = [item.from, item.to, item.step]
+ break
+ case 'start':
+ ;[from, to, step] = [item.from, Math.max(item.from, spec.top), item.step]
+ break
+ }
+ const out: number[] = []
+ for (let v = from; v <= to; v += step) out.push(spec.name === 'dayOfWeek' && v === 7 ? 0 : v)
+ return out
+}
+
+function valueName(spec: FieldSpec, v: number): string {
+ if (spec.names !== undefined && spec.nameBase !== undefined) {
+ return spec.names[(v - spec.nameBase) % spec.names.length] ?? String(v)
+ }
+ return String(v)
+}
+
+function describeItem(spec: FieldSpec, item: CronItem): string {
+ const named = spec.names !== undefined
+ const one = (v: number) => (named ? valueName(spec, v) : `${spec.singular} ${v}`)
+ const every = (step: number) => (step === 1 ? `every ${spec.singular}` : `every ${step} ${spec.plural}`)
+ switch (item.kind) {
+ case 'star':
+ return every(item.step)
+ case 'single':
+ return one(item.value)
+ case 'range': {
+ const span = `${named ? valueName(spec, item.from) : item.from} through ${named ? valueName(spec, item.to) : item.to}`
+ return item.step === 1 ? (named ? span : `${spec.plural} ${span}`) : `${every(item.step)} from ${span}`
+ }
+ case 'start':
+ return `${every(item.step)} starting at ${named ? valueName(spec, item.from) : item.from}`
+ }
+}
+
+function parseField(spec: FieldSpec, text: string, start: number): CronField {
+ for (const symbol of ['?', '#']) {
+ const i = text.indexOf(symbol)
+ if (i >= 0) quartz(spec, start + i, symbol)
+ }
+ const items: CronItem[] = []
+ let offset = 0
+ for (const part of text.split(',')) {
+ if (part === '') {
+ fail('syntax', spec.name, start + offset, `Empty list item in the ${spec.label.toLowerCase()} field.`)
+ }
+ items.push(parseItem(spec, part, start + offset))
+ offset += part.length + 1
+ }
+ const values = [...new Set(items.flatMap((item) => expand(spec, item)))].toSorted((a, b) => a - b)
+ return {
+ name: spec.name,
+ label: spec.label,
+ text,
+ start,
+ items,
+ values,
+ star: text.startsWith('*'),
+ meaning: items.map((item) => describeItem(spec, item)).join(', '),
+ }
+}
+
+function table(spec: FieldSpec, values: readonly number[]): boolean[] {
+ const t = Array.from({ length: spec.max + 1 }, () => false)
+ for (const v of values) t[v] = true
+ return t
+}
+
+interface Token {
+ text: string
+ start: number
+}
+
+function tokenize(input: string): Token[] {
+ const tokens: Token[] = []
+ for (const m of input.matchAll(/\S+/g)) tokens.push({ text: m[0], start: m.index })
+ return tokens
+}
+
+const ZERO_SECONDS: readonly boolean[] = Array.from({ length: 60 }, (_, i) => i === 0)
+
+function build(source: string, macro: string | null, tokens: readonly Token[], fromMacro: boolean): CronSchedule {
+ const names = tokens.length === 6 ? SIX : FIVE
+ const fields = tokens.map((t, i) => {
+ const name = names[i] as FieldName
+ return parseField(SPECS[name], t.text, fromMacro ? -1 : t.start)
+ })
+ const byName = Object.fromEntries(fields.map((f) => [f.name, f])) as Partial>
+ const get = (name: FieldName) => byName[name] as CronField
+ const hasSeconds = byName.second !== undefined
+ const dom = get('dayOfMonth')
+ const dow = get('dayOfWeek')
+ const dayOr = !dom.star && !dow.star
+ return {
+ kind: 'schedule',
+ source,
+ macro,
+ expression: tokens.map((t) => t.text).join(' '),
+ hasSeconds,
+ fields,
+ match: {
+ second: hasSeconds ? table(SPECS.second, get('second').values) : ZERO_SECONDS,
+ minute: table(SPECS.minute, get('minute').values),
+ hour: table(SPECS.hour, get('hour').values),
+ dayOfMonth: table(SPECS.dayOfMonth, get('dayOfMonth').values),
+ month: table(SPECS.month, get('month').values),
+ dayOfWeek: table(SPECS.dayOfWeek, get('dayOfWeek').values),
+ },
+ // Vixie decides by the first character, not by the resulting set: `*/2`
+ // counts as unrestricted, so `0 0 */2 * MON` needs both to match.
+ dayOr,
+ notes: notesFor(fields, dom, dow, dayOr),
+ }
+}
+
+function notesFor(fields: readonly CronField[], dom: CronField, dow: CronField, dayOr: boolean): string[] {
+ const notes: string[] = []
+ if (dayOr) {
+ notes.push(
+ 'Day of month and day of week are both restricted, so cron runs on any day that matches either one, not only days that match both.',
+ )
+ } else if (dom.text !== '*' && dow.text !== '*') {
+ const starred = dom.star ? dom : dow
+ notes.push(
+ `${starred.label} starts with "*", so cron counts it as unrestricted and runs only on days that match both day fields.`,
+ )
+ }
+ for (const field of fields) {
+ const spec = SPECS[field.name]
+ for (const item of field.items) {
+ if (item.kind === 'single' || item.step === 1) continue
+ const hits = expand(spec, item)
+ if (hits.length !== 1) continue
+ // `*/90` reads as "every 90 minutes", but steps restart at each field
+ // boundary, so it is only ever minute 0.
+ notes.push(
+ `A step of ${item.step} only matches ${field.label.toLowerCase()} ${valueName(spec, hits[0] as number)}: steps restart every ${parentUnit(field.name)}, so this is not "every ${item.step} ${spec.plural}".`,
+ )
+ }
+ }
+ return notes
+}
+
+function parentUnit(name: FieldName): string {
+ switch (name) {
+ case 'second':
+ return 'minute'
+ case 'minute':
+ return 'hour'
+ case 'hour':
+ return 'day'
+ case 'dayOfMonth':
+ return 'month'
+ case 'month':
+ return 'year'
+ case 'dayOfWeek':
+ return 'week'
+ }
+}
+
+/** Parse a cron expression. Never throws: errors come back with their field and position. */
+export function parseCron(input: string): CronParseResult {
+ try {
+ return { ok: true, value: parseOrThrow(input) }
+ } catch (e) {
+ if (e instanceof Fail) return { ok: false, error: e.error }
+ throw e
+ }
+}
+
+function parseOrThrow(input: string): CronSchedule | RebootSchedule {
+ const tokens = tokenize(input)
+ const source = input.trim()
+ const first = tokens[0]
+ if (first === undefined) fail('empty', null, 0, 'Enter a cron expression, such as */15 * * * *.')
+
+ if (first.text.startsWith('@')) {
+ const macro = first.text.toLowerCase()
+ const extra = tokens[1]
+ if (extra !== undefined) {
+ fail('field-count', null, extra.start, `${first.text} stands alone; remove what follows it (a crontab command goes elsewhere).`)
+ }
+ if (macro === '@reboot') return { kind: 'reboot', source, macro: '@reboot' }
+ const expansion = MACROS[macro]
+ if (expansion === undefined) {
+ fail('macro', null, first.start, `Unknown macro ${first.text}. Known: ${[...Object.keys(MACROS), '@reboot'].join(', ')}.`)
+ }
+ return build(source, macro, tokenize(expansion), true)
+ }
+
+ if (tokens.length > 5) {
+ const sixth = tokens[5] as Token
+ const seventh = tokens[6]
+ const quartzYear = tokens.length === 7 && seventh !== undefined && /^[\d*,/-]+$/.test(seventh.text)
+ // A pasted crontab line (`0 5 * * * backup.sh`) has a valid 5-field
+ // schedule followed by something that is not a field. Saying so beats
+ // reading the command as a day of week and complaining about that.
+ if (!quartzYear && succeeds(() => build(source, null, tokens.slice(0, 5), false))) {
+ const sixthIsField = succeeds(() => parseField(SPECS.dayOfWeek, sixth.text, sixth.start))
+ if (!sixthIsField) {
+ fail('field-count', null, sixth.start, 'This looks like a crontab line with a command after the schedule. Paste only the schedule fields.')
+ }
+ }
+ if (quartzYear) {
+ fail('quartz', null, (seventh as Token).start, "Quartz syntax isn't standard cron: a seventh (year) field is Quartz-only.")
+ }
+ }
+ if (tokens.length !== 5 && tokens.length !== 6) {
+ const at = tokens.length > 6 ? (tokens[6] as Token).start : input.length
+ fail(
+ 'field-count',
+ null,
+ at,
+ `Expected 5 fields (minute hour day-of-month month day-of-week), or 6 with seconds first; got ${tokens.length}.`,
+ )
+ }
+ return build(source, null, tokens, false)
+}
+
+function succeeds(run: () => unknown): boolean {
+ try {
+ run()
+ return true
+ } catch (e) {
+ if (e instanceof Fail) return false
+ throw e
+ }
+}
diff --git a/packages/core/src/css/format.test.ts b/packages/core/src/css/format.test.ts
new file mode 100644
index 0000000..9b23fe8
--- /dev/null
+++ b/packages/core/src/css/format.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from 'vitest'
+import { formatCss, parseCssError, type CssResult } from './format.ts'
+
+function output(r: CssResult): string {
+ if (!r.ok) throw new Error(`expected success, got: ${r.error.message}`)
+ return r.output
+}
+
+describe('formatCss', () => {
+ it('formats CSS', async () => {
+ expect(output(await formatCss('a{color:red;background:BLUE}'))).toBe(
+ 'a {\n color: red;\n background: BLUE;\n}\n',
+ )
+ })
+
+ it('formats SCSS', async () => {
+ const r = await formatCss('$c:red;.a{&:hover{color:$c}@include m(1px)}', { parser: 'scss' })
+ expect(output(r)).toBe('$c: red;\n.a {\n &:hover {\n color: $c;\n }\n @include m(1px);\n}\n')
+ })
+
+ it('formats Less', async () => {
+ const r = await formatCss('@c:red;.a{color:@c;.mixin();}', { parser: 'less' })
+ expect(output(r)).toBe('@c: red;\n.a {\n color: @c;\n .mixin();\n}\n')
+ })
+
+ it('honours tab width, tabs and quote style', async () => {
+ const src = 'a{content:"x"}'
+ expect(output(await formatCss(src, { tabWidth: 4 }))).toBe('a {\n content: "x";\n}\n')
+ expect(output(await formatCss(src, { useTabs: true }))).toBe('a {\n\tcontent: "x";\n}\n')
+ expect(output(await formatCss(src, { singleQuote: true }))).toBe("a {\n content: 'x';\n}\n")
+ })
+
+ it('honours print width', async () => {
+ const src = 'a{grid-template-columns:[full-start] minmax(1rem,1fr) [content-start] minmax(0,60rem) [content-end] minmax(1rem,1fr) [full-end]}'
+ const narrow = output(await formatCss(src, { printWidth: 40 }))
+ const wide = output(await formatCss(src, { printWidth: 200 }))
+ expect(narrow.split('\n').length).toBeGreaterThan(wide.split('\n').length)
+ })
+
+ it('returns empty output for blank input', async () => {
+ expect(await formatCss('\n ')).toEqual({ ok: true, output: '' })
+ })
+
+ it('reports a syntax error with its position', async () => {
+ const r = await formatCss('a {\n color: red;\n b: "unclosed\n}')
+ expect(r).toEqual({
+ ok: false,
+ error: { message: 'Unclosed string', line: 3, column: 6 },
+ })
+ })
+
+ it('reports an unclosed block', async () => {
+ const r = await formatCss('.a {\n color: red;\n')
+ expect(r.ok).toBe(false)
+ if (r.ok) return
+ expect(r.error.message).toBe('Unclosed block')
+ expect(r.error.line).toBe(1)
+ })
+
+ it('reports SCSS errors too', async () => {
+ const r = await formatCss('.a { color: red; }\n}', { parser: 'scss' })
+ expect(r.ok).toBe(false)
+ if (r.ok) return
+ expect(r.error.line).toBe(2)
+ })
+})
+
+describe('parseCssError', () => {
+ it('drops the code frame and the inline position', () => {
+ const e = Object.assign(new SyntaxError('CssSyntaxError: Unexpected } (1:4)\n> 1 | a{}}\n | ^'), {
+ loc: { start: { line: 1, column: 4 } },
+ })
+ expect(parseCssError(e)).toEqual({ message: 'Unexpected }', line: 1, column: 4 })
+ })
+
+ it('falls back to the position in the message', () => {
+ expect(parseCssError(new Error('Unknown word (2:5)'))).toEqual({
+ message: 'Unknown word',
+ line: 2,
+ column: 5,
+ })
+ })
+
+ it('has no position when there is none', () => {
+ expect(parseCssError('nope')).toEqual({ message: 'nope', line: null, column: null })
+ })
+})
diff --git a/packages/core/src/css/format.ts b/packages/core/src/css/format.ts
new file mode 100644
index 0000000..7eec996
--- /dev/null
+++ b/packages/core/src/css/format.ts
@@ -0,0 +1,97 @@
+export type CssParser = 'css' | 'scss' | 'less'
+
+export interface CssFormatOptions {
+ parser: CssParser
+ printWidth: number
+ tabWidth: number
+ useTabs: boolean
+ singleQuote: boolean
+}
+
+export const DEFAULT_CSS_FORMAT: CssFormatOptions = {
+ parser: 'css',
+ printWidth: 80,
+ tabWidth: 2,
+ useTabs: false,
+ singleQuote: false,
+}
+
+export interface CssError {
+ message: string
+ /** 1-based, when the parser reported a position. */
+ line: number | null
+ /** 1-based, when the parser reported a position. */
+ column: number | null
+}
+
+export type CssResult = { ok: true; output: string } | { ok: false; error: CssError }
+
+interface PrettierSyntaxError {
+ message: string
+ loc?: { start?: { line?: number; column?: number } }
+}
+
+function isPrettierSyntaxError(e: unknown): e is PrettierSyntaxError {
+ return e instanceof Error && 'loc' in e
+}
+
+/**
+ * Prettier's message is `CssSyntaxError: Unclosed block (3:6)` followed by a
+ * code frame. The UI draws its own position line, so keep only the sentence;
+ * the position comes from `loc`, which Prettier fills in for parser errors.
+ */
+export function parseCssError(e: unknown): CssError {
+ const raw = e instanceof Error ? e.message : String(e)
+ const first = (raw.split('\n')[0] ?? raw).trim()
+ const message =
+ first
+ .replace(/^(?:Css)?SyntaxError:\s*/, '')
+ .replace(/\s*\(\d+:\d+\)$/, '')
+ .trim() || 'Could not parse this stylesheet'
+
+ const start = isPrettierSyntaxError(e) ? e.loc?.start : undefined
+ let line = typeof start?.line === 'number' ? start.line : null
+ let column = typeof start?.column === 'number' ? start.column : null
+ if (line === null) {
+ // Some plugin errors carry the position only in the message.
+ const pos = /\((\d+):(\d+)\)$/.exec(first)
+ if (pos) {
+ line = Number(pos[1])
+ column = Number(pos[2])
+ }
+ }
+ return { message, line, column }
+}
+
+/**
+ * Pretty-print with Prettier. Only the standalone build and the postcss plugin
+ * are used: Prettier's Node entry points use `eval`, which the site's CSP
+ * forbids. Both are imported here rather than at the top of the module (~70 kB
+ * gzip together), so importing this subpath costs nothing until a stylesheet
+ * is actually formatted.
+ */
+export async function formatCss(
+ input: string,
+ opts: Partial = {},
+): Promise {
+ const o = { ...DEFAULT_CSS_FORMAT, ...opts }
+ if (input.trim() === '') return { ok: true, output: '' }
+
+ try {
+ const [prettier, postcss] = await Promise.all([
+ import('prettier/standalone'),
+ import('prettier/plugins/postcss'),
+ ])
+ const output = await prettier.format(input, {
+ parser: o.parser,
+ plugins: [postcss],
+ printWidth: o.printWidth,
+ tabWidth: o.tabWidth,
+ useTabs: o.useTabs,
+ singleQuote: o.singleQuote,
+ })
+ return { ok: true, output }
+ } catch (e) {
+ return { ok: false, error: parseCssError(e) }
+ }
+}
diff --git a/packages/core/src/css/index.ts b/packages/core/src/css/index.ts
new file mode 100644
index 0000000..764888d
--- /dev/null
+++ b/packages/core/src/css/index.ts
@@ -0,0 +1,2 @@
+export * from './format.ts'
+export * from './minify.ts'
diff --git a/packages/core/src/css/minify.test.ts b/packages/core/src/css/minify.test.ts
new file mode 100644
index 0000000..a00e422
--- /dev/null
+++ b/packages/core/src/css/minify.test.ts
@@ -0,0 +1,95 @@
+import fc from 'fast-check'
+import { describe, expect, it } from 'vitest'
+import { minifyCss } from './minify.ts'
+
+describe('minifyCss', () => {
+ it('collapses whitespace and drops the last semicolon in each block', () => {
+ expect(minifyCss('a ,\nb > c {\n color : red ;\n margin: 0 auto;\n}\n')).toBe(
+ 'a,b>c{color :red;margin:0 auto}',
+ )
+ })
+
+ it('handles nested blocks', () => {
+ expect(minifyCss('@media screen and (min-width: 600px) {\n a { b: c; }\n}')).toBe(
+ '@media screen and (min-width:600px){a{b:c}}',
+ )
+ })
+
+ it('strips comments but keeps /*! licence */ comments', () => {
+ expect(minifyCss('/*! license: MIT */\n/* gone */\na { /* also gone */ b: c; }')).toBe(
+ '/*! license: MIT */ a{b:c}',
+ )
+ })
+
+ it('keeps a separator where a comment sat between two tokens', () => {
+ expect(minifyCss('a{border:1px/**/solid}')).toBe('a{border:1px solid}')
+ })
+
+ it('leaves strings alone, braces and all', () => {
+ expect(minifyCss('a::before { content: " } { ; " ; }')).toBe(
+ 'a::before{content:" } { ; "}',
+ )
+ expect(minifyCss("a::after { content: '\\' x' }")).toBe("a::after{content:'\\' x'}")
+ })
+
+ it('copies an unquoted url(data:…) body verbatim', () => {
+ const uri = 'data:image/png;base64,iVBORw0KGgo+/=;a,b'
+ expect(minifyCss(`a { background: url( ${uri} ) no-repeat ; }`)).toBe(
+ `a{background:url(${uri}) no-repeat}`,
+ )
+ })
+
+ it('leaves a quoted url() string alone', () => {
+ expect(minifyCss(`a { b: url( "x y.png" ) }`)).toBe('a{b:url("x y.png")}')
+ })
+
+ it('keeps the spaces calc() needs around + and -', () => {
+ expect(minifyCss('a { width: calc( 100% - 2 * ( 1px + 2px ) ); }')).toBe(
+ 'a{width:calc(100% - 2 * (1px + 2px))}',
+ )
+ expect(minifyCss('a { width: calc(1px + -2px) }')).toBe('a{width:calc(1px + -2px)}')
+ })
+
+ it('tightens the + combinator outside parentheses', () => {
+ expect(minifyCss('a + b ~ c { x: y }')).toBe('a+b~c{x:y}')
+ })
+
+ it('keeps !important, tightened', () => {
+ expect(minifyCss('a { color: red !important; b: c ! important }')).toBe(
+ 'a{color:red!important;b:c!important}',
+ )
+ })
+
+ it('keeps the space that makes a descendant pseudo-class', () => {
+ expect(minifyCss('a :hover { x: y }')).toBe('a :hover{x:y}')
+ })
+
+ it('drops empty declarations', () => {
+ expect(minifyCss('a { b: c;; ; d: e; }')).toBe('a{b:c;d:e}')
+ })
+
+ it('keeps the space that ends a hex escape', () => {
+ // `.\31 0` is the class "10"; a second space would be a descendant combinator.
+ expect(minifyCss('.\\31 0 .x { a: b }')).toBe('.\\31 0 .x{a:b}')
+ expect(minifyCss('.\\31 .x { a: b }')).toBe('.\\31 .x{a:b}')
+ })
+
+ it('is empty for blank input', () => {
+ expect(minifyCss(' \n\t ')).toBe('')
+ })
+
+ it('keeps the newline that ends a broken string', () => {
+ expect(minifyCss("a{b:'x\n c:d}")).toBe("a{b:'x\nc:d}")
+ })
+
+ // Idempotence catches the subtle failures: a space dropped or added on the
+ // first pass that changes how the second pass tokenizes the result.
+ it('is idempotent, even on garbage', () => {
+ const pieces = fc.constantFrom(...'a1 \n{};:()+-*/,>!"\'\\', '/*', '*/', '/*!', 'url')
+ const css = fc.array(pieces, { maxLength: 40 }).map((a) => a.join(''))
+ fc.assert(
+ fc.property(css, (s) => minifyCss(minifyCss(s)) === minifyCss(s)),
+ { numRuns: 3000 },
+ )
+ })
+})
diff --git a/packages/core/src/css/minify.ts b/packages/core/src/css/minify.ts
new file mode 100644
index 0000000..3f319fb
--- /dev/null
+++ b/packages/core/src/css/minify.ts
@@ -0,0 +1,146 @@
+// A space touching one of these never separates two tokens that would
+// otherwise merge, in a selector or a value.
+const TIGHT = new Set(['{', '}', ';', ',', '>', '~', '!'])
+const HEX = /[0-9a-fA-F]/
+const SPACE = /\s/
+
+/** Whether the run of whitespace between `prev` and `next` must stay as one space. */
+function needsSpace(prev: string, next: string, parenDepth: number): boolean {
+ if (TIGHT.has(prev) || TIGHT.has(next) || prev === '\n') return false
+ // `a :hover` (any descendant that is hovered) is not `a:hover`, so only the
+ // space after a colon can go. Likewise `and (` in a media query must keep
+ // its space, or `and(` turns into a function call.
+ if (prev === ':' || prev === '(' || next === ')') return false
+ // Inside parentheses `+` may be calc() arithmetic, where the spaces are
+ // mandatory: `calc(1px+2px)` is invalid. Outside, it is a combinator.
+ if (parenDepth === 0 && (prev === '+' || next === '+')) return false
+ return true
+}
+
+/**
+ * Index just past a string opened at `start`. An unescaped newline ends a CSS
+ * string (as a parse error); it is taken along, because collapsing it into a
+ * space would let the broken string swallow the next line.
+ */
+function scanString(s: string, start: number): number {
+ const quote = s[start]
+ let i = start + 1
+ while (i < s.length) {
+ const ch = s[i]!
+ if (ch === '\\') {
+ i += 2
+ continue
+ }
+ if (ch === quote) return i + 1
+ if (ch === '\n') return i + 1
+ i++
+ }
+ return s.length
+}
+
+/** Index just past a backslash escape at `start`, including the space that may end a hex escape. */
+function scanEscape(s: string, start: number): number {
+ let i = start + 1
+ if (i >= s.length) return i
+ if (!HEX.test(s[i]!)) return i + 1
+ const limit = i + 6
+ while (i < s.length && i < limit && HEX.test(s[i]!)) i++
+ // `\31 0` is ".10": that one space belongs to the escape, not to the layout.
+ if (i < s.length && SPACE.test(s[i]!)) i += s.startsWith('\r\n', i) ? 2 : 1
+ return i
+}
+
+/**
+ * Minify plain CSS by hand, without a parser:
+ *
+ * - comments go, except `/*! … *\/`, which by convention holds a licence;
+ * - whitespace collapses, and disappears where no token needs it;
+ * - strings, escapes and unquoted `url(…)` bodies are copied verbatim, so a
+ * data URI or a `content: "{"` survives untouched;
+ * - the last semicolon in each block goes, and so do empty declarations.
+ *
+ * SCSS and Less are not handled: `//` comments, interpolation and mixins need
+ * their own parsers, and minifying them is the compiler's job.
+ */
+export function minifyCss(input: string): string {
+ let out = ''
+ let gap = false
+ let depth = 0
+
+ const emit = (piece: string) => {
+ if (gap && out !== '' && needsSpace(out.at(-1)!, piece[0]!, depth)) out += ' '
+ gap = false
+ out += piece
+ }
+
+ let i = 0
+ while (i < input.length) {
+ const ch = input[i]!
+
+ if (SPACE.test(ch)) {
+ while (i < input.length && SPACE.test(input[i]!)) i++
+ gap = true
+ continue
+ }
+
+ if (input.startsWith('/*', i)) {
+ const close = input.indexOf('*/', i + 2)
+ const end = close === -1 ? input.length : close + 2
+ const comment = input.slice(i, end)
+ if (comment.startsWith('/*!')) emit(comment)
+ // A dropped comment still separates what was either side of it:
+ // `1px/**/solid` must not become `1pxsolid`.
+ else gap = true
+ i = end
+ continue
+ }
+
+ if (ch === '"' || ch === "'") {
+ const end = scanString(input, i)
+ emit(input.slice(i, end))
+ i = end
+ continue
+ }
+
+ if (ch === '\\') {
+ const end = scanEscape(input, i)
+ emit(input.slice(i, end))
+ i = end
+ continue
+ }
+
+ // Only the tail is tested: an end-anchored regex over all of `out` would
+ // rescan it on every parenthesis.
+ if (ch === '(' && /(?:^|[^\w-])url$/i.test(out.slice(-4))) {
+ // An unquoted url() body is one token: its `;`, `,` and `+` are data.
+ let j = i + 1
+ while (j < input.length && SPACE.test(input[j]!)) j++
+ if (input[j] !== '"' && input[j] !== "'") {
+ let k = j
+ while (k < input.length && input[k] !== ')') k += input[k] === '\\' ? 2 : 1
+ // Unterminated: copy the rest as it is rather than invent a `)`.
+ const closed = k < input.length
+ emit(`(${input.slice(j, Math.min(k, input.length)).trimEnd()}${closed ? ')' : ''}`)
+ i = closed ? k + 1 : input.length
+ continue
+ }
+ }
+
+ if (ch === ';') {
+ // `;;` and `{;` are empty declarations.
+ gap = false
+ if (out.endsWith(';') || out.endsWith('{') || out === '') {
+ i++
+ continue
+ }
+ }
+ if (ch === '}' && out.endsWith(';')) out = out.slice(0, -1)
+ if (ch === '(') depth++
+ if (ch === ')') depth = Math.max(0, depth - 1)
+
+ emit(ch)
+ i++
+ }
+
+ return out
+}
diff --git a/packages/core/src/csv/index.ts b/packages/core/src/csv/index.ts
new file mode 100644
index 0000000..d8e5ee8
--- /dev/null
+++ b/packages/core/src/csv/index.ts
@@ -0,0 +1,3 @@
+export * from './infer.ts'
+export * from './parse.ts'
+export * from './serialize.ts'
diff --git a/packages/core/src/csv/infer.ts b/packages/core/src/csv/infer.ts
new file mode 100644
index 0000000..45c0239
--- /dev/null
+++ b/packages/core/src/csv/infer.ts
@@ -0,0 +1,63 @@
+export type ColumnType = 'number' | 'boolean' | 'date' | 'text' | 'empty'
+
+// Plain decimal or scientific, with an optional sign.
+const NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/
+// `1,50` (European decimal comma) and `1,234,567.89` (grouped). As a hint,
+// "number" is right either way; which one it is would be a guess.
+const DECIMAL_COMMA = /^[+-]?\d+,\d+$/
+const GROUPED = /^[+-]?\d{1,3}(?:[, ]\d{3})+(?:\.\d+)?$/
+const BOOLEAN = /^(?:true|false|yes|no)$/i
+const ISO_DATE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:[.,]\d+)?)?(?:Z|[+-]\d{2}(?::?\d{2})?)?)?$/
+// 15/01/2024, 1.5.24, 01-15-2024, optionally with a time.
+const DMY_DATE = /^\d{1,2}[/.-]\d{1,2}[/.-](?:\d{2}|\d{4})(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?$/
+const YMD_SLASH = /^\d{4}[/.]\d{1,2}[/.]\d{1,2}$/
+
+function isNumber(v: string): boolean {
+ return NUMBER.test(v) || DECIMAL_COMMA.test(v) || GROUPED.test(v)
+}
+
+function isDate(v: string): boolean {
+ return ISO_DATE.test(v) || DMY_DATE.test(v) || YMD_SLASH.test(v)
+}
+
+/** What one non-empty value looks like. */
+export function valueType(value: string): Exclude {
+ const v = value.trim()
+ if (isNumber(v)) return 'number'
+ if (BOOLEAN.test(v)) return 'boolean'
+ if (isDate(v)) return 'date'
+ return 'text'
+}
+
+export const TYPE_SAMPLE_ROWS = 1000
+
+/**
+ * A per-column hint: the one type every non-empty sampled cell agrees on, or
+ * `text` when they disagree. Empty cells are ignored, since a gap in a number
+ * column is still a number column. Only the first `sample` rows are read: it is
+ * a hint for the header, and a 10 MB file should not pay a regex per cell.
+ */
+export function inferColumnTypes(
+ rows: readonly (readonly string[])[],
+ columns: number,
+ sample = TYPE_SAMPLE_ROWS,
+): ColumnType[] {
+ const types: ColumnType[] = Array.from({ length: columns }, () => 'empty')
+ const limit = Math.min(rows.length, sample)
+ for (let c = 0; c < columns; c++) {
+ let t: ColumnType = 'empty'
+ for (let r = 0; r < limit; r++) {
+ const cell = rows[r]![c] ?? ''
+ if (cell.trim() === '') continue
+ const vt = valueType(cell)
+ if (t === 'empty') t = vt
+ else if (t !== vt) {
+ t = 'text'
+ break
+ }
+ if (t === 'text') break
+ }
+ types[c] = t
+ }
+ return types
+}
diff --git a/packages/core/src/csv/parse.test.ts b/packages/core/src/csv/parse.test.ts
new file mode 100644
index 0000000..315fe49
--- /dev/null
+++ b/packages/core/src/csv/parse.test.ts
@@ -0,0 +1,292 @@
+import fc from 'fast-check'
+import { describe, expect, it } from 'vitest'
+import { MAX_ISSUES, parseCsv, sniffDelimiter, tokenize, type Delimiter } from './parse.ts'
+import { toDelimited } from './serialize.ts'
+
+const rows = (text: string, delimiter: Delimiter = ',') => tokenize(text, delimiter).records
+
+describe('RFC 4180 examples (section 2)', () => {
+ it('1: records end with CRLF', () => {
+ expect(rows('aaa,bbb,ccc\r\nzzz,yyy,xxx\r\n')).toEqual([
+ ['aaa', 'bbb', 'ccc'],
+ ['zzz', 'yyy', 'xxx'],
+ ])
+ })
+
+ it('2: the last record may omit the line break', () => {
+ expect(rows('aaa,bbb,ccc\r\nzzz,yyy,xxx')).toEqual([
+ ['aaa', 'bbb', 'ccc'],
+ ['zzz', 'yyy', 'xxx'],
+ ])
+ })
+
+ it('3: an optional header line', () => {
+ const t = parseCsv('field_name,field_name2,field_name3\r\naaa,bbb,ccc\r\n', { delimiter: ',' })
+ expect(t.header).toEqual(['field_name', 'field_name2', 'field_name3'])
+ expect(t.rows).toEqual([['aaa', 'bbb', 'ccc']])
+ })
+
+ it('4: spaces are part of a field', () => {
+ expect(rows(' aaa , bbb ')).toEqual([[' aaa ', ' bbb ']])
+ })
+
+ it('5: fields may be quoted', () => {
+ expect(rows('"aaa","bbb","ccc"\r\nzzz,yyy,xxx')).toEqual([
+ ['aaa', 'bbb', 'ccc'],
+ ['zzz', 'yyy', 'xxx'],
+ ])
+ })
+
+ it('6: quoted fields hold line breaks and delimiters', () => {
+ expect(rows('"aaa","b\r\nbb","c,cc"\r\nzzz,yyy,xxx')).toEqual([
+ ['aaa', 'b\r\nbb', 'c,cc'],
+ ['zzz', 'yyy', 'xxx'],
+ ])
+ })
+
+ it('7: a doubled quote inside quotes is one quote', () => {
+ expect(rows('"aaa","b""bb","ccc"')).toEqual([['aaa', 'b"bb', 'ccc']])
+ expect(rows('""""')).toEqual([['"']])
+ expect(rows('"",x')).toEqual([['', 'x']])
+ })
+})
+
+describe('line endings and edges', () => {
+ it('reads LF, CRLF and lone CR alike', () => {
+ const want = [
+ ['a', 'b'],
+ ['c', 'd'],
+ ['e', 'f'],
+ ]
+ expect(rows('a,b\nc,d\ne,f')).toEqual(want)
+ expect(rows('a,b\r\nc,d\r\ne,f\r\n')).toEqual(want)
+ expect(rows('a,b\rc,d\re,f\r')).toEqual(want)
+ expect(rows('a,b\r\nc,d\re,f\n')).toEqual(want)
+ })
+
+ it('never leaves a \\r glued to a field', () => {
+ expect(rows('a,b\r\n').flat()).toEqual(['a', 'b'])
+ })
+
+ it('does not invent a row from a trailing newline', () => {
+ expect(rows('a,b\n')).toHaveLength(1)
+ expect(rows('a,b\r\n')).toHaveLength(1)
+ })
+
+ it('reads a trailing delimiter as an empty last field', () => {
+ expect(rows('a,\nb,')).toEqual([
+ ['a', ''],
+ ['b', ''],
+ ])
+ })
+
+ it('handles empty input', () => {
+ const t = parseCsv('')
+ expect(t.rows).toEqual([])
+ expect(t.header).toBeNull()
+ expect(t.columns).toBe(0)
+ expect(t.issues).toEqual([])
+ })
+
+ it('skips blank lines in a multi-column file, and counts them', () => {
+ const t = tokenize('a,b\n\n\r\nc,d\n', ',')
+ expect(t.records).toEqual([
+ ['a', 'b'],
+ ['c', 'd'],
+ ])
+ expect(t.blankLines).toBe(2)
+ })
+
+ it('keeps blank lines as empty values in a one-column file', () => {
+ expect(rows('a\n\nb\n')).toEqual([['a'], [''], ['b']])
+ })
+
+ it('keeps a quote inside an unquoted field', () => {
+ expect(rows(`5'10",tall`)).toEqual([[`5'10"`, 'tall']])
+ })
+})
+
+describe('BOM', () => {
+ it('is stripped and reported', () => {
+ const t = parseCsv('name,age\nann,3', { delimiter: 'auto' })
+ expect(t.bom).toBe(true)
+ expect(t.header).toEqual(['name', 'age'])
+ expect(t.rows).toEqual([['ann', '3']])
+ })
+
+ it('keeps offsets pointing into the original text', () => {
+ const input = 'a,b\n"x,y'
+ const t = parseCsv(input, { delimiter: ',', header: false })
+ const issue = t.issues.find((i) => i.kind === 'unterminated-quote')!
+ expect(input[issue.offset]).toBe('"')
+ })
+})
+
+describe('malformed quotes', () => {
+ it('reports an unterminated quote at its position and keeps parsing', () => {
+ const input = 'a,b\nc,"d\ne,f\n'
+ const t = parseCsv(input, { delimiter: ',', header: false })
+ const issue = t.issues[0]!
+ expect(issue).toMatchObject({ kind: 'unterminated-quote', line: 2, column: 3, row: 2 })
+ expect(input[issue.offset]).toBe('"')
+ // The quote is literal, so the rest still splits into rows.
+ expect(t.rows).toEqual([
+ ['a', 'b'],
+ ['c', '"d'],
+ ['e', 'f'],
+ ])
+ })
+
+ it('stays linear when an unterminated quote is followed by quote pairs', () => {
+ const input = '"x,' + '"",'.repeat(50_000)
+ const start = performance.now()
+ const t = tokenize(input, ',')
+ expect(performance.now() - start).toBeLessThan(1000)
+ expect(t.records[0]![0]).toBe('"x')
+ expect(t.issues[0]!.kind).toBe('unterminated-quote')
+ })
+
+ it('keeps text after a closing quote, and reports it', () => {
+ const t = tokenize('"a"b,c', ',')
+ expect(t.records).toEqual([['ab', 'c']])
+ expect(t.issues[0]).toMatchObject({ kind: 'text-after-quote', line: 1, column: 4 })
+ })
+
+ it('reports line numbers past quoted newlines', () => {
+ const t = tokenize('"a\nb",c\n"d\r\ne",f\n"g"h', ',')
+ expect(t.issues[0]).toMatchObject({ kind: 'text-after-quote', line: 5, column: 4 })
+ })
+})
+
+describe('ragged rows', () => {
+ it('reports each ragged row with expected vs got, and pads', () => {
+ const t = parseCsv('a,b,c\n1,2\n3,4,5\n6\n', { delimiter: ',', header: true })
+ expect(t.columns).toBe(3)
+ expect(t.rows).toEqual([
+ ['1', '2', ''],
+ ['3', '4', '5'],
+ ['6', '', ''],
+ ])
+ expect(t.issues.map((i) => [i.kind, i.row, i.line, i.expected, i.got])).toEqual([
+ ['ragged', 1, 2, 3, 2],
+ ['ragged', 3, 4, 3, 1],
+ ])
+ })
+
+ it('widens the table rather than drop extra cells', () => {
+ const t = parseCsv('a,b\n1,2,3\n', { delimiter: ',', header: true })
+ expect(t.columns).toBe(3)
+ expect(t.header).toEqual(['a', 'b', ''])
+ expect(t.rows).toEqual([['1', '2', '3']])
+ expect(t.issues[0]).toMatchObject({ kind: 'ragged', row: 1, expected: 2, got: 3 })
+ })
+
+ it('numbers rows from 1 when there is no header', () => {
+ const t = parseCsv('a,b\n1\n', { delimiter: ',', header: false })
+ expect(t.issues[0]).toMatchObject({ row: 2, line: 2 })
+ })
+
+ it('caps stored issues but counts all of them', () => {
+ const input = 'a,b\n' + '1\n'.repeat(MAX_ISSUES + 5)
+ const t = parseCsv(input, { delimiter: ',' })
+ expect(t.issues).toHaveLength(MAX_ISSUES)
+ expect(t.issueCount).toBe(MAX_ISSUES + 5)
+ })
+})
+
+describe('header', () => {
+ it('reports repeated column names first', () => {
+ const t = parseCsv('id,name,name\n1\n', { delimiter: ',' })
+ expect(t.issues.map((i) => i.kind)).toEqual(['duplicate-header', 'ragged'])
+ expect(t.issues[0]!.message).toContain('Column 3')
+ })
+
+ it('a header-only file has no rows', () => {
+ const t = parseCsv('a,b\n', { delimiter: ',' })
+ expect(t.header).toEqual(['a', 'b'])
+ expect(t.rows).toEqual([])
+ })
+})
+
+describe('sniffDelimiter', () => {
+ it('picks comma, tab, semicolon and pipe', () => {
+ expect(sniffDelimiter('a,b,c\n1,2,3\n')).toEqual({ delimiter: ',', confident: true })
+ expect(sniffDelimiter('a\tb\n1\t2\n').delimiter).toBe('\t')
+ expect(sniffDelimiter('a;b\n1;2\n').delimiter).toBe(';')
+ expect(sniffDelimiter('a|b\n1|2\n').delimiter).toBe('|')
+ })
+
+ it('picks semicolon in European CSV with decimal commas', () => {
+ const eu = 'Produkt;Preis;Menge\nÄpfel;1,50;3\nBirnen;2,25;10\nKirschen;12,00;1\n'
+ expect(sniffDelimiter(eu).delimiter).toBe(';')
+ const t = parseCsv(eu)
+ expect(t.delimiter).toBe(';')
+ expect(t.detected).toBe(true)
+ expect(t.rows[0]).toEqual(['Äpfel', '1,50', '3'])
+ })
+
+ it('prefers semicolon when every row also splits evenly on commas', () => {
+ expect(sniffDelimiter('1,5;2,5\n3,5;4,5\n').delimiter).toBe(';')
+ })
+
+ it('is not fooled by delimiters inside quotes', () => {
+ const text = 'name,notes\n"a","x;y;z"\n"b","p;q;r"\n"c","m;n;o"\n'
+ expect(sniffDelimiter(text).delimiter).toBe(',')
+ })
+
+ it('reads a quoted newline as part of the field, not a new record', () => {
+ expect(sniffDelimiter('a;b\n"multi\nline";2\nc;3\n').delimiter).toBe(';')
+ })
+
+ it('falls back to comma, unconfident, for a single column', () => {
+ expect(sniffDelimiter('just\none\ncolumn\n')).toEqual({ delimiter: ',', confident: false })
+ })
+
+ it('ignores a BOM', () => {
+ expect(sniffDelimiter('a;b\n1;2').delimiter).toBe(';')
+ })
+
+ it('a manual delimiter overrides sniffing', () => {
+ const t = parseCsv('a;b,c\n1;2,3\n', { delimiter: ',' })
+ expect(t.detected).toBe(false)
+ expect(t.header).toEqual(['a;b', 'c'])
+ })
+})
+
+describe('round trip: parse → serialise → parse', () => {
+ const cell = fc.string({
+ unit: fc.constantFrom('a', 'Z', '1', ' ', ',', ';', '|', '\t', '"', '\n', '\r', 'é', '🎉', ''),
+ maxLength: 6,
+ })
+ const table = fc
+ .integer({ min: 1, max: 5 })
+ .chain((w) => fc.array(fc.array(cell, { minLength: w, maxLength: w }), { maxLength: 12 }))
+ const delimiter = fc.constantFrom(',', '\t', ';', '|')
+
+ it('returns the same rows for any rectangular table', () => {
+ fc.assert(
+ fc.property(table, delimiter, (data, d) => {
+ const text = toDelimited({ header: null, rows: data }, d)
+ const back = parseCsv(text, { delimiter: d, header: false })
+ expect(back.rows).toEqual(data)
+ expect(back.issueCount).toBe(0)
+ }),
+ { numRuns: 500 },
+ )
+ })
+
+ it('is stable on any input, malformed or not', () => {
+ const text = fc.string({
+ unit: fc.constantFrom('a', ',', ';', '"', '\n', '\r', ' '),
+ maxLength: 60,
+ })
+ fc.assert(
+ fc.property(text, delimiter, (input, d) => {
+ const once = parseCsv(input, { delimiter: d, header: false })
+ const twice = parseCsv(toDelimited(once, d), { delimiter: d, header: false })
+ expect(twice.rows).toEqual(once.rows)
+ }),
+ { numRuns: 500 },
+ )
+ })
+})
diff --git a/packages/core/src/csv/parse.ts b/packages/core/src/csv/parse.ts
new file mode 100644
index 0000000..f035168
--- /dev/null
+++ b/packages/core/src/csv/parse.ts
@@ -0,0 +1,368 @@
+export type Delimiter = ',' | '\t' | ';' | '|'
+
+/** In sniffing priority order for ties; see `sniffDelimiter`. */
+export const DELIMITERS: readonly Delimiter[] = ['\t', ';', '|', ',']
+
+export type CsvIssueKind =
+ | 'unterminated-quote'
+ | 'text-after-quote'
+ | 'ragged'
+ | 'duplicate-header'
+
+export interface CsvIssue {
+ kind: CsvIssueKind
+ /** 1-based line in the input. */
+ line: number
+ /** 1-based column on that line, in UTF-16 units. */
+ column: number
+ /** UTF-16 offset into the input as given (BOM included), for selecting it. */
+ offset: number
+ /** Table row as displayed: 1-based data row, 0 for the header row. */
+ row: number
+ message: string
+ /** Ragged rows only: the field count every row is checked against. */
+ expected?: number
+ /** Ragged rows only: the field count this row actually has. */
+ got?: number
+}
+
+/**
+ * Stored issue details are capped: a 10 MB file where every row is ragged
+ * would otherwise build a million issue objects nobody reads. `issueCount`
+ * still counts them all.
+ */
+export const MAX_ISSUES = 1000
+
+const QUOTE = 34
+const LF = 10
+const CR = 13
+const BOM = ''
+
+export interface Tokenized {
+ records: string[][]
+ issues: CsvIssue[]
+ issueCount: number
+ /** Empty lines that were skipped rather than read as a record. */
+ blankLines: number
+ /** Field count of the first record, which every later record is checked against. */
+ expected: number
+ /** Widest record. */
+ width: number
+}
+
+interface TokenizeOpts {
+ /** Stop after this many records. Used by sniffing, which only needs a sample. */
+ maxRecords?: number
+ /** Only affects the `row` numbers on issues. */
+ header?: boolean
+ /** Added to every reported offset, so offsets point into the text before the BOM was cut. */
+ baseOffset?: number
+}
+
+/** Index of the next delimiter, CR or LF at or after `from`, or the end. */
+function fieldEnd(text: string, from: number, d: number): number {
+ const n = text.length
+ let i = from
+ while (i < n) {
+ const c = text.charCodeAt(i)
+ if (c === d || c === LF || c === CR) break
+ i++
+ }
+ return i
+}
+
+/**
+ * RFC 4180 tokenizer, single pass, linear time.
+ *
+ * Deliberately lenient where real files are not RFC-clean, and says so rather
+ * than throwing:
+ * - CRLF, LF and lone CR all end a record (old Mac exports still exist).
+ * - An unterminated quote is reported at its position and then read as a
+ * literal `"`, so one stray quote cannot swallow the rest of a 10 MB file
+ * into a single cell. This stays linear: a scan that runs off the end has
+ * seen only even runs of quotes after the opener, so any later field that
+ * opens a quote closes within its own run.
+ * - Text after a closing quote (`"a"b`) is kept and reported.
+ * - A quote inside an unquoted field (`5'10"`) is kept as-is.
+ * - Empty lines are skipped (and counted), except in a one-column file, where
+ * an empty line is a legitimately empty value.
+ * - A trailing newline ends the last record; it does not start an empty one.
+ */
+export function tokenize(text: string, delimiter: Delimiter, opts: TokenizeOpts = {}): Tokenized {
+ const maxRecords = opts.maxRecords ?? Infinity
+ const header = opts.header ?? false
+ const base = opts.baseOffset ?? 0
+ const n = text.length
+ const d = delimiter.charCodeAt(0)
+
+ const records: string[][] = []
+ const issues: CsvIssue[] = []
+ let issueCount = 0
+ let blankLines = 0
+ let expected = -1
+ let width = 0
+
+ let pos = 0
+ let line = 1
+ let lineStart = 0
+
+ const rowOf = (index: number) => (header ? index : index + 1)
+ const report = (issue: CsvIssue) => {
+ issueCount++
+ if (issues.length < MAX_ISSUES) issues.push(issue)
+ }
+
+ while (pos < n && records.length < maxRecords) {
+ const first = text.charCodeAt(pos)
+ if (first === CR || first === LF) {
+ if (expected === 1) records.push([''])
+ else blankLines++
+ pos += first === CR && text.charCodeAt(pos + 1) === LF ? 2 : 1
+ line++
+ lineStart = pos
+ continue
+ }
+
+ const recStart = pos
+ const recLine = line
+ const recColumn = pos - lineStart + 1
+ const record: string[] = []
+
+ for (;;) {
+ let value: string
+ if (pos < n && text.charCodeAt(pos) === QUOTE) {
+ const open = pos
+ const openLine = line
+ const openLineStart = lineStart
+ let acc = ''
+ let seg = pos + 1
+ let i = pos + 1
+ let close = -1
+ while (i < n) {
+ const c = text.charCodeAt(i)
+ if (c === QUOTE) {
+ if (text.charCodeAt(i + 1) === QUOTE) {
+ // `""` is one literal quote: keep the first, skip the second.
+ acc += text.slice(seg, i + 1)
+ i += 2
+ seg = i
+ continue
+ }
+ close = i
+ break
+ }
+ if (c === LF) {
+ line++
+ lineStart = i + 1
+ } else if (c === CR && text.charCodeAt(i + 1) !== LF) {
+ line++
+ lineStart = i + 1
+ }
+ i++
+ }
+
+ if (close >= 0) {
+ value = acc + text.slice(seg, close)
+ pos = close + 1
+ if (pos < n) {
+ const c = text.charCodeAt(pos)
+ if (c !== d && c !== CR && c !== LF) {
+ report({
+ kind: 'text-after-quote',
+ line,
+ column: pos - lineStart + 1,
+ offset: base + pos,
+ row: rowOf(records.length),
+ message: 'Text after a closing quote; kept as part of the field',
+ })
+ const end = fieldEnd(text, pos, d)
+ value += text.slice(pos, end)
+ pos = end
+ }
+ }
+ } else {
+ // Rewind the line count the failed scan advanced, then re-read the
+ // field as unquoted text starting at the quote itself.
+ line = openLine
+ lineStart = openLineStart
+ report({
+ kind: 'unterminated-quote',
+ line,
+ column: open - lineStart + 1,
+ offset: base + open,
+ row: rowOf(records.length),
+ message: 'This quote is never closed; read it as a literal quote character',
+ })
+ const end = fieldEnd(text, open, d)
+ value = text.slice(open, end)
+ pos = end
+ }
+ } else {
+ const end = fieldEnd(text, pos, d)
+ value = text.slice(pos, end)
+ pos = end
+ }
+
+ record.push(value)
+ if (pos < n && text.charCodeAt(pos) === d) {
+ pos++
+ continue
+ }
+ break
+ }
+
+ if (pos < n) {
+ const c = text.charCodeAt(pos)
+ pos += c === CR && text.charCodeAt(pos + 1) === LF ? 2 : 1
+ line++
+ lineStart = pos
+ }
+
+ const index = records.length
+ records.push(record)
+ if (record.length > width) width = record.length
+ if (expected === -1) {
+ expected = record.length
+ } else if (record.length !== expected) {
+ const fewer = record.length < expected
+ report({
+ kind: 'ragged',
+ line: recLine,
+ column: recColumn,
+ offset: base + recStart,
+ row: rowOf(index),
+ expected,
+ got: record.length,
+ message: fewer
+ ? `${record.length} of ${expected} fields; padded with empty cells`
+ : `${record.length} fields, expected ${expected}; the extra cells get columns of their own`,
+ })
+ }
+ }
+
+ return { records, issues, issueCount, blankLines, expected: Math.max(expected, 0), width }
+}
+
+export interface SniffResult {
+ delimiter: Delimiter
+ /** False when no candidate split the sample into two or more fields. */
+ confident: boolean
+}
+
+const SNIFF_RECORDS = 50
+
+/**
+ * Pick the delimiter that gives the most consistent field count over the first
+ * ~50 records, read quote-aware so a newline inside quotes does not count.
+ *
+ * Consistency, not field count, is what decides: in European CSV
+ * (`Name;Price\nApple;1,50`) commas split every row too, but the header has a
+ * different count than the rows. On a true tie, tab beats semicolon beats pipe
+ * beats comma, because commas are the delimiter most likely to also occur in
+ * the data (decimal commas, prose), and tabs the least.
+ */
+export function sniffDelimiter(text: string): SniffResult {
+ const body = text.startsWith(BOM) ? text.slice(1) : text
+ let best: { delimiter: Delimiter; score: number } | undefined
+ for (const delimiter of DELIMITERS) {
+ const { records } = tokenize(body, delimiter, { maxRecords: SNIFF_RECORDS })
+ if (records.length === 0) continue
+ const counts = new Map()
+ for (const r of records) counts.set(r.length, (counts.get(r.length) ?? 0) + 1)
+ let mode = 0
+ let modeCount = 0
+ for (const [width, count] of counts) {
+ if (count > modeCount || (count === modeCount && width > mode)) {
+ mode = width
+ modeCount = count
+ }
+ }
+ if (mode < 2) continue
+ const score = modeCount / records.length
+ // Strictly greater: DELIMITERS is in tie-break order.
+ if (best === undefined || score > best.score) best = { delimiter, score }
+ }
+ return best === undefined
+ ? { delimiter: ',', confident: false }
+ : { delimiter: best.delimiter, confident: true }
+}
+
+export interface CsvOptions {
+ /** `'auto'` sniffs; see `sniffDelimiter`. */
+ delimiter: Delimiter | 'auto'
+ /** Treat the first record as column names. */
+ header: boolean
+}
+
+export interface CsvTable {
+ delimiter: Delimiter
+ /** True when the delimiter was sniffed rather than given. */
+ detected: boolean
+ /** A UTF-8 BOM was present and stripped. */
+ bom: boolean
+ /** Column names, padded with `''` to `columns`. Null when `header` is off. */
+ header: string[] | null
+ /** Data rows, every one padded with `''` to `columns`. */
+ rows: string[][]
+ /** Width of the widest record, so no cell is ever dropped. */
+ columns: number
+ blankLines: number
+ issues: CsvIssue[]
+ issueCount: number
+}
+
+export const DEFAULT_CSV_OPTIONS: CsvOptions = { delimiter: 'auto', header: true }
+
+/** Parse CSV/TSV text into a rectangular table. Never throws; problems come back as `issues`. */
+export function parseCsv(input: string, options: Partial = {}): CsvTable {
+ const opts = { ...DEFAULT_CSV_OPTIONS, ...options }
+ const bom = input.startsWith(BOM)
+ const text = bom ? input.slice(1) : input
+ const detected = opts.delimiter === 'auto'
+ const delimiter = opts.delimiter === 'auto' ? sniffDelimiter(text).delimiter : opts.delimiter
+
+ const tok = tokenize(text, delimiter, { header: opts.header, baseOffset: bom ? 1 : 0 })
+ const columns = tok.width
+ const records = tok.records
+ for (const r of records) while (r.length < columns) r.push('')
+
+ const header = opts.header ? (records.shift() ?? null) : null
+ // Header problems come first, so the list reads top to bottom.
+ const headerIssues: CsvIssue[] = []
+ if (header !== null) {
+ // Reported because JSON output has to rename them, which changes the shape
+ // of every object; see `jsonKeys`.
+ const seen = new Map()
+ header.forEach((name, i) => {
+ if (name === '') return
+ const prev = seen.get(name)
+ if (prev === undefined) {
+ seen.set(name, i)
+ return
+ }
+ headerIssues.push({
+ kind: 'duplicate-header',
+ line: 1,
+ column: 1,
+ offset: bom ? 1 : 0,
+ row: 0,
+ message: `Column ${i + 1} repeats the name "${name}" from column ${prev + 1}`,
+ })
+ })
+ }
+ const issues =
+ headerIssues.length === 0 ? tok.issues : headerIssues.concat(tok.issues).slice(0, MAX_ISSUES)
+ const issueCount = tok.issueCount + headerIssues.length
+
+ return {
+ delimiter,
+ detected,
+ bom,
+ header,
+ rows: records,
+ columns,
+ blankLines: tok.blankLines,
+ issues,
+ issueCount,
+ }
+}
diff --git a/packages/core/src/csv/serialize.test.ts b/packages/core/src/csv/serialize.test.ts
new file mode 100644
index 0000000..587078e
--- /dev/null
+++ b/packages/core/src/csv/serialize.test.ts
@@ -0,0 +1,151 @@
+import { describe, expect, it } from 'vitest'
+import { inferColumnTypes, valueType } from './infer.ts'
+import { formatTable, jsonKeys, quoteField, toDelimited, toJson, toMarkdown } from './serialize.ts'
+
+describe('quoteField', () => {
+ it('quotes only when needed', () => {
+ expect(quoteField('plain', ',')).toBe('plain')
+ expect(quoteField('a,b', ',')).toBe('"a,b"')
+ expect(quoteField('a,b', '\t')).toBe('a,b')
+ expect(quoteField('a\tb', '\t')).toBe('"a\tb"')
+ expect(quoteField('say "hi"', ',')).toBe('"say ""hi"""')
+ expect(quoteField('two\nlines', ';')).toBe('"two\nlines"')
+ expect(quoteField('cr\r', '|')).toBe('"cr\r"')
+ expect(quoteField('x', ',')).toBe('"x"')
+ })
+})
+
+describe('toDelimited', () => {
+ it('writes the header first and ends with a newline', () => {
+ expect(toDelimited({ header: ['a', 'b'], rows: [['1', '2,3']] }, ',')).toBe('a,b\n1,"2,3"\n')
+ })
+
+ it('converts semicolon CSV to TSV', () => {
+ expect(toDelimited({ header: null, rows: [['1,50', 'x']] }, '\t')).toBe('1,50\tx\n')
+ })
+
+ it('writes a lone empty field as "" so it is not read back as a blank line', () => {
+ expect(toDelimited({ header: null, rows: [['a'], [''], ['b']] }, ',')).toBe('a\n""\nb\n')
+ })
+
+ it('supports CRLF', () => {
+ expect(toDelimited({ header: null, rows: [['a'], ['b']] }, ',', '\r\n')).toBe('a\r\nb\r\n')
+ })
+
+ it('is empty for an empty table', () => {
+ expect(toDelimited({ header: null, rows: [] }, ',')).toBe('')
+ })
+})
+
+describe('jsonKeys', () => {
+ it('names empty columns and de-duplicates repeats', () => {
+ expect(jsonKeys(['id', '', 'name', 'name', 'name_2'])).toEqual([
+ 'id',
+ 'field2',
+ 'name',
+ 'name_2',
+ 'name_2_2',
+ ])
+ })
+})
+
+describe('toJson', () => {
+ it('writes objects with the header as keys, values as exact strings', () => {
+ const out = toJson({ header: ['id', 'price'], rows: [['007', '1,50']] })
+ expect(JSON.parse(out)).toEqual([{ id: '007', price: '1,50' }])
+ })
+
+ it('keeps column order even for integer-like keys', () => {
+ const out = toJson({ header: ['name', '2024', '2023'], rows: [['a', '1', '2']] })
+ expect(Object.keys(JSON.parse(out)[0] as object)).toEqual(['2023', '2024', 'name'])
+ // The text itself is in column order, which is what the user copies.
+ expect(out.indexOf('"name"')).toBeLessThan(out.indexOf('"2024"'))
+ expect(out.indexOf('"2024"')).toBeLessThan(out.indexOf('"2023"'))
+ })
+
+ it('writes arrays when there is no header', () => {
+ expect(JSON.parse(toJson({ header: null, rows: [['a', 'b']] }))).toEqual([['a', 'b']])
+ })
+
+ it('escapes what JSON needs escaped, and handles __proto__', () => {
+ const out = toJson({ header: ['__proto__', 'q'], rows: [['x', 'say "hi"\n']] })
+ const parsed = JSON.parse(out) as Record[]
+ expect(Object.hasOwn(parsed[0]!, '__proto__')).toBe(true)
+ expect(parsed[0]!.q).toBe('say "hi"\n')
+ })
+
+ it('is [] for no rows', () => {
+ expect(toJson({ header: ['a'], rows: [] })).toBe('[]')
+ })
+})
+
+describe('toMarkdown', () => {
+ it('escapes pipes and newlines', () => {
+ const md = toMarkdown({ header: ['a|b', 'c'], rows: [['x|y', 'line1\nline2']] })
+ expect(md).toContain('a\\|b')
+ expect(md).toContain('x\\|y')
+ expect(md).toContain('line1
line2')
+ expect(md.trimEnd().split('\n')).toHaveLength(3)
+ })
+
+ it('escapes a backslash before a pipe so the pipe stays escaped', () => {
+ expect(toMarkdown({ header: ['h'], rows: [['a\\|b']] })).toContain('a\\\\\\|b')
+ })
+
+ it('names columns when there is no header', () => {
+ const md = toMarkdown({ header: null, rows: [['1', '2']] })
+ expect(md.split('\n')[0]).toBe('| Column 1 | Column 2 |')
+ })
+
+ it('pads columns and right-aligns numbers', () => {
+ const md = toMarkdown({ header: ['name', 'n'], rows: [['ann', '7'], ['bob', '12']] }, ['text', 'number'])
+ expect(md).toBe(
+ ['| name | n |', '| ---- | --: |', '| ann | 7 |', '| bob | 12 |', ''].join('\n'),
+ )
+ })
+
+ it('is empty for an empty table', () => {
+ expect(toMarkdown({ header: null, rows: [] })).toBe('')
+ })
+})
+
+describe('formatTable', () => {
+ it('dispatches to each writer', () => {
+ const t = { header: ['a'], rows: [['1']] }
+ expect(formatTable(t, 'csv')).toBe('a\n1\n')
+ expect(formatTable(t, 'tsv')).toBe('a\n1\n')
+ expect(formatTable(t, 'json')).toContain('"a": "1"')
+ expect(formatTable(t, 'markdown')).toContain('| a |')
+ })
+})
+
+describe('type hints', () => {
+ it('classifies single values', () => {
+ expect(valueType('42')).toBe('number')
+ expect(valueType('-3.5e2')).toBe('number')
+ expect(valueType('1,50')).toBe('number')
+ expect(valueType('1,234,567.89')).toBe('number')
+ expect(valueType('TRUE')).toBe('boolean')
+ expect(valueType('no')).toBe('boolean')
+ expect(valueType('2024-01-15')).toBe('date')
+ expect(valueType('2024-01-15T10:30:00Z')).toBe('date')
+ expect(valueType('15/01/2024')).toBe('date')
+ expect(valueType('1.5.24')).toBe('date')
+ expect(valueType('hello')).toBe('text')
+ expect(valueType('1.2.3')).toBe('text')
+ })
+
+ it('agrees per column, ignoring empty cells', () => {
+ const rows = [
+ ['1', 'true', '2024-01-01', 'x', ''],
+ ['', 'false', '2024-02-01', '2', ''],
+ ['3.5', 'yes', '', 'y', ''],
+ ]
+ expect(inferColumnTypes(rows, 5)).toEqual(['number', 'boolean', 'date', 'text', 'empty'])
+ })
+
+ it('reads only the sample', () => {
+ const rows = [['1'], ['2'], ['text']]
+ expect(inferColumnTypes(rows, 1, 2)).toEqual(['number'])
+ })
+})
diff --git a/packages/core/src/csv/serialize.ts b/packages/core/src/csv/serialize.ts
new file mode 100644
index 0000000..0019e63
--- /dev/null
+++ b/packages/core/src/csv/serialize.ts
@@ -0,0 +1,143 @@
+import type { ColumnType } from './infer.ts'
+import type { Delimiter } from './parse.ts'
+
+/** The parts of a parsed table the writers need. */
+export interface TableData {
+ header: readonly string[] | null
+ rows: readonly (readonly string[])[]
+}
+
+export type CsvOutputFormat = 'json' | 'markdown' | 'csv' | 'tsv'
+
+const NEEDS_QUOTE: Record = {
+ ',': /[",\r\n]/,
+ '\t': /["\t\r\n]/,
+ ';': /[";\r\n]/,
+ '|': /["|\r\n]/,
+}
+
+/**
+ * Quote only when the field would not survive otherwise. A leading BOM is
+ * quoted too: unquoted at the very start of the output, a reader (ours
+ * included) would strip it as a byte-order mark.
+ */
+export function quoteField(value: string, delimiter: Delimiter): string {
+ if (NEEDS_QUOTE[delimiter].test(value) || value.charCodeAt(0) === 0xfeff) {
+ return `"${value.replaceAll('"', '""')}"`
+ }
+ return value
+}
+
+function writeRecord(record: readonly string[], delimiter: Delimiter): string {
+ // A one-field record holding '' would be an empty line, which readers skip.
+ if (record.length === 1 && record[0] === '') return '""'
+ return record.map((v) => quoteField(v, delimiter)).join(delimiter)
+}
+
+/**
+ * Re-serialise with minimal quoting. Ends with a newline, which is a line
+ * terminator here, not an extra empty row.
+ */
+export function toDelimited(table: TableData, delimiter: Delimiter, eol: '\n' | '\r\n' = '\n'): string {
+ const out: string[] = []
+ if (table.header !== null) out.push(writeRecord(table.header, delimiter))
+ for (const r of table.rows) out.push(writeRecord(r, delimiter))
+ return out.length === 0 ? '' : out.join(eol) + eol
+}
+
+/**
+ * JSON object keys for a header row. Empty names become `field3` and repeats
+ * become `name_2`, because an object cannot hold two values under one key and
+ * silently dropping a column is the worse outcome.
+ */
+export function jsonKeys(header: readonly string[]): string[] {
+ const used = new Set()
+ return header.map((name, i) => {
+ const base = name === '' ? `field${i + 1}` : name
+ let key = base
+ for (let k = 2; used.has(key); k++) key = `${base}_${k}`
+ used.add(key)
+ return key
+ })
+}
+
+/**
+ * Values stay strings: `007`, `1,50` and 20-digit IDs must come out exactly as
+ * they went in. Objects are written by hand rather than via JSON.stringify of
+ * an object, which would reorder integer-like keys (`2023`, `2024`) ahead of
+ * the rest and so scramble the column order.
+ */
+export function toJson(table: TableData, indent = 2): string {
+ if (table.rows.length === 0) return '[]'
+ const pad = ' '.repeat(indent)
+ const nl = indent > 0 ? '\n' : ''
+ const sep = indent > 0 ? ': ' : ':'
+ const out: string[] = []
+ if (table.header === null) {
+ for (const r of table.rows) out.push(pad + JSON.stringify(r))
+ } else {
+ const keys = jsonKeys(table.header).map((k) => JSON.stringify(k))
+ for (const r of table.rows) {
+ const fields = keys.map((k, i) => pad + pad + k + sep + JSON.stringify(r[i] ?? ''))
+ out.push(`${pad}{${nl}${fields.join(`,${nl}`)}${nl}${pad}}`)
+ }
+ }
+ return `[${nl}${out.join(`,${nl}`)}${nl}]`
+}
+
+function markdownCell(value: string): string {
+ // A pipe would end the cell and a newline would end the table. Backslash
+ // first, so the escape we add is not itself re-escaped by a later pass.
+ return value.replaceAll('\\', '\\\\').replaceAll('|', '\\|').replace(/\r\n|\r|\n/g, '
')
+}
+
+/** Padding stops here, so one long cell does not widen every row of the table. */
+const MAX_PAD = 40
+
+/**
+ * A GitHub-flavoured Markdown table. Markdown requires a header row, so with
+ * the header off the columns are named `Column 1`, `Column 2`, …. Number
+ * columns are right-aligned when `types` is given.
+ */
+export function toMarkdown(table: TableData, types: readonly ColumnType[] = []): string {
+ const columns = Math.max(table.header?.length ?? 0, ...table.rows.slice(0, 1).map((r) => r.length))
+ if (columns === 0) return ''
+ const head = (table.header ?? Array.from({ length: columns }, (_, i) => `Column ${i + 1}`)).map(
+ markdownCell,
+ )
+ const body = table.rows.map((r) => r.map(markdownCell))
+
+ const widths = head.map((h) => Math.min(Math.max(h.length, 3), MAX_PAD))
+ for (const r of body) {
+ for (let c = 0; c < columns; c++) {
+ const len = r[c]?.length ?? 0
+ if (len > widths[c]! && widths[c]! < MAX_PAD) widths[c] = Math.min(len, MAX_PAD)
+ }
+ }
+
+ const line = (cells: readonly string[]) =>
+ `| ${widths.map((w, c) => {
+ const v = cells[c] ?? ''
+ return types[c] === 'number' ? v.padStart(w) : v.padEnd(w)
+ }).join(' | ')} |`
+
+ const rule = `| ${widths
+ .map((w, c) => (types[c] === 'number' ? `${'-'.repeat(w - 1)}:` : '-'.repeat(w)))
+ .join(' | ')} |`
+
+ return [line(head), rule, ...body.map(line)].join('\n') + '\n'
+}
+
+/** One entry point for the text outputs. */
+export function formatTable(table: TableData, format: CsvOutputFormat, types: readonly ColumnType[] = []): string {
+ switch (format) {
+ case 'json':
+ return toJson(table)
+ case 'markdown':
+ return toMarkdown(table, types)
+ case 'csv':
+ return toDelimited(table, ',')
+ case 'tsv':
+ return toDelimited(table, '\t')
+ }
+}
diff --git a/packages/core/src/env/collect.ts b/packages/core/src/env/collect.ts
new file mode 100644
index 0000000..377393f
--- /dev/null
+++ b/packages/core/src/env/collect.ts
@@ -0,0 +1,61 @@
+import { issueId, type EnvDoc, type EnvEntry, type EnvIssue } from './types.ts'
+
+// Internal helpers shared by the parsers; not part of the public API.
+
+/**
+ * Collects entries with "last wins" semantics and a warning per repeat.
+ *
+ * A Map keeps a key at the position of its first definition while taking the
+ * value of its last, which is exactly what `Object.assign`-style loaders like
+ * dotenv end up with in `process.env`.
+ */
+export class EntryCollector {
+ readonly #byKey = new Map()
+ readonly issues: EnvIssue[] = []
+
+ add(entry: EnvEntry): void {
+ const prev = this.#byKey.get(entry.key)
+ if (prev !== undefined) {
+ this.issues.push({
+ severity: 'warning',
+ line: entry.line,
+ message: `${entry.key} is already set on line ${prev.line}; this later value wins.`,
+ })
+ }
+ this.#byKey.set(entry.key, entry)
+ }
+
+ error(line: number, message: string): void {
+ this.issues.push({ severity: 'error', line, message })
+ }
+
+ warn(line: number, message: string): void {
+ this.issues.push({ severity: 'warning', line, message })
+ }
+
+ done(): EnvDoc {
+ return { entries: [...this.#byKey.values()], issues: sortIssues(this.issues) }
+ }
+}
+
+/**
+ * By line, stable, so issues read top to bottom like the input. An exact
+ * repeat (`{"A": {}, "A": {}}` on one line) says nothing new, so it is dropped,
+ * which also makes severity + line + message a unique key for a list.
+ */
+export function sortIssues(issues: readonly EnvIssue[]): EnvIssue[] {
+ const seen = new Set()
+ return issues
+ .filter((i) => {
+ const id = issueId(i)
+ if (seen.has(id)) return false
+ seen.add(id)
+ return true
+ })
+ .toSorted((a, b) => a.line - b.line)
+}
+
+/** Keys are user text too; keep a pasted megabyte out of an error message. */
+export function shortKey(key: string): string {
+ return key.length > 40 ? `${key.slice(0, 40)}…` : key
+}
diff --git a/packages/core/src/env/convert.ts b/packages/core/src/env/convert.ts
new file mode 100644
index 0000000..9c6c518
--- /dev/null
+++ b/packages/core/src/env/convert.ts
@@ -0,0 +1,53 @@
+import { parseDotenv } from './dotenv.ts'
+import { parseJsonEnv } from './json.ts'
+import { serializeEnv, type EnvOutputFormat } from './serialize.ts'
+import { sortIssues } from './collect.ts'
+import type { EnvEntry, EnvIssue } from './types.ts'
+
+export type EnvInputFormat = 'dotenv' | 'json'
+
+/**
+ * JSON when the first non-blank character opens an object or array. A .env
+ * line can never start with `{` or `[` (it would be an invalid key), so there
+ * is no ambiguity to guess about.
+ */
+export function detectEnvFormat(text: string): EnvInputFormat {
+ const m = /^[\s]*([\s\S])/.exec(text)
+ return m !== null && (m[1] === '{' || m[1] === '[') ? 'json' : 'dotenv'
+}
+
+export interface ConvertEnvOptions {
+ from: EnvInputFormat | 'auto'
+ to: EnvOutputFormat
+ /** JSON input only: flatten nested objects into `A__B` keys. */
+ flatten: boolean
+}
+
+export interface ConvertEnvResult {
+ /** The input format actually used, after auto-detection. */
+ from: EnvInputFormat
+ entries: EnvEntry[]
+ output: string
+ /** Parse and serialize issues together, by input line. */
+ issues: EnvIssue[]
+}
+
+export function convertEnv(text: string, options: ConvertEnvOptions): ConvertEnvResult {
+ const from = options.from === 'auto' ? detectEnvFormat(text) : options.from
+ if (text.trim() === '') return { from, entries: [], output: '', issues: [] }
+
+ const doc = from === 'json' ? parseJsonEnv(text, { flatten: options.flatten }) : parseDotenv(text)
+ const out = serializeEnv(doc.entries, options.to)
+ return { from, entries: doc.entries, output: out.text, issues: sortIssues([...doc.issues, ...out.issues]) }
+}
+
+export const MASK = '••••'
+
+/**
+ * Same keys, every non-empty value replaced, for a preview that is safe to
+ * screen-share. An empty value stays empty: that it is empty is often the very
+ * thing being checked, and it reveals nothing.
+ */
+export function maskEntries(entries: readonly EnvEntry[]): EnvEntry[] {
+ return entries.map((e) => (e.value === '' ? e : { ...e, value: MASK }))
+}
diff --git a/packages/core/src/env/dotenv.ts b/packages/core/src/env/dotenv.ts
new file mode 100644
index 0000000..c724f4d
--- /dev/null
+++ b/packages/core/src/env/dotenv.ts
@@ -0,0 +1,139 @@
+import { parseLines } from '../list/lines.ts'
+import { EntryCollector, shortKey } from './collect.ts'
+import { ENV_KEY, type EnvDoc } from './types.ts'
+
+type Quote = '"' | "'" | '`'
+
+const QUOTE_NAME: Record = { '"': 'double quote', "'": 'single quote', '`': 'backtick' }
+
+const EXPORT = /^export[ \t]+/
+
+/**
+ * Parse a .env file the way `dotenv` reads one.
+ *
+ * - `KEY=value`, optional `export `, spaces around `=`, empty values.
+ * - Full-line `#` comments, and inline ones after an unquoted value.
+ * - Single quotes and backticks are literal; double quotes process `\n`, `\r`,
+ * `\t`, `\"` and `\\`, and any of the three may span lines.
+ * - CRLF, lone CR and a BOM are handled by parseLines, so no `\r` ever ends up
+ * glued to a value.
+ *
+ * One deliberate difference: an inline comment needs whitespace before its `#`,
+ * as in sh, Docker Compose and python-dotenv. Node's dotenv cuts `pass#word` to
+ * `pass`; we keep it and warn, because silently truncating a password is the
+ * worse failure.
+ */
+export function parseDotenv(text: string): EnvDoc {
+ const { lines } = parseLines(text)
+ const out = new EntryCollector()
+
+ let i = 0
+ while (i < lines.length) {
+ const line = i + 1
+ let s = lines[i]!.trimStart()
+ i++
+ if (s === '' || s.startsWith('#')) continue
+
+ const exp = EXPORT.exec(s)
+ if (exp !== null) s = s.slice(exp[0].length)
+
+ const eq = s.indexOf('=')
+ if (eq < 0) {
+ const bare = s.trimEnd()
+ // Only echo the text when it is a plausible key: a line with no `=` may
+ // well be a pasted secret.
+ out.error(
+ line,
+ ENV_KEY.test(bare)
+ ? `${shortKey(bare)} has no "=". Write ${shortKey(bare)}= for an empty value.`
+ : 'Expected KEY=value.',
+ )
+ continue
+ }
+
+ const key = s.slice(0, eq).trimEnd()
+ if (!ENV_KEY.test(key)) {
+ out.error(
+ line,
+ key === ''
+ ? 'Missing a key before "=".'
+ : `"${shortKey(key)}" is not a valid key. Use letters, digits, _, . and -, not starting with a digit.`,
+ )
+ continue
+ }
+
+ const rest = s.slice(eq + 1).trimStart()
+ const q = rest[0]
+
+ if (q === '"' || q === "'" || q === '`') {
+ const r = readQuoted(lines, i - 1, rest.slice(1), q)
+ if (r === null) {
+ out.error(line, `${key}: the ${QUOTE_NAME[q]} is never closed.`)
+ continue
+ }
+ const after = r.after.trim()
+ if (after !== '' && !after.startsWith('#')) {
+ out.error(line, `${key}: unexpected text after the closing ${QUOTE_NAME[q]}.`)
+ // The quoted value may have run over several lines; skip them all, or
+ // its body would be re-read as a stream of bogus entries.
+ i = r.next
+ continue
+ }
+ i = r.next
+ out.add({ key, value: q === '"' ? unescapeDouble(r.body) : r.body, line })
+ continue
+ }
+
+ const hash = rest.startsWith('#') ? 0 : rest.search(/\s#/)
+ const value = (hash < 0 ? rest : rest.slice(0, hash)).trim()
+ if (value.includes('#')) {
+ out.warn(line, `${key}: Node's dotenv cuts an unquoted value at "#"; quote it so every loader reads it whole.`)
+ }
+ out.add({ key, value, line })
+ }
+
+ return out.done()
+}
+
+/**
+ * Find the closing quote, pulling in following lines as needed.
+ * `start` is the index of the line the value opens on; `first` is that line's
+ * text after the opening quote. Returns null when the quote never closes.
+ */
+function readQuoted(
+ lines: readonly string[],
+ start: number,
+ first: string,
+ q: Quote,
+): { body: string; after: string; next: number } | null {
+ let buf = first
+ let next = start + 1
+ let p = 0
+ for (;;) {
+ while (p < buf.length) {
+ const ch = buf[p]
+ if (q === '"' && ch === '\\') {
+ // A backslash at the end of a line escapes the line break, so the
+ // next line must be pulled in before we can step over it.
+ if (p + 1 >= buf.length) break
+ p += 2
+ continue
+ }
+ if (ch === q) return { body: buf.slice(0, p), after: buf.slice(p + 1), next }
+ p++
+ }
+ if (next >= lines.length) return null
+ buf += `\n${lines[next]}`
+ next++
+ }
+}
+
+const ESCAPES: Record = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' }
+
+/**
+ * One left-to-right pass, so `\\n` is a backslash then `n`, never a newline.
+ * Unknown escapes such as `\$` keep their backslash, as dotenv does.
+ */
+function unescapeDouble(s: string): string {
+ return s.replace(/\\([\s\S])/g, (m, c: string) => ESCAPES[c] ?? m)
+}
diff --git a/packages/core/src/env/env.test.ts b/packages/core/src/env/env.test.ts
new file mode 100644
index 0000000..39bb57f
--- /dev/null
+++ b/packages/core/src/env/env.test.ts
@@ -0,0 +1,424 @@
+import fc from 'fast-check'
+import { describe, expect, it } from 'vitest'
+import { convertEnv, detectEnvFormat, maskEntries, MASK } from './convert.ts'
+import { parseDotenv } from './dotenv.ts'
+import { parseJsonEnv } from './json.ts'
+import { quoteDotenvValue, serializeEnv, shellQuote, toDockerEnv, toDotenv, toJson, toShell } from './serialize.ts'
+import { parseShellExports } from './shell.ts'
+import type { EnvDoc, EnvEntry } from './types.ts'
+
+const vars = (doc: EnvDoc) => Object.fromEntries(doc.entries.map((e) => [e.key, e.value]))
+const errors = (doc: { issues: EnvDoc['issues'] }) => doc.issues.filter((i) => i.severity === 'error')
+const warnings = (doc: { issues: EnvDoc['issues'] }) => doc.issues.filter((i) => i.severity === 'warning')
+const entries = (o: Record): EnvEntry[] =>
+ Object.entries(o).map(([key, value], i) => ({ key, value, line: i + 1 }))
+
+describe('parseDotenv', () => {
+ it('reads plain assignments, export, and spaces around =', () => {
+ const doc = parseDotenv('A=1\nexport B=two\n C = three \nD=\n')
+ expect(vars(doc)).toEqual({ A: '1', B: 'two', C: 'three', D: '' })
+ expect(doc.issues).toEqual([])
+ expect(doc.entries.map((e) => e.line)).toEqual([1, 2, 3, 4])
+ })
+
+ it('treats a key named export as a key', () => {
+ expect(vars(parseDotenv('export=1'))).toEqual({ export: '1' })
+ })
+
+ it('skips full-line comments and blank lines', () => {
+ expect(vars(parseDotenv('# top\n\n # indented\nA=1\n'))).toEqual({ A: '1' })
+ })
+
+ it('strips an inline comment after an unquoted value', () => {
+ expect(vars(parseDotenv('A=value # note\nB= # only a comment\nC=#also'))).toEqual({ A: 'value', B: '', C: '' })
+ })
+
+ it('keeps # glued to an unquoted value, with a warning about Node dotenv', () => {
+ const doc = parseDotenv('PASS=abc#def')
+ expect(vars(doc)).toEqual({ PASS: 'abc#def' })
+ expect(warnings(doc)).toEqual([expect.objectContaining({ line: 1, message: expect.stringContaining('#') })])
+ })
+
+ it('keeps # inside quotes', () => {
+ expect(vars(parseDotenv(`A="x # y" # c\nB='x # y'`))).toEqual({ A: 'x # y', B: 'x # y' })
+ })
+
+ it('reads single quotes and backticks literally', () => {
+ expect(vars(parseDotenv(String.raw`A='a\nb "c" $D'` + '\nB=`it\'s "x"`'))).toEqual({
+ A: String.raw`a\nb "c" $D`,
+ B: `it's "x"`,
+ })
+ })
+
+ it('processes escapes in double quotes in one pass', () => {
+ const doc = parseDotenv(String.raw`A="l1\nl2\tt \"q\" \\ \\n \$x \r"`)
+ expect(vars(doc)).toEqual({ A: 'l1\nl2\tt "q" \\ \\n \\$x \r' })
+ })
+
+ it('reads a multiline double-quoted value and numbers lines from its start', () => {
+ const doc = parseDotenv('A=1\nKEY="-----BEGIN-----\nabc\n-----END-----"\nB=2')
+ expect(vars(doc)).toEqual({ A: '1', KEY: '-----BEGIN-----\nabc\n-----END-----', B: '2' })
+ expect(doc.entries.map((e) => [e.key, e.line])).toEqual([
+ ['A', 1],
+ ['KEY', 2],
+ ['B', 5],
+ ])
+ })
+
+ it('a backslash before a line break inside double quotes does not close the value', () => {
+ expect(vars(parseDotenv('A="x\\\ny"'))).toEqual({ A: 'x\\\ny' })
+ })
+
+ it('handles CRLF and lone CR without leaving \\r on values', () => {
+ const doc = parseDotenv('A=1\r\nB="x\r\ny"\r\nC=3\rD=4\r\n')
+ expect(vars(doc)).toEqual({ A: '1', B: 'x\ny', C: '3', D: '4' })
+ expect(doc.entries.find((e) => e.key === 'D')?.line).toBe(5)
+ })
+
+ it('ignores a BOM', () => {
+ expect(vars(parseDotenv('\uFEFFA=1'))).toEqual({ A: '1' })
+ })
+
+ it('lets the last duplicate win, keeping the first position, and warns', () => {
+ const doc = parseDotenv('A=1\nB=2\nA=3\nA=4')
+ expect(doc.entries).toEqual([
+ { key: 'A', value: '4', line: 4 },
+ { key: 'B', value: '2', line: 2 },
+ ])
+ expect(warnings(doc).map((w) => w.line)).toEqual([3, 4])
+ expect(warnings(doc)[0]!.message).toContain('line 1')
+ })
+
+ it('accepts . and - in keys', () => {
+ expect(vars(parseDotenv('a.b-c_D=1'))).toEqual({ 'a.b-c_D': '1' })
+ })
+
+ describe('errors', () => {
+ it('a line without = (and does not echo a non-key line)', () => {
+ const doc = parseDotenv('A=1\nFOO\nsk_live 123 secret\nB=2')
+ expect(vars(doc)).toEqual({ A: '1', B: '2' })
+ expect(errors(doc).map((e) => e.line)).toEqual([2, 3])
+ expect(errors(doc)[0]!.message).toContain('FOO=')
+ expect(errors(doc)[1]!.message).not.toContain('secret')
+ })
+
+ it('an invalid key', () => {
+ for (const src of ['1A=x', 'MY KEY=x', 'A$=x', '=x']) {
+ const doc = parseDotenv(src)
+ expect(doc.entries).toEqual([])
+ expect(errors(doc)).toHaveLength(1)
+ expect(errors(doc)[0]!.line).toBe(1)
+ }
+ })
+
+ it('an unterminated quote, recovering on the next line', () => {
+ const doc = parseDotenv(`A=1\nB="never closed\nC=3\nD='also`)
+ expect(vars(doc)).toEqual({ A: '1', C: '3' })
+ expect(errors(doc).map((e) => e.line)).toEqual([2, 4])
+ expect(errors(doc)[0]!.message).toContain('double quote')
+ })
+
+ it('text after a closing quote, skipping the whole quoted span', () => {
+ const doc = parseDotenv('A="x\ny" junk\nB=2')
+ expect(vars(doc)).toEqual({ B: '2' })
+ expect(errors(doc)).toEqual([expect.objectContaining({ line: 1, message: expect.stringContaining('after the closing') })])
+ })
+ })
+})
+
+describe('parseJsonEnv', () => {
+ it('turns scalars into strings, keeping number literals exactly', () => {
+ const doc = parseJsonEnv('{"A":"x","N":12345678901234567890,"F":1.0,"E":-2e5,"T":true,"Z":false}', { flatten: false })
+ expect(vars(doc)).toEqual({ A: 'x', N: '12345678901234567890', F: '1.0', E: '-2e5', T: 'true', Z: 'false' })
+ expect(doc.issues).toEqual([])
+ })
+
+ it('makes null an empty value with a warning', () => {
+ const doc = parseJsonEnv('{\n "A": null\n}', { flatten: false })
+ expect(vars(doc)).toEqual({ A: '' })
+ expect(warnings(doc)).toEqual([expect.objectContaining({ line: 2 })])
+ })
+
+ it('decodes string escapes, including surrogate pairs', () => {
+ expect(vars(parseJsonEnv(String.raw`{"A":"a\n\"b\"\u00e9\ud83d\ude00\/"}`, { flatten: false }))).toEqual({ A: 'a\n"b"é😀/' })
+ })
+
+ it('reports nested values as errors when flatten is off', () => {
+ const doc = parseJsonEnv('{\n"A": "1",\n"DB": {"HOST": "h"},\n"L": [1]\n}', { flatten: false })
+ expect(vars(doc)).toEqual({ A: '1' })
+ expect(errors(doc).map((e) => e.line)).toEqual([3, 4])
+ expect(errors(doc)[0]!.message).toContain('flatten')
+ })
+
+ it('flattens with __ and array indexes, keeping order', () => {
+ const doc = parseJsonEnv('{"DB":{"HOST":"h","PORT":5432,"OPTS":{"SSL":true}},"HOSTS":["a","b"],"X":1}', { flatten: true })
+ expect(doc.entries.map((e) => [e.key, e.value])).toEqual([
+ ['DB__HOST', 'h'],
+ ['DB__PORT', '5432'],
+ ['DB__OPTS__SSL', 'true'],
+ ['HOSTS__0', 'a'],
+ ['HOSTS__1', 'b'],
+ ['X', '1'],
+ ])
+ })
+
+ it('warns on an empty nested container when flattening', () => {
+ const doc = parseJsonEnv('{"A":{},"B":[]}', { flatten: true })
+ expect(doc.entries).toEqual([])
+ expect(warnings(doc)).toHaveLength(2)
+ })
+
+ it('reports flatten collisions and JSON duplicate keys as duplicates', () => {
+ const doc = parseJsonEnv('{"A__B":"1",\n"A":{"B":"2"},\n"A__B":"3"}', { flatten: true })
+ expect(vars(doc)).toEqual({ A__B: '3' })
+ expect(warnings(doc).map((w) => w.line)).toEqual([2, 3])
+ })
+
+ it('rejects keys that are not valid env names, by line', () => {
+ const doc = parseJsonEnv('{\n"ok":"1",\n"has space":"2",\n"1st":"3"\n}', { flatten: false })
+ expect(vars(doc)).toEqual({ ok: '1' })
+ expect(errors(doc).map((e) => e.line)).toEqual([3, 4])
+ })
+
+ it('rejects a non-object top level', () => {
+ for (const src of ['[1,2]', '"x"', '1', 'null']) {
+ const doc = parseJsonEnv(src, { flatten: true })
+ expect(doc.entries).toEqual([])
+ expect(errors(doc)[0]!.message).toContain('top level')
+ }
+ })
+
+ it('reports syntax errors with a line number', () => {
+ const cases: [string, number][] = [
+ ['{\n"A": 1,\n}', 3],
+ ['{"A": 1', 1],
+ ['{\n\n"A": "x\ny"}', 3],
+ ['{"A": tru}', 1],
+ ['{"A": 1} x', 1],
+ ['{"A": "\\q"}', 1],
+ ['{"A": "\\u12"}', 1],
+ ['{\r\n"A" 1}', 2],
+ ['{"A": "never', 1],
+ ['', 1],
+ ]
+ for (const [src, line] of cases) {
+ const doc = parseJsonEnv(src, { flatten: false })
+ expect(doc.entries, src).toEqual([])
+ expect(errors(doc), src).toEqual([expect.objectContaining({ line, message: expect.stringMatching(/^Invalid JSON/) })])
+ }
+ })
+
+ it('bounds nesting depth instead of overflowing the stack', () => {
+ const deep = `{"A":${'['.repeat(100_000)}`
+ expect(errors(parseJsonEnv(deep, { flatten: true }))[0]!.message).toContain('levels deep')
+ })
+
+ it('agrees with JSON.parse on what it accepts', () => {
+ fc.assert(
+ fc.property(fc.dictionary(fc.string(), fc.jsonValue()), (obj) => {
+ const doc = parseJsonEnv(JSON.stringify(obj), { flatten: true })
+ expect(doc.issues.some((i) => i.message.startsWith('Invalid JSON'))).toBe(false)
+ }),
+ )
+ })
+})
+
+describe('serializers', () => {
+ it('JSON keeps entry order and string values', () => {
+ expect(toJson(entries({ B: '1', A: 'x"y' }))).toBe('{\n "B": "1",\n "A": "x\\"y"\n}')
+ expect(toJson([])).toBe('{}')
+ })
+
+ it('.env picks the lightest safe quoting', () => {
+ expect(quoteDotenvValue('plain-value_1.2:/x=y')).toBe('plain-value_1.2:/x=y')
+ expect(quoteDotenvValue('café')).toBe('café')
+ expect(quoteDotenvValue('')).toBe('')
+ expect(quoteDotenvValue('has space')).toBe('"has space"')
+ expect(quoteDotenvValue('a#b')).toBe('"a#b"')
+ expect(quoteDotenvValue("it's")).toBe(`"it's"`)
+ expect(quoteDotenvValue(' lead')).toBe('" lead"')
+ expect(quoteDotenvValue('say "hi"')).toBe(`'say "hi"'`)
+ expect(quoteDotenvValue('C:\\path')).toBe(`'C:\\path'`)
+ expect(quoteDotenvValue('$HOME')).toBe(`'$HOME'`)
+ expect(quoteDotenvValue('l1\nl2')).toBe('"l1\\nl2"')
+ expect(quoteDotenvValue(`it's "x"`)).toBe(`"it's \\"x\\""`)
+ })
+
+ it('.env output ends with a newline, and is empty for nothing', () => {
+ expect(toDotenv(entries({ A: '1' }))).toBe('A=1\n')
+ expect(toDotenv([])).toBe('')
+ })
+
+ it('shell quotes with POSIX single quotes', () => {
+ expect(shellQuote("it's")).toBe(`'it'\\''s'`)
+ expect(shellQuote('')).toBe(`''`)
+ expect(toShell(entries({ A: 'x y', B: '' })).text).toBe(`export A='x y'\nexport B=''\n`)
+ })
+
+ it('shell reports keys that are not shell identifiers, and NUL', () => {
+ const r = toShell([
+ { key: 'OK', value: '1', line: 1 },
+ { key: 'a.b', value: '2', line: 2 },
+ { key: 'c-d', value: '3', line: 3 },
+ { key: 'NUL', value: 'a\0b', line: 4 },
+ ])
+ expect(r.text).toBe(`export OK='1'\n`)
+ expect(r.issues.map((i) => [i.severity, i.line])).toEqual([
+ ['error', 2],
+ ['error', 3],
+ ['error', 4],
+ ])
+ })
+
+ it('docker writes raw values and reports what it cannot hold', () => {
+ const r = toDockerEnv([
+ { key: 'A', value: 'say "hi" # not a comment', line: 1 },
+ { key: 'B', value: 'l1\nl2', line: 2 },
+ { key: 'C', value: ' padded', line: 3 },
+ { key: 'D', value: 'x\ry', line: 4 },
+ ])
+ expect(r.text).toBe('A=say "hi" # not a comment\nC= padded\n')
+ expect(r.issues.map((i) => [i.severity, i.line])).toEqual([
+ ['error', 2],
+ ['warning', 3],
+ ['error', 4],
+ ])
+ })
+
+ it('serializeEnv dispatches every format', () => {
+ const e = entries({ A: '1' })
+ expect(serializeEnv(e, 'json').text).toBe('{\n "A": "1"\n}')
+ expect(serializeEnv(e, 'dotenv').text).toBe('A=1\n')
+ expect(serializeEnv(e, 'shell').text).toBe(`export A='1'\n`)
+ expect(serializeEnv(e, 'docker').text).toBe('A=1\n')
+ })
+})
+
+describe('parseShellExports', () => {
+ it('reads quoting forms and concatenation', () => {
+ const doc = parseShellExports(
+ [`export A='it'\\''s'`, `B="x \\"y\\" \\$z \\q"`, `C=a\\ b`, `D=a#b # c`, `E='l1`, `l2'; # tail`, `F=`].join('\n'),
+ )
+ expect(vars(doc)).toEqual({ A: "it's", B: 'x "y" $z \\q', C: 'a b', D: 'a#b', E: 'l1\nl2', F: '' })
+ expect(doc.entries.find((e) => e.key === 'F')?.line).toBe(7)
+ expect(doc.issues).toEqual([])
+ })
+
+ it('reads a CRLF file, keeping a CR that is inside quotes', () => {
+ expect(vars(parseShellExports("export A='1'\r\nB=2 # c\r\nC='x\ry'\r\n"))).toEqual({ A: '1', B: '2', C: 'x\ry' })
+ })
+
+ it('reports bad lines and unclosed quotes', () => {
+ expect(errors(parseShellExports('export a.b=1')).map((e) => e.line)).toEqual([1])
+ expect(errors(parseShellExports('A=1 B')).map((e) => e.line)).toEqual([1])
+ expect(errors(parseShellExports("A=1\nB='open")).map((e) => e.line)).toEqual([2])
+ expect(errors(parseShellExports('A="open')).map((e) => e.line)).toEqual([1])
+ })
+})
+
+// Values that stress every quoting rule: quotes of all three kinds, `#`, `$`,
+// backslashes, CR/LF, tabs, leading/trailing spaces, and astral unicode.
+const nasty = fc.oneof(
+ fc.string({ unit: fc.constantFrom('a', ' ', '#', '"', "'", '`', '\\', '$', '\n', '\r', '\t', '=', 'é', '😀', 'n') }),
+ fc.string({ unit: 'grapheme' }),
+ fc.string({ unit: 'binary' }),
+)
+const envKey = fc.stringMatching(/^[A-Za-z_][A-Za-z0-9_.-]{0,11}$/)
+const shellKey = fc.stringMatching(/^[A-Za-z_][A-Za-z0-9_]{0,11}$/)
+const envMap = (key: fc.Arbitrary) => fc.uniqueArray(fc.tuple(key, nasty), { selector: ([k]) => k, maxLength: 12 })
+
+describe('round trips', () => {
+ it('.env → JSON → .env preserves every key and value, in order', () => {
+ fc.assert(
+ fc.property(envMap(envKey), (pairs) => {
+ // Blank input converts to blank output, not `{}`; covered separately.
+ fc.pre(pairs.length > 0)
+ const start = pairs.map(([key, value], i) => ({ key, value, line: i + 1 }))
+ const dotenv = toDotenv(start)
+ const first = parseDotenv(dotenv)
+ expect(first.issues.filter((i) => i.severity === 'error')).toEqual([])
+ expect(first.entries.map((e) => [e.key, e.value])).toEqual(pairs)
+
+ const json = convertEnv(dotenv, { from: 'auto', to: 'json', flatten: false })
+ expect(json.output).toBe(toJson(start))
+ const back = convertEnv(json.output, { from: 'auto', to: 'dotenv', flatten: false })
+ expect(back.from).toBe('json')
+ expect(back.output).toBe(dotenv)
+ }),
+ { numRuns: 500 },
+ )
+ })
+
+ it('shell output re-parses to the same values', () => {
+ fc.assert(
+ fc.property(envMap(shellKey), (pairs) => {
+ const clean = pairs.map(([k, v]) => [k, v.replaceAll('\0', '')] as const)
+ const r = toShell(clean.map(([key, value], i) => ({ key, value, line: i + 1 })))
+ expect(r.issues).toEqual([])
+ const doc = parseShellExports(r.text)
+ expect(doc.issues).toEqual([])
+ expect(doc.entries.map((e) => [e.key, e.value])).toEqual(clean)
+ }),
+ { numRuns: 500 },
+ )
+ })
+
+ it('docker output re-reads literally when it emits a value', () => {
+ fc.assert(
+ fc.property(envMap(envKey), (pairs) => {
+ const r = toDockerEnv(pairs.map(([key, value], i) => ({ key, value, line: i + 1 })))
+ const kept = pairs.filter(([, v]) => !/[\r\n\0]/.test(v))
+ // Docker's own reading: split on lines, cut at the first `=`.
+ const read = r.text === '' ? [] : r.text.slice(0, -1).split('\n').map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
+ expect(read).toEqual(kept)
+ }),
+ )
+ })
+})
+
+describe('convertEnv', () => {
+ it('detects the input format', () => {
+ expect(detectEnvFormat(' \n {"A":1}')).toBe('json')
+ expect(detectEnvFormat('[1]')).toBe('json')
+ expect(detectEnvFormat('\uFEFF{}')).toBe('json')
+ expect(detectEnvFormat('A=1')).toBe('dotenv')
+ expect(detectEnvFormat('')).toBe('dotenv')
+ })
+
+ it('returns nothing for blank input, without errors', () => {
+ expect(convertEnv(' \n', { from: 'json', to: 'shell', flatten: false })).toEqual({ from: 'json', entries: [], output: '', issues: [] })
+ })
+
+ it('merges parse and serialize issues by line', () => {
+ const r = convertEnv('a.b=1\nA=1\nA="x\ny"', { from: 'auto', to: 'shell', flatten: false })
+ expect(r.output).toBe(`export A='x\ny'\n`)
+ expect(r.issues.map((i) => [i.severity, i.line])).toEqual([
+ ['error', 1],
+ ['warning', 3],
+ ])
+ })
+
+ it('JSON input to every output', () => {
+ const src = '{"A": "x y", "N": 3}'
+ expect(convertEnv(src, { from: 'auto', to: 'dotenv', flatten: false }).output).toBe('A="x y"\nN=3\n')
+ expect(convertEnv(src, { from: 'json', to: 'shell', flatten: false }).output).toBe(`export A='x y'\nexport N='3'\n`)
+ expect(convertEnv(src, { from: 'json', to: 'docker', flatten: false }).output).toBe('A=x y\nN=3\n')
+ })
+
+ it('forcing .env on JSON text reports errors rather than throwing', () => {
+ const r = convertEnv('{"A": 1}', { from: 'dotenv', to: 'json', flatten: false })
+ expect(r.entries).toEqual([])
+ expect(errors(r)).toHaveLength(1)
+ })
+})
+
+describe('maskEntries', () => {
+ it('hides every non-empty value and keeps keys, lines and empties', () => {
+ const masked = maskEntries(entries({ A: 'secret', B: '' }))
+ expect(masked).toEqual([
+ { key: 'A', value: MASK, line: 1 },
+ { key: 'B', value: '', line: 2 },
+ ])
+ expect(toDotenv(masked)).toBe(`A=${MASK}\nB=\n`)
+ })
+})
diff --git a/packages/core/src/env/index.ts b/packages/core/src/env/index.ts
new file mode 100644
index 0000000..65ffe4f
--- /dev/null
+++ b/packages/core/src/env/index.ts
@@ -0,0 +1,6 @@
+export * from './convert.ts'
+export * from './dotenv.ts'
+export * from './json.ts'
+export * from './serialize.ts'
+export * from './shell.ts'
+export * from './types.ts'
diff --git a/packages/core/src/env/json.ts b/packages/core/src/env/json.ts
new file mode 100644
index 0000000..61a077b
--- /dev/null
+++ b/packages/core/src/env/json.ts
@@ -0,0 +1,273 @@
+import { EntryCollector, shortKey } from './collect.ts'
+import { ENV_KEY, type EnvDoc } from './types.ts'
+
+export interface JsonEnvOptions {
+ /**
+ * Turn `{"DB": {"HOST": "x"}}` into `DB__HOST=x` (arrays use the index:
+ * `HOSTS__0`). Off by default: a nested value is usually a mistake, and
+ * silently inventing key names would hide it.
+ */
+ flatten: boolean
+ /** Joins flattened key segments. Default `__`, the .NET and Pydantic convention. */
+ separator?: string
+}
+
+/*
+ * A small JSON parser of our own instead of JSON.parse, for three reasons:
+ * every key keeps its line number so issues point somewhere, number literals
+ * keep their exact text (`12345678901234567890` and `1.0` survive), and a
+ * repeated key is visible instead of being silently collapsed.
+ */
+type Node =
+ | { kind: 'object'; at: number; members: { key: string; at: number; value: Node }[] }
+ | { kind: 'array'; at: number; items: Node[] }
+ | { kind: 'string'; at: number; value: string }
+ | { kind: 'scalar'; at: number; text: string }
+ | { kind: 'null'; at: number }
+
+class JsonError {
+ constructor(
+ readonly at: number,
+ readonly message: string,
+ ) {}
+}
+
+const MAX_DEPTH = 512
+const NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y
+
+function parseJson(text: string): Node {
+ let p = 0
+
+ const ws = () => {
+ while (p < text.length) {
+ const c = text[p]
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') p++
+ else break
+ }
+ }
+
+ // A declaration, not an arrow: TS only narrows after a `never` call when the
+ // callee is declared with an explicit type.
+ function fail(message: string, at = p): never {
+ throw new JsonError(at, message)
+ }
+
+ const found = () => (p >= text.length ? 'the end of the input' : `"${text[p]}"`)
+
+ const str = (): string => {
+ const start = p
+ p++ // opening quote
+ let out = ''
+ let run = p
+ while (p < text.length) {
+ const c = text.charCodeAt(p)
+ if (c === 0x22) {
+ out += text.slice(run, p)
+ p++
+ return out
+ }
+ if (c < 0x20) fail('A string cannot contain a raw line break or control character; use \\n.')
+ if (c === 0x5c) {
+ out += text.slice(run, p)
+ const e = text[p + 1]
+ if (e === 'u') {
+ const hex = text.slice(p + 2, p + 6)
+ if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('Bad \\u escape; it needs four hex digits.')
+ out += String.fromCharCode(Number.parseInt(hex, 16))
+ p += 6
+ } else {
+ const m = e === undefined ? undefined : JSON_ESCAPES[e]
+ if (m === undefined) fail(`Bad escape \\${e ?? ''} in a string.`)
+ out += m
+ p += 2
+ }
+ run = p
+ continue
+ }
+ p++
+ }
+ return fail('This string is never closed.', start)
+ }
+
+ const value = (depth: number): Node => {
+ if (depth > MAX_DEPTH) fail(`Nested more than ${MAX_DEPTH} levels deep.`)
+ ws()
+ const at = p
+ const c = text[p]
+ if (c === '{') {
+ p++
+ const members: { key: string; at: number; value: Node }[] = []
+ ws()
+ if (text[p] === '}') {
+ p++
+ return { kind: 'object', at, members }
+ }
+ for (;;) {
+ ws()
+ if (text[p] !== '"') fail(`Expected a quoted key, found ${found()}.`)
+ const keyAt = p
+ const key = str()
+ ws()
+ if (text[p] !== ':') fail(`Expected ":" after the key, found ${found()}.`)
+ p++
+ members.push({ key, at: keyAt, value: value(depth + 1) })
+ ws()
+ if (text[p] === ',') {
+ p++
+ continue
+ }
+ if (text[p] === '}') {
+ p++
+ return { kind: 'object', at, members }
+ }
+ fail(`Expected "," or "}", found ${found()}.`)
+ }
+ }
+ if (c === '[') {
+ p++
+ const items: Node[] = []
+ ws()
+ if (text[p] === ']') {
+ p++
+ return { kind: 'array', at, items }
+ }
+ for (;;) {
+ items.push(value(depth + 1))
+ ws()
+ if (text[p] === ',') {
+ p++
+ continue
+ }
+ if (text[p] === ']') {
+ p++
+ return { kind: 'array', at, items }
+ }
+ fail(`Expected "," or "]", found ${found()}.`)
+ }
+ }
+ if (c === '"') return { kind: 'string', at, value: str() }
+ for (const lit of ['true', 'false'] as const) {
+ if (text.startsWith(lit, p)) {
+ p += lit.length
+ return { kind: 'scalar', at, text: lit }
+ }
+ }
+ if (text.startsWith('null', p)) {
+ p += 4
+ return { kind: 'null', at }
+ }
+ NUMBER.lastIndex = p
+ const m = NUMBER.exec(text)
+ if (m !== null && m[0] !== '' && m[0] !== '-') {
+ p += m[0].length
+ return { kind: 'scalar', at, text: m[0] }
+ }
+ return fail(`Expected a value, found ${found()}.`)
+ }
+
+ const root = value(0)
+ ws()
+ if (p < text.length) fail(`Unexpected ${found()} after the end of the JSON.`)
+ return root
+}
+
+const JSON_ESCAPES: Record = {
+ '"': '"',
+ '\\': '\\',
+ '/': '/',
+ b: '\b',
+ f: '\f',
+ n: '\n',
+ r: '\r',
+ t: '\t',
+}
+
+/** 1-based line of each offset, counting CRLF, LF and lone CR like parseLines does. */
+function lineIndex(text: string): (offset: number) => number {
+ const starts = [0]
+ for (let i = 0; i < text.length; i++) {
+ const c = text[i]
+ if (c === '\r') {
+ if (text[i + 1] === '\n') i++
+ starts.push(i + 1)
+ } else if (c === '\n') {
+ starts.push(i + 1)
+ }
+ }
+ return (offset) => {
+ let lo = 0
+ let hi = starts.length - 1
+ while (lo < hi) {
+ const mid = (lo + hi + 1) >> 1
+ if (starts[mid]! <= offset) lo = mid
+ else hi = mid - 1
+ }
+ return lo + 1
+ }
+}
+
+/**
+ * Read a JSON object as environment variables. Strings pass through, numbers
+ * keep their literal text, booleans become `true`/`false`, `null` becomes an
+ * empty value (with a warning). Nested objects and arrays are errors unless
+ * `flatten` is on.
+ */
+export function parseJsonEnv(input: string, options: JsonEnvOptions): EnvDoc {
+ const text = input.startsWith('') ? input.slice(1) : input
+ const lineAt = lineIndex(text)
+ const out = new EntryCollector()
+
+ let root: Node
+ try {
+ root = parseJson(text)
+ } catch (e) {
+ // The only throw inside parseJson is our own JsonError; anything else
+ // (a stack overflow we failed to bound) still comes back as data.
+ if (e instanceof JsonError) out.error(lineAt(e.at), `Invalid JSON: ${e.message}`)
+ else out.error(1, 'Invalid JSON.')
+ return out.done()
+ }
+
+ if (root.kind !== 'object') {
+ out.error(lineAt(root.at), 'The top level must be an object of "KEY": "value" pairs.')
+ return out.done()
+ }
+
+ const sep = options.separator ?? '__'
+
+ const emit = (key: string, node: Node, line: number): void => {
+ switch (node.kind) {
+ case 'string':
+ case 'scalar':
+ case 'null':
+ if (!ENV_KEY.test(key)) {
+ out.error(line, `"${shortKey(key)}" is not a valid key. Use letters, digits, _, . and -, not starting with a digit.`)
+ return
+ }
+ if (node.kind === 'null') out.warn(line, `${key} is null; it becomes an empty value.`)
+ out.add({ key, value: node.kind === 'string' ? node.value : node.kind === 'scalar' ? node.text : '', line })
+ return
+ case 'object':
+ case 'array': {
+ const what = node.kind === 'object' ? 'an object' : 'an array'
+ if (!options.flatten) {
+ out.error(line, `${shortKey(key)} is ${what}. A .env value is a single string; turn on flatten to get ${shortKey(key)}${sep}… keys.`)
+ return
+ }
+ const children =
+ node.kind === 'object'
+ ? node.members.map((m) => ({ key: m.key, node: m.value, line: lineAt(m.at) }))
+ : node.items.map((n, i) => ({ key: String(i), node: n, line: lineAt(n.at) }))
+ if (children.length === 0) {
+ out.warn(line, `${shortKey(key)} is an empty ${node.kind}, so it produces no variables.`)
+ return
+ }
+ for (const c of children) emit(`${key}${sep}${c.key}`, c.node, c.line)
+ return
+ }
+ }
+ }
+
+ for (const m of root.members) emit(m.key, m.value, lineAt(m.at))
+ return out.done()
+}
diff --git a/packages/core/src/env/serialize.ts b/packages/core/src/env/serialize.ts
new file mode 100644
index 0000000..3367367
--- /dev/null
+++ b/packages/core/src/env/serialize.ts
@@ -0,0 +1,135 @@
+import { sortIssues } from './collect.ts'
+import { SHELL_KEY, type EnvEntry, type EnvIssue } from './types.ts'
+
+export type EnvOutputFormat = 'dotenv' | 'json' | 'shell' | 'docker'
+
+export interface Serialized {
+ text: string
+ /** Entries the format cannot hold are left out and listed here, never mangled. */
+ issues: EnvIssue[]
+}
+
+/** `{ "KEY": "value" }`, two-space indent, in entry order. Every value is a string. */
+export function toJson(entries: readonly EnvEntry[]): string {
+ if (entries.length === 0) return '{}'
+ // Built by hand rather than via an object: keys can't start with a digit, so
+ // nothing would be reordered today, but entry order is the contract.
+ const body = entries.map((e) => ` ${JSON.stringify(e.key)}: ${JSON.stringify(e.value)}`)
+ return `{\n${body.join(',\n')}\n}`
+}
+
+/*
+ * Anything that would change meaning unquoted: whitespace (trimmed, and a line
+ * break ends the value), `#` (a comment), quotes (start a quoted value), `\`
+ * and `$` (escapes and ${VAR} expansion in some loaders), and control
+ * characters. Everything else, unicode included, is safe bare.
+ */
+const NEEDS_QUOTES = /[\s#"'`\\$\p{Cc}]/u
+
+/**
+ * Pick the quoting that every common loader reads the same way:
+ *
+ * 1. Bare, when nothing in the value is special.
+ * 2. Double quotes, when only whitespace or `#` is: no escapes are needed, so
+ * there is nothing for loaders to disagree about.
+ * 3. Single quotes, when the value has `"`, `\` or `$` but no `'` and no line
+ * break. Node's dotenv ignores `\"` and `\\` inside double quotes and
+ * dotenv-expand expands `$` there, but single quotes are literal everywhere.
+ * 4. Otherwise double quotes with `\\`, `\"`, `\n` and `\r` escapes.
+ */
+export function quoteDotenvValue(value: string): string {
+ if (!NEEDS_QUOTES.test(value)) return value
+ if (!/["\\$\r\n]/.test(value)) return `"${value}"`
+ if (!value.includes("'") && !/[\r\n]/.test(value)) return `'${value}'`
+ return `"${value.replace(/[\\"\n\r]/g, (c) => DOUBLE_ESCAPES[c]!)}"`
+}
+
+const DOUBLE_ESCAPES: Record = { '\\': '\\\\', '"': '\\"', '\n': '\\n', '\r': '\\r' }
+
+export function toDotenv(entries: readonly EnvEntry[]): string {
+ return lines(entries.map((e) => `${e.key}=${quoteDotenvValue(e.value)}`))
+}
+
+/** POSIX single quotes: nothing inside is special, and `'` becomes `'\''`. */
+export function shellQuote(value: string): string {
+ return `'${value.replaceAll("'", `'\\''`)}'`
+}
+
+/**
+ * `export KEY='value'` lines, safe to `source` from sh, bash or zsh. A key that
+ * isn't a shell identifier would be a syntax error, so it is reported instead.
+ */
+export function toShell(entries: readonly EnvEntry[]): Serialized {
+ const out: string[] = []
+ const issues: EnvIssue[] = []
+ for (const e of entries) {
+ if (!SHELL_KEY.test(e.key)) {
+ issues.push({
+ severity: 'error',
+ line: e.line,
+ message: `${e.key} is not a valid shell variable name (only letters, digits and _), so it was left out.`,
+ })
+ continue
+ }
+ if (e.value.includes('\0')) {
+ issues.push({ severity: 'error', line: e.line, message: `${e.key} contains a NUL character, which no shell variable can hold; left out.` })
+ continue
+ }
+ out.push(`export ${e.key}=${shellQuote(e.value)}`)
+ }
+ return { text: lines(out), issues }
+}
+
+/**
+ * `docker run --env-file` format. Docker reads everything after the first `=`
+ * literally (quotes included) and one variable per line, so values are written
+ * raw and a value with a line break can't be represented at all.
+ *
+ * Note that Compose's `env_file:` is a different, dotenv-like parser; use the
+ * .env output for that.
+ */
+export function toDockerEnv(entries: readonly EnvEntry[]): Serialized {
+ const out: string[] = []
+ const issues: EnvIssue[] = []
+ for (const e of entries) {
+ if (/[\r\n\0]/.test(e.value)) {
+ issues.push({
+ severity: 'error',
+ line: e.line,
+ message: `${e.key} spans several lines, which an --env-file can't hold; left out.`,
+ })
+ continue
+ }
+ if (e.value !== e.value.trim()) {
+ issues.push({
+ severity: 'warning',
+ line: e.line,
+ message: `${e.key} starts or ends with whitespace. Docker keeps it, but it is easy to lose in an editor.`,
+ })
+ }
+ out.push(`${e.key}=${e.value}`)
+ }
+ return { text: lines(out), issues }
+}
+
+export function serializeEnv(entries: readonly EnvEntry[], format: EnvOutputFormat): Serialized {
+ switch (format) {
+ case 'json':
+ return { text: toJson(entries), issues: [] }
+ case 'dotenv':
+ return { text: toDotenv(entries), issues: [] }
+ case 'shell':
+ return sortedIssues(toShell(entries))
+ case 'docker':
+ return sortedIssues(toDockerEnv(entries))
+ }
+}
+
+/** Line-based formats end with a newline, as a file should. */
+function lines(xs: readonly string[]): string {
+ return xs.length === 0 ? '' : `${xs.join('\n')}\n`
+}
+
+function sortedIssues(s: Serialized): Serialized {
+ return { text: s.text, issues: sortIssues(s.issues) }
+}
diff --git a/packages/core/src/env/shell.ts b/packages/core/src/env/shell.ts
new file mode 100644
index 0000000..69c3925
--- /dev/null
+++ b/packages/core/src/env/shell.ts
@@ -0,0 +1,127 @@
+import { EntryCollector, shortKey } from './collect.ts'
+import { SHELL_KEY, type EnvDoc } from './types.ts'
+
+/**
+ * Read `export KEY=word` lines as a POSIX shell would assign them, without
+ * running anything. A word is any mix of:
+ *
+ * - `'…'`: literal, may span lines
+ * - `"…"`: `\` escapes only `$`, `` ` ``, `"`, `\` and a line break
+ * - `\c`: the character itself; `\` + line break joins lines
+ * - anything else up to unquoted whitespace or `;` (a `#` inside a word is literal)
+ *
+ * Parameter expansion and command substitution are not performed: `$X` stays
+ * `$X`. This exists to check toShell output, and to read it back.
+ */
+export function parseShellExports(input: string): EnvDoc {
+ // No CRLF normalisation: a CR inside single quotes is part of the value, as
+ // it is to sh. Outside quotes a CR is just a blank.
+ const text = input
+ const out = new EntryCollector()
+ let p = 0
+ let line = 1
+
+ const advance = (to: number) => {
+ for (; p < to; p++) if (text[p] === '\n') line++
+ }
+ const skipToEol = () => {
+ const nl = text.indexOf('\n', p)
+ advance(nl < 0 ? text.length : nl)
+ }
+
+ while (p < text.length) {
+ // Leading blanks and blank lines.
+ while (p < text.length && /[ \t\r\n]/.test(text[p]!)) advance(p + 1)
+ if (p >= text.length) break
+ const start = line
+
+ if (text[p] === '#') {
+ skipToEol()
+ continue
+ }
+
+ const exp = /export[ \t]+/y
+ exp.lastIndex = p
+ if (exp.test(text)) advance(exp.lastIndex)
+
+ const name = /[^=\s;#]*/y
+ name.lastIndex = p
+ const key = name.exec(text)![0]
+ if (text[p + key.length] !== '=' || !SHELL_KEY.test(key)) {
+ out.error(start, key === '' ? 'Expected export KEY=value.' : `"${shortKey(key)}" is not a valid assignment.`)
+ skipToEol()
+ continue
+ }
+ advance(p + key.length + 1)
+
+ let value = ''
+ let ok = true
+ word: while (p < text.length) {
+ const c = text[p]!
+ if (c === "'") {
+ const close = text.indexOf("'", p + 1)
+ if (close < 0) {
+ out.error(start, `${key}: the single quote is never closed.`)
+ ok = false
+ break
+ }
+ value += text.slice(p + 1, close)
+ advance(close + 1)
+ } else if (c === '"') {
+ let q = p + 1
+ let body = ''
+ for (;;) {
+ if (q >= text.length) {
+ out.error(start, `${key}: the double quote is never closed.`)
+ ok = false
+ break word
+ }
+ const d = text[q]!
+ if (d === '"') break
+ if (d === '\\' && q + 1 < text.length) {
+ const e = text[q + 1]!
+ if (e === '\n') {
+ q += 2
+ continue
+ }
+ if ('$`"\\'.includes(e)) {
+ body += e
+ q += 2
+ continue
+ }
+ }
+ body += d
+ q++
+ }
+ value += body
+ advance(q + 1)
+ } else if (c === '\\') {
+ const e = text[p + 1]
+ if (e !== undefined && e !== '\n') value += e
+ advance(p + 2)
+ } else if (c === ' ' || c === '\t' || c === '\r' || c === '\n' || c === ';') {
+ break
+ } else {
+ value += c
+ advance(p + 1)
+ }
+ }
+
+ if (!ok) {
+ // Nothing after an unclosed quote can be trusted.
+ break
+ }
+ // The rest of the line may only be blanks, `;` or a comment.
+ const tail = /[ \t\r;]*(?:#[^\n]*)?(?:\n|$)/y
+ tail.lastIndex = p
+ if (!tail.test(text)) {
+ out.error(line, `${key}: unexpected text after the value.`)
+ skipToEol()
+ continue
+ }
+ out.add({ key, value, line: start })
+ advance(tail.lastIndex)
+ }
+
+ return out.done()
+}
diff --git a/packages/core/src/env/types.ts b/packages/core/src/env/types.ts
new file mode 100644
index 0000000..b0b4cd4
--- /dev/null
+++ b/packages/core/src/env/types.ts
@@ -0,0 +1,39 @@
+/** One variable. `line` is 1-based, where its definition starts in the input. */
+export interface EnvEntry {
+ key: string
+ value: string
+ line: number
+}
+
+export type EnvSeverity = 'error' | 'warning'
+
+/**
+ * A problem tied to a line of the input. Messages name keys but never quote a
+ * value: values are secrets, and the issue list stays on screen while values
+ * are masked.
+ */
+export interface EnvIssue {
+ severity: EnvSeverity
+ line: number
+ message: string
+}
+
+/** Unique within any issue list this module returns (exact repeats are dropped), so it can key a list. */
+export function issueId(issue: EnvIssue): string {
+ return `${issue.severity}:${issue.line}:${issue.message}`
+}
+
+/** What every parser returns: the variables (duplicates resolved) and what went wrong. */
+export interface EnvDoc {
+ entries: EnvEntry[]
+ issues: EnvIssue[]
+}
+
+/**
+ * The key grammar dotenv accepts. Wider than a shell identifier on purpose:
+ * `.` and `-` are legal in a .env file and in `process.env`, just not in `sh`.
+ */
+export const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_.-]*$/
+
+/** A POSIX shell variable name. */
+export const SHELL_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/
diff --git a/packages/core/src/escape/escape.test.ts b/packages/core/src/escape/escape.test.ts
new file mode 100644
index 0000000..f87bff0
--- /dev/null
+++ b/packages/core/src/escape/escape.test.ts
@@ -0,0 +1,167 @@
+import fc from 'fast-check'
+import vm from 'node:vm'
+import { describe, expect, it } from 'vitest'
+import {
+ DEFAULT_ESCAPE_OPTIONS,
+ ESCAPE_MODES,
+ convertString,
+ escapeString,
+ unescapeString,
+ type EscapeMode,
+ type EscapeOptions,
+} from './escape.ts'
+import { escapeJs, unescapeJs } from './js.ts'
+import { escapeJson, unescapeJson } from './json.ts'
+import { escapeRegex } from './regex.ts'
+import { escapeShell } from './shell.ts'
+
+// Any UTF-16 code unit, so lone surrogates and controls turn up; 'binary'
+// covers astral characters as proper pairs; the tricky alphabet hits the
+// escape characters far more often than uniform sampling would.
+const units = fc.string({ unit: fc.integer({ min: 0, max: 0xffff }).map((n) => String.fromCharCode(n)) })
+const tricky = fc.string({
+ unit: fc.constantFrom('\\', '"', "'", '`', '$', '{', '}', '\n', '\r', '\0', '1', 'u', 'x', '\u2028', '\ud83d', '\ude00', ' ', 'a', '&', ';', '#', '%', '_', '\x1a', '.', '*'),
+})
+const text = fc.oneof(units, fc.string({ unit: 'binary' }), tricky)
+
+const options: fc.Arbitrary = fc.record({
+ wrap: fc.constant(true),
+ asciiOnly: fc.boolean(),
+ quote: fc.constantFrom('"' as const, "'" as const, '`' as const),
+ sqlDialect: fc.constantFrom('standard' as const, 'mysql' as const),
+})
+
+describe('round trips', () => {
+ for (const { mode } of ESCAPE_MODES) {
+ it(`${mode}: unescape(escape(x)) === x`, () => {
+ fc.assert(
+ fc.property(text, options, (x, opts) => {
+ const escaped = escapeString(x, mode, opts)
+ if (!escaped.ok) throw new Error('escape never fails')
+ const back = unescapeString(escaped.output, mode, opts)
+ expect(back).toMatchObject({ ok: true, output: x })
+ }),
+ { numRuns: 500 },
+ )
+ })
+ }
+
+ it('also round-trips a bare body when its first and last characters are not one quote', () => {
+ fc.assert(
+ fc.property(text, (x) => {
+ const body = escapeJs(x, { quote: '"', wrap: false })
+ fc.pre(!(body.length >= 2 && /^['`]/.test(body) && body.at(-1) === body[0]))
+ expect(unescapeJs(body)).toMatchObject({ ok: true, output: x })
+ expect(unescapeJson(escapeJson(x, { wrap: false }))).toMatchObject({ ok: true, output: x })
+ }),
+ )
+ })
+})
+
+describe('JSON', () => {
+ it('JSON.parse(escapeJson(x)) returns x', () => {
+ fc.assert(
+ fc.property(text, fc.boolean(), (x, asciiOnly) => {
+ expect(JSON.parse(escapeJson(x, { asciiOnly }))).toBe(x)
+ }),
+ )
+ })
+
+ it('accepts exactly what JSON.parse accepts for a quoted literal, with the same value', () => {
+ const jsonish = fc.string({
+ unit: fc.constantFrom('\\', '"', 'u', '0', 'a', 'F', 'n', '/', '\n', '\t', ' ', 'x', "'", '{'),
+ })
+ fc.assert(
+ fc.property(jsonish, (body) => {
+ const literal = `"${body}"`
+ let expected: unknown
+ try {
+ expected = JSON.parse(literal)
+ } catch {
+ expect(unescapeJson(literal).ok).toBe(false)
+ return
+ }
+ expect(unescapeJson(literal)).toMatchObject({ ok: true, output: expected })
+ }),
+ { numRuns: 2000 },
+ )
+ })
+})
+
+// A real engine is the only honest oracle for "is this a valid literal with
+// this value". vm runs it in Node, in a fresh context; the app never does.
+// Parenthesised, or ASI would read `""\n""` as two statements.
+const evaluate = (src: string): unknown => new vm.Script(`'use strict'; (${src})`).runInNewContext()
+
+describe('JavaScript', () => {
+ it('evaluates to the original text in a real engine, for every quote', () => {
+ fc.assert(
+ fc.property(text, options, (x, { quote, asciiOnly }) => {
+ expect(evaluate(escapeJs(x, { quote, asciiOnly }))).toBe(x)
+ }),
+ { numRuns: 500 },
+ )
+ })
+
+ it('agrees with the engine on what a literal means', () => {
+ const jsish = fc.string({
+ unit: fc.constantFrom('\\', '"', 'u', '{', '}', '0', '1', '8', 'x', 'a', 'F', 'n', '\n', '\r', '\u2028'),
+ })
+ fc.assert(
+ fc.property(jsish, (body) => {
+ const literal = `"${body}"`
+ let expected: unknown
+ try {
+ expected = evaluate(literal)
+ } catch {
+ expect(unescapeJs(literal).ok).toBe(false)
+ return
+ }
+ expect(unescapeJs(literal)).toMatchObject({ ok: true, output: expected })
+ }),
+ { numRuns: 2000 },
+ )
+ })
+})
+
+describe('shell', () => {
+ it('quotes so that every single quote is a delimiter or the escaped \\\' of the idiom', () => {
+ // The whole output must be single-quoted runs (which cannot contain ')
+ // joined by \' — nothing else, so nothing in it is ever interpreted.
+ const SAFE = /^'[^']*'(?:\\''[^']*')*$/
+ fc.assert(
+ fc.property(text, (x) => {
+ expect(escapeShell(x)).toMatch(SAFE)
+ }),
+ )
+ })
+})
+
+describe('regex', () => {
+ it('matches exactly the original text, lone surrogates included, with and without the u and v flags', () => {
+ fc.assert(
+ fc.property(text, fc.constantFrom('', 'u', 'v'), (x, flags) => {
+ const re = new RegExp(`^(?:${escapeRegex(x)})$`, flags)
+ expect(re.test(x)).toBe(true)
+ expect(re.test(`${x}a`)).toBe(false)
+ }),
+ )
+ })
+
+ it('stays a valid regex literal', () => {
+ expect(escapeRegex('a/b\nc')).toBe('a\\/b\\nc')
+ })
+})
+
+describe('dispatch', () => {
+ it('passes the options to the right mode', () => {
+ const opts: EscapeOptions = { ...DEFAULT_ESCAPE_OPTIONS, quote: "'", wrap: false }
+ expect(escapeString("it's", 'js', opts)).toEqual({ ok: true, output: "it\\'s", warnings: [] })
+ expect(convertString("'it''s'", 'unescape', 'sql')).toEqual({ ok: true, output: "it's", warnings: [] })
+ })
+
+ it('lists every mode once', () => {
+ const modes: EscapeMode[] = ESCAPE_MODES.map((m) => m.mode)
+ expect(new Set(modes).size).toBe(6)
+ })
+})
diff --git a/packages/core/src/escape/escape.ts b/packages/core/src/escape/escape.ts
new file mode 100644
index 0000000..6b573d8
--- /dev/null
+++ b/packages/core/src/escape/escape.ts
@@ -0,0 +1,103 @@
+import { escapeHtml, unescapeHtml } from './html.ts'
+import { escapeJs, unescapeJs, type JsQuote } from './js.ts'
+import { escapeJson, unescapeJson } from './json.ts'
+import { escapeRegex, unescapeRegex } from './regex.ts'
+import { ok, type EscapeResult } from './result.ts'
+import { escapeShell, shellWarnings, unescapeShell } from './shell.ts'
+import { escapeSql, sqlWarnings, unescapeSql, type SqlDialect } from './sql.ts'
+
+export type EscapeMode = 'json' | 'js' | 'sql' | 'shell' | 'regex' | 'html'
+export type EscapeDirection = 'escape' | 'unescape'
+
+export interface EscapeOptions {
+ /** Include the surrounding quotes (JSON, JavaScript, SQL). */
+ wrap: boolean
+ /** Escape every non-ASCII character (JSON, JavaScript). */
+ asciiOnly: boolean
+ /** JavaScript only. */
+ quote: JsQuote
+ /** SQL only. */
+ sqlDialect: SqlDialect
+}
+
+export const DEFAULT_ESCAPE_OPTIONS: EscapeOptions = {
+ wrap: true,
+ asciiOnly: false,
+ quote: '"',
+ sqlDialect: 'standard',
+}
+
+export interface ModeInfo {
+ mode: EscapeMode
+ label: string
+ /** Which of the options change this mode's escaping. Unescaping takes none. */
+ options: { wrap: boolean; asciiOnly: boolean; quote: boolean; sqlDialect: boolean }
+}
+
+const NONE = { wrap: false, asciiOnly: false, quote: false, sqlDialect: false }
+
+/** In menu order. The UI reads which controls to show from here. */
+export const ESCAPE_MODES: readonly ModeInfo[] = [
+ { mode: 'json', label: 'JSON string', options: { ...NONE, wrap: true, asciiOnly: true } },
+ { mode: 'js', label: 'JavaScript string', options: { ...NONE, wrap: true, asciiOnly: true, quote: true } },
+ { mode: 'sql', label: 'SQL string', options: { ...NONE, wrap: true, sqlDialect: true } },
+ { mode: 'shell', label: 'Shell (POSIX sh)', options: NONE },
+ { mode: 'regex', label: 'Regex literal', options: NONE },
+ { mode: 'html', label: 'HTML entities', options: NONE },
+]
+
+export function escapeString(
+ input: string,
+ mode: EscapeMode,
+ opts: EscapeOptions = DEFAULT_ESCAPE_OPTIONS,
+): EscapeResult {
+ switch (mode) {
+ case 'json':
+ return ok(escapeJson(input, { wrap: opts.wrap, asciiOnly: opts.asciiOnly }))
+ case 'js':
+ return ok(escapeJs(input, { wrap: opts.wrap, asciiOnly: opts.asciiOnly, quote: opts.quote }))
+ case 'sql':
+ return ok(escapeSql(input, { wrap: opts.wrap, dialect: opts.sqlDialect }), sqlWarnings(input, opts.sqlDialect))
+ case 'shell':
+ return ok(escapeShell(input), shellWarnings(input))
+ case 'regex':
+ return ok(escapeRegex(input))
+ case 'html':
+ return ok(escapeHtml(input))
+ }
+}
+
+/**
+ * Unescaping reads the quote style from the input itself (a literal that
+ * starts and ends with a quote, or a bare body), so only the SQL dialect,
+ * which changes what a backslash means, is taken from the options.
+ */
+export function unescapeString(
+ input: string,
+ mode: EscapeMode,
+ opts: EscapeOptions = DEFAULT_ESCAPE_OPTIONS,
+): EscapeResult {
+ switch (mode) {
+ case 'json':
+ return unescapeJson(input)
+ case 'js':
+ return unescapeJs(input)
+ case 'sql':
+ return unescapeSql(input, opts.sqlDialect)
+ case 'shell':
+ return unescapeShell(input)
+ case 'regex':
+ return unescapeRegex(input)
+ case 'html':
+ return unescapeHtml(input)
+ }
+}
+
+export function convertString(
+ input: string,
+ direction: EscapeDirection,
+ mode: EscapeMode,
+ opts: EscapeOptions = DEFAULT_ESCAPE_OPTIONS,
+): EscapeResult {
+ return direction === 'escape' ? escapeString(input, mode, opts) : unescapeString(input, mode, opts)
+}
diff --git a/packages/core/src/escape/html.test.ts b/packages/core/src/escape/html.test.ts
new file mode 100644
index 0000000..6040a22
--- /dev/null
+++ b/packages/core/src/escape/html.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest'
+import { escapeHtml, unescapeHtml } from './html.ts'
+
+describe('escapeHtml', () => {
+ it('escapes the five characters that matter, and only those', () => {
+ expect(escapeHtml(`Tom & Jerry é`)).toBe(
+ '<a href="x" title='y'>Tom & Jerry</a> é',
+ )
+ })
+})
+
+describe('unescapeHtml', () => {
+ it('decodes named, decimal and hex references in one pass', () => {
+ expect(unescapeHtml('<b> © © © <')).toEqual({
+ ok: true,
+ output: ' © © © <',
+ warnings: [],
+ })
+ })
+
+ it('applies the windows-1252 remap browsers use', () => {
+ expect(unescapeHtml('')).toMatchObject({ output: '–' })
+ })
+
+ it('leaves unknown names and unterminated references as typed, with a warning for unknown names', () => {
+ const r = unescapeHtml('a &bogus; b &nope; c &')
+ expect(r).toMatchObject({ ok: true, output: 'a &bogus; b &nope; c &' })
+ if (r.ok) expect(r.warnings).toEqual(['&bogus; at position 3 is not an entity this tool knows, so it was left as is (2 in total).'])
+ })
+
+ it('warns when a numeric reference is not a character', () => {
+ const r = unescapeHtml('x�')
+ expect(r).toMatchObject({ ok: true, output: 'x\uFFFD\uFFFD\uFFFD' })
+ if (r.ok) expect(r.warnings).toEqual([' at position 2 is not a valid character, so it became U+FFFD, as a browser does (2 in total).'])
+ })
+})
diff --git a/packages/core/src/escape/html.ts b/packages/core/src/escape/html.ts
new file mode 100644
index 0000000..5bae7d9
--- /dev/null
+++ b/packages/core/src/escape/html.ts
@@ -0,0 +1,60 @@
+import { decodeEntities } from '../html-text/entities.ts'
+import { ok, type EscapeResult } from './result.ts'
+
+const OUT: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ // ' rather than ', which HTML 4 and old IE never knew.
+ "'": ''',
+}
+
+/** Safe in element text and in both single- and double-quoted attribute values. */
+export function escapeHtml(s: string): string {
+ return s.replace(/[&<>"']/g, (c) => OUT[c]!)
+}
+
+const REF = /&(?:#\d+|#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g
+
+/**
+ * Decode named, decimal and hex character references in one pass, so
+ * `<` becomes `<`, not `<`. Decoding is html-text's (a partial
+ * named table, plus the windows-1252 remap of `` that browsers apply);
+ * this adds the warnings, so a reference left undecoded is never silent.
+ */
+export function unescapeHtml(input: string): EscapeResult {
+ return ok(decodeEntities(input), input.includes('&') ? refWarnings(input) : [])
+}
+
+function refWarnings(input: string): string[] {
+ let unknown: { ref: string; at: number } | undefined
+ let unknownCount = 0
+ let invalid: { ref: string; at: number } | undefined
+ let invalidCount = 0
+ for (const m of input.matchAll(REF)) {
+ const ref = m[0]
+ const decoded = decodeEntities(ref)
+ if (decoded === ref) {
+ unknown ??= { ref, at: m.index }
+ unknownCount++
+ } else if (decoded === '\uFFFD' && !/^(?:65533|[xX]0*[fF][fF][fF][dD]);$/.test(ref)) {
+ invalid ??= { ref, at: m.index }
+ invalidCount++
+ }
+ }
+ const warnings: string[] = []
+ if (unknown !== undefined) {
+ warnings.push(
+ `${unknown.ref} at position ${unknown.at + 1} is not an entity this tool knows, so it was left as is` +
+ (unknownCount > 1 ? ` (${unknownCount} in total).` : '.'),
+ )
+ }
+ if (invalid !== undefined) {
+ warnings.push(
+ `${invalid.ref} at position ${invalid.at + 1} is not a valid character, so it became U+FFFD, as a browser does` +
+ (invalidCount > 1 ? ` (${invalidCount} in total).` : '.'),
+ )
+ }
+ return warnings
+}
diff --git a/packages/core/src/escape/index.ts b/packages/core/src/escape/index.ts
new file mode 100644
index 0000000..5dc9abd
--- /dev/null
+++ b/packages/core/src/escape/index.ts
@@ -0,0 +1,8 @@
+export * from './escape.ts'
+export * from './html.ts'
+export * from './js.ts'
+export * from './json.ts'
+export * from './regex.ts'
+export type { EscapeError, EscapeResult } from './result.ts'
+export * from './shell.ts'
+export * from './sql.ts'
diff --git a/packages/core/src/escape/js.test.ts b/packages/core/src/escape/js.test.ts
new file mode 100644
index 0000000..a6fd129
--- /dev/null
+++ b/packages/core/src/escape/js.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from 'vitest'
+import { escapeJs, unescapeJs } from './js.ts'
+
+const errorAt = (input: string) => {
+ const r = unescapeJs(input)
+ if (r.ok) throw new Error(`expected an error for ${JSON.stringify(input)}`)
+ return r.error
+}
+
+describe('escapeJs', () => {
+ it('escapes only the chosen quote', () => {
+ expect(escapeJs(`it's "x"`, { quote: "'" })).toBe(`'it\\'s "x"'`)
+ expect(escapeJs(`it's "x"`, { quote: '"' })).toBe(`"it's \\"x\\""`)
+ expect(escapeJs('a`b', { quote: '`', wrap: false })).toBe('a\\`b')
+ })
+
+ it('escapes ${ in a template, but not a lone $', () => {
+ expect(escapeJs('${x} $y {z}', { quote: '`' })).toBe('`\\${x} $y {z}`')
+ expect(escapeJs('${x}', { quote: '"' })).toBe('"${x}"')
+ })
+
+ it('escapes line separators, lone surrogates and controls', () => {
+ expect(escapeJs('\u2028\u2029', { wrap: false })).toBe('\\u2028\\u2029')
+ expect(escapeJs('\ud800x\udfff', { wrap: false })).toBe('\\uD800x\\uDFFF')
+ expect(escapeJs('\x01\x7f\x9f\v', { wrap: false })).toBe('\\x01\\x7F\\x9F\\v')
+ })
+
+ it('writes NUL as \\x00 before a digit, where \\0 would read as octal', () => {
+ expect(escapeJs('\x001\x00a', { wrap: false })).toBe('\\x001\\0a')
+ })
+
+ it('keeps emoji raw by default and uses \\u{…} in ASCII mode', () => {
+ expect(escapeJs('é😀', { wrap: false })).toBe('é😀')
+ expect(escapeJs('é€😀', { wrap: false, asciiOnly: true })).toBe('\\xE9\\u20AC\\u{1F600}')
+ })
+})
+
+describe('unescapeJs', () => {
+ it('reads whole literals of each quote style', () => {
+ expect(unescapeJs(`'it\\'s'`)).toMatchObject({ ok: true, output: "it's" })
+ expect(unescapeJs(` "a\\tb" `)).toMatchObject({ ok: true, output: 'a\tb' })
+ expect(unescapeJs('`a\\${b}`')).toMatchObject({ ok: true, output: 'a${b}' })
+ })
+
+ it('reads a body with quotes and line breaks as they are', () => {
+ expect(unescapeJs(`say "hi"\nit's`)).toMatchObject({ ok: true, output: `say "hi"\nit's` })
+ })
+
+ it('decodes every escape form', () => {
+ expect(unescapeJs('\\x41\\u0042\\u{43}\\u{1F600}\\0\\b\\f\\n\\r\\t\\v\\q')).toMatchObject({
+ ok: true,
+ output: 'ABC😀\0\b\f\n\r\t\vq',
+ })
+ })
+
+ it('drops line continuations', () => {
+ expect(unescapeJs('"a\\\nb\\\r\nc\\\u2028d"')).toMatchObject({ ok: true, output: 'abcd' })
+ })
+
+ it('normalises raw CRLF in a template, as the engine does', () => {
+ expect(unescapeJs('`a\r\nb\rc`')).toMatchObject({ ok: true, output: 'a\nb\nc' })
+ })
+
+ describe('error positions', () => {
+ it('legacy octal and \\8, \\9', () => {
+ expect(errorAt('ab\\1')).toMatchObject({ index: 2 })
+ expect(errorAt('\\01')).toMatchObject({ index: 0 })
+ expect(errorAt('x\\8')).toMatchObject({ index: 1 })
+ })
+
+ it('incomplete \\x', () => {
+ expect(errorAt('ab\\x4')).toMatchObject({ index: 2 })
+ expect(errorAt('"\\x4"')).toMatchObject({ index: 1 })
+ })
+
+ it('incomplete \\u and malformed \\u{…}', () => {
+ expect(errorAt('a\\u12')).toMatchObject({ index: 1 })
+ expect(errorAt('a\\u{}')).toMatchObject({ index: 1 })
+ expect(errorAt('a\\u{12')).toMatchObject({ index: 1 })
+ expect(errorAt('a\\u{xyz}')).toMatchObject({ index: 1 })
+ expect(errorAt('a\\u{110000}')).toMatchObject({ index: 1 })
+ })
+
+ it('unescaped quote inside a literal', () => {
+ expect(errorAt(`'it's'`)).toMatchObject({ index: 3 })
+ })
+
+ it('raw line break in a quoted literal, with line and column', () => {
+ expect(errorAt('"a\nb"')).toMatchObject({ index: 2, line: 1, column: 3 })
+ })
+
+ it('template substitution', () => {
+ expect(errorAt('`a${b}`')).toMatchObject({ index: 2 })
+ })
+
+ it('a backslash that escapes the closing quote', () => {
+ expect(errorAt('"abc\\"')).toMatchObject({ index: 4 })
+ expect(errorAt('abc\\').message).toContain('Lone backslash')
+ })
+
+ it('reports line and column on later lines', () => {
+ const e = errorAt('first\nsecond \\x')
+ expect(e).toMatchObject({ index: 13, line: 2, column: 8 })
+ expect(e.message).toContain('position 14 (line 2, column 8)')
+ })
+ })
+})
diff --git a/packages/core/src/escape/js.ts b/packages/core/src/escape/js.ts
new file mode 100644
index 0000000..3c2c961
--- /dev/null
+++ b/packages/core/src/escape/js.ts
@@ -0,0 +1,204 @@
+import { fail, hex, isHexDigit, ok, type EscapeResult } from './result.ts'
+
+export type JsQuote = '"' | "'" | '`'
+
+export interface JsEscapeOptions {
+ /** Which literal the text goes into. Default `"`. */
+ quote?: JsQuote
+ /** Include the surrounding quotes. Default true. */
+ wrap?: boolean
+ /** Write every non-ASCII character as an escape: `\xHH`, `\uHHHH` or `\u{HHHHH}`. Default false. */
+ asciiOnly?: boolean
+}
+
+const NAMED: Record = {
+ 0x08: '\\b',
+ 0x09: '\\t',
+ 0x0a: '\\n',
+ 0x0b: '\\v',
+ 0x0c: '\\f',
+ 0x0d: '\\r',
+ 0x5c: '\\\\',
+}
+
+/**
+ * Escape text for a JavaScript string literal of the chosen quote style.
+ *
+ * Beyond the obvious, three things free tools miss: U+2028/2029 are escaped so
+ * the output also survives pre-ES2019 engines and JSONP-style embedding; lone
+ * surrogates are escaped so the source file stays valid UTF-8; and in a
+ * template literal `${` is escaped so the text is never run as code. `\0` is
+ * written as `\x00` before a digit, where `\01` would be a legacy octal escape.
+ */
+export function escapeJs(s: string, opts: JsEscapeOptions = {}): string {
+ const quote = opts.quote ?? '"'
+ const q = quote.charCodeAt(0)
+ const ascii = opts.asciiOnly === true
+ let out = ''
+ let run = 0
+ for (let i = 0; i < s.length; i++) {
+ const c = s.charCodeAt(i)
+ let rep: string | undefined
+ let width = 1
+
+ if (c >= 0x20 && c < 0x7f) {
+ if (c === q || (c === 0x24 && q === 0x60 && s.charCodeAt(i + 1) === 0x7b)) rep = `\\${s[i]}`
+ else if (c === 0x5c) rep = '\\\\'
+ else continue
+ } else if (c < 0x20) {
+ rep = NAMED[c] ?? (c === 0 && !/[0-9]/.test(s[i + 1] ?? '') ? '\\0' : `\\x${hex(c, 2)}`)
+ } else if (c <= 0x9f) {
+ // DEL and the C1 controls: invisible, and some editors mangle them.
+ rep = `\\x${hex(c, 2)}`
+ } else if (c === 0x2028 || c === 0x2029) {
+ rep = `\\u${hex(c, 4)}`
+ } else if (c >= 0xd800 && c <= 0xdfff) {
+ const next = s.charCodeAt(i + 1)
+ const paired = c <= 0xdbff && next >= 0xdc00 && next <= 0xdfff
+ if (!paired) rep = `\\u${hex(c, 4)}`
+ else if (ascii) {
+ rep = `\\u{${hex(s.codePointAt(i)!, 1)}}`
+ width = 2
+ } else {
+ i++
+ continue
+ }
+ } else if (ascii) {
+ rep = c <= 0xff ? `\\x${hex(c, 2)}` : `\\u${hex(c, 4)}`
+ } else {
+ continue
+ }
+
+ out += s.slice(run, i) + rep
+ i += width - 1
+ run = i + 1
+ }
+ out += s.slice(run)
+ return opts.wrap === false ? out : quote + out + quote
+}
+
+function isJsSpace(c: string | undefined): boolean {
+ return c === ' ' || c === '\t' || c === '\n' || c === '\r'
+}
+
+const SIMPLE: Record = {
+ b: '\b',
+ f: '\f',
+ n: '\n',
+ r: '\r',
+ t: '\t',
+ v: '\v',
+}
+
+/**
+ * Unescape a JavaScript string with strict-mode rules.
+ *
+ * Text that starts and ends with the same quote is read as a whole literal:
+ * the quote must not appear unescaped inside, a `'`/`"` literal may not span
+ * lines, and a template may not contain `${`, which is code with no fixed
+ * value. Anything else is read as the body of a string, where quotes and line
+ * breaks are taken as they are. Legacy octal escapes are errors, as they are
+ * in strict mode and in templates.
+ */
+export function unescapeJs(input: string): EscapeResult {
+ let start = 0
+ let stop = input.length
+ while (isJsSpace(input[start])) start++
+ while (stop > start && isJsSpace(input[stop - 1])) stop--
+ const first = input[start]
+ const quoted =
+ stop - start >= 2 && (first === '"' || first === "'" || first === '`') && input[stop - 1] === first
+ const quote = quoted ? first : undefined
+ const from = quoted ? start + 1 : 0
+ const to = quoted ? stop - 1 : input.length
+
+ let out = ''
+ let run = from
+ for (let i = from; i < to; i++) {
+ const c = input[i]!
+
+ if (quote !== undefined) {
+ if (c === quote) {
+ return fail(input, i, `Unescaped ${quote}`, `it ends the string; write it as \\${quote}`)
+ }
+ if ((c === '\n' || c === '\r') && quote !== '`') {
+ return fail(input, i, 'Raw line break', 'only template literals may span lines; write it as \\n')
+ }
+ if (quote === '`' && c === '$' && input[i + 1] === '{') {
+ return fail(input, i, 'Template substitution ${', 'it is code with no fixed value; write \\${ to mean the text')
+ }
+ if (quote === '`' && c === '\r') {
+ // A template's cooked value normalises CRLF and CR to LF.
+ out += `${input.slice(run, i)}\n`
+ if (input[i + 1] === '\n') i++
+ run = i + 1
+ continue
+ }
+ }
+
+ if (c !== '\\') continue
+ out += input.slice(run, i)
+ const e = input[i + 1]
+ if (e === undefined || i + 1 >= to) {
+ return quoted
+ ? fail(input, i, 'Unterminated string', 'this backslash escapes the closing quote')
+ : fail(input, i, 'Lone backslash at the end', 'write a literal backslash as \\\\')
+ }
+
+ const simple = SIMPLE[e]
+ if (simple !== undefined) {
+ out += simple
+ i++
+ } else if (e >= '0' && e <= '9') {
+ const next = input[i + 2]
+ if (e !== '0' || (next !== undefined && next >= '0' && next <= '9' && i + 2 < to)) {
+ return fail(
+ input,
+ i,
+ `Legacy octal escape \\${e}`,
+ 'strict mode and template literals reject it; write \\xHH or \\u{…} instead',
+ )
+ }
+ out += '\0'
+ i++
+ } else if (e === 'x') {
+ if (!isHexDigit(input[i + 2]) || !isHexDigit(input[i + 3]) || i + 3 >= to) {
+ return fail(input, i, 'Incomplete \\x escape', 'it needs exactly two hex digits, like \\x41')
+ }
+ out += String.fromCharCode(Number.parseInt(input.slice(i + 2, i + 4), 16))
+ i += 3
+ } else if (e === 'u' && input[i + 2] === '{') {
+ const close = input.indexOf('}', i + 3)
+ const digits = close === -1 || close >= to ? '' : input.slice(i + 3, close)
+ if (digits === '' || ![...digits].every(isHexDigit)) {
+ return fail(input, i, 'Malformed \\u{…} escape', 'it needs hex digits and a closing brace, like \\u{1F600}')
+ }
+ const cp = Number.parseInt(digits, 16)
+ if (cp > 0x10ffff) {
+ return fail(input, i, `Code point \\u{${digits}} is out of range`, 'the largest is \\u{10FFFF}')
+ }
+ out += String.fromCodePoint(cp)
+ i = close
+ } else if (e === 'u') {
+ const digits = input.slice(i + 2, i + 6)
+ if (digits.length < 4 || ![...digits].every(isHexDigit) || i + 5 >= to) {
+ return fail(input, i, 'Incomplete \\u escape', 'it needs four hex digits like \\u00E9, or braces like \\u{1F600}')
+ }
+ out += String.fromCharCode(Number.parseInt(digits, 16))
+ i += 5
+ } else if (e === '\r') {
+ // Line continuation: backslash-newline contributes nothing.
+ i += input[i + 2] === '\n' ? 2 : 1
+ } else if (e === '\n' || e === '\u2028' || e === '\u2029') {
+ i++
+ } else {
+ // Any other escaped character stands for itself: \' \" \` \\ \$ \a …
+ // Only the first half of a surrogate pair is consumed here; the second is
+ // copied by the next run.
+ out += e
+ i++
+ }
+ run = i + 1
+ }
+ return ok(out + input.slice(run, to))
+}
diff --git a/packages/core/src/escape/json.test.ts b/packages/core/src/escape/json.test.ts
new file mode 100644
index 0000000..88a48a9
--- /dev/null
+++ b/packages/core/src/escape/json.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest'
+import { escapeJson, unescapeJson } from './json.ts'
+
+const errorAt = (input: string) => {
+ const r = unescapeJson(input)
+ if (r.ok) throw new Error(`expected an error for ${JSON.stringify(input)}`)
+ return r.error
+}
+
+describe('escapeJson', () => {
+ it('is JSON.stringify', () => {
+ expect(escapeJson('say "hi"\n\t\\')).toBe('"say \\"hi\\"\\n\\t\\\\"')
+ expect(escapeJson('\ud800')).toBe('"\\ud800"')
+ })
+
+ it('drops the quotes on request', () => {
+ expect(escapeJson('a"b', { wrap: false })).toBe('a\\"b')
+ })
+
+ it('writes non-ASCII as \\u escapes, astral as a surrogate pair', () => {
+ expect(escapeJson('é😀\x7f', { asciiOnly: true })).toBe('"\\u00e9\\ud83d\\ude00\\u007f"')
+ })
+})
+
+describe('unescapeJson', () => {
+ it('reads a quoted literal with surrounding whitespace', () => {
+ expect(unescapeJson(' "a\\u00e9\\/b"\n')).toEqual({ ok: true, output: 'aé/b', warnings: [] })
+ })
+
+ it('reads a bare body and drops a trailing newline from the copy', () => {
+ expect(unescapeJson('line\\nnext\n')).toMatchObject({ ok: true, output: 'line\nnext' })
+ expect(unescapeJson(' keeps leading spaces')).toMatchObject({ ok: true, output: ' keeps leading spaces' })
+ })
+
+ it('keeps a lone surrogate escape, as JSON.parse does', () => {
+ expect(unescapeJson('"\\ud800"')).toMatchObject({ ok: true, output: '\ud800' })
+ })
+
+ it('handles the empty string both ways', () => {
+ expect(unescapeJson('')).toMatchObject({ ok: true, output: '' })
+ expect(unescapeJson('""')).toMatchObject({ ok: true, output: '' })
+ })
+
+ describe('error positions', () => {
+ it('invalid escape: at the backslash', () => {
+ expect(errorAt('ab\\q')).toMatchObject({ index: 2, line: 1, column: 3 })
+ expect(errorAt("\\'").message).toContain("don't escape single quotes")
+ })
+
+ it('incomplete \\u: at the backslash, with a hint for \\u{…}', () => {
+ expect(errorAt('x\\u12')).toMatchObject({ index: 1 })
+ expect(errorAt('x\\u12g4')).toMatchObject({ index: 1 })
+ expect(errorAt('\\u{41}').message).toContain('JavaScript, not JSON')
+ })
+
+ it('lone backslash at the end', () => {
+ expect(errorAt('abc\\')).toMatchObject({ index: 3 })
+ expect(errorAt('abc\\\n')).toMatchObject({ index: 3 })
+ })
+
+ it('unescaped quote in a body', () => {
+ expect(errorAt('say "hi"')).toMatchObject({ index: 4 })
+ })
+
+ it('raw control character, with line and column past the first line', () => {
+ const e = errorAt('"a\nb"')
+ expect(e).toMatchObject({ index: 2, line: 1, column: 3 })
+ expect(e.message).toContain('\\n')
+ expect(errorAt('ab\tc')).toMatchObject({ index: 2 })
+ })
+
+ it('text after the closing quote', () => {
+ expect(errorAt('"a" x')).toMatchObject({ index: 4 })
+ })
+
+ it('unterminated string: at the opening quote', () => {
+ expect(errorAt(' "abc')).toMatchObject({ index: 2 })
+ expect(errorAt('"abc\\"')).toMatchObject({ index: 0 })
+ })
+
+ it('names the problem, the position and the fix', () => {
+ const e = errorAt('ok\\nfine\\z')
+ expect(e.message).toBe(
+ 'Invalid escape \\z at position 9: JSON allows only \\" \\\\ \\/ \\b \\f \\n \\r \\t and \\uXXXX.',
+ )
+ })
+ })
+})
diff --git a/packages/core/src/escape/json.ts b/packages/core/src/escape/json.ts
new file mode 100644
index 0000000..710358b
--- /dev/null
+++ b/packages/core/src/escape/json.ts
@@ -0,0 +1,129 @@
+import { fail, hex, isHexDigit, ok, show, type EscapeResult } from './result.ts'
+
+export interface JsonEscapeOptions {
+ /** Include the surrounding double quotes. Default true. */
+ wrap?: boolean
+ /** Write every non-ASCII character as `\uXXXX` (astral ones as a surrogate pair). Default false. */
+ asciiOnly?: boolean
+}
+
+/**
+ * Exactly `JSON.stringify(s)`, which since ES2019 also escapes lone surrogates,
+ * so the output is always valid UTF-8 JSON. U+2028/2029 stay raw: JSON allows
+ * them, and JavaScript has accepted them in strings since ES2019 too.
+ */
+export function escapeJson(s: string, opts: JsonEscapeOptions = {}): string {
+ let out = JSON.stringify(s)
+ if (opts.asciiOnly === true) {
+ // Per UTF-16 code unit (no `u` flag), so an emoji becomes the surrogate
+ // pair JSON requires; lowercase to match what JSON.stringify itself writes.
+ out = out.replace(/[^\x20-\x7e]/g, (c) => `\\u${hex(c.charCodeAt(0), 4).toLowerCase()}`)
+ }
+ return opts.wrap === false ? out.slice(1, -1) : out
+}
+
+const SIMPLE: Record = {
+ '"': '"',
+ '\\': '\\',
+ '/': '/',
+ b: '\b',
+ f: '\f',
+ n: '\n',
+ r: '\r',
+ t: '\t',
+}
+
+/** Insignificant whitespace in JSON: exactly these four, not String#trim's list. */
+function isJsonSpace(c: string | undefined): boolean {
+ return c === ' ' || c === '\t' || c === '\n' || c === '\r'
+}
+
+const CONTROL_HINT: Record = {
+ '\n': '\\n',
+ '\r': '\\r',
+ '\t': '\\t',
+ '\b': '\\b',
+ '\f': '\\f',
+}
+
+/**
+ * Strict JSON string unescaping, with the position of the first problem.
+ *
+ * A quoted literal (`"…"`, optionally surrounded by whitespace) is parsed
+ * exactly as `JSON.parse` would; anything else is taken as the body of a
+ * string, where a raw `"` is an error because it would end the string. The
+ * one leniency is that trailing line breaks after a body are dropped: they are
+ * almost always the newline a copy from a terminal or editor picked up.
+ */
+export function unescapeJson(input: string): EscapeResult {
+ let start = 0
+ while (isJsonSpace(input[start])) start++
+ const quoted = input[start] === '"'
+
+ let end = input.length
+ if (!quoted) {
+ while (end > 0 && (input[end - 1] === '\n' || input[end - 1] === '\r')) end--
+ }
+
+ let out = ''
+ let i = quoted ? start + 1 : 0
+ let run = i
+ for (; i < end; i++) {
+ const c = input[i]!
+ if (c === '"') {
+ if (!quoted) return fail(input, i, 'Unescaped "', 'write it as \\" inside a JSON string')
+ out += input.slice(run, i)
+ for (let j = i + 1; j < input.length; j++) {
+ if (!isJsonSpace(input[j])) {
+ return fail(input, j, 'Text after the closing quote', 'a JSON string ends at its first unescaped "')
+ }
+ }
+ return ok(out)
+ }
+ if (c === '\\') {
+ out += input.slice(run, i)
+ const e = input[i + 1]
+ if (e === undefined || i + 1 >= end) {
+ return fail(input, i, 'Lone backslash at the end', 'write a literal backslash as \\\\')
+ }
+ const simple = SIMPLE[e]
+ if (simple !== undefined) {
+ out += simple
+ i++
+ } else if (e === 'u') {
+ const digits = input.slice(i + 2, i + 6)
+ if (digits.length < 4 || ![...digits].every(isHexDigit) || i + 6 > end) {
+ return fail(
+ input,
+ i,
+ 'Incomplete \\u escape',
+ input[i + 2] === '{'
+ ? '\\u{…} is JavaScript, not JSON; JSON needs exactly four hex digits, like \\u00e9'
+ : 'it needs exactly four hex digits, like \\u00e9',
+ )
+ }
+ out += String.fromCharCode(Number.parseInt(digits, 16))
+ i += 5
+ } else {
+ const ch = String.fromCodePoint(input.codePointAt(i + 1)!)
+ return fail(
+ input,
+ i,
+ `Invalid escape \\${show(ch)}`,
+ e === "'"
+ ? "JSON strings don't escape single quotes; write ' as is"
+ : 'JSON allows only \\" \\\\ \\/ \\b \\f \\n \\r \\t and \\uXXXX',
+ )
+ }
+ run = i + 1
+ continue
+ }
+ if (c < ' ') {
+ const fix = CONTROL_HINT[c] ?? `\\u${hex(c.charCodeAt(0), 4).toLowerCase()}`
+ return fail(input, i, `Raw control character ${show(c)}`, `JSON strings must write it as ${fix}`)
+ }
+ }
+
+ if (quoted) return fail(input, start, 'Unterminated string', 'the opening quote is never closed')
+ return ok(out + input.slice(run, end))
+}
diff --git a/packages/core/src/escape/regex.test.ts b/packages/core/src/escape/regex.test.ts
new file mode 100644
index 0000000..bb3a877
--- /dev/null
+++ b/packages/core/src/escape/regex.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest'
+import { escapeRegex, unescapeRegex } from './regex.ts'
+
+describe('escapeRegex', () => {
+ it('escapes every metacharacter and the literal delimiter', () => {
+ expect(escapeRegex('a.b*c+d?e^f$g(h)i[j]k{l}m|n\\o/p')).toBe(
+ 'a\\.b\\*c\\+d\\?e\\^f\\$g\\(h\\)i\\[j\\]k\\{l\\}m\\|n\\\\o\\/p',
+ )
+ })
+
+ it('leaves characters that are not syntax alone, which the u flag requires', () => {
+ expect(escapeRegex('a-b,c=d:e')).toBe('a-b,c=d:e')
+ })
+})
+
+describe('unescapeRegex', () => {
+ it('reads a literal pattern back', () => {
+ expect(unescapeRegex('1\\.5\\+\\(x\\)\\/\\-\\n\\x41\\u0042\\u{1F600}\\cJ\\0')).toMatchObject({
+ ok: true,
+ output: '1.5+(x)/-\nAB😀\n\0',
+ })
+ })
+
+ describe('error positions', () => {
+ it('an unescaped metacharacter', () => {
+ expect(unescapeRegex('a.b')).toMatchObject({ ok: false, error: { index: 1 } })
+ expect(unescapeRegex('ab|c')).toMatchObject({ ok: false, error: { index: 2 } })
+ })
+
+ it('a class or assertion escape', () => {
+ expect(unescapeRegex('a\\d')).toMatchObject({ ok: false, error: { index: 1 } })
+ expect(unescapeRegex('\\bword')).toMatchObject({ ok: false, error: { index: 0 } })
+ })
+
+ it('a backreference', () => {
+ expect(unescapeRegex('(a)\\1'.slice(3))).toMatchObject({ ok: false, error: { index: 0 } })
+ expect(unescapeRegex('x\\01')).toMatchObject({ ok: false, error: { index: 1 } })
+ })
+
+ it('malformed hex escapes', () => {
+ expect(unescapeRegex('ab\\x4')).toMatchObject({ ok: false, error: { index: 2 } })
+ expect(unescapeRegex('a\\u12')).toMatchObject({ ok: false, error: { index: 1 } })
+ expect(unescapeRegex('a\\u{110000}')).toMatchObject({ ok: false, error: { index: 1 } })
+ })
+
+ it('an unknown letter escape', () => {
+ expect(unescapeRegex('a\\q')).toMatchObject({ ok: false, error: { index: 1 } })
+ })
+
+ it('a lone backslash at the end', () => {
+ expect(unescapeRegex('ab\\')).toMatchObject({ ok: false, error: { index: 2 } })
+ })
+ })
+})
diff --git a/packages/core/src/escape/regex.ts b/packages/core/src/escape/regex.ts
new file mode 100644
index 0000000..d846c57
--- /dev/null
+++ b/packages/core/src/escape/regex.ts
@@ -0,0 +1,117 @@
+import { fail, isHexDigit, ok, type EscapeResult } from './result.ts'
+
+/**
+ * Every character with a meaning in a pattern outside a character class, plus
+ * `/` so the result also drops into a `/…/` literal. All of these are legal
+ * escapes under the `u` and `v` flags as well, unlike escaping `-` or a letter.
+ */
+const SYNTAX = /[\\^$.*+?()[\]{}|/]/
+const SYNTAX_G = /[\\^$.*+?()[\]{}|/\n\r\u2028\u2029]/g
+
+const LINE: Record = {
+ '\n': '\\n',
+ '\r': '\\r',
+ '\u2028': '\\u2028',
+ '\u2029': '\\u2029',
+}
+
+/**
+ * A pattern that matches exactly `s`, with any flags. Line terminators are
+ * written as escapes, which a `RegExp` doesn't need but a regex literal does.
+ */
+export function escapeRegex(s: string): string {
+ return s.replace(SYNTAX_G, (c) => LINE[c] ?? `\\${c}`)
+}
+
+const SIMPLE: Record = {
+ n: '\n',
+ r: '\r',
+ t: '\t',
+ f: '\f',
+ v: '\v',
+}
+
+const CLASSES: Record = {
+ d: 'a digit class',
+ D: 'a non-digit class',
+ w: 'a word-character class',
+ W: 'a non-word-character class',
+ s: 'a whitespace class',
+ S: 'a non-whitespace class',
+ b: 'a word boundary',
+ B: 'a non-boundary',
+ p: 'a Unicode property class',
+ P: 'a Unicode property class',
+ k: 'a named backreference',
+}
+
+/**
+ * The reverse, for a pattern that is a plain literal: the exact string it
+ * matches. Anything that makes the pattern match more than one string (an
+ * unescaped `.`, a `\d`, a group) is an error at its position, because such a
+ * pattern has no single text to give back.
+ */
+export function unescapeRegex(input: string): EscapeResult {
+ let out = ''
+ let run = 0
+ for (let i = 0; i < input.length; i++) {
+ const c = input[i]!
+ if (c !== '\\') {
+ if (c !== '/' && SYNTAX.test(c)) {
+ return fail(input, i, `Unescaped ${c}`, 'it is regex syntax, so this pattern is not a plain literal; escape it as \\' + c)
+ }
+ continue
+ }
+ out += input.slice(run, i)
+ const e = input[i + 1]
+ if (e === undefined) return fail(input, i, 'Lone backslash at the end', 'write a literal backslash as \\\\')
+
+ const simple = SIMPLE[e]
+ const what = CLASSES[e]
+ if (simple !== undefined) {
+ out += simple
+ i++
+ } else if (what !== undefined) {
+ return fail(input, i, `\\${e} is ${what}`, 'it matches a set of strings, not one literal')
+ } else if (e === '0' && !/[0-9]/.test(input[i + 2] ?? '')) {
+ out += '\0'
+ i++
+ } else if (e >= '0' && e <= '9') {
+ return fail(input, i, `\\${e} is a backreference`, 'it repeats a group, so it is not a literal')
+ } else if (e === 'x') {
+ if (!isHexDigit(input[i + 2]) || !isHexDigit(input[i + 3])) {
+ return fail(input, i, 'Incomplete \\x escape', 'it needs exactly two hex digits, like \\x41')
+ }
+ out += String.fromCharCode(Number.parseInt(input.slice(i + 2, i + 4), 16))
+ i += 3
+ } else if (e === 'u' && input[i + 2] === '{') {
+ const close = input.indexOf('}', i + 3)
+ const digits = close === -1 ? '' : input.slice(i + 3, close)
+ const cp = Number.parseInt(digits, 16)
+ if (digits === '' || ![...digits].every(isHexDigit) || cp > 0x10ffff) {
+ return fail(input, i, 'Malformed \\u{…} escape', 'it needs up to six hex digits no higher than 10FFFF, like \\u{1F600}')
+ }
+ out += String.fromCodePoint(cp)
+ i = close
+ } else if (e === 'u') {
+ const digits = input.slice(i + 2, i + 6)
+ if (digits.length < 4 || ![...digits].every(isHexDigit)) {
+ return fail(input, i, 'Incomplete \\u escape', 'it needs four hex digits, like \\u00E9')
+ }
+ out += String.fromCharCode(Number.parseInt(digits, 16))
+ i += 5
+ } else if (e === 'c' && /[A-Za-z]/.test(input[i + 2] ?? '')) {
+ out += String.fromCharCode(input.charCodeAt(i + 2) % 32)
+ i += 2
+ } else if (/[A-Za-z]/.test(e)) {
+ return fail(input, i, `Unknown escape \\${e}`, 'it is not a character escape, and the u flag rejects it')
+ } else {
+ // Identity escape of punctuation: \. \- \, \/ …
+ out += e
+ i++
+ }
+ run = i + 1
+ }
+ return ok(out + input.slice(run))
+}
+
diff --git a/packages/core/src/escape/result.ts b/packages/core/src/escape/result.ts
new file mode 100644
index 0000000..6b15bf9
--- /dev/null
+++ b/packages/core/src/escape/result.ts
@@ -0,0 +1,54 @@
+import { buildLineIndex, offsetToLineCol } from '../text/offsets.ts'
+
+export interface EscapeError {
+ /** 0-based UTF-16 index into the input, pointing at the offending character. */
+ index: number
+ /** 1-based. */
+ line: number
+ /** 1-based, in UTF-16 code units. */
+ column: number
+ message: string
+}
+
+/**
+ * Bad input comes back as data, never as a throw: the tool renders the message
+ * inline and keeps the last good state of everything else.
+ */
+export type EscapeResult =
+ | { ok: true; output: string; warnings: string[] }
+ | { ok: false; error: EscapeError }
+
+export function ok(output: string, warnings: string[] = []): EscapeResult {
+ return { ok: true, output, warnings }
+}
+
+/**
+ * `what` names the problem, `hint` says how to fix it. The position is spelled
+ * out in the message too, because on a pasted one-line blob "column 4,113" is
+ * the only way to find it.
+ */
+export function fail(input: string, index: number, what: string, hint?: string): EscapeResult {
+ const { line, column } = offsetToLineCol(buildLineIndex(input), index)
+ const where = line > 1 ? `position ${index + 1} (line ${line}, column ${column})` : `position ${index + 1}`
+ return {
+ ok: false,
+ error: { index, line, column, message: `${what} at ${where}${hint === undefined ? '' : `: ${hint}`}.` },
+ }
+}
+
+export function hex(n: number, width: number): string {
+ return n.toString(16).toUpperCase().padStart(width, '0')
+}
+
+export function isHexDigit(c: string | undefined): boolean {
+ return c !== undefined && /^[0-9a-fA-F]$/.test(c)
+}
+
+/** Describe a character for an error message, so an invisible one is still visible. */
+export function show(c: string): string {
+ const cp = c.codePointAt(0)!
+ if (cp < 0x20 || cp === 0x7f || (cp >= 0x80 && cp <= 0x9f) || cp === 0x2028 || cp === 0x2029) {
+ return `U+${hex(cp, 4)}`
+ }
+ return c
+}
diff --git a/packages/core/src/escape/shell.test.ts b/packages/core/src/escape/shell.test.ts
new file mode 100644
index 0000000..6b9cf83
--- /dev/null
+++ b/packages/core/src/escape/shell.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest'
+import { escapeShell, shellWarnings, unescapeShell } from './shell.ts'
+
+describe('escapeShell', () => {
+ it('wraps in single quotes with the quote-escape idiom', () => {
+ expect(escapeShell("it's")).toBe("'it'\\''s'")
+ expect(escapeShell('$HOME `id` "x" \\n *')).toBe('\'$HOME `id` "x" \\n *\'')
+ })
+
+ it('keeps an empty string as an argument', () => {
+ expect(escapeShell('')).toBe("''")
+ })
+
+ it('warns that NUL cannot be passed', () => {
+ expect(shellWarnings('a\0b')).toHaveLength(1)
+ expect(shellWarnings('ab')).toEqual([])
+ })
+})
+
+describe('unescapeShell', () => {
+ it('resolves single quotes, double quotes and backslashes', () => {
+ expect(unescapeShell("'it'\\''s'")).toMatchObject({ ok: true, output: "it's" })
+ expect(unescapeShell('"a \\"b\\" \\$c \\\\ \\n $"')).toMatchObject({ ok: true, output: 'a "b" $c \\ \\n $' })
+ expect(unescapeShell('a\\ b\\\nc')).toMatchObject({ ok: true, output: 'a bc' })
+ expect(unescapeShell(" 'padded' ")).toMatchObject({ ok: true, output: 'padded' })
+ })
+
+ it('warns once about an unquoted glob', () => {
+ const r = unescapeShell('*.txt?')
+ expect(r).toMatchObject({ ok: true, output: '*.txt?' })
+ if (r.ok) expect(r.warnings).toHaveLength(1)
+ })
+
+ describe('error positions', () => {
+ it('unterminated quotes: at the opening quote', () => {
+ expect(unescapeShell("ab'cd")).toMatchObject({ ok: false, error: { index: 2 } })
+ expect(unescapeShell('ab"cd')).toMatchObject({ ok: false, error: { index: 2 } })
+ })
+
+ it('unquoted whitespace splits arguments', () => {
+ expect(unescapeShell('a b')).toMatchObject({ ok: false, error: { index: 1 } })
+ })
+
+ it('expansions, inside and outside double quotes', () => {
+ expect(unescapeShell('a$HOME')).toMatchObject({ ok: false, error: { index: 1 } })
+ expect(unescapeShell('"x$(id)"')).toMatchObject({ ok: false, error: { index: 2 } })
+ expect(unescapeShell('"x`id`"')).toMatchObject({ ok: false, error: { index: 2 } })
+ expect(unescapeShell('ab`id`')).toMatchObject({ ok: false, error: { index: 2 } })
+ })
+
+ it("Bash's $'…'", () => {
+ expect(unescapeShell("x$'\\n'")).toMatchObject({ ok: false, error: { index: 1 } })
+ })
+
+ it('operators, comments and tildes', () => {
+ expect(unescapeShell('a;b')).toMatchObject({ ok: false, error: { index: 1 } })
+ expect(unescapeShell('a|b')).toMatchObject({ ok: false, error: { index: 1 } })
+ expect(unescapeShell('#x')).toMatchObject({ ok: false, error: { index: 0 } })
+ expect(unescapeShell('~/x')).toMatchObject({ ok: false, error: { index: 0 } })
+ expect(unescapeShell('a#b~')).toMatchObject({ ok: true, output: 'a#b~' })
+ })
+
+ it('a lone backslash at the end', () => {
+ expect(unescapeShell('ab\\')).toMatchObject({ ok: false, error: { index: 2 } })
+ })
+ })
+})
diff --git a/packages/core/src/escape/shell.ts b/packages/core/src/escape/shell.ts
new file mode 100644
index 0000000..bf194b7
--- /dev/null
+++ b/packages/core/src/escape/shell.ts
@@ -0,0 +1,114 @@
+import { fail, ok, type EscapeResult } from './result.ts'
+
+/**
+ * Quote one argument for POSIX `sh` (and bash, zsh, dash, ksh).
+ *
+ * Inside single quotes nothing is special, not even a backslash, so the only
+ * character that needs care is `'` itself: close the quote, add an escaped
+ * quote, reopen (`'\''`). That makes the output safe for any input, with no
+ * list of metacharacters to get wrong. Always quoted, even when it isn't
+ * strictly needed, so the result is predictable and an empty string stays an
+ * argument (`''`).
+ */
+export function escapeShell(s: string): string {
+ return `'${s.replaceAll("'", "'\\''")}'`
+}
+
+export function shellWarnings(s: string): string[] {
+ return s.includes('\0')
+ ? ['The text has a NUL character (U+0000). No shell argument can hold one: the program receives the text cut short there.']
+ : []
+}
+
+const SPACE = /[ \t\n]/
+const OPERATOR = /[|&;<>()]/
+// What can follow `$` to start an expansion: a name, a positional or special
+// parameter, `${…}`, `$(…)`.
+const EXPANSION = /[A-Za-z0-9_{(@*#?$!-]/
+
+/**
+ * Turn one shell word back into the text the program would receive, without
+ * running anything. Single quotes, double quotes and backslashes are
+ * resolved; anything that would expand (`$x`, `$(…)`, backticks), split the
+ * word, or act as shell syntax is reported, because its value depends on a
+ * running shell.
+ */
+export function unescapeShell(input: string): EscapeResult {
+ let start = 0
+ let stop = input.length
+ while (SPACE.test(input[start] ?? '')) start++
+ while (stop > start && SPACE.test(input[stop - 1]!)) stop--
+
+ const warnings: string[] = []
+ let out = ''
+ for (let i = start; i < stop; i++) {
+ const c = input[i]!
+
+ if (c === "'") {
+ const close = input.indexOf("'", i + 1)
+ if (close === -1 || close >= stop) {
+ return fail(input, i, 'Unterminated single quote', "add a closing '")
+ }
+ out += input.slice(i + 1, close)
+ i = close
+ } else if (c === '"') {
+ let j = i + 1
+ for (; j < stop; j++) {
+ const d = input[j]!
+ if (d === '"') break
+ if (d === '\\') {
+ // Inside double quotes a backslash escapes only these; before
+ // anything else it is a literal backslash.
+ const e = input[j + 1]
+ if (e === '$' || e === '`' || e === '"' || e === '\\') {
+ out += e
+ j++
+ } else if (e === '\n') {
+ j++
+ } else {
+ out += d
+ }
+ } else if (d === '`' || (d === '$' && EXPANSION.test(input[j + 1] ?? ''))) {
+ return expansion(input, j)
+ } else {
+ out += d
+ }
+ }
+ if (j >= stop) return fail(input, i, 'Unterminated double quote', 'add a closing "')
+ i = j
+ } else if (c === '\\') {
+ const e = input[i + 1]
+ if (e === undefined || i + 1 >= stop) {
+ return fail(input, i, 'Lone backslash at the end', "the shell would wait for another line; write it as '\\'")
+ }
+ if (e !== '\n') out += e
+ i++
+ } else if (SPACE.test(c)) {
+ return fail(input, i, 'Unquoted whitespace', 'the shell splits the text into separate arguments here; quote it')
+ } else if (c === '`' || (c === '$' && EXPANSION.test(input[i + 1] ?? ''))) {
+ return expansion(input, i)
+ } else if (c === '$' && input[i + 1] === "'") {
+ return fail(input, i, "$'…' quoting", 'that is a Bash extension, not POSIX sh')
+ } else if (OPERATOR.test(c)) {
+ return fail(input, i, `Unquoted ${c}`, 'it is shell syntax, not part of the argument; quote it')
+ } else if (c === '#' && i === start) {
+ return fail(input, i, 'Unquoted # starts a comment', 'the shell ignores the rest of the line; quote it')
+ } else if (c === '~' && i === start) {
+ return fail(input, i, 'Unquoted ~', 'the shell replaces it with a home directory; quote it')
+ } else {
+ if ((c === '*' || c === '?' || c === '[') && warnings.length === 0) {
+ warnings.push(
+ `Unquoted ${c} at position ${i + 1}: the shell would expand it to matching file names, if there are any. The output assumes there are none.`,
+ )
+ }
+ out += c
+ }
+ }
+ return ok(out, warnings)
+}
+
+function expansion(input: string, i: number): EscapeResult {
+ return input[i] === '`'
+ ? fail(input, i, 'Command substitution `…`', 'its value comes from running a command; quote it with single quotes')
+ : fail(input, i, 'Unquoted $', 'the shell expands it to a variable or a command’s output; put it in single quotes')
+}
diff --git a/packages/core/src/escape/sql.test.ts b/packages/core/src/escape/sql.test.ts
new file mode 100644
index 0000000..34c9f70
--- /dev/null
+++ b/packages/core/src/escape/sql.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest'
+import { escapeSql, sqlWarnings, unescapeSql } from './sql.ts'
+
+describe('escapeSql', () => {
+ it('doubles quotes in standard SQL, and nothing else', () => {
+ expect(escapeSql("O'Brien \\ \"x\"")).toBe("'O''Brien \\ \"x\"'")
+ expect(escapeSql("it's", { wrap: false })).toBe("it''s")
+ })
+
+ it('uses backslash escapes for MySQL', () => {
+ expect(escapeSql("a'b\"c\\d\0e\nf\rg\x1ah", { dialect: 'mysql' })).toBe(
+ "'a\\'b\\\"c\\\\d\\0e\\nf\\rg\\Zh'",
+ )
+ })
+
+ it('warns about backslashes in standard mode and about NUL', () => {
+ expect(sqlWarnings('a\\b', 'standard')[0]).toContain('MySQL')
+ expect(sqlWarnings('a\\b', 'mysql')).toEqual([])
+ expect(sqlWarnings('a\0', 'mysql')[0]).toContain('NUL')
+ })
+})
+
+describe('unescapeSql', () => {
+ it('reads a whole literal or a body', () => {
+ expect(unescapeSql(" 'it''s' ")).toMatchObject({ ok: true, output: "it's" })
+ expect(unescapeSql("it''s")).toMatchObject({ ok: true, output: "it's" })
+ expect(unescapeSql("''")).toMatchObject({ ok: true, output: '' })
+ })
+
+ it('leaves backslashes alone in standard SQL', () => {
+ expect(unescapeSql("'a\\nb'")).toMatchObject({ ok: true, output: 'a\\nb' })
+ })
+
+ it('decodes MySQL escapes, keeping \\% and \\_ for LIKE', () => {
+ expect(unescapeSql("'\\0\\'\\\"\\b\\n\\r\\t\\Z\\\\\\%\\_\\q'''", 'mysql')).toMatchObject({
+ ok: true,
+ output: "\0'\"\b\n\r\t\x1a\\\\%\\_q'",
+ })
+ })
+
+ describe('error positions', () => {
+ it('a lone quote', () => {
+ const r = unescapeSql("'it's'")
+ expect(r).toMatchObject({ ok: false, error: { index: 3 } })
+ expect(unescapeSql("it's")).toMatchObject({ ok: false, error: { index: 2 } })
+ })
+
+ it('a quote doubled at the very end is still a lone closing quote short', () => {
+ expect(unescapeSql("'a''")).toMatchObject({ ok: false, error: { index: 2 } })
+ })
+
+ it('a MySQL backslash that escapes the closing quote', () => {
+ expect(unescapeSql("'abc\\'", 'mysql')).toMatchObject({ ok: false, error: { index: 4 } })
+ expect(unescapeSql('abc\\', 'mysql')).toMatchObject({ ok: false, error: { index: 3 } })
+ })
+ })
+})
diff --git a/packages/core/src/escape/sql.ts b/packages/core/src/escape/sql.ts
new file mode 100644
index 0000000..98444eb
--- /dev/null
+++ b/packages/core/src/escape/sql.ts
@@ -0,0 +1,111 @@
+import { fail, ok, type EscapeResult } from './result.ts'
+
+/**
+ * `standard` is the SQL-92 rule every engine accepts: a quote is doubled and
+ * nothing else is special. `mysql` is MySQL/MariaDB's default mode, where a
+ * backslash is also an escape character (unless `NO_BACKSLASH_ESCAPES` is on).
+ */
+export type SqlDialect = 'standard' | 'mysql'
+
+export interface SqlEscapeOptions {
+ dialect?: SqlDialect
+ /** Include the surrounding single quotes. Default true. */
+ wrap?: boolean
+}
+
+// What mysql_real_escape_string escapes: enough that the literal survives a
+// log line or a binary-unsafe client, not just the parser.
+const MYSQL_OUT: Record = {
+ '\0': '\\0',
+ '\n': '\\n',
+ '\r': '\\r',
+ '\x1a': '\\Z',
+ "'": "\\'",
+ '"': '\\"',
+ '\\': '\\\\',
+}
+
+export function escapeSql(s: string, opts: SqlEscapeOptions = {}): string {
+ let out: string
+ if (opts.dialect === 'mysql') {
+ out = ''
+ let run = 0
+ for (let i = 0; i < s.length; i++) {
+ const rep = MYSQL_OUT[s[i]!]
+ if (rep === undefined) continue
+ out += s.slice(run, i) + rep
+ run = i + 1
+ }
+ out += s.slice(run)
+ } else {
+ out = s.replaceAll("'", "''")
+ }
+ return opts.wrap === false ? out : `'${out}'`
+}
+
+/** Notes on input that escapes correctly but may still surprise. */
+export function sqlWarnings(s: string, dialect: SqlDialect): string[] {
+ const warnings: string[] = []
+ if (dialect === 'standard' && s.includes('\\')) {
+ warnings.push(
+ 'The text has a backslash. MySQL and MariaDB read it as an escape unless NO_BACKSLASH_ESCAPES is on; use the MySQL variant there.',
+ )
+ }
+ if (s.includes('\0')) {
+ warnings.push('The text has a NUL character (U+0000). PostgreSQL text columns reject it, and some drivers cut the string there.')
+ }
+ return warnings
+}
+
+const MYSQL_IN: Record = {
+ '0': '\0',
+ b: '\b',
+ n: '\n',
+ r: '\r',
+ t: '\t',
+ Z: '\x1a',
+ // \% and \_ keep their backslash: they are LIKE-pattern escapes, and MySQL
+ // deliberately leaves them for the pattern matcher.
+ '%': '\\%',
+ _: '\\_',
+}
+
+/**
+ * Text that starts and ends with `'` is read as a whole literal and its outer
+ * quotes are dropped; anything else is read as a literal's body. Either way a
+ * lone `'` is an error, because it would end the string early.
+ */
+export function unescapeSql(input: string, dialect: SqlDialect = 'standard'): EscapeResult {
+ let start = 0
+ let stop = input.length
+ while (/\s/.test(input[start] ?? '')) start++
+ while (stop > start && /\s/.test(input[stop - 1]!)) stop--
+ const quoted = stop - start >= 2 && input[start] === "'" && input[stop - 1] === "'"
+ const from = quoted ? start + 1 : 0
+ const to = quoted ? stop - 1 : input.length
+
+ let out = ''
+ let run = from
+ for (let i = from; i < to; i++) {
+ const c = input[i]
+ if (c === "'") {
+ if (input[i + 1] !== "'" || i + 1 >= to) {
+ return fail(input, i, 'Unescaped quote', "a lone ' ends the string; double it as ''")
+ }
+ out += input.slice(run, i + 1)
+ i++
+ run = i + 1
+ } else if (c === '\\' && dialect === 'mysql') {
+ const e = input[i + 1]
+ if (e === undefined || i + 1 >= to) {
+ return quoted
+ ? fail(input, i, 'Unterminated string', 'this backslash escapes the closing quote')
+ : fail(input, i, 'Lone backslash at the end', 'write a literal backslash as \\\\')
+ }
+ out += input.slice(run, i) + (MYSQL_IN[e] ?? e)
+ i++
+ run = i + 1
+ }
+ }
+ return ok(out + input.slice(run, to))
+}
diff --git a/packages/core/src/lorem/index.ts b/packages/core/src/lorem/index.ts
new file mode 100644
index 0000000..47e9bd4
--- /dev/null
+++ b/packages/core/src/lorem/index.ts
@@ -0,0 +1 @@
+export * from './lorem.ts'
diff --git a/packages/core/src/lorem/lorem.test.ts b/packages/core/src/lorem/lorem.test.ts
new file mode 100644
index 0000000..733d684
--- /dev/null
+++ b/packages/core/src/lorem/lorem.test.ts
@@ -0,0 +1,126 @@
+import fc from 'fast-check'
+import { describe, expect, it } from 'vitest'
+import { mulberry32 } from '../random/random.ts'
+import {
+ LOREM_LIMITS,
+ LOREM_WORDS,
+ clampLoremCount,
+ generateLorem,
+ type LoremFormat,
+ type LoremOptions,
+ type LoremUnit,
+} from './lorem.ts'
+
+const base: LoremOptions = { count: 3, unit: 'paragraphs', format: 'text', startWithLorem: true }
+const gen = (opts: Partial, seed = 1) => generateLorem({ ...base, ...opts }, mulberry32(seed))
+
+/** Back to bare words, whatever the format. */
+const plain = (s: string) => s.replace(/<\/?p>|\*/g, '')
+const countWords = (s: string) => plain(s).split(/\s+/).filter(Boolean).length
+const countSentences = (s: string) => (plain(s).match(/\./g) ?? []).length
+const paragraphsOf = (s: string, format: LoremFormat) =>
+ s === '' ? [] : format === 'html' ? s.split('\n') : s.split('\n\n')
+
+describe('generateLorem', () => {
+ it('gives exact counts in every unit and format', () => {
+ fc.assert(
+ fc.property(
+ fc.integer({ min: 0, max: 300 }),
+ fc.constantFrom('words', 'sentences', 'paragraphs'),
+ fc.constantFrom('text', 'html', 'markdown'),
+ fc.boolean(),
+ fc.integer(),
+ (count, unit, format, startWithLorem, seed) => {
+ const r = generateLorem({ count, unit, format, startWithLorem }, mulberry32(seed))
+ expect(r.count).toBe(count)
+ expect(countWords(r.output)).toBe(r.words)
+ expect(countSentences(r.output)).toBe(r.sentences)
+ expect(paragraphsOf(r.output, format)).toHaveLength(r.paragraphs)
+ const exact = unit === 'words' ? r.words : unit === 'sentences' ? r.sentences : r.paragraphs
+ expect(exact).toBe(count)
+ },
+ ),
+ { numRuns: 300 },
+ )
+ })
+
+ it('gives the same text for the same seed, and different text for another', () => {
+ expect(gen({}, 42).output).toBe(gen({}, 42).output)
+ expect(gen({}, 42).output).not.toBe(gen({}, 43).output)
+ })
+
+ it('is pinned for a fixed seed', () => {
+ expect(gen({ unit: 'sentences', count: 3, startWithLorem: false }, 7).output).toBe(
+ 'Nisl minim adipiscing ut ultricies laborum excepteur. Do amet sit cupidatat tellus, ante cupidatat fermentum est dapibus. Faucibus a in fugiat vestibulum convallis mauris exercitation pulvinar velit, ultricies posuere.',
+ )
+ })
+
+ it('gives the same words in every format for one seed', () => {
+ const text = gen({ format: 'text' }, 9).output
+ expect(plain(gen({ format: 'markdown' }, 9).output)).toBe(text)
+ expect(gen({ format: 'html' }, 9).output).toBe(
+ text
+ .split('\n\n')
+ .map((p) => `${p}
`)
+ .join('\n'),
+ )
+ })
+
+ it('starts with the classic sentence on request', () => {
+ expect(gen({ unit: 'sentences', count: 1 }).output).toBe('Lorem ipsum dolor sit amet, consectetur adipiscing elit.')
+ expect(gen({ unit: 'paragraphs', count: 2 }).output).toMatch(/^Lorem ipsum dolor sit amet, consectetur adipiscing elit\. [A-Z]/)
+ expect(gen({ unit: 'sentences', count: 3, startWithLorem: false }).output.startsWith('Lorem ipsum dolor sit amet,')).toBe(false)
+ })
+
+ it('cuts the classic sentence short for a few words, without a dangling comma', () => {
+ expect(gen({ unit: 'words', count: 2 }).output).toBe('Lorem ipsum.')
+ expect(gen({ unit: 'words', count: 5 }).output).toBe('Lorem ipsum dolor sit amet.')
+ expect(gen({ unit: 'words', count: 6 }).output).toBe('Lorem ipsum dolor sit amet, consectetur.')
+ })
+
+ it('wraps HTML paragraphs in , one per line', () => {
+ const out = gen({ format: 'html', count: 2 }).output
+ expect(out.split('\n')).toHaveLength(2)
+ expect(out).toMatch(/^
Lorem .*<\/p>\n
[A-Z].*<\/p>$/)
+ })
+
+ it('emphasises the odd word in Markdown, never the first of a sentence', () => {
+ const out = gen({ format: 'markdown', count: 20 }).output
+ expect(out).toMatch(/\*\*[a-z]+\*\*/)
+ expect(out).toMatch(/(? {
+ const r = gen({ unit: 'paragraphs', count: 5000 })
+ expect(r).toMatchObject({ count: 1000, capped: true, paragraphs: 1000 })
+ expect(gen({ unit: 'words', count: 1e9 })).toMatchObject({ count: LOREM_LIMITS.words, capped: true })
+ })
+
+ it('treats zero, negative and unparseable counts as nothing', () => {
+ for (const count of [0, -5, Number.NaN, Number.POSITIVE_INFINITY]) {
+ expect(gen({ count })).toMatchObject({ output: '', count: 0, capped: false, words: 0 })
+ }
+ })
+
+ it('never repeats a word back to back by chance more than the redraw allows', () => {
+ const words = plain(gen({ unit: 'words', count: 5000 }, 3).output).toLowerCase().split(/[\s,.]+/)
+ let repeats = 0
+ for (let i = 1; i < words.length; i++) if (words[i] === words[i - 1]) repeats++
+ // One redraw leaves a 1-in-~180² chance per pair.
+ expect(repeats).toBeLessThan(5)
+ })
+})
+
+describe('clampLoremCount', () => {
+ it('floors fractions', () => {
+ expect(clampLoremCount(2.9, 'words')).toEqual({ count: 2, capped: false })
+ })
+})
+
+describe('LOREM_WORDS', () => {
+ it('has no duplicates and nothing a format would need to escape', () => {
+ expect(new Set(LOREM_WORDS).size).toBe(LOREM_WORDS.length)
+ for (const w of LOREM_WORDS) expect(w).toMatch(/^[a-z]+$/)
+ })
+})
diff --git a/packages/core/src/lorem/lorem.ts b/packages/core/src/lorem/lorem.ts
new file mode 100644
index 0000000..09fe529
--- /dev/null
+++ b/packages/core/src/lorem/lorem.ts
@@ -0,0 +1,171 @@
+import { cryptoRandomSource, unbiasedBelow, type RandomSource } from '../random/random.ts'
+
+export type LoremUnit = 'words' | 'sentences' | 'paragraphs'
+export type LoremFormat = 'text' | 'html' | 'markdown'
+
+export interface LoremOptions {
+ count: number
+ unit: LoremUnit
+ format: LoremFormat
+ /** Open with the classic "Lorem ipsum dolor sit amet, consectetur adipiscing elit." */
+ startWithLorem: boolean
+}
+
+/**
+ * The output lands in a textarea, and one more order of magnitude would make
+ * typing in the count field lag. 1,000 paragraphs is ~60k words already, far
+ * past any layout test; the other two caps are set to roughly match it.
+ */
+export const LOREM_LIMITS: Readonly> = {
+ words: 50_000,
+ sentences: 5_000,
+ paragraphs: 1_000,
+}
+
+/**
+ * The words of the classic passage first, then the rest of the vocabulary the
+ * common generators draw from. All lowercase ASCII, so no format needs to
+ * escape anything.
+ */
+export const LOREM_WORDS: readonly string[] = (
+ 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et ' +
+ 'dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea ' +
+ 'commodo consequat duis aute irure in reprehenderit voluptate velit esse cillum eu fugiat nulla pariatur ' +
+ 'excepteur sint occaecat cupidatat non proident sunt culpa qui officia deserunt mollit anim id est laborum ' +
+ 'a ac accumsan aenean aliquam ante arcu at auctor augue bibendum blandit condimentum congue convallis cras ' +
+ 'cursus dapibus diam dictum dignissim donec egestas eget eleifend elementum erat eros etiam euismod ' +
+ 'facilisis fames faucibus felis fermentum feugiat finibus fringilla fusce gravida hendrerit iaculis ' +
+ 'imperdiet integer interdum justo lacinia lacus laoreet lectus leo libero ligula lobortis luctus maecenas ' +
+ 'massa mattis mauris metus mi molestie morbi nam nec neque nibh nisl nunc odio orci ornare pellentesque ' +
+ 'pharetra phasellus placerat porta porttitor posuere praesent pretium proin pulvinar purus quam quisque ' +
+ 'rhoncus risus rutrum sagittis sapien scelerisque semper senectus sodales sollicitudin suscipit ' +
+ 'suspendisse tellus tempus tincidunt tortor tristique turpis ultrices ultricies urna varius vehicula vel ' +
+ 'vestibulum vitae vivamus viverra volutpat vulputate'
+).split(' ')
+
+const CLASSIC = { words: LOREM_WORDS.slice(0, 8), comma: 4 }
+
+interface Sentence {
+ words: string[]
+ /** Index of the word followed by a comma, or -1. */
+ comma: number
+ /** Index of a word Markdown emphasises, or -1; `strong` picks ** over *. */
+ emphasis: number
+ strong: boolean
+}
+
+export interface LoremResult {
+ output: string
+ /** The count actually generated, after clamping. */
+ count: number
+ /** True when the requested count was over the unit's limit. */
+ capped: boolean
+ words: number
+ sentences: number
+ paragraphs: number
+}
+
+/** A whole number from 0 to the unit's limit; anything unparseable is 0. */
+export function clampLoremCount(count: number, unit: LoremUnit): { count: number; capped: boolean } {
+ const n = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0
+ const limit = LOREM_LIMITS[unit]
+ return n > limit ? { count: limit, capped: true } : { count: n, capped: false }
+}
+
+/**
+ * Takes a RandomSource so a seed (`mulberry32`) gives the same text every time,
+ * which is what the tests rely on. Every random choice is drawn the same way
+ * whatever the format, so one seed gives the same words in text, HTML and
+ * Markdown.
+ */
+export function generateLorem(opts: LoremOptions, next: RandomSource = cryptoRandomSource()): LoremResult {
+ const { count, capped } = clampLoremCount(opts.count, opts.unit)
+ let previous = ''
+ const word = (): string => {
+ let w = LOREM_WORDS[unbiasedBelow(next, LOREM_WORDS.length)]!
+ // One redraw: "et et" reads as a bug, not as filler.
+ if (w === previous) w = LOREM_WORDS[unbiasedBelow(next, LOREM_WORDS.length)]!
+ previous = w
+ return w
+ }
+ const sentence = (): Sentence => {
+ const length = 4 + unbiasedBelow(next, 11)
+ const words = Array.from({ length }, word)
+ const comma = length >= 8 && unbiasedBelow(next, 2) === 0 ? 2 + unbiasedBelow(next, length - 4) : -1
+ const style = unbiasedBelow(next, 8)
+ const emphasis = style < 2 ? 1 + unbiasedBelow(next, length - 1) : -1
+ return { words, comma, emphasis, strong: style === 1 }
+ }
+
+ let first = opts.startWithLorem
+ const firstSentence = (): Sentence => {
+ if (!first) return sentence()
+ first = false
+ previous = CLASSIC.words.at(-1)!
+ return { words: [...CLASSIC.words], comma: CLASSIC.comma, emphasis: -1, strong: false }
+ }
+
+ const paragraphs: Sentence[][] = []
+ if (count > 0) {
+ if (opts.unit === 'paragraphs') {
+ for (let p = 0; p < count; p++) {
+ const n = 3 + unbiasedBelow(next, 5)
+ paragraphs.push(Array.from({ length: n }, firstSentence))
+ }
+ } else if (opts.unit === 'sentences') {
+ paragraphs.push(Array.from({ length: count }, firstSentence))
+ } else {
+ // Whole sentences until the last, which is cut to land on the exact
+ // count; a comma that would end up last is dropped with it.
+ const para: Sentence[] = []
+ let remaining = count
+ while (remaining > 0) {
+ const s = firstSentence()
+ if (s.words.length > remaining) {
+ s.words.length = remaining
+ if (s.comma >= remaining - 1) s.comma = -1
+ if (s.emphasis >= remaining) s.emphasis = -1
+ }
+ remaining -= s.words.length
+ para.push(s)
+ }
+ paragraphs.push(para)
+ }
+ }
+
+ let words = 0
+ let sentences = 0
+ for (const p of paragraphs) {
+ sentences += p.length
+ for (const s of p) words += s.words.length
+ }
+
+ return {
+ output: render(paragraphs, opts.format),
+ count,
+ capped,
+ words,
+ sentences,
+ paragraphs: paragraphs.length,
+ }
+}
+
+function render(paragraphs: Sentence[][], format: LoremFormat): string {
+ const text = paragraphs.map((p) => p.map((s) => renderSentence(s, format === 'markdown')).join(' '))
+ return format === 'html' ? text.map((p) => `${p}
`).join('\n') : text.join('\n\n')
+}
+
+function renderSentence(s: Sentence, markdown: boolean): string {
+ let out = ''
+ for (let i = 0; i < s.words.length; i++) {
+ let w = s.words[i]!
+ if (i === 0) w = w[0]!.toUpperCase() + w.slice(1)
+ // Markdown gets the odd *italic* or **bold** word, so a renderer has
+ // something to render; it stays inside the word, so counts don't change.
+ if (markdown && i === s.emphasis) w = s.strong ? `**${w}**` : `*${w}*`
+ if (i > 0) out += ' '
+ out += w
+ if (i === s.comma) out += ','
+ }
+ return `${out}.`
+}
diff --git a/packages/core/src/qr/color.test.ts b/packages/core/src/qr/color.test.ts
new file mode 100644
index 0000000..414fecb
--- /dev/null
+++ b/packages/core/src/qr/color.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest'
+import { checkColors, contrastRatio, normalizeHex } from './color.ts'
+
+describe('normalizeHex', () => {
+ it.each([
+ ['#000', '#000000'],
+ ['#ABCDEF', '#abcdef'],
+ ['fff', '#ffffff'],
+ [' #123456 ', '#123456'],
+ ])('%j → %j', (input, expected) => {
+ expect(normalizeHex(input)).toBe(expected)
+ })
+
+ it.each(['', '#', '#12', '#1234', '#12345678', 'red', '#ggg'])('rejects %j', (input) => {
+ expect(normalizeHex(input)).toBeNull()
+ })
+})
+
+describe('contrastRatio', () => {
+ it('is 21 for black on white and 1 for a colour on itself', () => {
+ expect(contrastRatio({ r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 })).toBeCloseTo(21, 5)
+ expect(contrastRatio({ r: 10, g: 200, b: 30 }, { r: 10, g: 200, b: 30 })).toBe(1)
+ })
+})
+
+describe('checkColors', () => {
+ it('passes black on white with no warnings', () => {
+ expect(checkColors('#000', '#fff')).toMatchObject({
+ ok: true,
+ foreground: '#000000',
+ background: '#ffffff',
+ warnings: [],
+ })
+ })
+
+ it('warns on low contrast', () => {
+ const r = checkColors('#777777', '#888888')
+ expect(r.ok && r.warnings.some((w) => w.startsWith('Low contrast'))).toBe(true)
+ })
+
+ it('warns on an inverted code even at full contrast', () => {
+ const r = checkColors('#ffffff', '#000000')
+ expect(r.ok && r.warnings).toHaveLength(1)
+ expect(r.ok && r.warnings[0]).toMatch(/inverted/)
+ })
+
+ it('names the invalid field', () => {
+ expect(checkColors('nope', '#fff')).toMatchObject({ ok: false, field: 'foreground' })
+ expect(checkColors('#000', 'nope')).toMatchObject({ ok: false, field: 'background' })
+ })
+})
diff --git a/packages/core/src/qr/color.ts b/packages/core/src/qr/color.ts
new file mode 100644
index 0000000..bcc7865
--- /dev/null
+++ b/packages/core/src/qr/color.ts
@@ -0,0 +1,80 @@
+/**
+ * Colour checks for the code's two colours. A QR scanner thresholds the image
+ * to black and white, so what matters is the luminance gap, not the hue.
+ */
+
+export interface Rgb {
+ r: number
+ g: number
+ b: number
+}
+
+const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i
+
+/**
+ * `#rgb` or `#rrggbb`, with or without the `#`, normalised to lowercase
+ * `#rrggbb`. No alpha: a translucent code scans differently on every
+ * background it is printed over.
+ */
+export function normalizeHex(input: string): string | null {
+ const m = HEX.exec(input.trim())
+ if (m === null) return null
+ const hex = m[1]!.toLowerCase()
+ const full = hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex
+ return `#${full}`
+}
+
+function toRgb(hex: string): Rgb {
+ const n = Number.parseInt(hex.slice(1), 16)
+ return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff }
+}
+
+/** sRGB channel (0-255) to linear light, the WCAG 2 formula. */
+function linear(c: number): number {
+ const s = c / 255
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
+}
+
+/** WCAG 2 relative luminance, 0 (black) to 1 (white). */
+export function luminance({ r, g, b }: Rgb): number {
+ return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
+}
+
+/** WCAG 2 contrast ratio, 1 to 21. */
+export function contrastRatio(a: Rgb, b: Rgb): number {
+ const la = luminance(a)
+ const lb = luminance(b)
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)
+}
+
+/**
+ * Below this the code tends to fail on phone cameras under poor light. It is
+ * stricter than the 3:1 WCAG asks of UI parts, because a scanner has to
+ * binarise the image, and a glossy print or dim room eats contrast.
+ */
+export const MIN_SCAN_CONTRAST = 4
+
+export type ColorCheck =
+ | { ok: true; foreground: string; background: string; contrast: number; warnings: string[] }
+ | { ok: false; field: 'foreground' | 'background'; message: string }
+
+export function checkColors(foreground: string, background: string): ColorCheck {
+ const fg = normalizeHex(foreground)
+ if (fg === null) return { ok: false, field: 'foreground', message: `“${foreground}” is not a hex colour like #000000.` }
+ const bg = normalizeHex(background)
+ if (bg === null) return { ok: false, field: 'background', message: `“${background}” is not a hex colour like #ffffff.` }
+
+ const fgRgb = toRgb(fg)
+ const bgRgb = toRgb(bg)
+ const contrast = contrastRatio(fgRgb, bgRgb)
+ const warnings: string[] = []
+ if (contrast < MIN_SCAN_CONTRAST) {
+ warnings.push(`Low contrast (${contrast.toFixed(1)}:1). Scanners may fail; aim for ${MIN_SCAN_CONTRAST}:1 or more.`)
+ }
+ // Checked separately from contrast: white-on-black has full contrast but
+ // many scanners only look for dark modules on a light ground.
+ if (luminance(fgRgb) > luminance(bgRgb)) {
+ warnings.push('Light modules on a dark background: some scanners cannot read an inverted code.')
+ }
+ return { ok: true, foreground: fg, background: bg, contrast, warnings }
+}
diff --git a/packages/core/src/qr/encode.test.ts b/packages/core/src/qr/encode.test.ts
new file mode 100644
index 0000000..9e28c2a
--- /dev/null
+++ b/packages/core/src/qr/encode.test.ts
@@ -0,0 +1,144 @@
+import fc from 'fast-check'
+import { describe, expect, it } from 'vitest'
+import { CAPACITY, ECC_LEVELS, detectMode, encodeQr, type QrCode, type QrMode } from './encode.ts'
+
+function ok(result: ReturnType): QrCode {
+ if (!result.ok) throw new Error(result.message)
+ return result
+}
+
+/** A 7x7 finder: dark ring, light ring, dark 3x3 centre. */
+function hasFinder(matrix: boolean[][], top: number, left: number): boolean {
+ for (let dy = 0; dy < 7; dy++) {
+ for (let dx = 0; dx < 7; dx++) {
+ const ring = Math.max(Math.abs(dy - 3), Math.abs(dx - 3))
+ if (matrix[top + dy]![left + dx] !== (ring !== 2)) return false
+ }
+ }
+ return true
+}
+
+describe('encodeQr: shape', () => {
+ it('is square, sized 17 + 4v + 2·border', () => {
+ fc.assert(
+ fc.property(
+ fc.string({ minLength: 1, maxLength: 300 }).filter((s) => !/[\uD800-\uDFFF]/u.test(s)),
+ fc.constantFrom(...ECC_LEVELS),
+ fc.integer({ min: 0, max: 8 }),
+ (text, ecc, border) => {
+ const qr = ok(encodeQr(text, { ecc, border }))
+ expect(qr.size).toBe(17 + 4 * qr.version + 2 * border)
+ expect(qr.modules).toBe(17 + 4 * qr.version)
+ expect(qr.matrix).toHaveLength(qr.size)
+ for (const row of qr.matrix) expect(row).toHaveLength(qr.size)
+ },
+ ),
+ { numRuns: 60 },
+ )
+ })
+
+ it('has finder patterns in three corners and not the fourth', () => {
+ const qr = ok(encodeQr('https://devtools.fadeltd.dev/qr', { ecc: 'M', border: 4 }))
+ const far = qr.size - qr.border - 7
+ expect(hasFinder(qr.matrix, qr.border, qr.border)).toBe(true)
+ expect(hasFinder(qr.matrix, qr.border, far)).toBe(true)
+ expect(hasFinder(qr.matrix, far, qr.border)).toBe(true)
+ expect(hasFinder(qr.matrix, far, far)).toBe(false)
+ })
+
+ it('keeps the quiet zone light', () => {
+ const qr = ok(encodeQr('hello', { border: 4 }))
+ for (let i = 0; i < qr.size; i++) {
+ for (let b = 0; b < 4; b++) {
+ expect(qr.matrix[b]![i]).toBe(false)
+ expect(qr.matrix[qr.size - 1 - b]![i]).toBe(false)
+ expect(qr.matrix[i]![b]).toBe(false)
+ expect(qr.matrix[i]![qr.size - 1 - b]).toBe(false)
+ }
+ }
+ })
+
+ it('defaults to border 4 and clamps nonsense borders', () => {
+ expect(ok(encodeQr('x')).border).toBe(4)
+ expect(ok(encodeQr('x', { border: -3 })).border).toBe(0)
+ expect(ok(encodeQr('x', { border: Number.NaN })).border).toBe(4)
+ expect(ok(encodeQr('x', { border: 2.6 })).border).toBe(3)
+ })
+
+ it('reports the ECC level asked for, without boosting it', () => {
+ // "hi" fits version 1 at H, so a boosting encoder would raise L to H.
+ expect(ok(encodeQr('hi', { ecc: 'L' })).ecc).toBe('L')
+ })
+
+ it('picks a larger version at a higher ECC level for the same data', () => {
+ const text = 'x'.repeat(100)
+ expect(ok(encodeQr(text, { ecc: 'H' })).version).toBeGreaterThan(ok(encodeQr(text, { ecc: 'L' })).version)
+ })
+})
+
+describe('encodeQr: warnings', () => {
+ it('warns on a thin quiet zone, not on the default', () => {
+ expect(ok(encodeQr('x', { border: 1 })).warnings[0]).toMatch(/quiet zone of 1 module /)
+ expect(ok(encodeQr('x', { border: 2 })).warnings).toEqual([])
+ })
+
+ it('warns on a dense version', () => {
+ expect(ok(encodeQr('x'.repeat(1500), { ecc: 'L' })).warnings.some((w) => w.includes('dense'))).toBe(true)
+ expect(ok(encodeQr('x'.repeat(50))).warnings).toEqual([])
+ })
+})
+
+describe('encodeQr: modes and byte counts', () => {
+ it('detects the mode uqr will use', () => {
+ expect(detectMode('0123')).toBe('numeric')
+ expect(detectMode('HELLO WORLD: $1.50')).toBe('alphanumeric')
+ expect(detectMode('hello')).toBe('byte')
+ // Non-ASCII digits are not numeric mode.
+ expect(detectMode('١٢٣')).toBe('byte')
+ })
+
+ it('counts UTF-8 bytes in byte mode', () => {
+ expect(ok(encodeQr('é☕🛜')).bytes).toBe(2 + 3 + 4)
+ })
+
+ it('refuses empty input and lone surrogates as data', () => {
+ expect(encodeQr('')).toMatchObject({ ok: false, reason: 'empty' })
+ expect(encodeQr('a\uD800b')).toMatchObject({ ok: false, reason: 'invalid' })
+ })
+})
+
+describe('encodeQr: too long', () => {
+ const sample: Record = { numeric: '7', alphanumeric: 'A', byte: 'a' }
+
+ // Each limit fits at version 40, and one more character fails. This pins the
+ // capacity table to what uqr actually does.
+ for (const mode of ['numeric', 'alphanumeric', 'byte'] as const) {
+ for (const ecc of ECC_LEVELS) {
+ it(`${mode} at ${ecc}: ${CAPACITY[mode][ecc]} fits, one more does not`, () => {
+ const limit = CAPACITY[mode][ecc]
+ expect(ok(encodeQr(sample[mode].repeat(limit), { ecc })).version).toBe(40)
+ expect(encodeQr(sample[mode].repeat(limit + 1), { ecc })).toMatchObject({
+ ok: false,
+ reason: 'too-long',
+ bytes: limit + 1,
+ limit,
+ mode,
+ ecc,
+ })
+ })
+ }
+ }
+
+ it('counts multibyte characters against the byte limit', () => {
+ // 1000 × 2-byte é = 2000 bytes: over Q's 1663 although only 1000 characters.
+ const r = encodeQr('é'.repeat(1000), { ecc: 'Q' })
+ expect(r).toMatchObject({ ok: false, reason: 'too-long', bytes: 2000, limit: 1663 })
+ expect(!r.ok && r.message).toMatch(/2000 bytes, the limit is 1663/)
+ })
+
+ it('fails fast on a huge paste', () => {
+ const start = performance.now()
+ expect(encodeQr('x'.repeat(5_000_000)).ok).toBe(false)
+ expect(performance.now() - start).toBeLessThan(500)
+ })
+})
diff --git a/packages/core/src/qr/encode.ts b/packages/core/src/qr/encode.ts
new file mode 100644
index 0000000..77e8462
--- /dev/null
+++ b/packages/core/src/qr/encode.ts
@@ -0,0 +1,145 @@
+import { encode } from 'uqr'
+
+export type Ecc = 'L' | 'M' | 'Q' | 'H'
+
+export const ECC_LEVELS: readonly Ecc[] = ['L', 'M', 'Q', 'H']
+
+/** How much of the symbol can be damaged and still read, for the UI's labels. */
+export const ECC_RECOVERY: Readonly> = { L: '7%', M: '15%', Q: '25%', H: '30%' }
+
+/** The encoding uqr picks: the densest single mode the whole payload fits. */
+export type QrMode = 'numeric' | 'alphanumeric' | 'byte'
+
+/**
+ * Characters that fit in a version 40 symbol, from ISO/IEC 18004 table 7. In
+ * numeric and alphanumeric mode every character is one ASCII byte, so these
+ * are byte limits too. Checked against uqr itself in the tests.
+ */
+export const CAPACITY: Readonly>>> = {
+ numeric: { L: 7089, M: 5596, Q: 3993, H: 3057 },
+ alphanumeric: { L: 4296, M: 3391, Q: 2420, H: 1852 },
+ byte: { L: 2953, M: 2331, Q: 1663, H: 1273 },
+}
+
+export const DEFAULT_BORDER = 4
+const MAX_BORDER = 32
+// Below two modules, a busy surround blends into the finder patterns.
+const MIN_SAFE_BORDER = 2
+// From here a phone has to be held close or the code shown big: modules get
+// small and the camera's focus starts to matter.
+const DENSE_VERSION = 20
+
+export interface QrOptions {
+ ecc: Ecc
+ /** Quiet zone in modules. The spec asks for 4; scanners struggle below 2. */
+ border: number
+}
+
+export const DEFAULT_QR_OPTIONS: QrOptions = { ecc: 'M', border: DEFAULT_BORDER }
+
+export interface QrCode {
+ ok: true
+ /** `matrix[y][x]`, true for a dark module, quiet zone included. */
+ matrix: boolean[][]
+ /** Width and height of `matrix`: `17 + 4 * version + 2 * border`. */
+ size: number
+ /** 1 to 40. */
+ version: number
+ /** Modules across the symbol itself, without the quiet zone. */
+ modules: number
+ border: number
+ ecc: Ecc
+ mode: QrMode
+ /** UTF-8 length of the payload. */
+ bytes: number
+ /** Will encode, but may scan badly. */
+ warnings: string[]
+}
+
+export type QrError =
+ | { ok: false; reason: 'empty' | 'invalid'; message: string }
+ | { ok: false; reason: 'too-long'; message: string; bytes: number; limit: number; mode: QrMode; ecc: Ecc }
+
+export type QrResult = QrCode | QrError
+
+// The same tests uqr uses to choose a mode, so the capacity check agrees with
+// what it will actually encode. No `u` flag: `\d` must stay ASCII-only.
+const NUMERIC = /^\d*$/
+const ALPHANUMERIC = /^[A-Z0-9 $%*+./:-]*$/
+
+export function detectMode(text: string): QrMode {
+ if (NUMERIC.test(text)) return 'numeric'
+ if (ALPHANUMERIC.test(text)) return 'alphanumeric'
+ return 'byte'
+}
+
+// With the `u` flag a surrogate pair is one code point outside this range, so
+// only an unpaired half matches.
+const LONE_SURROGATE = /[\uD800-\uDFFF]/u
+
+function clampBorder(border: number): number {
+ if (!Number.isFinite(border)) return DEFAULT_BORDER
+ return Math.min(MAX_BORDER, Math.max(0, Math.round(border)))
+}
+
+/**
+ * Encode a payload into a module matrix at a fixed ECC level. The level is the
+ * user's choice, so uqr's `boostEcc` stays off: the result reports exactly the
+ * level that was asked for.
+ */
+export function encodeQr(payload: string, options: Partial = {}): QrResult {
+ const ecc = options.ecc ?? DEFAULT_QR_OPTIONS.ecc
+ const border = clampBorder(options.border ?? DEFAULT_QR_OPTIONS.border)
+
+ if (payload === '') return { ok: false, reason: 'empty', message: 'Nothing to encode.' }
+ // uqr converts to UTF-8 through encodeURI, which throws on a lone surrogate;
+ // TextEncoder would instead swap it for U+FFFD and encode something else.
+ if (LONE_SURROGATE.test(payload)) {
+ return { ok: false, reason: 'invalid', message: 'The text contains a broken character (an unpaired surrogate).' }
+ }
+
+ const mode = detectMode(payload)
+ const bytes = mode === 'byte' ? new TextEncoder().encode(payload).length : payload.length
+ const limit = CAPACITY[mode][ecc]
+ // Checked up front rather than by catching uqr's error: for a pasted
+ // megabyte, uqr would build an eight-million-entry bit array before failing.
+ if (bytes > limit) {
+ const unit = mode === 'byte' ? 'bytes' : 'characters'
+ const hint = ecc === 'L' ? '' : ' A lower error correction level fits more.'
+ return {
+ ok: false,
+ reason: 'too-long',
+ message: `Too long for any QR version at error correction ${ecc}: ${bytes} ${unit}, the limit is ${limit}.${hint}`,
+ bytes,
+ limit,
+ mode,
+ ecc,
+ }
+ }
+
+ try {
+ const qr = encode(payload, { ecc, border, boostEcc: false })
+ const warnings: string[] = []
+ if (border < MIN_SAFE_BORDER) {
+ warnings.push(`A quiet zone of ${border} module${border === 1 ? '' : 's'} only scans on a plain, light surround. The spec asks for ${DEFAULT_BORDER}.`)
+ }
+ if (qr.version >= DENSE_VERSION) {
+ warnings.push(`Version ${qr.version} is dense. Print or show it large, or shorten the content.`)
+ }
+ return {
+ ok: true,
+ matrix: qr.data,
+ size: qr.size,
+ version: qr.version,
+ modules: 17 + 4 * qr.version,
+ border,
+ ecc,
+ mode,
+ bytes,
+ warnings,
+ }
+ } catch (err) {
+ // Not expected after the checks above, but a library throw must not reach the UI.
+ return { ok: false, reason: 'invalid', message: err instanceof Error ? err.message : String(err) }
+ }
+}
diff --git a/packages/core/src/qr/index.ts b/packages/core/src/qr/index.ts
new file mode 100644
index 0000000..710ce29
--- /dev/null
+++ b/packages/core/src/qr/index.ts
@@ -0,0 +1,4 @@
+export * from './color.ts'
+export * from './encode.ts'
+export * from './payload.ts'
+export * from './svg.ts'
diff --git a/packages/core/src/qr/payload.test.ts b/packages/core/src/qr/payload.test.ts
new file mode 100644
index 0000000..ffb791e
--- /dev/null
+++ b/packages/core/src/qr/payload.test.ts
@@ -0,0 +1,160 @@
+import fc from 'fast-check'
+import { describe, expect, it } from 'vitest'
+import { buildText, buildUrl, buildWifi, escapeWifi, type WifiInput } from './payload.ts'
+
+/** The inverse of escapeWifi, as a scanner reads a field. */
+const unescapeWifi = (s: string) => s.replace(/\\(.)/gs, '$1')
+
+describe('escapeWifi', () => {
+ it.each([
+ ['home', 'home'],
+ ['a;b', 'a\\;b'],
+ ['a,b', 'a\\,b'],
+ ['a:b', 'a\\:b'],
+ ['a"b', 'a\\"b'],
+ ['a\\b', 'a\\\\b'],
+ // The backslash is escaped once, not again after the others are.
+ ['\\;', '\\\\\\;'],
+ ['"My;Net,1:2"', '\\"My\\;Net\\,1\\:2\\"'],
+ ['café ☕ 🛜', 'café ☕ 🛜'],
+ ["it's (5G) & more!", "it's (5G) & more!"],
+ ])('%j → %j', (input, expected) => {
+ expect(escapeWifi(input)).toBe(expected)
+ })
+
+ it('round-trips through a scanner-side unescape', () => {
+ fc.assert(fc.property(fc.string(), (s) => unescapeWifi(escapeWifi(s)) === s))
+ })
+
+ it('leaves no unescaped reserved character', () => {
+ fc.assert(
+ fc.property(fc.string(), (s) => {
+ // Strip escape pairs; whatever is left must hold no reserved character.
+ return !/[\\;,:"]/.test(escapeWifi(s).replace(/\\./gs, ''))
+ }),
+ )
+ })
+})
+
+describe('buildWifi', () => {
+ const base: WifiInput = { ssid: 'Home', password: 'correct horse', security: 'WPA', hidden: false }
+
+ it('builds a WPA payload', () => {
+ expect(buildWifi(base)).toEqual({ ok: true, payload: 'WIFI:T:WPA;S:Home;P:correct horse;;', warnings: [] })
+ })
+
+ it('adds H:true only for a hidden network', () => {
+ const r = buildWifi({ ...base, hidden: true })
+ expect(r.ok && r.payload).toBe('WIFI:T:WPA;S:Home;P:correct horse;H:true;;')
+ })
+
+ it('escapes both the SSID and the password', () => {
+ const r = buildWifi({ ...base, ssid: 'Cafe;Bar', password: 'p:a"s,s\\w' })
+ expect(r.ok && r.payload).toBe('WIFI:T:WPA;S:Cafe\\;Bar;P:p\\:a\\"s\\,s\\\\w;;')
+ })
+
+ it('omits the password for an open network', () => {
+ const r = buildWifi({ ...base, security: 'nopass', password: 'ignored' })
+ expect(r.ok && r.payload).toBe('WIFI:T:nopass;S:Home;;')
+ })
+
+ it('builds a WEP payload', () => {
+ const r = buildWifi({ ...base, security: 'WEP', password: 'abcde' })
+ expect(r).toEqual({ ok: true, payload: 'WIFI:T:WEP;S:Home;P:abcde;;', warnings: [] })
+ })
+
+ it('requires an SSID, and a password unless open', () => {
+ expect(buildWifi({ ...base, ssid: '' }).ok).toBe(false)
+ expect(buildWifi({ ...base, password: '' }).ok).toBe(false)
+ expect(buildWifi({ ...base, security: 'WEP', password: '' }).ok).toBe(false)
+ expect(buildWifi({ ...base, security: 'nopass', password: '' }).ok).toBe(true)
+ })
+
+ it('warns on a WPA password outside 8..63, but accepts a 64-hex raw key', () => {
+ const short = buildWifi({ ...base, password: 'short' })
+ expect(short.ok && short.warnings).toHaveLength(1)
+ const long = buildWifi({ ...base, password: 'x'.repeat(64) })
+ expect(long.ok && long.warnings).toHaveLength(1)
+ const raw = buildWifi({ ...base, password: 'a1'.repeat(32) })
+ expect(raw.ok && raw.warnings).toEqual([])
+ })
+
+ it('warns on a WEP key of the wrong shape', () => {
+ const bad = buildWifi({ ...base, security: 'WEP', password: 'abcdef' })
+ expect(bad.ok && bad.warnings).toHaveLength(1)
+ const hex = buildWifi({ ...base, security: 'WEP', password: '0123456789' })
+ expect(hex.ok && hex.warnings).toEqual([])
+ })
+
+ it('warns when the SSID is over 32 bytes, counting UTF-8', () => {
+ // Eight emoji are eight characters but 32 bytes; nine are 36.
+ const fits = buildWifi({ ...base, ssid: '🛜'.repeat(8) })
+ expect(fits.ok && fits.warnings).toEqual([])
+ const over = buildWifi({ ...base, ssid: '🛜'.repeat(9) })
+ expect(over.ok && over.warnings[0]).toMatch(/36 bytes/)
+ })
+})
+
+describe('buildText', () => {
+ it('passes text through untouched, whitespace included', () => {
+ expect(buildText(' hi\n')).toEqual({ ok: true, payload: ' hi\n', warnings: [] })
+ })
+
+ it('refuses empty text', () => {
+ expect(buildText('').ok).toBe(false)
+ })
+})
+
+describe('buildUrl', () => {
+ it('accepts an https URL as typed', () => {
+ expect(buildUrl('https://example.com')).toEqual({
+ ok: true,
+ payload: 'https://example.com',
+ warnings: [],
+ addedScheme: false,
+ })
+ })
+
+ it('trims surrounding whitespace', () => {
+ const r = buildUrl(' https://example.com/a?b=1 ')
+ expect(r.ok && r.payload).toBe('https://example.com/a?b=1')
+ })
+
+ it('adds https:// to a bare host', () => {
+ expect(buildUrl('example.com/path')).toMatchObject({ ok: true, payload: 'https://example.com/path', addedScheme: true })
+ })
+
+ it('treats host:port as a host, not a scheme', () => {
+ expect(buildUrl('localhost:3000')).toMatchObject({ payload: 'https://localhost:3000', addedScheme: true })
+ expect(buildUrl('example.com:8080/x')).toMatchObject({ payload: 'https://example.com:8080/x', addedScheme: true })
+ })
+
+ it('keeps a real scheme followed by digits', () => {
+ expect(buildUrl('tel:12345')).toMatchObject({ ok: true, payload: 'tel:12345', addedScheme: false })
+ })
+
+ it('warns on non-http(s) schemes', () => {
+ const tel = buildUrl('tel:+15551234')
+ expect(tel.ok && tel.warnings[0]).toMatch(/Not an http\(s\) URL/)
+ const js = buildUrl('javascript:alert(1)')
+ expect(js.ok && js.warnings[0]).toMatch(/blocked/)
+ })
+
+ it('warns on plain http', () => {
+ const r = buildUrl('http://example.com')
+ expect(r.ok && r.warnings[0]).toMatch(/unencrypted/)
+ })
+
+ it('warns on a dotless public host but not on localhost or IPv6', () => {
+ const r = buildUrl('https://intranet/x')
+ expect(r.ok && r.warnings[0]).toMatch(/no dot/)
+ expect(buildUrl('https://localhost/')).toMatchObject({ warnings: [] })
+ expect(buildUrl('https://[::1]/')).toMatchObject({ warnings: [] })
+ })
+
+ it('rejects what the URL parser rejects', () => {
+ expect(buildUrl('https://exa mple.com').ok).toBe(false)
+ expect(buildUrl('').ok).toBe(false)
+ expect(buildUrl(' ').ok).toBe(false)
+ })
+})
diff --git a/packages/core/src/qr/payload.ts b/packages/core/src/qr/payload.ts
new file mode 100644
index 0000000..7934289
--- /dev/null
+++ b/packages/core/src/qr/payload.ts
@@ -0,0 +1,142 @@
+/**
+ * What goes inside the QR code. Each builder returns the exact string to
+ * encode plus any warnings, or an error -- never a throw, so the UI can render
+ * a half-typed form without a try/catch.
+ */
+
+export interface PayloadOk {
+ ok: true
+ payload: string
+ /** Things that will encode fine but may not scan the way the user expects. */
+ warnings: string[]
+}
+
+export interface PayloadError {
+ ok: false
+ message: string
+}
+
+export type PayloadResult = PayloadOk | PayloadError
+
+/** Plain text is encoded as-is: no trimming, since trailing spaces may be meant. */
+export function buildText(text: string): PayloadResult {
+ if (text === '') return { ok: false, message: 'Type some text to encode.' }
+ return { ok: true, payload: text, warnings: [] }
+}
+
+const SCHEME = /^[a-z][a-z0-9+.-]*:/i
+
+// An IPv6 literal has no dot either, but is a perfectly good host.
+const isDotless = (host: string) => !host.includes('.') && host !== 'localhost' && !host.startsWith('[')
+
+export interface UrlPayloadOk extends PayloadOk {
+ /** True when the input had no scheme and `https://` was put in front. */
+ addedScheme: boolean
+}
+
+export type UrlPayloadResult = UrlPayloadOk | PayloadError
+
+/**
+ * A URL, checked by the platform parser. A bare `example.com` gets `https://`
+ * in front, because without a scheme every scanner treats it as plain text and
+ * offers to search for it instead of opening it.
+ *
+ * The user's text is encoded as typed rather than as `URL.href`: the parser
+ * would add a trailing slash and rewrite the host, and the QR code should say
+ * what the user wrote.
+ */
+export function buildUrl(input: string): UrlPayloadResult {
+ const trimmed = input.trim()
+ if (trimmed === '') return { ok: false, message: 'Type a URL to encode.' }
+
+ // `localhost:3000` and `example.com:8080` match SCHEME but are a host and
+ // port. `tel:123` is a real scheme followed by digits, so the test is the
+ // shape of the host, not the digits.
+ const hostAndPort = /^(?:localhost|[a-z0-9-]+(?:\.[a-z0-9-]+)+):\d+(?:[/?#]|$)/i.test(trimmed)
+ const hasScheme = SCHEME.test(trimmed) && !hostAndPort
+ const candidate = hasScheme ? trimmed : `https://${trimmed}`
+
+ let url: URL
+ try {
+ url = new URL(candidate)
+ } catch {
+ return { ok: false, message: `“${trimmed}” is not a valid URL.` }
+ }
+
+ const warnings: string[] = []
+ const scheme = url.protocol.slice(0, -1).toLowerCase()
+ if (scheme === 'javascript' || scheme === 'data' || scheme === 'file') {
+ warnings.push(`${scheme}: URLs are blocked or ignored by most scanners.`)
+ } else if (scheme !== 'http' && scheme !== 'https') {
+ warnings.push(`Not an http(s) URL: scanners only open ${scheme}: if an app handles it.`)
+ } else if (scheme === 'http') {
+ warnings.push('http:// is unencrypted; some phones warn before opening it.')
+ }
+ if ((scheme === 'http' || scheme === 'https') && isDotless(url.hostname)) {
+ warnings.push(`“${url.hostname}” has no dot, so it is not a public host name.`)
+ }
+ if (/\s/.test(trimmed)) {
+ warnings.push('The URL contains spaces; percent-encode them as %20 for reliable scanning.')
+ }
+
+ return { ok: true, payload: candidate, warnings, addedScheme: !hasScheme }
+}
+
+export type WifiSecurity = 'WPA' | 'WEP' | 'nopass'
+
+export interface WifiInput {
+ ssid: string
+ password: string
+ security: WifiSecurity
+ hidden: boolean
+}
+
+/**
+ * Escape a field of the `WIFI:` scheme. The ZXing spec reserves exactly these
+ * five characters; the backslash must go first or it would double-escape the
+ * others.
+ */
+export function escapeWifi(value: string): string {
+ return value.replace(/[\\;,:"]/g, (ch) => `\\${ch}`)
+}
+
+const utf8Length = (s: string) => new TextEncoder().encode(s).length
+const isHex = (s: string) => /^[0-9a-f]+$/i.test(s)
+
+/**
+ * `WIFI:T:WPA;S:;P:;H:true;;` -- the format iOS and Android
+ * cameras join a network from. `T:WPA` covers WPA2 and WPA3 as well; phones
+ * negotiate the actual protocol.
+ */
+export function buildWifi(input: WifiInput): PayloadResult {
+ if (input.ssid === '') return { ok: false, message: 'Type the network name (SSID).' }
+
+ const warnings: string[] = []
+ // 802.11 caps an SSID at 32 bytes, not characters: an emoji is four.
+ if (utf8Length(input.ssid) > 32) {
+ warnings.push(`The network name is ${utf8Length(input.ssid)} bytes; Wi-Fi allows at most 32.`)
+ }
+
+ const pw = input.password
+ if (input.security === 'WPA') {
+ if (pw === '') return { ok: false, message: 'WPA needs a password. Choose “None” for an open network.' }
+ // A 64-character hex string is a raw pre-shared key, which WPA accepts.
+ const rawKey = pw.length === 64 && isHex(pw)
+ if (!rawKey && (pw.length < 8 || pw.length > 63)) {
+ warnings.push(`A WPA password is 8 to 63 characters; this one is ${pw.length}.`)
+ }
+ } else if (input.security === 'WEP') {
+ if (pw === '') return { ok: false, message: 'WEP needs a key. Choose “None” for an open network.' }
+ const valid = pw.length === 5 || pw.length === 13 || ((pw.length === 10 || pw.length === 26) && isHex(pw))
+ if (!valid) warnings.push('A WEP key is 5 or 13 characters, or 10 or 26 hex digits.')
+ }
+
+ let payload = `WIFI:T:${input.security};S:${escapeWifi(input.ssid)};`
+ // An open network has no password field at all; an empty `P:;` confuses some
+ // Android scanners into prompting for one.
+ if (input.security !== 'nopass') payload += `P:${escapeWifi(pw)};`
+ if (input.hidden) payload += 'H:true;'
+ payload += ';'
+
+ return { ok: true, payload, warnings }
+}
diff --git a/packages/core/src/qr/svg.test.ts b/packages/core/src/qr/svg.test.ts
new file mode 100644
index 0000000..c2064d7
--- /dev/null
+++ b/packages/core/src/qr/svg.test.ts
@@ -0,0 +1,89 @@
+import fc from 'fast-check'
+import { describe, expect, it } from 'vitest'
+import { encodeQr } from './encode.ts'
+import { buildSvg, modulePath, pixelScale, PIXEL_SIZE_MAX, PIXEL_SIZE_MIN } from './svg.ts'
+
+/** Paint a path made of `M x y h w v1 h-w z` runs back into cells. */
+function paint(d: string, size: number): boolean[][] {
+ const grid = Array.from({ length: size }, () => Array.from({ length: size }, () => false))
+ for (const [, x, y, w] of d.matchAll(/M(\d+) (\d+)h(\d+)v1h-\3z/g)) {
+ for (let i = 0; i < Number(w); i++) grid[Number(y)]![Number(x) + i] = true
+ }
+ return grid
+}
+
+describe('modulePath', () => {
+ it('merges horizontal runs', () => {
+ expect(modulePath([[true, true, false, true]])).toBe('M0 0h2v1h-2zM3 0h1v1h-1z')
+ })
+
+ it('is empty for an all-light matrix', () => {
+ expect(
+ modulePath([
+ [false, false],
+ [false, false],
+ ]),
+ ).toBe('')
+ })
+
+ it('paints back exactly the dark modules', () => {
+ fc.assert(
+ fc.property(
+ fc
+ .integer({ min: 1, max: 24 })
+ .chain((n) => fc.array(fc.array(fc.boolean(), { minLength: n, maxLength: n }), { minLength: n, maxLength: n })),
+ (matrix) => {
+ expect(paint(modulePath(matrix), matrix.length)).toEqual(matrix)
+ },
+ ),
+ )
+ })
+})
+
+describe('pixelScale', () => {
+ it('snaps down to whole pixels per module', () => {
+ expect(pixelScale(512, 29)).toEqual({ scale: 17, pixels: 493 })
+ })
+
+ it('never goes below one pixel per module', () => {
+ // A version 40 code with a border is 185 modules: more than the 64 px minimum.
+ expect(pixelScale(10, 185)).toEqual({ scale: 1, pixels: 185 })
+ })
+
+ it('clamps the request to the allowed range', () => {
+ expect(pixelScale(1e9, 21).pixels).toBeLessThanOrEqual(PIXEL_SIZE_MAX)
+ expect(pixelScale(0, 21).pixels).toBeGreaterThan(PIXEL_SIZE_MIN - 21)
+ expect(pixelScale(Number.NaN, 21).scale).toBeGreaterThan(1)
+ })
+})
+
+describe('buildSvg', () => {
+ const qr = encodeQr('https://example.com', { ecc: 'M', border: 4 })
+ if (!qr.ok) throw new Error(qr.message)
+ const opts = { foreground: '#000000', background: '#ffffff', pixelSize: 512 }
+
+ it('has exactly one path', () => {
+ const r = buildSvg(qr.matrix, opts)
+ expect(r.ok && r.svg.match(/ {
+ const r = buildSvg(qr.matrix, opts)
+ if (!r.ok) throw new Error(r.message)
+ const { pixels } = pixelScale(512, qr.size)
+ expect(r.pixels).toBe(pixels)
+ expect(r.svg).toContain(`viewBox="0 0 ${qr.size} ${qr.size}"`)
+ expect(r.svg).toContain(`width="${pixels}" height="${pixels}"`)
+ expect(r.svg).toContain(``)
+ expect(r.svg).toContain(`d="${modulePath(qr.matrix)}"`)
+ })
+
+ it('refuses anything but a #rrggbb colour, so nothing can break out of the attribute', () => {
+ expect(buildSvg(qr.matrix, { ...opts, foreground: 'red' }).ok).toBe(false)
+ expect(buildSvg(qr.matrix, { ...opts, background: '#fff"/>