From c2069ffb19a9f1db8f3d7a2e98b48c082be03034 Mon Sep 17 00:00:00 2001 From: Steven Le Date: Fri, 16 May 2025 22:58:01 -0700 Subject: [PATCH 1/4] feat(cms): make slug regex configurable --- packages/root-cms/core/app.tsx | 1 + packages/root-cms/core/schema.ts | 5 ++++ .../components/CopyDocModal/CopyDocModal.tsx | 3 +- .../ui/components/NewDocModal/NewDocModal.tsx | 29 ++----------------- .../ui/components/SlugInput/SlugInput.tsx | 3 +- packages/root-cms/ui/utils/slug.ts | 8 +++-- 6 files changed, 19 insertions(+), 30 deletions(-) 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/ui/components/CopyDocModal/CopyDocModal.tsx b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx index d96aead6..b0e8e0d4 100644 --- a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx +++ b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx @@ -52,7 +52,8 @@ export function CopyDocModal(modalProps: ContextModalProps) { return; } const cleanSlug = normalizeSlug(toSlug); - if (!isSlugValid(cleanSlug)) { + const pattern = window.__ROOT_CTX.collections[toCollectionId]?.slugRegex; + if (!isSlugValid(cleanSlug, pattern)) { setError('Please enter a valid slug (e.g. "foo-bar-123").'); setLoading(false); return; diff --git a/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx b/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx index e33b947f..1c710061 100644 --- a/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx +++ b/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx @@ -7,6 +7,7 @@ import {SlugInput} from '../SlugInput/SlugInput.js'; import './NewDocModal.css'; import {logAction} from '../../utils/actions.js'; import {useCollectionSchema} from '../../hooks/useCollectionSchema.js'; +import {isSlugValid, normalizeSlug} from '../../utils/slug.js'; interface NewDocModalProps { collection: string; @@ -14,31 +15,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(''); @@ -64,7 +40,8 @@ export function NewDocModal(props: NewDocModalProps) { setSlugError(''); const cleanSlug = normalizeSlug(slug); - if (!isSlugValid(cleanSlug)) { + const pattern = rootCollection.slugRegex; + if (!isSlugValid(cleanSlug, pattern)) { setSlugError('Please enter a valid slug (e.g. "foo-bar-123").'); setRpcLoading(false); return; diff --git a/packages/root-cms/ui/components/SlugInput/SlugInput.tsx b/packages/root-cms/ui/components/SlugInput/SlugInput.tsx index b13d0682..c65ab7c8 100644 --- a/packages/root-cms/ui/components/SlugInput/SlugInput.tsx +++ b/packages/root-cms/ui/components/SlugInput/SlugInput.tsx @@ -39,7 +39,8 @@ export function SlugInput(props: SlugInputProps) { if (rootCollection?.url) { if (slug) { const cleanSlug = normalizeSlug(slug); - if (isSlugValid(cleanSlug)) { + const pattern = rootCollection?.slugRegex; + if (isSlugValid(cleanSlug, pattern)) { urlHelp = getDocServingUrl({ collectionId: props.collectionId!, slug: cleanSlug, diff --git a/packages/root-cms/ui/utils/slug.ts b/packages/root-cms/ui/utils/slug.ts index afd57571..61971337 100644 --- a/packages/root-cms/ui/utils/slug.ts +++ b/packages/root-cms/ui/utils/slug.ts @@ -1,5 +1,9 @@ -export function isSlugValid(slug: string): boolean { - return Boolean(slug && slug.match(/^[a-z0-9]+(?:--?[a-z0-9]+)*$/)); +export function isSlugValid( + slug: string, + pattern: string | RegExp = /^[a-z0-9]+(?:--?[a-z0-9]+)*$/ +): boolean { + const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern; + return Boolean(slug && regex.test(slug)); } /** From dd6223ef8b1270d177b722c767f04c9989971d8b Mon Sep 17 00:00:00 2001 From: Steven Le Date: Sat, 17 May 2025 08:12:25 -0700 Subject: [PATCH 2/4] chore: update code style --- .../ui/components/CopyDocModal/CopyDocModal.tsx | 4 ++-- .../ui/components/NewDocModal/NewDocModal.tsx | 10 ++++------ .../ui/components/SlugInput/SlugInput.tsx | 8 ++++---- packages/root-cms/ui/utils/slug.ts | 15 ++++++++------- 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx index b0e8e0d4..c461617f 100644 --- a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx +++ b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx @@ -52,8 +52,8 @@ export function CopyDocModal(modalProps: ContextModalProps) { return; } const cleanSlug = normalizeSlug(toSlug); - const pattern = window.__ROOT_CTX.collections[toCollectionId]?.slugRegex; - if (!isSlugValid(cleanSlug, pattern)) { + 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/NewDocModal/NewDocModal.tsx b/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx index 1c710061..3f157de5 100644 --- a/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx +++ b/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx @@ -1,13 +1,12 @@ import {Button, Modal, useMantineTheme} from '@mantine/core'; import {useState} from 'preact/hooks'; import {route} from 'preact-router'; +import {useCollectionSchema} from '../../hooks/useCollectionSchema.js'; import {cmsCreateDoc} from '../../utils/doc.js'; import {getDefaultFieldValue} from '../../utils/fields.js'; +import {isSlugValid, normalizeSlug} from '../../utils/slug.js'; import {SlugInput} from '../SlugInput/SlugInput.js'; import './NewDocModal.css'; -import {logAction} from '../../utils/actions.js'; -import {useCollectionSchema} from '../../hooks/useCollectionSchema.js'; -import {isSlugValid, normalizeSlug} from '../../utils/slug.js'; interface NewDocModalProps { collection: string; @@ -15,7 +14,6 @@ interface NewDocModalProps { onClose?: () => void; } - export function NewDocModal(props: NewDocModalProps) { const [slug, setSlug] = useState(''); const [rpcLoading, setRpcLoading] = useState(false); @@ -40,8 +38,8 @@ export function NewDocModal(props: NewDocModalProps) { setSlugError(''); const cleanSlug = normalizeSlug(slug); - const pattern = rootCollection.slugRegex; - if (!isSlugValid(cleanSlug, pattern)) { + 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/SlugInput/SlugInput.tsx b/packages/root-cms/ui/components/SlugInput/SlugInput.tsx index c65ab7c8..649655bf 100644 --- a/packages/root-cms/ui/components/SlugInput/SlugInput.tsx +++ b/packages/root-cms/ui/components/SlugInput/SlugInput.tsx @@ -39,10 +39,10 @@ export function SlugInput(props: SlugInputProps) { if (rootCollection?.url) { if (slug) { const cleanSlug = normalizeSlug(slug); - const pattern = rootCollection?.slugRegex; - if (isSlugValid(cleanSlug, pattern)) { + const slugRegex = rootCollection?.slugRegex; + if (isSlugValid(cleanSlug, slugRegex)) { urlHelp = getDocServingUrl({ - collectionId: props.collectionId!, + collectionId: collectionId, slug: cleanSlug, }); } else { @@ -50,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/utils/slug.ts b/packages/root-cms/ui/utils/slug.ts index 61971337..a650d0f2 100644 --- a/packages/root-cms/ui/utils/slug.ts +++ b/packages/root-cms/ui/utils/slug.ts @@ -1,9 +1,11 @@ -export function isSlugValid( - slug: string, - pattern: string | RegExp = /^[a-z0-9]+(?:--?[a-z0-9]+)*$/ -): boolean { - const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern; - return Boolean(slug && regex.test(slug)); +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)); } /** @@ -24,6 +26,5 @@ export function normalizeSlug(slug: string): string { .replace(/^[\s/]*/g, '') .replace(/[\s/]*$/g, '') .replace(/^\/+|\/+$/g, '') - .toLowerCase() .replaceAll('/', '--'); } From 17442528026c4409c0473a4d58613d4e52d0f236 Mon Sep 17 00:00:00 2001 From: Steven Le Date: Sat, 17 May 2025 08:13:38 -0700 Subject: [PATCH 3/4] chore: add changeset --- .changeset/flat-zoos-do.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/flat-zoos-do.md 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) From 88c831e7341175beed88ac5dbafbee1a1fc579ef Mon Sep 17 00:00:00 2001 From: Steven Le Date: Sat, 17 May 2025 08:27:44 -0700 Subject: [PATCH 4/4] chore: move slug.ts to shared folder and add tests --- packages/root-cms/core/tsconfig.json | 1 + packages/root-cms/shared/slug.test.ts | 40 +++++++++++++++++++ .../root-cms/{ui/utils => shared}/slug.ts | 3 +- .../components/CopyDocModal/CopyDocModal.tsx | 2 +- .../DataSourceForm/DataSourceForm.tsx | 2 +- .../ui/components/NewDocModal/NewDocModal.tsx | 2 +- .../ui/components/ReleaseForm/ReleaseForm.tsx | 2 +- .../ui/components/SlugInput/SlugInput.tsx | 2 +- .../ui/components/Viewers/Viewers.tsx | 2 +- packages/root-cms/ui/tsconfig.json | 1 + 10 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 packages/root-cms/shared/slug.test.ts rename packages/root-cms/{ui/utils => shared}/slug.ts (91%) 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 91% rename from packages/root-cms/ui/utils/slug.ts rename to packages/root-cms/shared/slug.ts index a650d0f2..57fc20e8 100644 --- a/packages/root-cms/ui/utils/slug.ts +++ b/packages/root-cms/shared/slug.ts @@ -1,4 +1,4 @@ -const DEFAULT_SLUG_PATTERN = /^[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) { @@ -18,7 +18,6 @@ export function isSlugValid(slug: string, pattern?: string | RegExp): 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 { diff --git a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx index c461617f..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'; 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 3f157de5..bace3e2b 100644 --- a/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx +++ b/packages/root-cms/ui/components/NewDocModal/NewDocModal.tsx @@ -1,10 +1,10 @@ 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 {isSlugValid, normalizeSlug} from '../../utils/slug.js'; import {SlugInput} from '../SlugInput/SlugInput.js'; import './NewDocModal.css'; 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 649655bf..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 { 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", ] }