Skip to content

Commit 51c7269

Browse files
authored
feat: add slugRegex config option for collections (#538)
1 parent bf117c9 commit 51c7269

File tree

13 files changed

+76
-42
lines changed

13 files changed

+76
-42
lines changed

.changeset/flat-zoos-do.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@blinkk/root-cms': patch
3+
---
4+
5+
feat: add slugRegex config option for collections (#538)

packages/root-cms/core/app.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ function serializeCollection(collection: Collection): Partial<Collection> {
154154
url: collection.url,
155155
previewUrl: collection.previewUrl,
156156
preview: collection.preview,
157+
slugRegex: collection.slugRegex,
157158
};
158159
}
159160

packages/root-cms/core/schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,11 @@ export type Collection = Schema & {
288288
src: string;
289289
};
290290
};
291+
/**
292+
* Regular expression used to validate document slugs. Should be provided as a
293+
* string so it can be serialized to the CMS UI.
294+
*/
295+
slugRegex?: string;
291296
};
292297

293298
export function defineCollection(

packages/root-cms/core/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,6 @@
3030
"**/*.ts",
3131
"*.tsx",
3232
"**/*.tsx",
33+
"../shared/*.ts"
3334
]
3435
}

packages/root-cms/shared/slug.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {describe, it, expect} from 'vitest';
2+
import {isSlugValid, normalizeSlug} from './slug.js';
3+
4+
describe('isSlugValid', () => {
5+
it('validates good slugs', () => {
6+
expect(isSlugValid('1')).toBe(true);
7+
expect(isSlugValid('a')).toBe(true);
8+
expect(isSlugValid('foo')).toBe(true);
9+
expect(isSlugValid('foo-bar')).toBe(true);
10+
expect(isSlugValid('foo--bar')).toBe(true);
11+
expect(isSlugValid('foo-bar-123')).toBe(true);
12+
expect(isSlugValid('foo--bar--123')).toBe(true);
13+
expect(isSlugValid('foo_bar')).toBe(true);
14+
expect(isSlugValid('foo_bar-123')).toBe(true);
15+
expect(isSlugValid('_foo_bar-123')).toBe(true);
16+
});
17+
18+
it('invalidates bad slugs', () => {
19+
expect(isSlugValid('Foo')).toBe(false);
20+
expect(isSlugValid('-asdf-')).toBe(false);
21+
expect(isSlugValid('-a!!')).toBe(false);
22+
expect(isSlugValid('!!a')).toBe(false);
23+
expect(isSlugValid('/foo')).toBe(false);
24+
expect(isSlugValid('--foo--bar')).toBe(false);
25+
});
26+
});
27+
28+
describe('normalizeSlug', () => {
29+
it('converts / to --', () => {
30+
expect(normalizeSlug('foo')).toEqual('foo');
31+
expect(normalizeSlug('foo/bar')).toEqual('foo--bar');
32+
expect(normalizeSlug('foo/bar/baz')).toEqual('foo--bar--baz');
33+
});
34+
35+
it('removes whitespace', () => {
36+
expect(normalizeSlug(' foo ')).toEqual('foo');
37+
expect(normalizeSlug(' foo/bar ')).toEqual('foo--bar');
38+
expect(normalizeSlug(' foo/bar/baz ')).toEqual('foo--bar--baz');
39+
});
40+
});

packages/root-cms/ui/utils/slug.ts renamed to packages/root-cms/shared/slug.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
export function isSlugValid(slug: string): boolean {
2-
return Boolean(slug && slug.match(/^[a-z0-9]+(?:--?[a-z0-9]+)*$/));
1+
const DEFAULT_SLUG_PATTERN = /^[a-z0-9_]+(?:--?[a-z0-9_]+)*$/;
2+
3+
export function isSlugValid(slug: string, pattern?: string | RegExp): boolean {
4+
if (!pattern) {
5+
pattern = DEFAULT_SLUG_PATTERN;
6+
}
7+
const re = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
8+
return Boolean(slug && re.test(slug));
39
}
410

511
/**
@@ -12,14 +18,12 @@ export function isSlugValid(slug: string): boolean {
1218
* Transformations include:
1319
* Remove leading and trailing space
1420
* Remove leading and trailing slash
15-
* Lower case
1621
* Replace '/' with '--', e.g. 'foo/bar' -> 'foo--bar'
1722
*/
1823
export function normalizeSlug(slug: string): string {
1924
return slug
2025
.replace(/^[\s/]*/g, '')
2126
.replace(/[\s/]*$/g, '')
2227
.replace(/^\/+|\/+$/g, '')
23-
.toLowerCase()
2428
.replaceAll('/', '--');
2529
}

packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import {ContextModalProps, useModals} from '@mantine/modals';
33
import {showNotification} from '@mantine/notifications';
44
import {useState} from 'preact/hooks';
55
import {route} from 'preact-router';
6+
import {isSlugValid, normalizeSlug} from '../../../shared/slug.js';
67
import {useModalTheme} from '../../hooks/useModalTheme.js';
78
import {cmsCopyDoc} from '../../utils/doc.js';
8-
import {isSlugValid, normalizeSlug} from '../../utils/slug.js';
99
import {SlugInput} from '../SlugInput/SlugInput.js';
1010
import {Text} from '../Text/Text.js';
1111
import './CopyDocModal.css';
@@ -52,7 +52,8 @@ export function CopyDocModal(modalProps: ContextModalProps<CopyDocModalProps>) {
5252
return;
5353
}
5454
const cleanSlug = normalizeSlug(toSlug);
55-
if (!isSlugValid(cleanSlug)) {
55+
const slugRegex = window.__ROOT_CTX.collections[toCollectionId]?.slugRegex;
56+
if (!isSlugValid(cleanSlug, slugRegex)) {
5657
setError('Please enter a valid slug (e.g. "foo-bar-123").');
5758
setLoading(false);
5859
return;

packages/root-cms/ui/components/DataSourceForm/DataSourceForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import {showNotification} from '@mantine/notifications';
99
import {useEffect, useRef, useState} from 'preact/hooks';
1010
import {route} from 'preact-router';
11+
import {isSlugValid} from '../../../shared/slug.js';
1112
import {useGapiClient} from '../../hooks/useGapiClient.js';
1213
import {
1314
DataSource,
@@ -20,7 +21,6 @@ import {
2021
} from '../../utils/data-source.js';
2122
import {parseSpreadsheetUrl} from '../../utils/gsheets.js';
2223
import {notifyErrors} from '../../utils/notifications.js';
23-
import {isSlugValid} from '../../utils/slug.js';
2424
import './DataSourceForm.css';
2525

2626
const HTTP_URL_HELP = 'Enter the URL to make the HTTP request.';

packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,19 @@
11
import {Button, Modal, useMantineTheme} from '@mantine/core';
22
import {useState} from 'preact/hooks';
33
import {route} from 'preact-router';
4+
import {isSlugValid, normalizeSlug} from '../../../shared/slug.js';
5+
import {useCollectionSchema} from '../../hooks/useCollectionSchema.js';
46
import {cmsCreateDoc} from '../../utils/doc.js';
57
import {getDefaultFieldValue} from '../../utils/fields.js';
68
import {SlugInput} from '../SlugInput/SlugInput.js';
79
import './NewDocModal.css';
8-
import {logAction} from '../../utils/actions.js';
9-
import {useCollectionSchema} from '../../hooks/useCollectionSchema.js';
1010

1111
interface NewDocModalProps {
1212
collection: string;
1313
opened?: boolean;
1414
onClose?: () => void;
1515
}
1616

17-
function isSlugValid(slug: string): boolean {
18-
return Boolean(slug && slug.match(/^[a-z0-9]+(?:--?[a-z0-9]+)*$/));
19-
}
20-
21-
/**
22-
* Normalizes a user-entered slug value into one appropriate for the CMS.
23-
*
24-
* In order to keep the slugs "flat" within firestore, nested paths use a double
25-
* dash separator. For example, a URL like "/about/foo" should have a slug like
26-
* "about--foo".
27-
*
28-
* Transformations include:
29-
* Remove leading and trailing space
30-
* Remove leading and trailing slash
31-
* Lower case
32-
* Replace '/' with '--', e.g. 'foo/bar' -> 'foo--bar'
33-
*/
34-
function normalizeSlug(slug: string): string {
35-
return slug
36-
.replace(/^[\s/]*/g, '')
37-
.replace(/[\s/]*$/g, '')
38-
.replace(/^\/+|\/+$/g, '')
39-
.toLowerCase()
40-
.replaceAll('/', '--');
41-
}
42-
4317
export function NewDocModal(props: NewDocModalProps) {
4418
const [slug, setSlug] = useState('');
4519
const [rpcLoading, setRpcLoading] = useState(false);
@@ -64,7 +38,8 @@ export function NewDocModal(props: NewDocModalProps) {
6438
setSlugError('');
6539

6640
const cleanSlug = normalizeSlug(slug);
67-
if (!isSlugValid(cleanSlug)) {
41+
const slugRegex = rootCollection.slugRegex;
42+
if (!isSlugValid(cleanSlug, slugRegex)) {
6843
setSlugError('Please enter a valid slug (e.g. "foo-bar-123").');
6944
setRpcLoading(false);
7045
return;

packages/root-cms/ui/components/ReleaseForm/ReleaseForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ import {showNotification} from '@mantine/notifications';
1111
import {IconArrowUpRight, IconTrash} from '@tabler/icons-preact';
1212
import {useEffect, useRef, useState} from 'preact/hooks';
1313
import {route} from 'preact-router';
14+
import {isSlugValid} from '../../../shared/slug.js';
1415
import {notifyErrors} from '../../utils/notifications.js';
1516
import {
1617
Release,
1718
addRelease,
1819
getRelease,
1920
updateRelease,
2021
} from '../../utils/release.js';
21-
import {isSlugValid} from '../../utils/slug.js';
2222
import {DocPreviewCard} from '../DocPreviewCard/DocPreviewCard.js';
2323
import {useDocSelectModal} from '../DocSelectModal/DocSelectModal.js';
2424
import './ReleaseForm.css';

packages/root-cms/ui/components/SlugInput/SlugInput.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import {Select, TextInput} from '@mantine/core';
22
import {ChangeEvent} from 'preact/compat';
33
import {useRef, useState} from 'preact/hooks';
4+
import {isSlugValid, normalizeSlug} from '../../../shared/slug.js';
45
import {Text} from '../../components/Text/Text.js';
56
import {joinClassNames} from '../../utils/classes.js';
67
import {getDocServingUrl} from '../../utils/doc-urls.js';
7-
import {isSlugValid, normalizeSlug} from '../../utils/slug.js';
88
import './SlugInput.css';
99

1010
export interface SlugInputProps {
@@ -39,17 +39,18 @@ export function SlugInput(props: SlugInputProps) {
3939
if (rootCollection?.url) {
4040
if (slug) {
4141
const cleanSlug = normalizeSlug(slug);
42-
if (isSlugValid(cleanSlug)) {
42+
const slugRegex = rootCollection?.slugRegex;
43+
if (isSlugValid(cleanSlug, slugRegex)) {
4344
urlHelp = getDocServingUrl({
44-
collectionId: props.collectionId!,
45+
collectionId: collectionId,
4546
slug: cleanSlug,
4647
});
4748
} else {
4849
urlHelp = 'INVALID SLUG';
4950
}
5051
} else {
5152
urlHelp = getDocServingUrl({
52-
collectionId: props.collectionId!,
53+
collectionId: collectionId,
5354
slug: '[slug]',
5455
});
5556
}

packages/root-cms/ui/components/Viewers/Viewers.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import {
1010
updateDoc,
1111
} from 'firebase/firestore';
1212
import {useEffect, useState} from 'preact/hooks';
13+
import {normalizeSlug} from '../../../shared/slug.js';
1314
import {joinClassNames} from '../../utils/classes.js';
1415
import {EventListener} from '../../utils/events.js';
15-
import {normalizeSlug} from '../../utils/slug.js';
1616
import {throttle} from '../../utils/throttle.js';
1717
import {TIME_UNITS} from '../../utils/time.js';
1818
import {Timer} from '../../utils/timer.js';

packages/root-cms/ui/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,6 @@
3333
"**/*.ts",
3434
"*.tsx",
3535
"**/*.tsx",
36+
"../shared/*.ts",
3637
]
3738
}

0 commit comments

Comments
 (0)