diff --git a/.changeset/flat-zoos-do.md b/.changeset/flat-zoos-do.md new file mode 100644 index 00000000..1e666f61 --- /dev/null +++ b/.changeset/flat-zoos-do.md @@ -0,0 +1,5 @@ +--- +'@blinkk/root-cms': patch +--- + +feat: add slugRegex config option for collections (#538) diff --git a/packages/root-cms/core/app.tsx b/packages/root-cms/core/app.tsx index 1ea58546..930e4300 100644 --- a/packages/root-cms/core/app.tsx +++ b/packages/root-cms/core/app.tsx @@ -154,6 +154,7 @@ function serializeCollection(collection: Collection): Partial { url: collection.url, previewUrl: collection.previewUrl, preview: collection.preview, + slugRegex: collection.slugRegex, }; } diff --git a/packages/root-cms/core/schema.ts b/packages/root-cms/core/schema.ts index 29ec3a40..3448b054 100644 --- a/packages/root-cms/core/schema.ts +++ b/packages/root-cms/core/schema.ts @@ -288,6 +288,11 @@ export type Collection = Schema & { src: string; }; }; + /** + * Regular expression used to validate document slugs. Should be provided as a + * string so it can be serialized to the CMS UI. + */ + slugRegex?: string; }; export function defineCollection( diff --git a/packages/root-cms/core/tsconfig.json b/packages/root-cms/core/tsconfig.json index 428be5d8..fa63b44a 100644 --- a/packages/root-cms/core/tsconfig.json +++ b/packages/root-cms/core/tsconfig.json @@ -30,5 +30,6 @@ "**/*.ts", "*.tsx", "**/*.tsx", + "../shared/*.ts" ] } diff --git a/packages/root-cms/shared/slug.test.ts b/packages/root-cms/shared/slug.test.ts new file mode 100644 index 00000000..3199d226 --- /dev/null +++ b/packages/root-cms/shared/slug.test.ts @@ -0,0 +1,40 @@ +import {describe, it, expect} from 'vitest'; +import {isSlugValid, normalizeSlug} from './slug.js'; + +describe('isSlugValid', () => { + it('validates good slugs', () => { + expect(isSlugValid('1')).toBe(true); + expect(isSlugValid('a')).toBe(true); + expect(isSlugValid('foo')).toBe(true); + expect(isSlugValid('foo-bar')).toBe(true); + expect(isSlugValid('foo--bar')).toBe(true); + expect(isSlugValid('foo-bar-123')).toBe(true); + expect(isSlugValid('foo--bar--123')).toBe(true); + expect(isSlugValid('foo_bar')).toBe(true); + expect(isSlugValid('foo_bar-123')).toBe(true); + expect(isSlugValid('_foo_bar-123')).toBe(true); + }); + + it('invalidates bad slugs', () => { + expect(isSlugValid('Foo')).toBe(false); + expect(isSlugValid('-asdf-')).toBe(false); + expect(isSlugValid('-a!!')).toBe(false); + expect(isSlugValid('!!a')).toBe(false); + expect(isSlugValid('/foo')).toBe(false); + expect(isSlugValid('--foo--bar')).toBe(false); + }); +}); + +describe('normalizeSlug', () => { + it('converts / to --', () => { + expect(normalizeSlug('foo')).toEqual('foo'); + expect(normalizeSlug('foo/bar')).toEqual('foo--bar'); + expect(normalizeSlug('foo/bar/baz')).toEqual('foo--bar--baz'); + }); + + it('removes whitespace', () => { + expect(normalizeSlug(' foo ')).toEqual('foo'); + expect(normalizeSlug(' foo/bar ')).toEqual('foo--bar'); + expect(normalizeSlug(' foo/bar/baz ')).toEqual('foo--bar--baz'); + }); +}); diff --git a/packages/root-cms/ui/utils/slug.ts b/packages/root-cms/shared/slug.ts similarity index 66% rename from packages/root-cms/ui/utils/slug.ts rename to packages/root-cms/shared/slug.ts index afd57571..57fc20e8 100644 --- a/packages/root-cms/ui/utils/slug.ts +++ b/packages/root-cms/shared/slug.ts @@ -1,5 +1,11 @@ -export function isSlugValid(slug: string): boolean { - return Boolean(slug && slug.match(/^[a-z0-9]+(?:--?[a-z0-9]+)*$/)); +const DEFAULT_SLUG_PATTERN = /^[a-z0-9_]+(?:--?[a-z0-9_]+)*$/; + +export function isSlugValid(slug: string, pattern?: string | RegExp): boolean { + if (!pattern) { + pattern = DEFAULT_SLUG_PATTERN; + } + const re = typeof pattern === 'string' ? new RegExp(pattern) : pattern; + return Boolean(slug && re.test(slug)); } /** @@ -12,7 +18,6 @@ export function isSlugValid(slug: string): boolean { * Transformations include: * Remove leading and trailing space * Remove leading and trailing slash - * Lower case * Replace '/' with '--', e.g. 'foo/bar' -> 'foo--bar' */ export function normalizeSlug(slug: string): string { @@ -20,6 +25,5 @@ export function normalizeSlug(slug: string): string { .replace(/^[\s/]*/g, '') .replace(/[\s/]*$/g, '') .replace(/^\/+|\/+$/g, '') - .toLowerCase() .replaceAll('/', '--'); } diff --git a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx index d96aead6..e302706c 100644 --- a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx +++ b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx @@ -3,9 +3,9 @@ import {ContextModalProps, useModals} from '@mantine/modals'; import {showNotification} from '@mantine/notifications'; import {useState} from 'preact/hooks'; import {route} from 'preact-router'; +import {isSlugValid, normalizeSlug} from '../../../shared/slug.js'; import {useModalTheme} from '../../hooks/useModalTheme.js'; import {cmsCopyDoc} from '../../utils/doc.js'; -import {isSlugValid, normalizeSlug} from '../../utils/slug.js'; import {SlugInput} from '../SlugInput/SlugInput.js'; import {Text} from '../Text/Text.js'; import './CopyDocModal.css'; @@ -52,7 +52,8 @@ export function CopyDocModal(modalProps: ContextModalProps) { return; } const cleanSlug = normalizeSlug(toSlug); - if (!isSlugValid(cleanSlug)) { + const slugRegex = window.__ROOT_CTX.collections[toCollectionId]?.slugRegex; + if (!isSlugValid(cleanSlug, slugRegex)) { setError('Please enter a valid slug (e.g. "foo-bar-123").'); setLoading(false); return; diff --git a/packages/root-cms/ui/components/DataSourceForm/DataSourceForm.tsx b/packages/root-cms/ui/components/DataSourceForm/DataSourceForm.tsx index e63964e8..7a3c5e8b 100644 --- a/packages/root-cms/ui/components/DataSourceForm/DataSourceForm.tsx +++ b/packages/root-cms/ui/components/DataSourceForm/DataSourceForm.tsx @@ -8,6 +8,7 @@ import { import {showNotification} from '@mantine/notifications'; import {useEffect, useRef, useState} from 'preact/hooks'; import {route} from 'preact-router'; +import {isSlugValid} from '../../../shared/slug.js'; import {useGapiClient} from '../../hooks/useGapiClient.js'; import { DataSource, @@ -20,7 +21,6 @@ import { } from '../../utils/data-source.js'; import {parseSpreadsheetUrl} from '../../utils/gsheets.js'; import {notifyErrors} from '../../utils/notifications.js'; -import {isSlugValid} from '../../utils/slug.js'; import './DataSourceForm.css'; const HTTP_URL_HELP = 'Enter the URL to make the HTTP request.'; diff --git a/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx b/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx index e33b947f..bace3e2b 100644 --- a/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx +++ b/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx @@ -1,12 +1,12 @@ import {Button, Modal, useMantineTheme} from '@mantine/core'; import {useState} from 'preact/hooks'; import {route} from 'preact-router'; +import {isSlugValid, normalizeSlug} from '../../../shared/slug.js'; +import {useCollectionSchema} from '../../hooks/useCollectionSchema.js'; import {cmsCreateDoc} from '../../utils/doc.js'; import {getDefaultFieldValue} from '../../utils/fields.js'; import {SlugInput} from '../SlugInput/SlugInput.js'; import './NewDocModal.css'; -import {logAction} from '../../utils/actions.js'; -import {useCollectionSchema} from '../../hooks/useCollectionSchema.js'; interface NewDocModalProps { collection: string; @@ -14,32 +14,6 @@ interface NewDocModalProps { onClose?: () => void; } -function isSlugValid(slug: string): boolean { - return Boolean(slug && slug.match(/^[a-z0-9]+(?:--?[a-z0-9]+)*$/)); -} - -/** - * Normalizes a user-entered slug value into one appropriate for the CMS. - * - * In order to keep the slugs "flat" within firestore, nested paths use a double - * dash separator. For example, a URL like "/about/foo" should have a slug like - * "about--foo". - * - * Transformations include: - * Remove leading and trailing space - * Remove leading and trailing slash - * Lower case - * Replace '/' with '--', e.g. 'foo/bar' -> 'foo--bar' - */ -function normalizeSlug(slug: string): string { - return slug - .replace(/^[\s/]*/g, '') - .replace(/[\s/]*$/g, '') - .replace(/^\/+|\/+$/g, '') - .toLowerCase() - .replaceAll('/', '--'); -} - export function NewDocModal(props: NewDocModalProps) { const [slug, setSlug] = useState(''); const [rpcLoading, setRpcLoading] = useState(false); @@ -64,7 +38,8 @@ export function NewDocModal(props: NewDocModalProps) { setSlugError(''); const cleanSlug = normalizeSlug(slug); - if (!isSlugValid(cleanSlug)) { + const slugRegex = rootCollection.slugRegex; + if (!isSlugValid(cleanSlug, slugRegex)) { setSlugError('Please enter a valid slug (e.g. "foo-bar-123").'); setRpcLoading(false); return; diff --git a/packages/root-cms/ui/components/ReleaseForm/ReleaseForm.tsx b/packages/root-cms/ui/components/ReleaseForm/ReleaseForm.tsx index aa2c7377..5fea1fd6 100644 --- a/packages/root-cms/ui/components/ReleaseForm/ReleaseForm.tsx +++ b/packages/root-cms/ui/components/ReleaseForm/ReleaseForm.tsx @@ -11,6 +11,7 @@ import {showNotification} from '@mantine/notifications'; import {IconArrowUpRight, IconTrash} from '@tabler/icons-preact'; import {useEffect, useRef, useState} from 'preact/hooks'; import {route} from 'preact-router'; +import {isSlugValid} from '../../../shared/slug.js'; import {notifyErrors} from '../../utils/notifications.js'; import { Release, @@ -18,7 +19,6 @@ import { getRelease, updateRelease, } from '../../utils/release.js'; -import {isSlugValid} from '../../utils/slug.js'; import {DocPreviewCard} from '../DocPreviewCard/DocPreviewCard.js'; import {useDocSelectModal} from '../DocSelectModal/DocSelectModal.js'; import './ReleaseForm.css'; diff --git a/packages/root-cms/ui/components/SlugInput/SlugInput.tsx b/packages/root-cms/ui/components/SlugInput/SlugInput.tsx index b13d0682..ea1977c9 100644 --- a/packages/root-cms/ui/components/SlugInput/SlugInput.tsx +++ b/packages/root-cms/ui/components/SlugInput/SlugInput.tsx @@ -1,10 +1,10 @@ import {Select, TextInput} from '@mantine/core'; import {ChangeEvent} from 'preact/compat'; import {useRef, useState} from 'preact/hooks'; +import {isSlugValid, normalizeSlug} from '../../../shared/slug.js'; import {Text} from '../../components/Text/Text.js'; import {joinClassNames} from '../../utils/classes.js'; import {getDocServingUrl} from '../../utils/doc-urls.js'; -import {isSlugValid, normalizeSlug} from '../../utils/slug.js'; import './SlugInput.css'; export interface SlugInputProps { @@ -39,9 +39,10 @@ export function SlugInput(props: SlugInputProps) { if (rootCollection?.url) { if (slug) { const cleanSlug = normalizeSlug(slug); - if (isSlugValid(cleanSlug)) { + const slugRegex = rootCollection?.slugRegex; + if (isSlugValid(cleanSlug, slugRegex)) { urlHelp = getDocServingUrl({ - collectionId: props.collectionId!, + collectionId: collectionId, slug: cleanSlug, }); } else { @@ -49,7 +50,7 @@ export function SlugInput(props: SlugInputProps) { } } else { urlHelp = getDocServingUrl({ - collectionId: props.collectionId!, + collectionId: collectionId, slug: '[slug]', }); } diff --git a/packages/root-cms/ui/components/Viewers/Viewers.tsx b/packages/root-cms/ui/components/Viewers/Viewers.tsx index c5ff3195..ec0b215f 100644 --- a/packages/root-cms/ui/components/Viewers/Viewers.tsx +++ b/packages/root-cms/ui/components/Viewers/Viewers.tsx @@ -10,9 +10,9 @@ import { updateDoc, } from 'firebase/firestore'; import {useEffect, useState} from 'preact/hooks'; +import {normalizeSlug} from '../../../shared/slug.js'; import {joinClassNames} from '../../utils/classes.js'; import {EventListener} from '../../utils/events.js'; -import {normalizeSlug} from '../../utils/slug.js'; import {throttle} from '../../utils/throttle.js'; import {TIME_UNITS} from '../../utils/time.js'; import {Timer} from '../../utils/timer.js'; diff --git a/packages/root-cms/ui/tsconfig.json b/packages/root-cms/ui/tsconfig.json index 6ee373d3..ca5d1374 100644 --- a/packages/root-cms/ui/tsconfig.json +++ b/packages/root-cms/ui/tsconfig.json @@ -33,5 +33,6 @@ "**/*.ts", "*.tsx", "**/*.tsx", + "../shared/*.ts", ] }