@crombolo/delivery-client 0.36.2 → 0.38.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/dist/index.d.ts +159 -4
- package/dist/index.js +139 -3
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -33,6 +33,8 @@ export type ApiContentReference<Alias extends string = string, Properties = Reco
|
|
|
33
33
|
export interface ApiContentResponse<Alias extends string = string, Properties = Record<string, unknown>> extends ApiContent<Alias, Properties> {
|
|
34
34
|
/** Its route in each culture; empty for a page that does not vary. */
|
|
35
35
|
cultures: Record<string, ApiRoute>;
|
|
36
|
+
/** Search and sharing details, when asked for with `seo: true`. */
|
|
37
|
+
seo?: ApiSeo;
|
|
36
38
|
}
|
|
37
39
|
/** A block or picked element: its properties are always there. */
|
|
38
40
|
export interface ApiElement<Alias extends string = string, Properties = Record<string, unknown>> {
|
|
@@ -117,11 +119,87 @@ export interface ApiBlockGrid<Content = ApiElement, Settings = ApiElement> {
|
|
|
117
119
|
gridColumns: number;
|
|
118
120
|
items: ApiBlockGridItem<Content, Settings>[];
|
|
119
121
|
}
|
|
120
|
-
/**
|
|
121
|
-
export interface
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
/** Text in a rich-text tree. */
|
|
123
|
+
export interface RichTextTextElement {
|
|
124
|
+
$type: 'RichTextTextElement';
|
|
125
|
+
tag: '#text';
|
|
126
|
+
text: string;
|
|
124
127
|
}
|
|
128
|
+
/** A tag in a rich-text tree, its `data-*` attributes without their prefix. */
|
|
129
|
+
export interface RichTextGenericElement<Tag extends string = string, Attributes = RichTextAttributes> {
|
|
130
|
+
$type: 'RichTextGenericElement';
|
|
131
|
+
tag: Tag;
|
|
132
|
+
attributes: Attributes;
|
|
133
|
+
elements: RichTextElement[];
|
|
134
|
+
}
|
|
135
|
+
export type RichTextAttributes = Record<string, unknown>;
|
|
136
|
+
/**
|
|
137
|
+
* A link: to a page, with its `route` and no `href`; to media, with `href` its
|
|
138
|
+
* URL; or anywhere else, with `href` as written. A local link to nothing has
|
|
139
|
+
* neither.
|
|
140
|
+
*/
|
|
141
|
+
export interface RichTextLinkAttributes extends RichTextAttributes {
|
|
142
|
+
href?: string;
|
|
143
|
+
target?: string;
|
|
144
|
+
title?: string;
|
|
145
|
+
destinationId?: string;
|
|
146
|
+
destinationType?: string;
|
|
147
|
+
linkType?: 'Content' | 'Media';
|
|
148
|
+
route?: ApiRoute;
|
|
149
|
+
}
|
|
150
|
+
/** An image; from the media library, `src` is its URL with the crop query kept. */
|
|
151
|
+
export interface RichTextImageAttributes extends RichTextAttributes {
|
|
152
|
+
src?: string;
|
|
153
|
+
alt?: string;
|
|
154
|
+
width?: string;
|
|
155
|
+
height?: string;
|
|
156
|
+
}
|
|
157
|
+
/** Where a block sits: `content-id` names its entry in the value's `blocks`. */
|
|
158
|
+
export interface RichTextBlockAttributes extends RichTextAttributes {
|
|
159
|
+
'content-id': string;
|
|
160
|
+
}
|
|
161
|
+
/** The tags whose attributes are known. */
|
|
162
|
+
export interface RichTextTags {
|
|
163
|
+
a: RichTextGenericElement<'a', RichTextLinkAttributes>;
|
|
164
|
+
img: RichTextGenericElement<'img', RichTextImageAttributes>;
|
|
165
|
+
'umb-rte-block': RichTextGenericElement<'umb-rte-block', RichTextBlockAttributes>;
|
|
166
|
+
'umb-rte-block-inline': RichTextGenericElement<'umb-rte-block-inline', RichTextBlockAttributes>;
|
|
167
|
+
}
|
|
168
|
+
export type RichTextBlockElement = RichTextTags['umb-rte-block' | 'umb-rte-block-inline'];
|
|
169
|
+
/** Any element below the root. */
|
|
170
|
+
export type RichTextElement = RichTextGenericElement | RichTextTextElement;
|
|
171
|
+
/**
|
|
172
|
+
* A rich-text value: Umbraco's `RichTextOutputAsJson` tree, the only form the
|
|
173
|
+
* Delivery API answers rich text in (story 216).
|
|
174
|
+
*/
|
|
175
|
+
export interface RichTextValue<Content = ApiElement, Settings = ApiElement> {
|
|
176
|
+
tag: '#root';
|
|
177
|
+
attributes: RichTextAttributes;
|
|
178
|
+
elements: RichTextElement[];
|
|
179
|
+
blocks: ApiBlockListItem<Content, Settings>[];
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* What `walkRichText` makes of each node, given what it made of the node's
|
|
183
|
+
* children. A tag with no visitor of its own goes to `element`; a block to
|
|
184
|
+
* `block` when there is one, with its entry in `blocks`.
|
|
185
|
+
*/
|
|
186
|
+
export interface RichTextVisitors<Result, Content = ApiElement, Settings = ApiElement> {
|
|
187
|
+
text(node: RichTextTextElement): Result;
|
|
188
|
+
element(node: RichTextGenericElement, children: Result[]): Result;
|
|
189
|
+
block?(item: ApiBlockListItem<Content, Settings> | undefined, node: RichTextBlockElement): Result;
|
|
190
|
+
tags?: {
|
|
191
|
+
[Tag in keyof RichTextTags]?: (node: RichTextTags[Tag], children: Result[]) => Result;
|
|
192
|
+
} & {
|
|
193
|
+
[tag: string]: ((node: never, children: Result[]) => Result) | undefined;
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Text or an attribute from a rich-text tree as characters: the tree keeps
|
|
198
|
+
* entities as the CMS stored them, as Umbraco's does. Unknown names are left.
|
|
199
|
+
*/
|
|
200
|
+
export declare function decodeEntities(text: string): string;
|
|
201
|
+
/** A rich-text tree folded through `visitors`, children first: what each top-level node made. */
|
|
202
|
+
export declare function walkRichText<Result, Content = ApiElement, Settings = ApiElement>(tree: RichTextValue<Content, Settings>, visitors: RichTextVisitors<Result, Content, Settings>): Result[];
|
|
125
203
|
export interface ApiColor {
|
|
126
204
|
value: string;
|
|
127
205
|
label: string;
|
|
@@ -205,6 +283,62 @@ type Narrowed<Item> = Item extends {
|
|
|
205
283
|
type Fielded<Item, Options> = Options extends {
|
|
206
284
|
fields: string;
|
|
207
285
|
} ? Narrowed<Item> : Item;
|
|
286
|
+
/** A page's search and sharing details, with `seo: true` (story 055). */
|
|
287
|
+
export interface ApiSeo {
|
|
288
|
+
title: string;
|
|
289
|
+
description: string | null;
|
|
290
|
+
image: {
|
|
291
|
+
url: string;
|
|
292
|
+
width: number | null;
|
|
293
|
+
height: number | null;
|
|
294
|
+
} | null;
|
|
295
|
+
canonical: string | null;
|
|
296
|
+
robots: string | null;
|
|
297
|
+
alternates: Array<{
|
|
298
|
+
culture: string;
|
|
299
|
+
url: string;
|
|
300
|
+
}>;
|
|
301
|
+
xDefault: string | null;
|
|
302
|
+
siteName: string;
|
|
303
|
+
}
|
|
304
|
+
/** One page `sitemap(host)` lists. */
|
|
305
|
+
export interface ApiSitemapItem {
|
|
306
|
+
id: string;
|
|
307
|
+
url: string;
|
|
308
|
+
lastmod: string;
|
|
309
|
+
alternates: Array<{
|
|
310
|
+
culture: string;
|
|
311
|
+
url: string;
|
|
312
|
+
}>;
|
|
313
|
+
}
|
|
314
|
+
/** What a request learned, for a cache that tags what it keeps. */
|
|
315
|
+
export interface DeliveryAnswer {
|
|
316
|
+
url: string;
|
|
317
|
+
status: number;
|
|
318
|
+
/** Every page and media key the answer holds, lower case. */
|
|
319
|
+
keys: string[];
|
|
320
|
+
/** The purge tags the site sent, when it sends them. */
|
|
321
|
+
tags: string[];
|
|
322
|
+
etag: string | null;
|
|
323
|
+
cacheControl: string | null;
|
|
324
|
+
/** The answer came from a kept copy after a `304`. */
|
|
325
|
+
revalidated: boolean;
|
|
326
|
+
}
|
|
327
|
+
/** Copies kept for `If-None-Match`, by address. */
|
|
328
|
+
export interface ConditionalStore {
|
|
329
|
+
get(url: string): {
|
|
330
|
+
etag: string;
|
|
331
|
+
body: string;
|
|
332
|
+
} | undefined;
|
|
333
|
+
set(url: string, copy: {
|
|
334
|
+
etag: string;
|
|
335
|
+
body: string;
|
|
336
|
+
}): void;
|
|
337
|
+
}
|
|
338
|
+
/** A store of the most recent `limit` copies, in memory. */
|
|
339
|
+
export declare function memoryConditionalStore(limit?: number): ConditionalStore;
|
|
340
|
+
/** Every page and media key in an answer, lower case: items, pickers, links, list entries. */
|
|
341
|
+
export declare function contentKeysIn(body: unknown): string[];
|
|
208
342
|
export interface RequestOptions {
|
|
209
343
|
/** A culture name, such as `en-US`; otherwise the client's, then the site's default. */
|
|
210
344
|
culture?: string;
|
|
@@ -214,6 +348,8 @@ export interface RequestOptions {
|
|
|
214
348
|
expand?: string;
|
|
215
349
|
/** Which properties to include: `properties[title,image]`. */
|
|
216
350
|
fields?: string;
|
|
351
|
+
/** Adds each page's search and sharing details as `seo`. */
|
|
352
|
+
seo?: boolean;
|
|
217
353
|
signal?: AbortSignal;
|
|
218
354
|
}
|
|
219
355
|
export interface ContentQuery extends RequestOptions {
|
|
@@ -261,6 +397,21 @@ export interface DeliveryClientOptions {
|
|
|
261
397
|
memberToken?: () => string | undefined | Promise<string | undefined>;
|
|
262
398
|
/** Headers sent with every request. */
|
|
263
399
|
headers?: Record<string, string>;
|
|
400
|
+
/**
|
|
401
|
+
* Drafts instead of what is published (story 053): `Preview: true`, with the
|
|
402
|
+
* editor's preview token when there is one. Asked for on every request.
|
|
403
|
+
*/
|
|
404
|
+
preview?: () => boolean | {
|
|
405
|
+
token?: string;
|
|
406
|
+
} | undefined | Promise<boolean | {
|
|
407
|
+
token?: string;
|
|
408
|
+
} | undefined>;
|
|
409
|
+
/** Told about every answer: its keys and tags, for a cache to tag what it keeps. */
|
|
410
|
+
onAnswer?: (answer: DeliveryAnswer) => void;
|
|
411
|
+
/** Keeps copies and revalidates them with `If-None-Match`; a `304` reuses the copy. */
|
|
412
|
+
conditional?: ConditionalStore;
|
|
413
|
+
/** Where the site sends purge tags; `Surrogate-Key` and `Cache-Tag` are read when unset. */
|
|
414
|
+
tagHeader?: string;
|
|
264
415
|
/** Defaults to the global `fetch`. */
|
|
265
416
|
fetch?: (input: string, init: RequestInit) => Promise<Response>;
|
|
266
417
|
}
|
|
@@ -294,6 +445,10 @@ export interface DeliveryClient<Schema extends DeliverySchema = DeliverySchema>
|
|
|
294
445
|
route(path: string, options?: Omit<RequestOptions, 'culture' | 'fields'> & {
|
|
295
446
|
host?: string;
|
|
296
447
|
}): Promise<RouteAnswer<Schema>>;
|
|
448
|
+
/** The pages `/sitemap.xml` lists for a hostname; undefined when the site serves none. */
|
|
449
|
+
sitemap(host?: string, options?: Pick<RequestOptions, 'signal'>): Promise<Paged<ApiSitemapItem> | undefined>;
|
|
450
|
+
/** A form to draw, by key or alias (story 056); undefined when there is none. */
|
|
451
|
+
form(keyOrAlias: string, options?: Pick<RequestOptions, 'culture' | 'signal'>): Promise<Record<string, unknown> | undefined>;
|
|
297
452
|
}
|
|
298
453
|
/** Where a crombolo site serves it; Umbraco's own path, `/umbraco/delivery/api/v2`, answers too. */
|
|
299
454
|
export declare const DELIVERY_API_PATH = "/crombolo/delivery/api/v2";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,108 @@
|
|
|
1
1
|
// packages/delivery-client/src/index.ts
|
|
2
|
+
var ENTITIES = {
|
|
3
|
+
amp: "&",
|
|
4
|
+
lt: "<",
|
|
5
|
+
gt: ">",
|
|
6
|
+
quot: '"',
|
|
7
|
+
apos: "'",
|
|
8
|
+
nbsp: " ",
|
|
9
|
+
shy: "",
|
|
10
|
+
copy: "©",
|
|
11
|
+
reg: "®",
|
|
12
|
+
trade: "™",
|
|
13
|
+
hellip: "…",
|
|
14
|
+
mdash: "—",
|
|
15
|
+
ndash: "–",
|
|
16
|
+
lsquo: "‘",
|
|
17
|
+
rsquo: "’",
|
|
18
|
+
ldquo: "“",
|
|
19
|
+
rdquo: "”",
|
|
20
|
+
laquo: "«",
|
|
21
|
+
raquo: "»",
|
|
22
|
+
bull: "•",
|
|
23
|
+
middot: "·",
|
|
24
|
+
deg: "°",
|
|
25
|
+
times: "×",
|
|
26
|
+
divide: "÷",
|
|
27
|
+
euro: "€",
|
|
28
|
+
pound: "£"
|
|
29
|
+
};
|
|
30
|
+
function decodeEntities(text) {
|
|
31
|
+
if (!text.includes("&"))
|
|
32
|
+
return text;
|
|
33
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+\d*);/gi, (whole, name) => {
|
|
34
|
+
if (name[0] !== "#")
|
|
35
|
+
return ENTITIES[name] ?? ENTITIES[name.toLowerCase()] ?? whole;
|
|
36
|
+
const code = name[1] === "x" || name[1] === "X" ? Number.parseInt(name.slice(2), 16) : Number(name.slice(1));
|
|
37
|
+
return code > 0 && code <= 1114111 ? String.fromCodePoint(code) : whole;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
var BLOCK_TAGS = new Set(["umb-rte-block", "umb-rte-block-inline"]);
|
|
41
|
+
function walkRichText(tree, visitors) {
|
|
42
|
+
const blocks = new Map(tree.blocks.flatMap((item) => {
|
|
43
|
+
const id = item.content?.id;
|
|
44
|
+
return typeof id === "string" ? [[id.toLowerCase(), item]] : [];
|
|
45
|
+
}));
|
|
46
|
+
const visit = (node) => {
|
|
47
|
+
if (node.tag === "#text")
|
|
48
|
+
return visitors.text(node);
|
|
49
|
+
const element = node;
|
|
50
|
+
if (visitors.block && BLOCK_TAGS.has(element.tag)) {
|
|
51
|
+
const block = element;
|
|
52
|
+
const id = String(block.attributes["content-id"] ?? "").toLowerCase();
|
|
53
|
+
return visitors.block(blocks.get(id), block);
|
|
54
|
+
}
|
|
55
|
+
const children = element.elements.map(visit);
|
|
56
|
+
const own = visitors.tags?.[element.tag];
|
|
57
|
+
return own ? own(element, children) : visitors.element(element, children);
|
|
58
|
+
};
|
|
59
|
+
return tree.elements.map(visit);
|
|
60
|
+
}
|
|
2
61
|
function isExpanded(reference) {
|
|
3
62
|
return Object.keys(reference.properties).length > 0;
|
|
4
63
|
}
|
|
64
|
+
function memoryConditionalStore(limit = 500) {
|
|
65
|
+
const copies = new Map;
|
|
66
|
+
return {
|
|
67
|
+
get(url) {
|
|
68
|
+
const copy = copies.get(url);
|
|
69
|
+
if (copy) {
|
|
70
|
+
copies.delete(url);
|
|
71
|
+
copies.set(url, copy);
|
|
72
|
+
}
|
|
73
|
+
return copy;
|
|
74
|
+
},
|
|
75
|
+
set(url, copy) {
|
|
76
|
+
copies.delete(url);
|
|
77
|
+
copies.set(url, copy);
|
|
78
|
+
while (copies.size > limit)
|
|
79
|
+
copies.delete(copies.keys().next().value);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
84
|
+
function contentKeysIn(body) {
|
|
85
|
+
const keys = new Set;
|
|
86
|
+
const walk = (value) => {
|
|
87
|
+
if (Array.isArray(value)) {
|
|
88
|
+
for (const item of value)
|
|
89
|
+
walk(item);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!value || typeof value !== "object")
|
|
93
|
+
return;
|
|
94
|
+
const record = value;
|
|
95
|
+
const id = record.id;
|
|
96
|
+
if (typeof id === "string" && UUID.test(id) && (("contentType" in record) || ("mediaType" in record)))
|
|
97
|
+
keys.add(id.toLowerCase());
|
|
98
|
+
if (typeof record.destinationId === "string" && UUID.test(record.destinationId))
|
|
99
|
+
keys.add(record.destinationId.toLowerCase());
|
|
100
|
+
for (const child of Object.values(record))
|
|
101
|
+
walk(child);
|
|
102
|
+
};
|
|
103
|
+
walk(body);
|
|
104
|
+
return [...keys];
|
|
105
|
+
}
|
|
5
106
|
|
|
6
107
|
class DeliveryError extends Error {
|
|
7
108
|
status;
|
|
@@ -34,11 +135,39 @@ function createDeliveryClient(options) {
|
|
|
34
135
|
const token = await options.memberToken?.();
|
|
35
136
|
if (token)
|
|
36
137
|
headers.authorization = `Bearer ${token}`;
|
|
138
|
+
const preview = await options.preview?.();
|
|
139
|
+
if (preview) {
|
|
140
|
+
headers.preview = "true";
|
|
141
|
+
if (typeof preview === "object" && preview.token)
|
|
142
|
+
headers["preview-token"] = preview.token;
|
|
143
|
+
}
|
|
144
|
+
const keeps = options.conditional && !token && !preview ? options.conditional : undefined;
|
|
145
|
+
const kept = keeps?.get(url);
|
|
146
|
+
if (kept)
|
|
147
|
+
headers["if-none-match"] = kept.etag;
|
|
37
148
|
const response = await send(url, { headers, signal: request.signal });
|
|
38
149
|
if (response.status === 404 && missing === "undefined")
|
|
39
150
|
return;
|
|
40
|
-
const
|
|
151
|
+
const revalidated = response.status === 304 && kept !== undefined;
|
|
152
|
+
const text = revalidated ? kept.body : await response.text();
|
|
41
153
|
const body = text ? JSON.parse(text) : null;
|
|
154
|
+
const etag = revalidated ? kept.etag : response.headers.get("etag");
|
|
155
|
+
if (keeps && etag && response.ok && !revalidated)
|
|
156
|
+
keeps.set(url, { etag, body: text });
|
|
157
|
+
if (options.onAnswer) {
|
|
158
|
+
const tagged = response.headers.get(options.tagHeader ?? "surrogate-key") ?? (options.tagHeader ? null : response.headers.get("cache-tag"));
|
|
159
|
+
options.onAnswer({
|
|
160
|
+
url,
|
|
161
|
+
status: revalidated ? 200 : response.status,
|
|
162
|
+
keys: contentKeysIn(body),
|
|
163
|
+
tags: tagged ? tagged.split(/[\s,]+/).filter(Boolean) : [],
|
|
164
|
+
etag,
|
|
165
|
+
cacheControl: response.headers.get("cache-control"),
|
|
166
|
+
revalidated
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (revalidated)
|
|
170
|
+
return body;
|
|
42
171
|
if (response.status === 404 && missing === "body")
|
|
43
172
|
return body;
|
|
44
173
|
if (!response.ok)
|
|
@@ -47,7 +176,8 @@ function createDeliveryClient(options) {
|
|
|
47
176
|
}
|
|
48
177
|
const shaping = (request) => [
|
|
49
178
|
...request.expand ? [["expand", request.expand]] : [],
|
|
50
|
-
...request.fields ? [["fields", request.fields]] : []
|
|
179
|
+
...request.fields ? [["fields", request.fields]] : [],
|
|
180
|
+
...request.seo ? [["seo", "true"]] : []
|
|
51
181
|
];
|
|
52
182
|
const paging = (query) => [
|
|
53
183
|
...list(query.filter).map((filter) => ["filter", filter]),
|
|
@@ -72,6 +202,8 @@ function createDeliveryClient(options) {
|
|
|
72
202
|
...request.host ? [["host", request.host]] : [],
|
|
73
203
|
...shaping(request)
|
|
74
204
|
], request, "body"),
|
|
205
|
+
sitemap: (host, request = {}) => get("sitemap", host ? [["host", host]] : [], request, "undefined"),
|
|
206
|
+
form: (keyOrAlias, request = {}) => get(`forms/${encodeURIComponent(keyOrAlias)}`, [], request, "undefined"),
|
|
75
207
|
search: (term, search = {}) => get("search", [
|
|
76
208
|
["term", term],
|
|
77
209
|
...list(search.contentType).map((type) => ["contentType", type]),
|
|
@@ -82,6 +214,10 @@ function createDeliveryClient(options) {
|
|
|
82
214
|
export {
|
|
83
215
|
DELIVERY_API_PATH,
|
|
84
216
|
DeliveryError,
|
|
217
|
+
contentKeysIn,
|
|
85
218
|
createDeliveryClient,
|
|
86
|
-
|
|
219
|
+
decodeEntities,
|
|
220
|
+
isExpanded,
|
|
221
|
+
memoryConditionalStore,
|
|
222
|
+
walkRichText
|
|
87
223
|
};
|
package/package.json
CHANGED