diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..66ebdfd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: Typecheck, build, test + runs-on: ubuntu-latest + timeout-minutes: 6 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install + run: npm install --no-audit --no-fund + + - name: Typecheck, build, test + run: | + npm run typecheck + npm run build + npm test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..f57447f --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,55 @@ +# Publish @llazyemail/validator to GitHub Packages when a Release is published. +# +# Auth is the workflow GITHUB_TOKEN. The package scope must match the owner +# (LLazyEmail -> @llazyemail). Repo visibility controls package visibility. +# No NPM_TOKEN secret is required. + +name: Publish to GitHub Packages + +on: + release: + types: [published] + workflow_dispatch: + inputs: + dry_run: + description: 'Pack only (do not publish)' + required: false + type: boolean + default: false + +concurrency: + group: github-packages-publish + cancel-in-progress: false + +permissions: + contents: read + packages: write + +jobs: + publish: + name: Build, test, and publish + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://npm.pkg.github.com + scope: '@llazyemail' + + - run: npm install --no-audit --no-fund + + - run: npm run typecheck + - run: npm run build + - run: npm test + - run: npm pack --dry-run + + - name: Publish to GitHub Packages + if: ${{ github.event_name == 'release' || inputs.dry_run != true }} + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npm publish diff --git a/.npmrc.example b/.npmrc.example new file mode 100644 index 0000000..4b8fde2 --- /dev/null +++ b/.npmrc.example @@ -0,0 +1,2 @@ +@llazyemail:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN} diff --git a/README.md b/README.md index b6d2a74..20b07c0 100644 --- a/README.md +++ b/README.md @@ -9,29 +9,43 @@ Small TypeScript validation helpers extracted from npm install @llazyemail/validator ``` +Requires a `read:packages` token for GitHub Packages. See `.npmrc.example`. + ## Usage ```ts -import { validateInput } from '@llazyemail/validator'; - -validateInput( - { title: 'Newsletter', headStyles: '' }, - [ - { field: 'title', errorMessage: 'no title was passed' }, - { field: 'headStyles', errorMessage: 'no headStyles was passed' }, - ], -); +import { validateInput, ValidationError, minLength } from '@llazyemail/validator'; + +try { + validateInput( + { title: 'Newsletter', email: 'user@example.com' }, + [ + { field: 'title', errorMessage: 'no title was passed' }, + { field: 'email', errorMessage: 'invalid email', rules: ['required', 'email'] }, + { field: 'title', errorMessage: 'title too short', rules: [minLength(3)] }, + ], + ); +} catch (err) { + if (err instanceof ValidationError) { + console.error(err.field, err.message, err.ruleMessage); + } +} ``` -Built-in rules: `required`, `nonEmptyString`, `string`. +Built-in rules: `required`, `nonEmptyString`, `string`, `email`, `url`. + +Length helpers: `minLength(n)`, `maxLength(n)`. A check defaults to `['required', 'nonEmptyString']` when `rules` is omitted. +Custom `Rule` functions are also accepted in the `rules` array. + ## Develop ```bash npm install npm test +npm run typecheck npm run build ``` diff --git a/package.json b/package.json index 3c814cf..629d612 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@llazyemail/validator", - "version": "0.1.0", + "version": "0.2.0", "description": "Small input validation helpers extracted from LLazyEmail email templates.", "license": "MIT", "type": "module", @@ -8,7 +8,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { - ".": { + "." : { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" @@ -18,22 +18,26 @@ "dist" ], "sideEffects": false, + "engines": { + "node": ">=20" + }, "scripts": { "build": "tsup", "dev": "tsup --watch", "test": "tsx --test src/*.test.ts", - "prepublishOnly": "npm run build" + "typecheck": "tsc --noEmit", + "prepublishOnly": "npm run typecheck && npm run build" }, "devDependencies": { "tsup": "^8.5.0", "tsx": "^4.20.0", - "typescript": "^7.0.0" + "typescript": "^5.6.3" }, "repository": { "type": "git", "url": "git+https://github.com/LLazyEmail/validator.git" }, "publishConfig": { - "access": "public" + "registry": "https://npm.pkg.github.com" } } diff --git a/src/index.ts b/src/index.ts index 5aa97ed..53e8c10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ -export { required, nonEmptyString, string } from './rules'; +export { required, nonEmptyString, string, email, url, minLength, maxLength } from './rules'; export { validateInput } from './validateInput'; +export { ValidationError } from './types'; export type { BuiltInRuleName, FieldCheck, Rule, RuleResult } from './types'; diff --git a/src/rules.ts b/src/rules.ts index 6d8bbc5..75a634f 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -23,3 +23,45 @@ export const string: Rule = (value) => { } return null; }; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export const email: Rule = (value) => { + if (value === undefined || value === null || value === '') { + return null; + } + if (typeof value !== 'string' || !EMAIL_RE.test(value)) { + return 'must be a valid email'; + } + return null; +}; + +const URL_RE = /^https?:\/\/[^\s/$.?#].[^\s]*$/i; + +export const url: Rule = (value) => { + if (value === undefined || value === null || value === '') { + return null; + } + if (typeof value !== 'string' || !URL_RE.test(value)) { + return 'must be a valid URL'; + } + return null; +}; + +/** Factory for a minimum length rule (strings only; other types pass). */ +export const minLength = (min: number): Rule => (value) => { + if (typeof value !== 'string') return null; + if (value.length < min) { + return `must be at least ${min} characters`; + } + return null; +}; + +/** Factory for a maximum length rule (strings only; other types pass). */ +export const maxLength = (max: number): Rule => (value) => { + if (typeof value !== 'string') return null; + if (value.length > max) { + return `must be at most ${max} characters`; + } + return null; +}; diff --git a/src/types.ts b/src/types.ts index a9ddbbb..3d91677 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,10 +2,28 @@ export type RuleResult = string | null; export type Rule = (value: unknown) => RuleResult; -export type BuiltInRuleName = 'required' | 'nonEmptyString' | 'string'; +export type BuiltInRuleName = + | 'required' + | 'nonEmptyString' + | 'string' + | 'email' + | 'url'; export type FieldCheck = { field: string; errorMessage: string; - rules?: BuiltInRuleName[]; + /** Built-in rule names or custom Rule functions. Defaults to ['required', 'nonEmptyString']. */ + rules?: (BuiltInRuleName | Rule)[]; }; + +export class ValidationError extends Error { + readonly field: string; + readonly ruleMessage: string | null; + + constructor(message: string, field: string, ruleMessage: string | null = null) { + super(message); + this.name = 'ValidationError'; + this.field = field; + this.ruleMessage = ruleMessage; + } +} diff --git a/src/validateInput.test.ts b/src/validateInput.test.ts index 413a67f..7193f5d 100644 --- a/src/validateInput.test.ts +++ b/src/validateInput.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { nonEmptyString, required, string } from './rules'; +import { email, maxLength, minLength, nonEmptyString, required, string, url } from './rules'; import { validateInput } from './validateInput'; +import { ValidationError } from './types'; describe('rules', () => { it('required fails on null and undefined', () => { @@ -21,6 +22,28 @@ describe('rules', () => { assert.equal(string(null), null); assert.equal(string('ok'), null); }); + + it('email validates basic email format', () => { + assert.equal(email('user@example.com'), null); + assert.equal(email('bad'), 'must be a valid email'); + assert.equal(email(null), null); + }); + + it('url validates basic http(s) URLs', () => { + assert.equal(url('https://example.com'), null); + assert.equal(url('not-a-url'), 'must be a valid URL'); + assert.equal(url(null), null); + }); + + it('minLength and maxLength work on strings', () => { + const atLeast3 = minLength(3); + const atMost5 = maxLength(5); + assert.equal(atLeast3('ab'), 'must be at least 3 characters'); + assert.equal(atLeast3('abc'), null); + assert.equal(atMost5('abcdef'), 'must be at most 5 characters'); + assert.equal(atMost5('abc'), null); + assert.equal(atLeast3(123), null); + }); }); describe('validateInput', () => { @@ -33,14 +56,20 @@ describe('validateInput', () => { ); }); - it('throws the mapped error when a required field is missing', () => { + it('throws ValidationError with the mapped error when a required field is missing', () => { assert.throws( () => validateInput( {}, [{ field: 'title', errorMessage: 'no title was passed' }], ), - /no title was passed/, + (err) => { + assert.ok(err instanceof ValidationError); + assert.equal(err.message, 'no title was passed'); + assert.equal(err.field, 'title'); + assert.equal(err.ruleMessage, 'is required'); + return true; + }, ); }); @@ -58,4 +87,43 @@ describe('validateInput', () => { /Unknown validation rule/, ); }); + + it('supports custom rule functions', () => { + const isEven: (v: unknown) => string | null = (v) => + typeof v === 'number' && v % 2 === 0 ? null : 'must be even'; + + assert.doesNotThrow(() => + validateInput( + { count: 4 }, + [{ field: 'count', errorMessage: 'bad count', rules: [isEven] }], + ), + ); + + assert.throws( + () => + validateInput( + { count: 3 }, + [{ field: 'count', errorMessage: 'bad count', rules: [isEven] }], + ), + (err) => err instanceof ValidationError && err.message === 'bad count', + ); + }); + + it('supports new built-in rules', () => { + assert.doesNotThrow(() => + validateInput( + { to: 'user@example.com' }, + [{ field: 'to', errorMessage: 'bad email', rules: ['required', 'email'] }], + ), + ); + + assert.throws( + () => + validateInput( + { to: 'not-an-email' }, + [{ field: 'to', errorMessage: 'bad email', rules: ['email'] }], + ), + /bad email/, + ); + }); }); diff --git a/src/validateInput.ts b/src/validateInput.ts index 21c0eb2..1eb3ce2 100644 --- a/src/validateInput.ts +++ b/src/validateInput.ts @@ -1,12 +1,33 @@ -import { nonEmptyString, required, string } from './rules'; +import { email, maxLength, minLength, nonEmptyString, required, string, url } from './rules'; import type { BuiltInRuleName, FieldCheck, Rule } from './types'; +import { ValidationError } from './types'; -const RULES: Record = { +const BUILT_IN_RULES: Record = { required, nonEmptyString, string, + email, + url, }; +function resolveRule(rule: BuiltInRuleName | Rule): Rule { + if (typeof rule === 'function') { + return rule; + } + const builtIn = BUILT_IN_RULES[rule]; + if (!builtIn) { + throw new Error(`Unknown validation rule: ${String(rule)}`); + } + return builtIn; +} + +/** + * Validate an input object against a list of field checks. + * Throws ValidationError on the first failure (using the check's errorMessage). + * + * @param input - Object to validate (null/undefined treated as {}). + * @param checks - Field checks to run. + */ export const validateInput = ( input: Record | null | undefined, checks: FieldCheck[], @@ -17,15 +38,14 @@ export const validateInput = ( const fieldRules = check.rules ?? ['required', 'nonEmptyString']; const value = normalized[check.field]; - for (const ruleName of fieldRules) { - const rule = RULES[ruleName]; - if (!rule) { - throw new Error(`Unknown validation rule: ${ruleName}`); - } - - if (rule(value) !== null) { - throw new Error(check.errorMessage); + for (const ruleSpec of fieldRules) { + const rule = resolveRule(ruleSpec); + const result = rule(value); + if (result !== null) { + throw new ValidationError(check.errorMessage, check.field, result); } } } }; + +export { minLength, maxLength };