musora-content-services 2.160.4 → 2.161.2
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/.agent/decisions/2026-05-20-live-event-fetch-permissions-id.md +23 -0
- package/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/contentTypeConfig.js +13 -15
- package/src/index.d.ts +6 -2
- package/src/index.js +6 -2
- package/src/infrastructure/sanity/README.md +230 -0
- package/src/infrastructure/sanity/SanityClient.ts +105 -0
- package/src/infrastructure/sanity/clients/ContentClient.ts +164 -0
- package/src/infrastructure/sanity/examples/usage.ts +101 -0
- package/src/infrastructure/sanity/executors/FetchQueryExecutor.ts +110 -0
- package/src/infrastructure/sanity/index.ts +19 -0
- package/src/infrastructure/sanity/interfaces/ConfigProvider.ts +6 -0
- package/src/infrastructure/sanity/interfaces/FetchByIdOptions.ts +7 -0
- package/src/infrastructure/sanity/interfaces/QueryExecutor.ts +8 -0
- package/src/infrastructure/sanity/interfaces/SanityConfig.ts +10 -0
- package/src/infrastructure/sanity/interfaces/SanityError.ts +7 -0
- package/src/infrastructure/sanity/interfaces/SanityQuery.ts +5 -0
- package/src/infrastructure/sanity/interfaces/SanityResponse.ts +6 -0
- package/src/infrastructure/sanity/providers/DefaultConfigProvider.ts +38 -0
- package/src/lib/sanity/decorators/base.ts +142 -0
- package/src/lib/sanity/decorators/examples.ts +229 -0
- package/src/lib/sanity/decorators/navigate-to.ts +139 -0
- package/src/lib/sanity/decorators/need-access.ts +40 -0
- package/src/lib/sanity/decorators/page-type.ts +35 -0
- package/src/services/awards/award-query.js +71 -0
- package/src/services/contentAggregator.js +1 -1
- package/src/services/multi-user-accounts/multi-user-accounts.ts +11 -7
- package/src/services/user/memberships.ts +46 -34
- package/src/services/user/profile.ts +66 -0
- package/test/unit/infrastructure/sanity/ContentClient.test.ts +168 -0
- package/test/unit/infrastructure/sanity/DefaultConfigProvider.test.ts +93 -0
- package/test/unit/infrastructure/sanity/FetchQueryExecutor.test.ts +174 -0
- package/test/unit/infrastructure/sanity/SanityClient.test.ts +140 -0
- package/test/unit/lib/sanity/decorators/base.test.ts +368 -0
- package/test/unit/lib/sanity/decorators/navigate-to.test.ts +266 -0
- package/test/unit/lib/sanity/decorators/need-access.test.ts +89 -0
- package/test/unit/lib/sanity/decorators/page-type.test.ts +81 -0
- package/.claude/settings.local.json +0 -23
- package/src/services/user/profile.js +0 -43
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { SanityClient } from '../SanityClient'
|
|
2
|
+
import { FetchByIdOptions } from '../interfaces/FetchByIdOptions'
|
|
3
|
+
import { ConfigProvider } from '../interfaces/ConfigProvider'
|
|
4
|
+
import { QueryExecutor } from '../interfaces/QueryExecutor'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ContentClient extends SanityClient with content-specific methods
|
|
8
|
+
* for easier content fetching and management
|
|
9
|
+
*/
|
|
10
|
+
export class ContentClient extends SanityClient {
|
|
11
|
+
constructor(configProvider?: ConfigProvider, queryExecutor?: QueryExecutor) {
|
|
12
|
+
super(configProvider, queryExecutor)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fetch content by type and ID (similar to fetchByRailContentId)
|
|
17
|
+
*/
|
|
18
|
+
public async fetchById<T>(options: FetchByIdOptions): Promise<T | null> {
|
|
19
|
+
try {
|
|
20
|
+
const { type, id, fields, includeChildren = false } = options
|
|
21
|
+
|
|
22
|
+
// Build the base query
|
|
23
|
+
let query = `*[railcontent_id == ${id} && _type == '${type}']`
|
|
24
|
+
|
|
25
|
+
// Build fields string
|
|
26
|
+
let fieldsString = this.buildFieldsString(type, fields, includeChildren)
|
|
27
|
+
|
|
28
|
+
// Complete the query
|
|
29
|
+
query += `{${fieldsString}}[0]`
|
|
30
|
+
|
|
31
|
+
return await this.fetchSingle<T>(query)
|
|
32
|
+
} catch (error: any) {
|
|
33
|
+
this.handleContentError(error, `fetchById(${JSON.stringify(options)})`)
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fetch multiple content items by their IDs
|
|
40
|
+
*/
|
|
41
|
+
public async fetchByIds<T>(
|
|
42
|
+
ids: (number | string)[],
|
|
43
|
+
type?: string,
|
|
44
|
+
brand?: string,
|
|
45
|
+
fields?: string[]
|
|
46
|
+
): Promise<T[]> {
|
|
47
|
+
try {
|
|
48
|
+
if (!ids || ids.length === 0) {
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const idsString = ids.join(',')
|
|
53
|
+
const typeFilter = type ? ` && _type == '${type}'` : ''
|
|
54
|
+
const brandFilter = brand ? ` && brand == "${brand}"` : ''
|
|
55
|
+
const fieldsString = this.buildFieldsString(type || '', fields, false)
|
|
56
|
+
|
|
57
|
+
const query = `*[railcontent_id in [${idsString}]${typeFilter}${brandFilter}]{${fieldsString}}`
|
|
58
|
+
|
|
59
|
+
const results = await this.fetchList<T>(query)
|
|
60
|
+
|
|
61
|
+
// Sort results to match the order of input IDs
|
|
62
|
+
return results.sort((a: any, b: any) => {
|
|
63
|
+
const indexA = ids.indexOf(a.id || a.railcontent_id)
|
|
64
|
+
const indexB = ids.indexOf(b.id || b.railcontent_id)
|
|
65
|
+
return indexA - indexB
|
|
66
|
+
})
|
|
67
|
+
} catch (error: any) {
|
|
68
|
+
this.handleContentError(error, `fetchByIds([${ids.join(',')}])`)
|
|
69
|
+
return []
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fetch content by brand and type with basic filtering
|
|
75
|
+
*/
|
|
76
|
+
public async fetchByBrandAndType<T>(
|
|
77
|
+
brand: string,
|
|
78
|
+
type: string,
|
|
79
|
+
options: {
|
|
80
|
+
limit?: number
|
|
81
|
+
offset?: number
|
|
82
|
+
sortBy?: string
|
|
83
|
+
fields?: string[]
|
|
84
|
+
} = {}
|
|
85
|
+
): Promise<T[]> {
|
|
86
|
+
try {
|
|
87
|
+
const { limit = 10, offset = 0, sortBy = 'published_on desc', fields } = options
|
|
88
|
+
const fieldsString = this.buildFieldsString(type, fields, false)
|
|
89
|
+
|
|
90
|
+
const query = `*[brand == "${brand}" && _type == "${type}"] | order(${sortBy})[${offset}...${offset + limit}]{${fieldsString}}`
|
|
91
|
+
|
|
92
|
+
return await this.fetchList<T>(query)
|
|
93
|
+
} catch (error: any) {
|
|
94
|
+
this.handleContentError(error, `fetchByBrandAndType(${brand}, ${type})`)
|
|
95
|
+
return []
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build fields string for queries based on content type and options
|
|
101
|
+
*/
|
|
102
|
+
private buildFieldsString(type: string, customFields?: string[], includeChildren: boolean = false): string {
|
|
103
|
+
// Default fields that are commonly used
|
|
104
|
+
const defaultFields = [
|
|
105
|
+
"'sanity_id': _id",
|
|
106
|
+
"'id': railcontent_id",
|
|
107
|
+
'railcontent_id',
|
|
108
|
+
'title',
|
|
109
|
+
"'image': thumbnail.asset->url",
|
|
110
|
+
"'thumbnail': thumbnail.asset->url",
|
|
111
|
+
'difficulty',
|
|
112
|
+
'difficulty_string',
|
|
113
|
+
'web_url_path',
|
|
114
|
+
"'url': web_url_path",
|
|
115
|
+
'published_on',
|
|
116
|
+
"'type': _type",
|
|
117
|
+
'brand',
|
|
118
|
+
'status',
|
|
119
|
+
"'slug': slug.current",
|
|
120
|
+
"'permission_id': permission[]->railcontent_id",
|
|
121
|
+
'length_in_seconds',
|
|
122
|
+
"'artist': artist->name",
|
|
123
|
+
"'instructors': instructor[]->name"
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
// Use custom fields if provided, otherwise use defaults
|
|
127
|
+
let fields = customFields || defaultFields
|
|
128
|
+
|
|
129
|
+
// Add children-related fields if requested
|
|
130
|
+
if (includeChildren) {
|
|
131
|
+
fields = [
|
|
132
|
+
...fields,
|
|
133
|
+
"'child_count': coalesce(count(child[]->), 0)",
|
|
134
|
+
`"lessons": child[]->{
|
|
135
|
+
"id": railcontent_id,
|
|
136
|
+
title,
|
|
137
|
+
"image": thumbnail.asset->url,
|
|
138
|
+
"instructors": instructor[]->name,
|
|
139
|
+
length_in_seconds,
|
|
140
|
+
web_url_path
|
|
141
|
+
}`
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return fields.join(',\n ')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Handle and rethrow errors with additional context
|
|
150
|
+
*/
|
|
151
|
+
private handleContentError(error: any, context: string): never {
|
|
152
|
+
if ('message' in error && 'query' in error) {
|
|
153
|
+
// This is already a SanityError
|
|
154
|
+
throw error
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Convert to SanityError with context
|
|
158
|
+
throw {
|
|
159
|
+
message: error.message || `ContentClient operation failed: ${context}`,
|
|
160
|
+
query: context,
|
|
161
|
+
originalError: error,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { SanityClient, ContentClient } from '../index'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Example usage of the SanityClient and ContentClient
|
|
5
|
+
*
|
|
6
|
+
* This demonstrates how to use both the base SanityClient for raw queries
|
|
7
|
+
* and the ContentClient for content-specific operations.
|
|
8
|
+
* Both clients automatically use the global configuration from the config service.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Create client instances
|
|
12
|
+
const sanityClient = new SanityClient()
|
|
13
|
+
const contentClient = new ContentClient()
|
|
14
|
+
|
|
15
|
+
// Example: Fetch a single song by ID using the ContentClient
|
|
16
|
+
export async function fetchSongExample(songId: number) {
|
|
17
|
+
return await contentClient.fetchById({
|
|
18
|
+
type: 'song',
|
|
19
|
+
id: songId
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Example: Fetch a single song by ID with custom fields
|
|
24
|
+
export async function fetchSongWithCustomFieldsExample(songId: number) {
|
|
25
|
+
return await contentClient.fetchById({
|
|
26
|
+
type: 'song',
|
|
27
|
+
id: songId,
|
|
28
|
+
fields: [
|
|
29
|
+
"'id': railcontent_id",
|
|
30
|
+
'title',
|
|
31
|
+
"'artist': artist->name",
|
|
32
|
+
"'thumbnail': thumbnail.asset->url",
|
|
33
|
+
'difficulty_string',
|
|
34
|
+
'published_on',
|
|
35
|
+
'album',
|
|
36
|
+
'soundslice'
|
|
37
|
+
]
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Example: Fetch a course with its children/lessons
|
|
42
|
+
export async function fetchCourseWithLessonsExample(courseId: number) {
|
|
43
|
+
return await contentClient.fetchById({
|
|
44
|
+
type: 'course',
|
|
45
|
+
id: courseId,
|
|
46
|
+
includeChildren: true
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Example: Fetch multiple content items by IDs
|
|
51
|
+
export async function fetchMultipleSongsExample(songIds: number[]) {
|
|
52
|
+
return await contentClient.fetchByIds(songIds, 'song', 'drumeo')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Example: Fetch content by brand and type
|
|
56
|
+
export async function fetchDrumeoSongsExample() {
|
|
57
|
+
return await contentClient.fetchByBrandAndType('drumeo', 'song', {
|
|
58
|
+
limit: 20,
|
|
59
|
+
sortBy: 'published_on desc'
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Example: Fetch multiple songs with pagination
|
|
64
|
+
export async function fetchSongsExample(brand: string, page: number = 1, limit: number = 10) {
|
|
65
|
+
const start = (page - 1) * limit
|
|
66
|
+
const end = start + limit
|
|
67
|
+
|
|
68
|
+
const query = `*[_type == "song" && brand == "${brand}"] | order(published_on desc)[${start}...${end}]{
|
|
69
|
+
"id": railcontent_id,
|
|
70
|
+
title,
|
|
71
|
+
"artist": artist->name,
|
|
72
|
+
"thumbnail": thumbnail.asset->url,
|
|
73
|
+
difficulty_string,
|
|
74
|
+
published_on
|
|
75
|
+
}`
|
|
76
|
+
|
|
77
|
+
return await sanityClient.fetchList(query)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Example: Execute a complex query that returns custom structure
|
|
81
|
+
export async function fetchSongsWithCountExample(brand: string) {
|
|
82
|
+
const query = `{
|
|
83
|
+
"songs": *[_type == "song" && brand == "${brand}"] | order(published_on desc)[0...10]{
|
|
84
|
+
"id": railcontent_id,
|
|
85
|
+
title,
|
|
86
|
+
"artist": artist->name
|
|
87
|
+
},
|
|
88
|
+
"total": count(*[_type == "song" && brand == "${brand}"])
|
|
89
|
+
}`
|
|
90
|
+
|
|
91
|
+
return await sanityClient.executeQuery(query)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Example: Using with parameters (though GROQ doesn't support parameters like SQL)
|
|
95
|
+
export async function fetchSongWithParamsExample(songId: number) {
|
|
96
|
+
// Note: Sanity GROQ doesn't support parameterized queries like SQL
|
|
97
|
+
// Parameters would be used for client-side processing if needed
|
|
98
|
+
const query = `*[_type == "song" && railcontent_id == ${songId}][0]`
|
|
99
|
+
|
|
100
|
+
return await sanityClient.fetchSingle(query, { songId })
|
|
101
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { QueryExecutor } from '../interfaces/QueryExecutor'
|
|
2
|
+
import { SanityQuery } from '../interfaces/SanityQuery'
|
|
3
|
+
import { SanityResponse } from '../interfaces/SanityResponse'
|
|
4
|
+
import { SanityConfig } from '../interfaces/SanityConfig'
|
|
5
|
+
import { SanityError } from '../interfaces/SanityError'
|
|
6
|
+
|
|
7
|
+
const GET_URL_MAX_LENGTH = 8000
|
|
8
|
+
|
|
9
|
+
export class FetchQueryExecutor implements QueryExecutor {
|
|
10
|
+
async execute<T>(query: SanityQuery, config: SanityConfig): Promise<SanityResponse<T>> {
|
|
11
|
+
const baseUrl = this.buildUrl(config)
|
|
12
|
+
const { url, options } = this.buildRequest(baseUrl, query, config)
|
|
13
|
+
|
|
14
|
+
if (config.debug) {
|
|
15
|
+
console.log('Sanity Query:', query.query)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const response = await fetch(url, options)
|
|
20
|
+
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
throw await this.createSanityError(response, query)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const result = await response.json()
|
|
26
|
+
|
|
27
|
+
if (config.debug) {
|
|
28
|
+
console.log('Sanity Results:', result)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return result as SanityResponse<T>
|
|
32
|
+
} catch (error: any) {
|
|
33
|
+
if ('message' in error && 'query' in error) {
|
|
34
|
+
throw error as SanityError
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
throw {
|
|
38
|
+
message: error.message || 'Sanity query execution failed',
|
|
39
|
+
query: query.query,
|
|
40
|
+
params: query.params,
|
|
41
|
+
originalError: error,
|
|
42
|
+
} as SanityError
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private buildUrl(config: SanityConfig): string {
|
|
47
|
+
const perspective = config.perspective ?? 'published'
|
|
48
|
+
const api = config.useCachedAPI ? 'apicdn' : 'api'
|
|
49
|
+
return `https://${config.projectId}.${api}.sanity.io/v${config.version}/data/query/${config.dataset}?perspective=${perspective}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private buildRequest(
|
|
53
|
+
baseUrl: string,
|
|
54
|
+
query: SanityQuery,
|
|
55
|
+
config: SanityConfig
|
|
56
|
+
): { url: string; options: RequestInit } {
|
|
57
|
+
const authHeader: Record<string, string> = config.token
|
|
58
|
+
? { Authorization: `Bearer ${config.token}` }
|
|
59
|
+
: {}
|
|
60
|
+
|
|
61
|
+
const hasParams = query.params && Object.keys(query.params).length > 0
|
|
62
|
+
const encodedQuery = encodeURIComponent(query.query)
|
|
63
|
+
const getUrl = `${baseUrl}&query=${encodedQuery}`
|
|
64
|
+
const canUseGet = !hasParams && getUrl.length < GET_URL_MAX_LENGTH
|
|
65
|
+
|
|
66
|
+
if (canUseGet) {
|
|
67
|
+
return {
|
|
68
|
+
url: getUrl,
|
|
69
|
+
options: {
|
|
70
|
+
method: 'GET',
|
|
71
|
+
headers: authHeader,
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
url: baseUrl,
|
|
78
|
+
options: {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
...authHeader,
|
|
82
|
+
'Content-Type': 'application/json',
|
|
83
|
+
},
|
|
84
|
+
body: JSON.stringify(query),
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async createSanityError(
|
|
90
|
+
response: Response,
|
|
91
|
+
query: SanityQuery
|
|
92
|
+
): Promise<SanityError> {
|
|
93
|
+
let errorMessage = `Sanity API error: ${response.status} - ${response.statusText}`
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const errorBody = await response.json()
|
|
97
|
+
if (errorBody.message) {
|
|
98
|
+
errorMessage = errorBody.message
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {
|
|
101
|
+
// If we can't parse the error body, use the default message
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
message: errorMessage,
|
|
106
|
+
query: query.query,
|
|
107
|
+
params: query.params,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Main clients
|
|
2
|
+
export { SanityClient } from './SanityClient'
|
|
3
|
+
export { ContentClient } from './clients/ContentClient'
|
|
4
|
+
|
|
5
|
+
// Interfaces
|
|
6
|
+
export type { SanityConfig } from './interfaces/SanityConfig'
|
|
7
|
+
export type { SanityQuery } from './interfaces/SanityQuery'
|
|
8
|
+
export type { SanityResponse } from './interfaces/SanityResponse'
|
|
9
|
+
export type { SanityError } from './interfaces/SanityError'
|
|
10
|
+
export type { QueryExecutor } from './interfaces/QueryExecutor'
|
|
11
|
+
export type { ConfigProvider } from './interfaces/ConfigProvider'
|
|
12
|
+
export type { FetchByIdOptions } from './interfaces/FetchByIdOptions'
|
|
13
|
+
|
|
14
|
+
// Providers
|
|
15
|
+
export { DefaultConfigProvider } from './providers/DefaultConfigProvider'
|
|
16
|
+
|
|
17
|
+
// Executors
|
|
18
|
+
export { FetchQueryExecutor } from './executors/FetchQueryExecutor'
|
|
19
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { SanityQuery } from './SanityQuery'
|
|
2
|
+
import { SanityResponse } from './SanityResponse'
|
|
3
|
+
import { SanityConfig } from './SanityConfig'
|
|
4
|
+
|
|
5
|
+
export interface QueryExecutor {
|
|
6
|
+
execute<T>(query: SanityQuery, config: SanityConfig): Promise<SanityResponse<T>>
|
|
7
|
+
}
|
|
8
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ConfigProvider } from '../interfaces/ConfigProvider'
|
|
2
|
+
import { SanityConfig } from '../interfaces/SanityConfig'
|
|
3
|
+
import { globalConfig } from '../../../services/config.js'
|
|
4
|
+
|
|
5
|
+
export class DefaultConfigProvider implements ConfigProvider {
|
|
6
|
+
getConfig(): SanityConfig {
|
|
7
|
+
if (!globalConfig.sanityConfig) {
|
|
8
|
+
throw new Error('Sanity configuration is not available in globalConfig')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const config = globalConfig.sanityConfig
|
|
12
|
+
|
|
13
|
+
// Validate required fields
|
|
14
|
+
if (!config.token) {
|
|
15
|
+
throw new Error('Sanity token is missing in configuration')
|
|
16
|
+
}
|
|
17
|
+
if (!config.projectId) {
|
|
18
|
+
throw new Error('Sanity projectId is missing in configuration')
|
|
19
|
+
}
|
|
20
|
+
if (!config.dataset) {
|
|
21
|
+
throw new Error('Sanity dataset is missing in configuration')
|
|
22
|
+
}
|
|
23
|
+
if (!config.version) {
|
|
24
|
+
throw new Error('Sanity version is missing in configuration')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
projectId: config.projectId,
|
|
29
|
+
dataset: config.dataset,
|
|
30
|
+
version: config.version,
|
|
31
|
+
token: config.token,
|
|
32
|
+
perspective: (config as any).perspective ?? 'published',
|
|
33
|
+
useCachedAPI: (config as any).useCachedAPI ?? false,
|
|
34
|
+
debug: (config as any).debug ?? false,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
export interface Decoratable {
|
|
2
|
+
children?: Decoratable[]
|
|
3
|
+
[key: string]: unknown
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type DecorateFn<T, V> = (item: T) => V
|
|
7
|
+
|
|
8
|
+
export type DecorateFnAsync<T, V> = (item: T) => Promise<V>
|
|
9
|
+
|
|
10
|
+
export interface FieldDecorator<T extends Decoratable, K extends string = string, V = unknown> {
|
|
11
|
+
field: K
|
|
12
|
+
compute: DecorateFn<T, V>
|
|
13
|
+
recurse?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FieldDecoratorAsync<
|
|
17
|
+
T extends Decoratable,
|
|
18
|
+
K extends string = string,
|
|
19
|
+
V = unknown,
|
|
20
|
+
> {
|
|
21
|
+
field: K
|
|
22
|
+
compute: DecorateFnAsync<T, V>
|
|
23
|
+
recurse?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Decorated<T, K extends string, V> = T & { [P in K]: V }
|
|
27
|
+
|
|
28
|
+
const MAX_CHILD_DEPTH = 3
|
|
29
|
+
|
|
30
|
+
export function decorate<T extends Decoratable, K extends string, V>(
|
|
31
|
+
items: T[],
|
|
32
|
+
field: K,
|
|
33
|
+
compute: DecorateFn<T, V>
|
|
34
|
+
): Decorated<T, K, V>[]
|
|
35
|
+
export function decorate<T extends Decoratable, K extends string, V>(
|
|
36
|
+
items: T,
|
|
37
|
+
field: K,
|
|
38
|
+
compute: DecorateFn<T, V>
|
|
39
|
+
): Decorated<T, K, V>
|
|
40
|
+
export function decorate<T extends Decoratable, K extends string, V>(
|
|
41
|
+
items: T | T[],
|
|
42
|
+
field: K,
|
|
43
|
+
compute: DecorateFn<T, V>
|
|
44
|
+
): Decorated<T, K, V> | Decorated<T, K, V>[] {
|
|
45
|
+
const list = Array.isArray(items) ? items : [items]
|
|
46
|
+
for (const item of list) visit(item, [{ field, compute }], 0)
|
|
47
|
+
return items as Decorated<T, K, V> | Decorated<T, K, V>[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function decorateAll<T extends Decoratable>(
|
|
51
|
+
items: T[],
|
|
52
|
+
decorators: ReadonlyArray<FieldDecorator<T>>
|
|
53
|
+
): T[]
|
|
54
|
+
export function decorateAll<T extends Decoratable>(
|
|
55
|
+
items: T,
|
|
56
|
+
decorators: ReadonlyArray<FieldDecorator<T>>
|
|
57
|
+
): T
|
|
58
|
+
export function decorateAll<T extends Decoratable>(
|
|
59
|
+
items: T | T[],
|
|
60
|
+
decorators: ReadonlyArray<FieldDecorator<T>>
|
|
61
|
+
): T | T[] {
|
|
62
|
+
const list = Array.isArray(items) ? items : [items]
|
|
63
|
+
for (const item of list) visit(item, decorators, 0)
|
|
64
|
+
return items
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function visit<T extends Decoratable>(
|
|
68
|
+
item: T,
|
|
69
|
+
decorators: ReadonlyArray<FieldDecorator<T, string, unknown>>,
|
|
70
|
+
depth: number
|
|
71
|
+
): void {
|
|
72
|
+
if (!item) return
|
|
73
|
+
const target = item as Record<string, unknown>
|
|
74
|
+
for (const { field, compute } of decorators) {
|
|
75
|
+
target[field] = compute(item)
|
|
76
|
+
}
|
|
77
|
+
if (depth >= MAX_CHILD_DEPTH - 1) return
|
|
78
|
+
const children = item.children
|
|
79
|
+
if (!Array.isArray(children)) return
|
|
80
|
+
const recursing = decorators.filter((d) => d.recurse !== false)
|
|
81
|
+
if (recursing.length === 0) return
|
|
82
|
+
for (const child of children) {
|
|
83
|
+
visit(child as T, recursing, depth + 1)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function decorateAsync<T extends Decoratable, K extends string, V>(
|
|
88
|
+
items: T[],
|
|
89
|
+
field: K,
|
|
90
|
+
compute: DecorateFnAsync<T, V>
|
|
91
|
+
): Promise<Decorated<T, K, V>[]>
|
|
92
|
+
export function decorateAsync<T extends Decoratable, K extends string, V>(
|
|
93
|
+
items: T,
|
|
94
|
+
field: K,
|
|
95
|
+
compute: DecorateFnAsync<T, V>
|
|
96
|
+
): Promise<Decorated<T, K, V>>
|
|
97
|
+
export function decorateAsync<T extends Decoratable, K extends string, V>(
|
|
98
|
+
items: T | T[],
|
|
99
|
+
field: K,
|
|
100
|
+
compute: DecorateFnAsync<T, V>
|
|
101
|
+
): Promise<Decorated<T, K, V> | Decorated<T, K, V>[]> {
|
|
102
|
+
return decorateAllAsync(items as T[], [{ field, compute }]) as Promise<
|
|
103
|
+
Decorated<T, K, V> | Decorated<T, K, V>[]
|
|
104
|
+
>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function decorateAllAsync<T extends Decoratable>(
|
|
108
|
+
items: T[],
|
|
109
|
+
decorators: ReadonlyArray<FieldDecoratorAsync<T>>
|
|
110
|
+
): Promise<T[]>
|
|
111
|
+
export function decorateAllAsync<T extends Decoratable>(
|
|
112
|
+
items: T,
|
|
113
|
+
decorators: ReadonlyArray<FieldDecoratorAsync<T>>
|
|
114
|
+
): Promise<T>
|
|
115
|
+
export async function decorateAllAsync<T extends Decoratable>(
|
|
116
|
+
items: T | T[],
|
|
117
|
+
decorators: ReadonlyArray<FieldDecoratorAsync<T>>
|
|
118
|
+
): Promise<T | T[]> {
|
|
119
|
+
const list = Array.isArray(items) ? items : [items]
|
|
120
|
+
await Promise.all(list.map((item) => visitAsync(item, decorators, 0)))
|
|
121
|
+
return items
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function visitAsync<T extends Decoratable>(
|
|
125
|
+
item: T,
|
|
126
|
+
decorators: ReadonlyArray<FieldDecoratorAsync<T, string, unknown>>,
|
|
127
|
+
depth: number
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
if (!item) return
|
|
130
|
+
const target = item as Record<string, unknown>
|
|
131
|
+
await Promise.all(
|
|
132
|
+
decorators.map(async ({ field, compute }) => {
|
|
133
|
+
target[field] = await compute(item)
|
|
134
|
+
})
|
|
135
|
+
)
|
|
136
|
+
if (depth >= MAX_CHILD_DEPTH - 1) return
|
|
137
|
+
const children = item.children
|
|
138
|
+
if (!Array.isArray(children)) return
|
|
139
|
+
const recursing = decorators.filter((d) => d.recurse !== false)
|
|
140
|
+
if (recursing.length === 0) return
|
|
141
|
+
await Promise.all(children.map((child) => visitAsync(child as T, recursing, depth + 1)))
|
|
142
|
+
}
|