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.
- package/LICENSE +21 -0
- package/README.md +676 -0
- package/dist/index.d.mts +336 -0
- package/dist/index.d.ts +336 -0
- package/dist/index.js +3494 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3502 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +100 -0
- package/sanity.json +8 -0
- package/src/actions/helpers.ts +55 -0
- package/src/actions/wrapDuplicateAction.ts +92 -0
- package/src/components/MapUpdater.tsx +447 -0
- package/src/components/RecursiveListWrapper.tsx +180 -0
- package/src/index.ts +95 -0
- package/src/initialValueTemplates.ts +72 -0
- package/src/lib/apiVersion.ts +18 -0
- package/src/lib/defaultStructureFilter.ts +47 -0
- package/src/lib/helpers.ts +23 -0
- package/src/lib/hooks.ts +10 -0
- package/src/recursiveNestedList/ListLoading.tsx +10 -0
- package/src/recursiveNestedList/createDraftMenuItems.ts +67 -0
- package/src/recursiveNestedList/fields.ts +155 -0
- package/src/recursiveNestedList/index.ts +7 -0
- package/src/recursiveNestedList/parentChildMap.ts +564 -0
- package/src/recursiveNestedList/recursiveMapStore.tsx +60 -0
- package/src/recursiveNestedList/recursiveNestedStructureDescendentList.tsx +315 -0
- package/src/recursiveNestedList/recursiveNestedStructureParentList.ts +413 -0
- package/src/recursiveNestedList/routing.ts +259 -0
- package/src/types.ts +216 -0
- package/src/urlSlug/components/AsyncUrlSlugInput.tsx +167 -0
- package/src/urlSlug/components/PrefixErrorBoundary.tsx +43 -0
- package/src/urlSlug/components/useAsyncUrlSlugLogic.ts +132 -0
- package/src/urlSlug/index.ts +15 -0
- package/src/urlSlug/lib/slugGeneration.ts +86 -0
- package/src/urlSlug/lib/slugValidationPerspective.ts +13 -0
- package/src/urlSlug/lib/slugify.ts +22 -0
- package/src/urlSlug/urlSlugObject.ts +43 -0
- 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 "generate" 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
|
+
}
|