Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
55 changes: 55 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .npmrc.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@llazyemail:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
34 changes: 24 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<style></style>' },
[
{ 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
```

Expand Down
14 changes: 9 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
{
"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",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"." : {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
Expand All @@ -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"
}
}
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
42 changes: 42 additions & 0 deletions src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
22 changes: 20 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
74 changes: 71 additions & 3 deletions src/validateInput.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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;
},
);
});

Expand All @@ -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/,
);
});
});
Loading
Loading