sanity-plugin-recursive-hierarchy 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +676 -0
  3. package/dist/index.d.mts +336 -0
  4. package/dist/index.d.ts +336 -0
  5. package/dist/index.js +3494 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/index.mjs +3502 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +100 -0
  10. package/sanity.json +8 -0
  11. package/src/actions/helpers.ts +55 -0
  12. package/src/actions/wrapDuplicateAction.ts +92 -0
  13. package/src/components/MapUpdater.tsx +447 -0
  14. package/src/components/RecursiveListWrapper.tsx +180 -0
  15. package/src/index.ts +95 -0
  16. package/src/initialValueTemplates.ts +72 -0
  17. package/src/lib/apiVersion.ts +18 -0
  18. package/src/lib/defaultStructureFilter.ts +47 -0
  19. package/src/lib/helpers.ts +23 -0
  20. package/src/lib/hooks.ts +10 -0
  21. package/src/recursiveNestedList/ListLoading.tsx +10 -0
  22. package/src/recursiveNestedList/createDraftMenuItems.ts +67 -0
  23. package/src/recursiveNestedList/fields.ts +155 -0
  24. package/src/recursiveNestedList/index.ts +7 -0
  25. package/src/recursiveNestedList/parentChildMap.ts +564 -0
  26. package/src/recursiveNestedList/recursiveMapStore.tsx +60 -0
  27. package/src/recursiveNestedList/recursiveNestedStructureDescendentList.tsx +315 -0
  28. package/src/recursiveNestedList/recursiveNestedStructureParentList.ts +413 -0
  29. package/src/recursiveNestedList/routing.ts +259 -0
  30. package/src/types.ts +216 -0
  31. package/src/urlSlug/components/AsyncUrlSlugInput.tsx +167 -0
  32. package/src/urlSlug/components/PrefixErrorBoundary.tsx +43 -0
  33. package/src/urlSlug/components/useAsyncUrlSlugLogic.ts +132 -0
  34. package/src/urlSlug/index.ts +15 -0
  35. package/src/urlSlug/lib/slugGeneration.ts +86 -0
  36. package/src/urlSlug/lib/slugValidationPerspective.ts +13 -0
  37. package/src/urlSlug/lib/slugify.ts +22 -0
  38. package/src/urlSlug/urlSlugObject.ts +43 -0
  39. package/v2-incompatible.js +11 -0
package/src/types.ts ADDED
@@ -0,0 +1,216 @@
1
+ import type {ComponentType} from 'react'
2
+ import type {CurrentUser, SanityDocument, Slug} from 'sanity'
3
+
4
+ /* eslint-disable no-use-before-define */
5
+
6
+ // --- Constants ---
7
+
8
+ /** @public */
9
+ export const ACTION_GROUP_ADD = 'ADD' as const
10
+ /** @public */
11
+ export const ACTION_GROUP_DELETE = 'DELETE' as const
12
+ /** @public */
13
+ export const ACTION_GROUP_EDIT = 'EDIT' as const
14
+ /** @public */
15
+ export const DEFAULT_LOCALE_KEY = 'default'
16
+ export const DEFAULT_LOCALES: LocaleOption[] = [{id: 'en-US', title: 'English', flag: '🇺🇸'}]
17
+
18
+ // --- Types ---
19
+
20
+ /** @public */
21
+ export type CreateDraftParams = {
22
+ timestamp: number
23
+ type: string
24
+ template: string
25
+ parentId: string
26
+ parentSummary: SanityDocumentWithDraftInfo | undefined
27
+ parentPaneId: string
28
+ additionalAttributes?: {
29
+ title?: string
30
+ language?: string
31
+ filters?: string[]
32
+ topLevelNode?: boolean
33
+ }
34
+ }
35
+
36
+ /** @public */
37
+ export type Locale = {
38
+ fallbackLocale?: null | string
39
+ flag: string
40
+ id: string
41
+ releaseId?: string
42
+ status?: 'draft' | 'in_release' | 'published'
43
+ title: string
44
+ }
45
+
46
+ /** @public */
47
+ export type LocaleOption = {id: string; title: string; flag?: string}
48
+
49
+ /** @public */
50
+ export type MapUpdatePayload = {
51
+ timestamp: number
52
+ treeId: string
53
+ documentType: string
54
+ nodeDocumentType: string
55
+ leafDocumentType: string
56
+ documentId: string
57
+ oldParentIds: Array<string>
58
+ newParentIds: Array<string>
59
+ language: string
60
+ suppressRouting: boolean
61
+ rebuildFromRoot: boolean
62
+ patchFields?: {
63
+ title?: string
64
+ slug?: {current?: string; fullUrl?: string}
65
+ }
66
+ actionGroup: typeof ACTION_GROUP_ADD | typeof ACTION_GROUP_DELETE | typeof ACTION_GROUP_EDIT | ''
67
+ }
68
+
69
+ /**
70
+ * Outer key = treeId, inner key = localeKey
71
+ * @public
72
+ */
73
+ export type NodeAndLeafMap = Record<string, Record<string, StructureMapAndNodesDict>>
74
+
75
+ export type NodeInfo = {
76
+ leaves: SanityDocumentWithDraftInfo[]
77
+ node: SanityDocumentWithDraftInfo | undefined
78
+ }
79
+
80
+ export type NodesDictionary = Record<string, NodeInfo>
81
+
82
+ /** @public */
83
+ export type PluginOptions = {
84
+ trees: TreeConfig[]
85
+ t?: (key: string, language?: string, params?: Record<string, unknown>) => string
86
+ createStructureFilter?: () => FilterBuilder
87
+ icons?: {
88
+ plus?: ComponentType
89
+ folderTree?: ComponentType
90
+ }
91
+ apiVersion?: string
92
+ }
93
+
94
+ export type RecursiveMapStoreState = {
95
+ inMultiLanguageWorkspace: boolean
96
+ setInMultiLanguageWorkspace: (inMultiLanguageWorkspace: boolean) => void
97
+ createDraftRequested: CreateDraftParams
98
+ setCreateDraftRequested: (createRequested: CreateDraftParams) => void
99
+ resetCreateDraftRequested: () => void
100
+ nodeAndLeafMap: NodeAndLeafMap
101
+ setNodeAndLeafMap: (nodeAndLeafMap: NodeAndLeafMap, timestamp: number) => void
102
+ timestampOfLastUpdate: number
103
+ updateTriggered: MapUpdatePayload
104
+ setUpdateTriggered: (updateTriggered: MapUpdatePayload) => void
105
+ resetUpdateTriggered: () => void
106
+ }
107
+
108
+ /** @public */
109
+ export type SanityDocumentWithDraftInfo = Omit<
110
+ SanityDocument,
111
+ '_rev' | 'createdAt' | 'updatedAt'
112
+ > & {
113
+ _id: string
114
+ _type: string
115
+ _originalId: string
116
+ language?: string
117
+ title: string
118
+ slug: Slug & {fullUrl?: string}
119
+ parentIds?: Array<string>
120
+ topLevelNode?: boolean
121
+ }
122
+
123
+ export type StructureMap = Record<
124
+ string,
125
+ {children: Array<SanityDocumentWithDraftInfo>; title: string}
126
+ >
127
+
128
+ /** @public */
129
+ export type StructureMapAndNodesDict = {
130
+ nodesDictionary: NodesDictionary
131
+ structureMap: StructureMap
132
+ }
133
+
134
+ /** @public */
135
+ export type TreeConfig = {
136
+ id: string
137
+ documentTypes: {node: string; leaf: string}
138
+ languageFieldName?: string
139
+ getLocales?: (client: unknown) => LocaleOption[]
140
+ leafParentReferenceField?: string
141
+ }
142
+
143
+ /* eslint-enable no-use-before-define */
144
+
145
+ /** @public */
146
+ export type TreeTemplateIds = {
147
+ parentNode: string
148
+ descendentNode: string
149
+ leaf: string
150
+ }
151
+
152
+ // --- Interfaces ---
153
+
154
+ /** @public */
155
+ export interface FilterBuilder {
156
+ type: (type: string) => FilterBuilder
157
+ language: (lang: string, langFieldName?: string) => FilterBuilder
158
+ userScope: (user: CurrentUser | null) => FilterBuilder
159
+ excludeSingletons: (ids: string[]) => FilterBuilder
160
+ custom: (filter: string) => FilterBuilder
161
+ build: () => string
162
+ getParams: () => Record<string, unknown>
163
+ }
164
+
165
+ // --- Functions ---
166
+
167
+ let pluginOptions: PluginOptions = {trees: []}
168
+ let cachedLocales: LocaleOption[] = DEFAULT_LOCALES
169
+
170
+ /** @public */
171
+ export function getAllTrees(): TreeConfig[] {
172
+ return pluginOptions.trees ?? []
173
+ }
174
+
175
+ export function getLocalesForRouting(): LocaleOption[] {
176
+ return cachedLocales
177
+ }
178
+
179
+ /** @public */
180
+ export function getPluginOptions(): PluginOptions {
181
+ return pluginOptions
182
+ }
183
+
184
+ /** @public */
185
+ export function getStructureApiVersion(): string {
186
+ const v = pluginOptions.apiVersion
187
+ if (v) return v.startsWith('v') ? v : `v${v}`
188
+ const envVersion = typeof process === 'undefined' ? undefined : process.env?.SANITY_API_VERSION
189
+ if (envVersion) {
190
+ return envVersion.startsWith('v') ? envVersion : `v${envVersion}`
191
+ }
192
+ return 'v2026-02-01'
193
+ }
194
+
195
+ /** @public */
196
+ export function getTreeConfig(treeId: string): TreeConfig {
197
+ const tree = pluginOptions.trees?.find((t) => t.id === treeId)
198
+ if (!tree) throw new Error(`recursiveNestedListPlugin: no tree config found for id "${treeId}"`)
199
+ return tree
200
+ }
201
+
202
+ /** @public */
203
+ export function getTreeForDocumentType(docType: string): TreeConfig | undefined {
204
+ return pluginOptions.trees?.find(
205
+ (t) => t.documentTypes.node === docType || t.documentTypes.leaf === docType,
206
+ )
207
+ }
208
+
209
+ export function setCachedLocales(locales: LocaleOption[]): void {
210
+ cachedLocales = locales
211
+ }
212
+
213
+ /** @public */
214
+ export function setPluginOptions(options: PluginOptions): void {
215
+ pluginOptions = options ?? {trees: []}
216
+ }
@@ -0,0 +1,167 @@
1
+ import {CloseCircleIcon} from '@sanity/icons'
2
+ import {Box, Button, Card, Code, Flex, Spinner, Text, TextInput, Tooltip} from '@sanity/ui'
3
+ import React, {Suspense, use, useEffect, useRef} from 'react'
4
+ import {type ObjectInputProps, set, unset, useFormValue, usePerspective} from 'sanity'
5
+
6
+ import {formatUrlPrefix, type UrlSlugValue} from '../lib/slugGeneration'
7
+ import {cleanSlug} from '../lib/slugify'
8
+ import {setSlugValidationPerspective} from '../lib/slugValidationPerspective'
9
+ import {PrefixErrorBoundary} from './PrefixErrorBoundary'
10
+ import {useAsyncUrlSlugLogic} from './useAsyncUrlSlugLogic'
11
+
12
+ const urlPrefixStyle: React.CSSProperties = {
13
+ flex: '0 1 min-content',
14
+ }
15
+
16
+ const codeOverflowStyle: React.CSSProperties = {
17
+ whiteSpace: 'nowrap',
18
+ }
19
+
20
+ function PrefixLoading() {
21
+ return (
22
+ <Card style={urlPrefixStyle}>
23
+ <Flex align="center" gap={2} padding={3}>
24
+ <Spinner muted size={1} />
25
+ <Text muted size={1}>
26
+ Loading URL...
27
+ </Text>
28
+ </Flex>
29
+ </Card>
30
+ )
31
+ }
32
+
33
+ function PrefixDisplay({
34
+ promise,
35
+ sourceField,
36
+ }: {
37
+ promise: Promise<string | undefined>
38
+ sourceField?: string
39
+ }) {
40
+ const rawPrefix = use(promise)
41
+ const prefix = formatUrlPrefix(rawPrefix || '')
42
+
43
+ if (!prefix) return null
44
+
45
+ const maxChars = sourceField ? 30 : 35
46
+ const isTruncated = prefix.length > maxChars
47
+ const displayPrefix = isTruncated ? `${prefix.slice(0, maxChars - 1)}\u2026` : prefix
48
+
49
+ const codeElement = (
50
+ <Card style={urlPrefixStyle}>
51
+ <Code size={1} style={codeOverflowStyle}>
52
+ {displayPrefix}
53
+ </Code>
54
+ </Card>
55
+ )
56
+
57
+ if (isTruncated) {
58
+ return (
59
+ <Tooltip
60
+ content={
61
+ <Box padding={2}>
62
+ <Text>{prefix}</Text>
63
+ </Box>
64
+ }
65
+ >
66
+ {codeElement}
67
+ </Tooltip>
68
+ )
69
+ }
70
+
71
+ return codeElement
72
+ }
73
+
74
+ export const AsyncUrlSlugInput = (props: ObjectInputProps): React.JSX.Element => {
75
+ const {value, schemaType, onChange} = props
76
+ const sourceField = schemaType.options?.source as string | undefined
77
+ const sourceValue = useFormValue([sourceField ?? '']) as string | undefined
78
+ const sourceValueSlugified = sourceValue ? cleanSlug(sourceValue) : ''
79
+ const urlSlugValue = value as UrlSlugValue | undefined
80
+
81
+ const slugifiedSourceAtLastSyncRef = useRef<string | null>(null)
82
+ const currentSlug = urlSlugValue?.current ?? ''
83
+ if (currentSlug && sourceValueSlugified && currentSlug === sourceValueSlugified) {
84
+ // eslint-disable-next-line react-hooks/refs -- intentional: sync ref during render for slug tracking
85
+ slugifiedSourceAtLastSyncRef.current = sourceValueSlugified
86
+ }
87
+
88
+ const perspectiveContextValue = usePerspective()
89
+
90
+ useEffect(() => {
91
+ setSlugValidationPerspective(perspectiveContextValue)
92
+ return () => setSlugValidationPerspective(undefined)
93
+ }, [perspectiveContextValue])
94
+
95
+ const {optimisticSlug, prefixPromise, generateSlug, updateSlugValue, formatSlug} =
96
+ useAsyncUrlSlugLogic({
97
+ ...props,
98
+ value: urlSlugValue as UrlSlugValue,
99
+ onSlugChange: (newValue: UrlSlugValue | undefined) => {
100
+ onChange(newValue ? set(newValue) : unset())
101
+ },
102
+ })
103
+
104
+ function handleGenerateClick() {
105
+ generateSlug()
106
+ slugifiedSourceAtLastSyncRef.current = sourceValueSlugified || null
107
+ }
108
+
109
+ function onSlugChange(event: React.FormEvent<HTMLInputElement>) {
110
+ updateSlugValue(event.currentTarget.value)
111
+ }
112
+
113
+ function onSlugBlur(event: React.FocusEvent<HTMLInputElement, Element>) {
114
+ formatSlug(event.currentTarget.value)
115
+ props.elementProps.onBlur?.(event)
116
+ }
117
+
118
+ /* eslint-disable react-hooks/refs, no-negated-condition -- intentional: read ref during render for slug change detection */
119
+ const slugRefValue = slugifiedSourceAtLastSyncRef.current
120
+ const sourceValueChanged =
121
+ sourceField &&
122
+ sourceValueSlugified &&
123
+ (slugRefValue !== null
124
+ ? sourceValueSlugified !== slugRefValue
125
+ : (urlSlugValue?.current?.length ?? 0) > 0 && urlSlugValue?.current !== sourceValueSlugified)
126
+ /* eslint-enable react-hooks/refs, no-negated-condition */
127
+
128
+ return (
129
+ <Flex direction="column" gap={3}>
130
+ <Flex style={{gap: '0.5em'}} align="center">
131
+ <PrefixErrorBoundary>
132
+ <Suspense fallback={<PrefixLoading />}>
133
+ <PrefixDisplay promise={prefixPromise} sourceField={sourceField} />
134
+ </Suspense>
135
+ </PrefixErrorBoundary>
136
+ <Box flex={3}>
137
+ <TextInput
138
+ value={optimisticSlug?.current || ''}
139
+ readOnly={props.readOnly}
140
+ {...props.elementProps}
141
+ onChange={onSlugChange} // eslint-disable-line react/jsx-no-bind
142
+ onBlur={onSlugBlur} // eslint-disable-line react/jsx-no-bind
143
+ placeholder="url-friendly-slug"
144
+ />
145
+ </Box>
146
+ {sourceField && (
147
+ <Button
148
+ mode="ghost"
149
+ type="button"
150
+ disabled={props.readOnly}
151
+ onClick={handleGenerateClick} // eslint-disable-line react/jsx-no-bind
152
+ text={'Generate'}
153
+ />
154
+ )}
155
+ </Flex>
156
+ {sourceValueChanged && (
157
+ <Text accent size={0}>
158
+ <Flex direction="row" gap={2} align="center" justify="flex-start">
159
+ <CloseCircleIcon style={{flexShrink: 0}} />
160
+ The source value for the slug has changed. Please press &quot;generate&quot; to get the
161
+ updated slug (publish when ready).
162
+ </Flex>
163
+ </Text>
164
+ )}
165
+ </Flex>
166
+ )
167
+ }
@@ -0,0 +1,43 @@
1
+ import {Box, Card, Code, Text, Tooltip} from '@sanity/ui'
2
+ import React from 'react'
3
+
4
+ const urlPrefixStyle: React.CSSProperties = {flex: '0 1 min-content'}
5
+
6
+ interface PrefixErrorBoundaryProps {
7
+ children: React.ReactNode
8
+ }
9
+
10
+ interface PrefixErrorBoundaryState {
11
+ error: Error | null
12
+ }
13
+
14
+ export class PrefixErrorBoundary extends React.Component<
15
+ PrefixErrorBoundaryProps,
16
+ PrefixErrorBoundaryState
17
+ > {
18
+ state: PrefixErrorBoundaryState = {error: null}
19
+
20
+ static getDerivedStateFromError(error: Error): PrefixErrorBoundaryState {
21
+ return {error}
22
+ }
23
+
24
+ render(): React.ReactNode {
25
+ if (this.state.error) {
26
+ return (
27
+ <Tooltip
28
+ content={
29
+ <Box padding={2}>
30
+ <Text>Error loading prefix: {this.state.error.message}</Text>
31
+ </Box>
32
+ }
33
+ >
34
+ <Card style={urlPrefixStyle}>
35
+ <Code size={1}>Error</Code>
36
+ </Card>
37
+ </Tooltip>
38
+ )
39
+ }
40
+
41
+ return this.props.children
42
+ }
43
+ }
@@ -0,0 +1,132 @@
1
+ import {startTransition, useEffect, useEffectEvent, useOptimistic, useRef, useState} from 'react'
2
+ import type {ObjectInputProps, SanityDocument} from 'sanity'
3
+ import {useFormValue} from 'sanity'
4
+
5
+ import {
6
+ formatSlugString,
7
+ formatUrlPrefix,
8
+ resolveUrlPrefix,
9
+ type SlugGenerationOptions,
10
+ type UrlSlugValue,
11
+ } from '../lib/slugGeneration'
12
+
13
+ interface UseAsyncUrlSlugLogicProps extends ObjectInputProps {
14
+ value: UrlSlugValue
15
+ onSlugChange: (value: UrlSlugValue | undefined) => void
16
+ }
17
+
18
+ export function useAsyncUrlSlugLogic(props: UseAsyncUrlSlugLogicProps): {
19
+ optimisticSlug: UrlSlugValue | undefined
20
+ prefix: string
21
+ prefixPromise: Promise<string | undefined>
22
+ generateSlug: () => void
23
+ updateSlugValue: (currentValue: string) => void
24
+ formatSlug: (input?: string) => void
25
+ } {
26
+ const {value, schemaType, onSlugChange, readOnly} = props
27
+
28
+ const document = useFormValue([]) as SanityDocument | undefined
29
+ const options = schemaType.options as SlugGenerationOptions
30
+
31
+ const [optimisticSlug, setOptimisticSlug] = useOptimistic<
32
+ UrlSlugValue | undefined,
33
+ UrlSlugValue | undefined
34
+ >(value, (_prev, next) => next)
35
+ const [urlPrefix, setUrlPrefix] = useState<string | undefined>()
36
+ const [prefixPromise, setPrefixPromise] = useState<Promise<string | undefined>>(() =>
37
+ Promise.resolve(undefined),
38
+ )
39
+ const initialPrefixRef = useRef<string | null>(null)
40
+ const finalPrefix = formatUrlPrefix(urlPrefix || '')
41
+ const parentIds =
42
+ document?.parents && Array.isArray(document.parents)
43
+ ? document.parents.map((parent) => parent?._ref).filter(Boolean)
44
+ : []
45
+ const parentIdsString = parentIds.join(',')
46
+ const parentRef =
47
+ document?.parent && typeof document.parent === 'object' && '_ref' in document.parent
48
+ ? (document.parent as {_ref?: string})._ref
49
+ : undefined
50
+
51
+ const createPrefixPromise = useEffectEvent(
52
+ (doc: SanityDocument | undefined): Promise<string | undefined> => {
53
+ if (!doc || !doc._id) {
54
+ return Promise.resolve(undefined)
55
+ }
56
+ return resolveUrlPrefix(doc, options)
57
+ },
58
+ )
59
+
60
+ useEffect(() => {
61
+ const promise = createPrefixPromise(document)
62
+ setPrefixPromise(promise)
63
+ promise.then(
64
+ (prefix) => setUrlPrefix(prefix),
65
+ (error) => {
66
+ console.error(`[async-url-slug] Couldn't generate URL prefix: `, error)
67
+ setUrlPrefix(undefined)
68
+ },
69
+ )
70
+ // eslint-disable-next-line react-hooks/exhaustive-deps
71
+ }, [parentIdsString, parentRef, document?.language])
72
+
73
+ const syncFullUrl = useEffectEvent(() => {
74
+ if (readOnly || !value?.current || !finalPrefix) return
75
+ if (initialPrefixRef.current === null) {
76
+ initialPrefixRef.current = finalPrefix
77
+ if (value.fullUrl) return
78
+ }
79
+ const newFullUrl = `${finalPrefix}${value.current}`
80
+ if (newFullUrl === value.fullUrl) return
81
+
82
+ const newValue: UrlSlugValue = {
83
+ current: value.current,
84
+ fullUrl: `${finalPrefix}${value.current}`,
85
+ }
86
+ onSlugChange(newValue)
87
+ })
88
+
89
+ useEffect(() => {
90
+ syncFullUrl()
91
+ // eslint-disable-next-line react-hooks/exhaustive-deps
92
+ }, [readOnly, finalPrefix, value?.current, value?.fullUrl])
93
+
94
+ function updateSlugValue(currentValue: string) {
95
+ const newValue: UrlSlugValue | undefined = currentValue
96
+ ? {
97
+ current: currentValue,
98
+ fullUrl: finalPrefix ? `${finalPrefix}${currentValue}` : undefined,
99
+ }
100
+ : undefined
101
+
102
+ startTransition(() => {
103
+ setOptimisticSlug(newValue)
104
+ })
105
+ onSlugChange(newValue)
106
+ }
107
+
108
+ function formatSlug(input?: string) {
109
+ const finalSlug = formatSlugString(input || value?.current || '', options?.maxLength)
110
+ updateSlugValue(finalSlug)
111
+ }
112
+
113
+ function generateSlug() {
114
+ if (!document) return
115
+
116
+ const sourceValue =
117
+ typeof options?.source === 'string'
118
+ ? (document[options.source] as string | undefined)
119
+ : undefined
120
+
121
+ formatSlug(sourceValue)
122
+ }
123
+
124
+ return {
125
+ optimisticSlug,
126
+ prefix: finalPrefix,
127
+ prefixPromise,
128
+ generateSlug,
129
+ updateSlugValue,
130
+ formatSlug,
131
+ }
132
+ }
@@ -0,0 +1,15 @@
1
+ export {AsyncUrlSlugInput} from './components/AsyncUrlSlugInput'
2
+ export {type SlugGenerationOptions, type UrlSlugValue} from './lib/slugGeneration'
3
+ export {
4
+ createUrlSlugValue,
5
+ formatSlugString,
6
+ formatUrlPrefix,
7
+ generateUrlSlug,
8
+ resolveUrlPrefix,
9
+ } from './lib/slugGeneration'
10
+ export {cleanSlug as cleanUrlSlug, createPathSlug, sanitySlugify} from './lib/slugify'
11
+ export {
12
+ getSlugValidationPerspective,
13
+ setSlugValidationPerspective,
14
+ } from './lib/slugValidationPerspective'
15
+ export {urlSlugObject} from './urlSlugObject'
@@ -0,0 +1,86 @@
1
+ import {sanitySlugify} from './slugify'
2
+
3
+ export interface UrlSlugValue {
4
+ current?: string
5
+ fullUrl?: string
6
+ }
7
+
8
+ export interface SlugGenerationOptions {
9
+ urlPrefix?: string | ((document: Record<string, unknown>) => Promise<string> | string)
10
+ source?: string
11
+ maxLength?: number
12
+ }
13
+
14
+ export async function resolveUrlPrefix(
15
+ document: Record<string, unknown>,
16
+ options: SlugGenerationOptions,
17
+ ): Promise<string> {
18
+ if (!document || !document._id) {
19
+ return ''
20
+ }
21
+
22
+ if (typeof options?.urlPrefix === 'string') {
23
+ return options.urlPrefix
24
+ }
25
+
26
+ if (typeof options?.urlPrefix === 'function') {
27
+ const value = await Promise.resolve(options.urlPrefix(document))
28
+ return value as string
29
+ }
30
+
31
+ return ''
32
+ }
33
+
34
+ export function formatUrlPrefix(prefix: string): string {
35
+ if (!prefix) return ''
36
+
37
+ return `${prefix}${
38
+ !prefix.endsWith('/') && !prefix.includes('#') && !prefix.includes('?') ? '/' : ''
39
+ }`
40
+ }
41
+
42
+ export function formatSlugString(input: string, maxLength?: number): string {
43
+ let finalSlug = input || ''
44
+
45
+ finalSlug = finalSlug
46
+ .split('/')
47
+ .filter((segment) => !!segment)
48
+ .map((segment) => sanitySlugify(segment))
49
+ .join('/')
50
+
51
+ if (maxLength && finalSlug.length > maxLength) {
52
+ finalSlug = finalSlug.substring(0, maxLength)
53
+ }
54
+
55
+ return finalSlug
56
+ }
57
+
58
+ export async function generateUrlSlug(
59
+ document: Record<string, unknown>,
60
+ options: SlugGenerationOptions,
61
+ ): Promise<UrlSlugValue> {
62
+ const sourceValue = options.source ? (document[options.source] as string | undefined) : ''
63
+
64
+ if (!sourceValue) {
65
+ return {current: '', fullUrl: ''}
66
+ }
67
+
68
+ const slugValue = formatSlugString(sourceValue, options.maxLength)
69
+
70
+ const prefix = await resolveUrlPrefix(document, options)
71
+ const finalPrefix = formatUrlPrefix(prefix)
72
+
73
+ return {
74
+ current: slugValue,
75
+ fullUrl: finalPrefix ? `${finalPrefix}${slugValue}` : slugValue,
76
+ }
77
+ }
78
+
79
+ export function createUrlSlugValue(currentSlug: string, prefix: string): UrlSlugValue {
80
+ const finalPrefix = formatUrlPrefix(prefix)
81
+
82
+ return {
83
+ current: currentSlug,
84
+ fullUrl: finalPrefix ? `${finalPrefix}${currentSlug}` : currentSlug,
85
+ }
86
+ }
@@ -0,0 +1,13 @@
1
+ import {type PerspectiveContextValue} from 'sanity'
2
+
3
+ let current: PerspectiveContextValue | undefined
4
+
5
+ export function getSlugValidationPerspective(): PerspectiveContextValue | undefined {
6
+ return current
7
+ }
8
+
9
+ export function setSlugValidationPerspective(
10
+ perspective: PerspectiveContextValue | undefined,
11
+ ): void {
12
+ current = perspective
13
+ }
@@ -0,0 +1,22 @@
1
+ import {cleanSlug} from '../../lib/helpers'
2
+
3
+ export {cleanSlug}
4
+
5
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- required by Sanity slugify signature
6
+ export const sanitySlugify = (input: string, _schemaType?: unknown): string => {
7
+ return cleanSlug(input)
8
+ }
9
+
10
+ export const createPathSlug = (input: string): string => {
11
+ const segments = input.split('/')
12
+
13
+ if (segments.length === 1) {
14
+ return cleanSlug(segments[0] || '')
15
+ }
16
+
17
+ const pathPrefix = segments.slice(0, -1)
18
+ const titleSegment = segments[segments.length - 1] || ''
19
+ const cleanedTitle = cleanSlug(titleSegment)
20
+
21
+ return `${pathPrefix.join('/')}/${cleanedTitle}`
22
+ }