Skip to content

Commit e467211

Browse files
committed
feat: slug
1 parent 7be89db commit e467211

6 files changed

Lines changed: 131 additions & 0 deletions

File tree

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * as vk from './valibot/index.js';
2+
export * as zk from './zod/index.js';

src/valibot/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { slug } from './schemas/slug.js';

src/valibot/schemas/slug.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import * as v from 'valibot';
2+
3+
/**
4+
* Slug schema
5+
* - Allows lowercase alphanumeric characters and hyphens
6+
* - Hyphens cannot be at the start or end, nor can they be consecutive
7+
* - Case insensitive
8+
* - No spaces or special characters
9+
* - Transforms to lowercase and trims whitespace
10+
*
11+
* @example
12+
* ```ts
13+
* import { vk } from 'valikit';
14+
*
15+
* const postSchema = z.object({
16+
* slug: vk.slug(),
17+
* });
18+
*
19+
* // Valid slugs
20+
* postSchema.parse({ slug: 'valid-slug' }); // passes
21+
* postSchema.parse({ slug: 'another-valid-slug-123' }); // passes
22+
*
23+
* // Invalid slugs
24+
* postSchema.parse({ slug: 'Invalid-Slug' }); // throws error (uppercase letters)
25+
* postSchema.parse({ slug: 'invalid_slug' }); // throws error (underscores)
26+
* postSchema.parse({ slug: 'invalid slug' }); // throws error (spaces)
27+
* postSchema.parse({ slug: '-leading-hyphen' }); // throws error (leading hyphen)
28+
* ```
29+
*/
30+
31+
const slug = () => v.pipe(v.string(), v.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/));
32+
33+
export { slug };

src/zod/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { slug } from './schemas/slug.js';

src/zod/schemas/slug.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import * as z from 'zod';
2+
3+
/**
4+
* Slug schema
5+
* - Allows lowercase alphanumeric characters and hyphens
6+
* - Hyphens cannot be at the start or end, nor can they be consecutive
7+
* - Case insensitive
8+
* - No spaces or special characters
9+
* - Transforms to lowercase and trims whitespace
10+
*
11+
* @example
12+
* ```ts
13+
* import { zk } from 'valikit';
14+
*
15+
* const postSchema = z.object({
16+
* slug: zk.slug(),
17+
* });
18+
*
19+
* // Valid slugs
20+
* postSchema.parse({ slug: 'valid-slug' }); // passes
21+
* postSchema.parse({ slug: 'another-valid-slug-123' }); // passes
22+
*
23+
* // Invalid slugs
24+
* postSchema.parse({ slug: 'Invalid-Slug' }); // throws error (uppercase letters)
25+
* postSchema.parse({ slug: 'invalid_slug' }); // throws error (underscores)
26+
* postSchema.parse({ slug: 'invalid slug' }); // throws error (spaces)
27+
* postSchema.parse({ slug: '-leading-hyphen' }); // throws error (leading hyphen)
28+
* ```
29+
*/
30+
31+
const slug = () => z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/);
32+
33+
export { slug };

tests/schemas/slug.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, it } from 'vitest';
2+
import * as v from 'valibot';
3+
4+
import { zk, vk } from '../../src';
5+
6+
const testSlug = (slug: string, shouldPass: boolean) => {
7+
// Zod
8+
const zodResult = zk.slug().safeParse(slug);
9+
expect(zodResult.success).toBe(shouldPass);
10+
11+
// Valibot
12+
const valibotResult = v.safeParse(vk.slug(), slug);
13+
expect(valibotResult.success).toBe(shouldPass);
14+
};
15+
16+
describe('slug', () => {
17+
describe('valid slugs', () => {
18+
const validCases = [
19+
{ slug: 'valid-slug', desc: 'basic slug' },
20+
{ slug: 'another-valid-slug-123', desc: 'with numbers' },
21+
{ slug: 'slug-with-multiple-parts', desc: 'multiple parts' },
22+
{ slug: 'a', desc: 'single letter' },
23+
{ slug: 'z-0-9', desc: 'mixed letters and numbers' },
24+
{ slug: '123', desc: 'only numbers' },
25+
{ slug: '1', desc: 'single digit' },
26+
{
27+
slug: 'very-long-slug-with-many-parts-and-numbers-123',
28+
desc: 'long slug',
29+
},
30+
];
31+
32+
it.each(validCases)('should pass - %s ("%s")', ({ slug }) => {
33+
testSlug(slug, true);
34+
});
35+
});
36+
37+
describe('invalid slugs', () => {
38+
const invalidCases = [
39+
{ slug: 'Invalid-Slug', desc: 'uppercase letters' },
40+
{ slug: 'invalid_slug', desc: 'underscores' },
41+
{ slug: 'invalid slug', desc: 'spaces' },
42+
{ slug: 'invalid@slug!', desc: 'special characters' },
43+
{ slug: '', desc: 'empty string' },
44+
{ slug: '-leading-hyphen', desc: 'leading hyphen' },
45+
{ slug: 'trailing-hyphen-', desc: 'trailing hyphen' },
46+
{ slug: 'double--hyphen', desc: 'consecutive hyphens' },
47+
{ slug: 'with/slash', desc: 'slash' },
48+
{ slug: 'with.dot', desc: 'dot' },
49+
{ slug: 'with..dot', desc: 'consecutive dots' },
50+
{ slug: '-', desc: 'single hyphen' },
51+
{ slug: 'Café', desc: 'unicode characters' },
52+
{ slug: 'سلام', desc: 'non-latin characters' },
53+
{ slug: ' leading-space', desc: 'leading space' },
54+
{ slug: 'trailing-space ', desc: 'trailing space' },
55+
];
56+
57+
it.each(invalidCases)('should fail - %s ("%s")', ({ slug }) => {
58+
testSlug(slug, false);
59+
});
60+
});
61+
});

0 commit comments

Comments
 (0)