@crombolo/api-delivery 0.0.0-stage → 0.13.1
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/NOTICE +69 -0
- package/package.json +33 -4
- package/src/coverage.ts +27 -0
- package/src/expansion.ts +81 -0
- package/src/handler.ts +487 -0
- package/src/index.ts +7 -0
- package/src/output.ts +363 -0
- package/src/protection.ts +51 -0
- package/src/query.ts +198 -0
- package/src/settings.ts +137 -0
- package/README.md +0 -3
package/src/output.ts
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The JSON the Delivery API answers with, built from what the published cache
|
|
3
|
+
* holds: content, media, links, blocks and each editor's value, in Umbraco
|
|
4
|
+
* 18.2's shapes. docs/delivery-api.md, "Output".
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
type BlockGridItem,
|
|
8
|
+
type BlockItem,
|
|
9
|
+
convertValue,
|
|
10
|
+
ImageCropperValue,
|
|
11
|
+
type Link,
|
|
12
|
+
MediaWithCrops,
|
|
13
|
+
PublishedContent,
|
|
14
|
+
type PublishedElement,
|
|
15
|
+
PublishedMedia,
|
|
16
|
+
type PublishedProperty,
|
|
17
|
+
RawHtml,
|
|
18
|
+
} from '@crombolo/render'
|
|
19
|
+
import { type ExpansionNode, keeps, nextNode } from './expansion.ts'
|
|
20
|
+
import type { Access } from './protection.ts'
|
|
21
|
+
|
|
22
|
+
export interface ApiRoute {
|
|
23
|
+
path: string
|
|
24
|
+
queryString: string | null
|
|
25
|
+
startItem: { id: string; path: string }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What building output needs from the request and the cache. */
|
|
29
|
+
export interface OutputContext {
|
|
30
|
+
/** The route of a page in the request's culture; undefined when it has none. */
|
|
31
|
+
route(content: PublishedContent): ApiRoute | undefined
|
|
32
|
+
/** A page by key, in the request's culture. */
|
|
33
|
+
content(key: string): PublishedContent | undefined
|
|
34
|
+
allowedType(alias: string): boolean
|
|
35
|
+
/** Whether the caller may see a page's properties when a reference to it is expanded. */
|
|
36
|
+
access(content: PublishedContent): Access
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const iso = (date: Date): string => date.toISOString()
|
|
40
|
+
|
|
41
|
+
/** A property's stored shape and settings, for the one alias in the culture in scope. */
|
|
42
|
+
function propertyOf(
|
|
43
|
+
properties: readonly PublishedProperty[],
|
|
44
|
+
alias: string,
|
|
45
|
+
culture: string | null,
|
|
46
|
+
): PublishedProperty | undefined {
|
|
47
|
+
const own = properties.filter((p) => p.alias === alias)
|
|
48
|
+
return (
|
|
49
|
+
own.find((p) => p.culture && culture && p.culture.toLowerCase() === culture.toLowerCase()) ??
|
|
50
|
+
own.find((p) => p.culture === null) ??
|
|
51
|
+
own[0]
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const empty = (value: unknown): boolean =>
|
|
56
|
+
value === null ||
|
|
57
|
+
value === undefined ||
|
|
58
|
+
value === '' ||
|
|
59
|
+
(Array.isArray(value) && value.length === 0)
|
|
60
|
+
|
|
61
|
+
/** A page as a reference: everything but `cultures`, its properties only when expanded. */
|
|
62
|
+
export function apiContent(
|
|
63
|
+
content: PublishedContent,
|
|
64
|
+
ctx: OutputContext,
|
|
65
|
+
expand: ExpansionNode | undefined,
|
|
66
|
+
include: ExpansionNode | undefined,
|
|
67
|
+
topLevel = false,
|
|
68
|
+
): Record<string, unknown> | null {
|
|
69
|
+
const route = ctx.route(content)
|
|
70
|
+
if (!route) return null
|
|
71
|
+
const showProperties = topLevel || (expand !== undefined && ctx.access(content) === 'allowed')
|
|
72
|
+
return {
|
|
73
|
+
contentType: content.contentType.alias,
|
|
74
|
+
name: content.name,
|
|
75
|
+
createDate: iso(content.createDate),
|
|
76
|
+
updateDate: iso(content.updateDate),
|
|
77
|
+
route,
|
|
78
|
+
id: content.key,
|
|
79
|
+
properties: showProperties ? properties(content, ctx, expand, include) : {},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function properties(
|
|
84
|
+
item: PublishedContent | PublishedElement,
|
|
85
|
+
ctx: OutputContext,
|
|
86
|
+
expand: ExpansionNode | undefined,
|
|
87
|
+
include: ExpansionNode | undefined,
|
|
88
|
+
): Record<string, unknown> {
|
|
89
|
+
const culture = item instanceof PublishedContent ? item.culture : null
|
|
90
|
+
const out: Record<string, unknown> = {}
|
|
91
|
+
const aliases = [...new Set(item.properties.map((p) => p.alias))]
|
|
92
|
+
for (const alias of aliases) {
|
|
93
|
+
if (!keeps(include, alias)) continue
|
|
94
|
+
const property = propertyOf(item.properties, alias, culture) as PublishedProperty
|
|
95
|
+
const converted = item.value(alias)
|
|
96
|
+
out[alias] = deliveryValue(
|
|
97
|
+
property,
|
|
98
|
+
converted,
|
|
99
|
+
ctx,
|
|
100
|
+
nextNode(expand, alias),
|
|
101
|
+
include === undefined || include.children.length === 0 ? include : nextNode(include, alias),
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
return out
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** An element (a block, a library element): its properties are always mapped. */
|
|
108
|
+
function apiElement(
|
|
109
|
+
element: PublishedElement | null,
|
|
110
|
+
ctx: OutputContext,
|
|
111
|
+
expand: ExpansionNode | undefined,
|
|
112
|
+
include: ExpansionNode | undefined,
|
|
113
|
+
): Record<string, unknown> | null {
|
|
114
|
+
if (!element) return null
|
|
115
|
+
return {
|
|
116
|
+
contentType: element.contentType.alias ?? '',
|
|
117
|
+
id: element.key,
|
|
118
|
+
name: '',
|
|
119
|
+
properties: properties(element, ctx, expand, include ?? { key: '', children: [] }),
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const numberOf = (media: PublishedMedia, alias: string): number | null => {
|
|
124
|
+
const value = Number(media.value(alias))
|
|
125
|
+
return media.value(alias) === null || Number.isNaN(value) ? null : value
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Media's own fields; its properties exclude the `umbraco*` ones, and appear only when expanded. */
|
|
129
|
+
function mediaCore(
|
|
130
|
+
media: PublishedMedia,
|
|
131
|
+
expand: ExpansionNode | undefined,
|
|
132
|
+
topLevel: boolean,
|
|
133
|
+
): Record<string, unknown> {
|
|
134
|
+
const extension = media.value('umbracoExtension')
|
|
135
|
+
return {
|
|
136
|
+
id: media.key,
|
|
137
|
+
name: media.name,
|
|
138
|
+
mediaType: media.mediaType.alias,
|
|
139
|
+
url: media.url,
|
|
140
|
+
extension: typeof extension === 'string' && extension ? extension : null,
|
|
141
|
+
width: numberOf(media, 'umbracoWidth'),
|
|
142
|
+
height: numberOf(media, 'umbracoHeight'),
|
|
143
|
+
bytes: numberOf(media, 'umbracoBytes'),
|
|
144
|
+
properties: topLevel || expand !== undefined ? mediaProperties(media) : {},
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function mediaProperties(media: PublishedMedia): Record<string, unknown> {
|
|
149
|
+
const out: Record<string, unknown> = {}
|
|
150
|
+
for (const property of media.properties) {
|
|
151
|
+
if (property.alias.toLowerCase().startsWith('umbraco')) continue
|
|
152
|
+
const value = convertValue(property.editorAlias, property.value, undefined, property.config)
|
|
153
|
+
out[property.alias] = empty(value) ? null : plain(value)
|
|
154
|
+
}
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const crops = (value: ImageCropperValue) =>
|
|
159
|
+
value.crops.map((crop) => ({
|
|
160
|
+
alias: crop.alias,
|
|
161
|
+
width: crop.width,
|
|
162
|
+
height: crop.height,
|
|
163
|
+
coordinates: crop.coordinates ?? null,
|
|
164
|
+
}))
|
|
165
|
+
|
|
166
|
+
/** A media item as a query or item endpoint answers it: its tree path, dates, crops. */
|
|
167
|
+
export function apiMediaResponse(media: PublishedMedia, path: string): Record<string, unknown> {
|
|
168
|
+
const image = media.image
|
|
169
|
+
return {
|
|
170
|
+
path,
|
|
171
|
+
createDate: iso(media.createDate),
|
|
172
|
+
updateDate: iso(media.updateDate),
|
|
173
|
+
...mediaCore(media, undefined, true),
|
|
174
|
+
focalPoint: image.focalPoint,
|
|
175
|
+
crops: image.src || image.crops.length > 0 ? crops(image) : null,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function mediaWithCrops(
|
|
180
|
+
pick: MediaWithCrops,
|
|
181
|
+
expand: ExpansionNode | undefined,
|
|
182
|
+
): Record<string, unknown> {
|
|
183
|
+
// The pick's own crops and focal point win; the media's fill in what it lacks.
|
|
184
|
+
const own = pick.media.image
|
|
185
|
+
const merged = own.crops.map((crop) => {
|
|
186
|
+
const local = pick.crops.find((c) => c.alias === crop.alias)
|
|
187
|
+
return local ? { ...crop, ...local, coordinates: local.coordinates ?? crop.coordinates } : crop
|
|
188
|
+
})
|
|
189
|
+
return {
|
|
190
|
+
focalPoint: pick.focalPoint ?? own.focalPoint ?? null,
|
|
191
|
+
crops: crops(new ImageCropperValue({ crops: merged })),
|
|
192
|
+
...mediaCore(pick.media, expand, false),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function link(linkValue: Link, ctx: OutputContext): Record<string, unknown> | null {
|
|
197
|
+
if (linkValue.type === 'document' && linkValue.key) {
|
|
198
|
+
const target = ctx.content(linkValue.key)
|
|
199
|
+
if (!target) return null
|
|
200
|
+
const route = ctx.route(target)
|
|
201
|
+
if (!route) return null
|
|
202
|
+
return {
|
|
203
|
+
url: null,
|
|
204
|
+
queryString: null,
|
|
205
|
+
title: linkValue.name || target.name,
|
|
206
|
+
target: linkValue.target,
|
|
207
|
+
destinationId: target.key,
|
|
208
|
+
destinationType: target.contentType.alias,
|
|
209
|
+
route,
|
|
210
|
+
linkType: 'Content',
|
|
211
|
+
culture: target.culture,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
url: linkValue.url,
|
|
216
|
+
queryString: null,
|
|
217
|
+
title: linkValue.name,
|
|
218
|
+
target: linkValue.target,
|
|
219
|
+
destinationId: linkValue.key,
|
|
220
|
+
destinationType: linkValue.type === 'media' ? 'Media' : null,
|
|
221
|
+
route: null,
|
|
222
|
+
linkType: linkValue.type === 'media' ? 'Media' : 'External',
|
|
223
|
+
culture: null,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function blockItem(
|
|
228
|
+
item: BlockItem,
|
|
229
|
+
ctx: OutputContext,
|
|
230
|
+
expand: ExpansionNode | undefined,
|
|
231
|
+
include: ExpansionNode | undefined,
|
|
232
|
+
) {
|
|
233
|
+
return {
|
|
234
|
+
content: apiElement(item.content, ctx, expand, include),
|
|
235
|
+
settings: apiElement(item.settings, ctx, expand, include),
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function gridItem(
|
|
240
|
+
item: BlockGridItem,
|
|
241
|
+
ctx: OutputContext,
|
|
242
|
+
columns: number,
|
|
243
|
+
expand: ExpansionNode | undefined,
|
|
244
|
+
include: ExpansionNode | undefined,
|
|
245
|
+
): Record<string, unknown> {
|
|
246
|
+
return {
|
|
247
|
+
rowSpan: item.rowSpan ?? 1,
|
|
248
|
+
columnSpan: item.columnSpan ?? columns,
|
|
249
|
+
areaGridColumns: columns,
|
|
250
|
+
areas: item.areas.map((area) => ({
|
|
251
|
+
alias: area.key,
|
|
252
|
+
rowSpan: 1,
|
|
253
|
+
columnSpan: columns,
|
|
254
|
+
items: area.items.map((inner) => gridItem(inner, ctx, columns, expand, include)),
|
|
255
|
+
})),
|
|
256
|
+
...blockItem(item, ctx, expand, include),
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** What a value is when nothing more specific applies: dates as ISO, markup as text. */
|
|
261
|
+
function plain(value: unknown): unknown {
|
|
262
|
+
if (value instanceof Date) return iso(value)
|
|
263
|
+
if (value instanceof RawHtml) return value.value
|
|
264
|
+
if (value instanceof ImageCropperValue)
|
|
265
|
+
return { url: value.src || null, focalPoint: value.focalPoint, crops: crops(value) }
|
|
266
|
+
if (Array.isArray(value)) return value.map(plain)
|
|
267
|
+
return value
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const list = <T>(value: unknown): T[] =>
|
|
271
|
+
Array.isArray(value) ? (value as T[]) : value === null || value === undefined ? [] : [value as T]
|
|
272
|
+
|
|
273
|
+
/** One property's Delivery API value. Empty is `null`, as in Umbraco. */
|
|
274
|
+
export function deliveryValue(
|
|
275
|
+
property: PublishedProperty,
|
|
276
|
+
converted: unknown,
|
|
277
|
+
ctx: OutputContext,
|
|
278
|
+
expand: ExpansionNode | undefined,
|
|
279
|
+
include: ExpansionNode | undefined,
|
|
280
|
+
): unknown {
|
|
281
|
+
if (empty(property.value) || empty(converted)) return null
|
|
282
|
+
const config = property.config ?? {}
|
|
283
|
+
switch (property.editorAlias) {
|
|
284
|
+
case 'Umbraco.MemberPicker':
|
|
285
|
+
return '(unsupported)'
|
|
286
|
+
case 'Umbraco.RichText':
|
|
287
|
+
return { markup: plain(converted), blocks: [] }
|
|
288
|
+
case 'Umbraco.ContentPicker': {
|
|
289
|
+
const content = converted instanceof PublishedContent ? converted : undefined
|
|
290
|
+
if (!content || !ctx.allowedType(content.contentType.alias)) return null
|
|
291
|
+
return apiContent(content, ctx, expand, include)
|
|
292
|
+
}
|
|
293
|
+
case 'Umbraco.MultiNodeTreePicker':
|
|
294
|
+
return list<unknown>(converted).flatMap((item) => {
|
|
295
|
+
if (item instanceof PublishedContent)
|
|
296
|
+
return ctx.allowedType(item.contentType.alias)
|
|
297
|
+
? [apiContent(item, ctx, expand, include)].filter(Boolean)
|
|
298
|
+
: []
|
|
299
|
+
if (item instanceof PublishedMedia) return [mediaCore(item, expand, false)]
|
|
300
|
+
return []
|
|
301
|
+
})
|
|
302
|
+
case 'Umbraco.MediaPicker3':
|
|
303
|
+
return list<MediaWithCrops>(converted)
|
|
304
|
+
.filter((pick) => pick instanceof MediaWithCrops)
|
|
305
|
+
.map((pick) => mediaWithCrops(pick, expand))
|
|
306
|
+
case 'Umbraco.MultiUrlPicker':
|
|
307
|
+
return list<Link>(converted).flatMap((l) => {
|
|
308
|
+
const built = link(l, ctx)
|
|
309
|
+
return built ? [built] : []
|
|
310
|
+
})
|
|
311
|
+
case 'Umbraco.BlockList':
|
|
312
|
+
return { items: list<BlockItem>(converted).map((i) => blockItem(i, ctx, expand, include)) }
|
|
313
|
+
case 'Umbraco.BlockGrid': {
|
|
314
|
+
const columns = Number(config.gridColumns) || 12
|
|
315
|
+
return {
|
|
316
|
+
gridColumns: columns,
|
|
317
|
+
items: list<BlockGridItem>(converted).map((i) =>
|
|
318
|
+
gridItem(i, ctx, columns, expand, include),
|
|
319
|
+
),
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
case 'Umbraco.ElementPicker':
|
|
323
|
+
return list<PublishedElement>(converted).map((e) => apiElement(e, ctx, expand, include))
|
|
324
|
+
case 'Umbraco.DropDown.Flexible': {
|
|
325
|
+
const values = list<string>(converted)
|
|
326
|
+
return config.multiple === true ? values : (values[0] ?? null)
|
|
327
|
+
}
|
|
328
|
+
case 'Umbraco.CheckBoxList':
|
|
329
|
+
case 'Umbraco.MultipleTextstring':
|
|
330
|
+
case 'Umbraco.Tags':
|
|
331
|
+
return list<string>(converted)
|
|
332
|
+
case 'Umbraco.DateTime':
|
|
333
|
+
// Umbraco's legacy date picker has no time zone, so no `Z`.
|
|
334
|
+
return typeof converted === 'string' ? converted.replace(' ', 'T') : plain(converted)
|
|
335
|
+
case 'Umbraco.ColorPicker': {
|
|
336
|
+
if (typeof converted === 'object' && converted !== null) {
|
|
337
|
+
const color = converted as { value?: unknown; label?: unknown }
|
|
338
|
+
return { value: color.value ?? null, label: color.label ?? color.value ?? null }
|
|
339
|
+
}
|
|
340
|
+
return { value: converted, label: converted }
|
|
341
|
+
}
|
|
342
|
+
default:
|
|
343
|
+
return plain(converted)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** A page as an item or query endpoint answers it: a reference, plus its routes per culture. */
|
|
348
|
+
export function apiContentResponse(
|
|
349
|
+
content: PublishedContent,
|
|
350
|
+
ctx: OutputContext,
|
|
351
|
+
routeIn: (culture: string) => ApiRoute | undefined,
|
|
352
|
+
expand: ExpansionNode | undefined,
|
|
353
|
+
include: ExpansionNode | undefined,
|
|
354
|
+
): Record<string, unknown> | null {
|
|
355
|
+
const built = apiContent(content, ctx, expand, include, true)
|
|
356
|
+
if (!built) return null
|
|
357
|
+
const cultures: Record<string, ApiRoute> = {}
|
|
358
|
+
for (const culture of content.cultures) {
|
|
359
|
+
const route = routeIn(culture)
|
|
360
|
+
if (route) cultures[culture] = route
|
|
361
|
+
}
|
|
362
|
+
return { ...built, cultures }
|
|
363
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Members-only content: the nearest protected ancestor governs, as on the
|
|
3
|
+
* site, and a member is let in by name or by one of their groups. Umbraco's
|
|
4
|
+
* `PublicAccessChecker`, over rules read once per request.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface ProtectionRule {
|
|
8
|
+
/** The protected node's id: its own, or the ancestor whose rule covers it. */
|
|
9
|
+
nodeId: number
|
|
10
|
+
memberUserNames: string[]
|
|
11
|
+
memberGroupNames: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DeliveryMember {
|
|
15
|
+
key: string
|
|
16
|
+
userName: string
|
|
17
|
+
groups: string[]
|
|
18
|
+
approved: boolean
|
|
19
|
+
lockedOut: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `allowed`; `unauthorized` (401: nobody, or a member who may not sign in); `forbidden` (403). */
|
|
23
|
+
export type Access = 'allowed' | 'unauthorized' | 'forbidden'
|
|
24
|
+
|
|
25
|
+
/** The rule governing a node by its path (`-1,1050,1063`), deepest first. Pure. */
|
|
26
|
+
export function ruleFor(
|
|
27
|
+
path: string,
|
|
28
|
+
rules: ReadonlyMap<number, ProtectionRule>,
|
|
29
|
+
): ProtectionRule | undefined {
|
|
30
|
+
const ids = path.split(',').map(Number)
|
|
31
|
+
for (let i = ids.length - 1; i >= 0; i--) {
|
|
32
|
+
const rule = rules.get(ids[i] as number)
|
|
33
|
+
if (rule) return rule
|
|
34
|
+
}
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What a caller may do with a node: a user name matches in any case, a group exactly. Pure. */
|
|
39
|
+
export function accessTo(
|
|
40
|
+
path: string,
|
|
41
|
+
rules: ReadonlyMap<number, ProtectionRule>,
|
|
42
|
+
member: DeliveryMember | undefined,
|
|
43
|
+
): Access {
|
|
44
|
+
const rule = ruleFor(path, rules)
|
|
45
|
+
if (!rule) return 'allowed'
|
|
46
|
+
if (!member?.approved || member.lockedOut) return 'unauthorized'
|
|
47
|
+
const name = member.userName.toLowerCase()
|
|
48
|
+
if (rule.memberUserNames.some((n) => n.toLowerCase() === name)) return 'allowed'
|
|
49
|
+
if (rule.memberGroupNames.some((g) => member.groups.includes(g))) return 'allowed'
|
|
50
|
+
return 'forbidden'
|
|
51
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The query endpoints' `fetch`, `filter` and `sort`, parsed as Umbraco 18.2
|
|
3
|
+
* parses them: content and media differ, and so do their errors.
|
|
4
|
+
* docs/delivery-api.md, "Queries".
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type Selector =
|
|
8
|
+
| { kind: 'all' }
|
|
9
|
+
| { kind: 'ancestors' | 'children' | 'descendants'; target: string }
|
|
10
|
+
|
|
11
|
+
export type ContentFilter =
|
|
12
|
+
| { kind: 'contentType'; values: string[]; negate: boolean }
|
|
13
|
+
| { kind: 'name'; values: string[]; negate: boolean }
|
|
14
|
+
| {
|
|
15
|
+
kind: 'createDate' | 'updateDate'
|
|
16
|
+
operator: '=' | '>' | '>=' | '<' | '<=' | 'none'
|
|
17
|
+
date: Date
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type SortField = 'createDate' | 'updateDate' | 'level' | 'name' | 'sortOrder'
|
|
21
|
+
export interface Sort {
|
|
22
|
+
field: SortField
|
|
23
|
+
ascending: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type QueryError = 'selector' | 'filter' | 'sort'
|
|
27
|
+
|
|
28
|
+
export const QUERY_ERRORS: Record<QueryError, { title: string; detail: string }> = {
|
|
29
|
+
selector: {
|
|
30
|
+
title: 'Selector option not found',
|
|
31
|
+
detail: "The attempted 'fetch' option does not exist",
|
|
32
|
+
},
|
|
33
|
+
filter: {
|
|
34
|
+
title: 'Filter option not found',
|
|
35
|
+
detail: "One of the attempted 'filter' options does not exist",
|
|
36
|
+
},
|
|
37
|
+
sort: {
|
|
38
|
+
title: 'Sort option not found',
|
|
39
|
+
detail: "One of the attempted 'sort' options does not exist",
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `ancestors:`, `children:` or `descendants:` and what follows; absent is everything. Pure. */
|
|
44
|
+
export function parseContentSelector(fetch: string | null): Selector | QueryError {
|
|
45
|
+
if (fetch === null || fetch === '') return { kind: 'all' }
|
|
46
|
+
const match = /^(ancestors|children|descendants):(.*)$/is.exec(fetch)
|
|
47
|
+
if (!match) return 'selector'
|
|
48
|
+
return {
|
|
49
|
+
kind: (match[1] as string).toLowerCase() as 'ancestors' | 'children' | 'descendants',
|
|
50
|
+
target: (match[2] as string).trim(),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DATE_FILTER = /(createDate|updateDate)(?<operator>[><:]{1,2})(?<value>.*)/i
|
|
55
|
+
|
|
56
|
+
/** Every filter, or which kind of error; an unparsable date is dropped, as Umbraco drops it. Pure. */
|
|
57
|
+
export function parseContentFilters(filters: readonly string[]): ContentFilter[] | QueryError {
|
|
58
|
+
const out: ContentFilter[] = []
|
|
59
|
+
for (const raw of filters) {
|
|
60
|
+
if (!raw.trim()) continue
|
|
61
|
+
const listed = /^(contentType|name):(!?)(.*)$/is.exec(raw)
|
|
62
|
+
if (listed) {
|
|
63
|
+
out.push({
|
|
64
|
+
kind: (listed[1] as string).toLowerCase() === 'name' ? 'name' : 'contentType',
|
|
65
|
+
negate: listed[2] === '!',
|
|
66
|
+
values: (listed[3] as string).split(',').filter((v) => v !== ''),
|
|
67
|
+
})
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
const dated = DATE_FILTER.exec(raw)
|
|
71
|
+
if (dated?.groups) {
|
|
72
|
+
const field = dated[1]?.toLowerCase() === 'createdate' ? 'createDate' : 'updateDate'
|
|
73
|
+
const date = new Date(dated.groups.value as string)
|
|
74
|
+
if (Number.isNaN(date.getTime())) continue
|
|
75
|
+
const operator =
|
|
76
|
+
({ ':': '=', '>': '>', '>:': '>=', '<': '<', '<:': '<=' } as const)[
|
|
77
|
+
dated.groups.operator as ':'
|
|
78
|
+
] ?? 'none'
|
|
79
|
+
out.push({ kind: field, operator, date })
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
return 'filter'
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const SORT_FIELDS: Record<string, SortField> = {
|
|
88
|
+
createdate: 'createDate',
|
|
89
|
+
updatedate: 'updateDate',
|
|
90
|
+
level: 'level',
|
|
91
|
+
name: 'name',
|
|
92
|
+
sortorder: 'sortOrder',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** In order, primary first; ascending only when the direction starts with `asc`. Pure. */
|
|
96
|
+
export function parseContentSorts(sorts: readonly string[]): Sort[] | QueryError {
|
|
97
|
+
const out: Sort[] = []
|
|
98
|
+
for (const raw of sorts) {
|
|
99
|
+
if (!raw.trim()) continue
|
|
100
|
+
const at = raw.indexOf(':')
|
|
101
|
+
const field = at < 0 ? undefined : SORT_FIELDS[raw.slice(0, at).toLowerCase()]
|
|
102
|
+
if (!field) return 'sort'
|
|
103
|
+
out.push({ field, ascending: raw.slice(at + 1).startsWith('asc') })
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Media's own rules: `children:` is required, filters are `key:value`, the last sort wins. Pure. */
|
|
109
|
+
export function parseMediaQuery(input: {
|
|
110
|
+
fetch: string | null
|
|
111
|
+
filters: readonly string[]
|
|
112
|
+
sorts: readonly string[]
|
|
113
|
+
}):
|
|
114
|
+
| {
|
|
115
|
+
parent: string
|
|
116
|
+
filters: Array<{ kind: 'mediaType' | 'name'; value: string }>
|
|
117
|
+
sort: Sort | undefined
|
|
118
|
+
}
|
|
119
|
+
| QueryError {
|
|
120
|
+
const fetch = /^children:(.*)$/is.exec(input.fetch ?? '')
|
|
121
|
+
if (!fetch) return 'selector'
|
|
122
|
+
const filters: Array<{ kind: 'mediaType' | 'name'; value: string }> = []
|
|
123
|
+
for (const raw of input.filters) {
|
|
124
|
+
const parts = raw.split(':')
|
|
125
|
+
if (parts.length !== 2 || (parts[0] !== 'mediaType' && parts[0] !== 'name')) return 'filter'
|
|
126
|
+
filters.push({ kind: parts[0], value: parts[1] as string })
|
|
127
|
+
}
|
|
128
|
+
let sort: Sort | undefined
|
|
129
|
+
for (const raw of input.sorts) {
|
|
130
|
+
const parts = raw.split(':')
|
|
131
|
+
const fields: Record<string, SortField> = {
|
|
132
|
+
createDate: 'createDate',
|
|
133
|
+
updateDate: 'updateDate',
|
|
134
|
+
name: 'name',
|
|
135
|
+
sortOrder: 'sortOrder',
|
|
136
|
+
}
|
|
137
|
+
const field = parts.length === 2 ? fields[parts[0] as string] : undefined
|
|
138
|
+
if (!field) return 'sort'
|
|
139
|
+
sort = { field, ascending: (parts[1] as string).startsWith('asc') }
|
|
140
|
+
}
|
|
141
|
+
return { parent: (fetch[1] as string).trim(), filters, sort }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The comparison a sort makes, for anything with these fields. Pure. */
|
|
145
|
+
export function compareBy<
|
|
146
|
+
T extends { createDate: Date; updateDate: Date; level: number; name: string; sortOrder: number },
|
|
147
|
+
>(sorts: readonly Sort[]): (a: T, b: T) => number {
|
|
148
|
+
return (a, b) => {
|
|
149
|
+
for (const { field, ascending } of sorts) {
|
|
150
|
+
const left = field === 'name' ? a.name.toLowerCase() : a[field]
|
|
151
|
+
const right = field === 'name' ? b.name.toLowerCase() : b[field]
|
|
152
|
+
const order =
|
|
153
|
+
left instanceof Date && right instanceof Date
|
|
154
|
+
? left.getTime() - right.getTime()
|
|
155
|
+
: left < right
|
|
156
|
+
? -1
|
|
157
|
+
: left > right
|
|
158
|
+
? 1
|
|
159
|
+
: 0
|
|
160
|
+
if (order !== 0) return ascending ? order : -order
|
|
161
|
+
}
|
|
162
|
+
return 0
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Whether an item passes every content filter: the filters AND, each one's values OR. Pure. */
|
|
167
|
+
export function passesFilters(
|
|
168
|
+
item: { contentType: string; name: string; createDate: Date; updateDate: Date },
|
|
169
|
+
filters: readonly ContentFilter[],
|
|
170
|
+
): boolean {
|
|
171
|
+
return filters.every((filter) => {
|
|
172
|
+
if (filter.kind === 'contentType') {
|
|
173
|
+
const hit = filter.values.includes(item.contentType)
|
|
174
|
+
return filter.negate ? !hit : hit
|
|
175
|
+
}
|
|
176
|
+
if (filter.kind === 'name') {
|
|
177
|
+
const name = item.name.toLowerCase()
|
|
178
|
+
const hit = filter.values.some((v) => name.includes(v.toLowerCase()))
|
|
179
|
+
return filter.negate ? !hit : hit
|
|
180
|
+
}
|
|
181
|
+
const at = item[filter.kind].getTime()
|
|
182
|
+
const to = filter.date.getTime()
|
|
183
|
+
switch (filter.operator) {
|
|
184
|
+
case '=':
|
|
185
|
+
return at === to
|
|
186
|
+
case '>':
|
|
187
|
+
return at > to
|
|
188
|
+
case '>=':
|
|
189
|
+
return at >= to
|
|
190
|
+
case '<':
|
|
191
|
+
return at < to
|
|
192
|
+
case '<=':
|
|
193
|
+
return at <= to
|
|
194
|
+
default:
|
|
195
|
+
return false
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
}
|