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 };