@crombolo/delivery-client 0.36.2 → 0.37.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/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>> {
@@ -205,6 +207,62 @@ type Narrowed<Item> = Item extends {
205
207
  type Fielded<Item, Options> = Options extends {
206
208
  fields: string;
207
209
  } ? Narrowed<Item> : Item;
210
+ /** A page's search and sharing details, with `seo: true` (story 055). */
211
+ export interface ApiSeo {
212
+ title: string;
213
+ description: string | null;
214
+ image: {
215
+ url: string;
216
+ width: number | null;
217
+ height: number | null;
218
+ } | null;
219
+ canonical: string | null;
220
+ robots: string | null;
221
+ alternates: Array<{
222
+ culture: string;
223
+ url: string;
224
+ }>;
225
+ xDefault: string | null;
226
+ siteName: string;
227
+ }
228
+ /** One page `sitemap(host)` lists. */
229
+ export interface ApiSitemapItem {
230
+ id: string;
231
+ url: string;
232
+ lastmod: string;
233
+ alternates: Array<{
234
+ culture: string;
235
+ url: string;
236
+ }>;
237
+ }
238
+ /** What a request learned, for a cache that tags what it keeps. */
239
+ export interface DeliveryAnswer {
240
+ url: string;
241
+ status: number;
242
+ /** Every page and media key the answer holds, lower case. */
243
+ keys: string[];
244
+ /** The purge tags the site sent, when it sends them. */
245
+ tags: string[];
246
+ etag: string | null;
247
+ cacheControl: string | null;
248
+ /** The answer came from a kept copy after a `304`. */
249
+ revalidated: boolean;
250
+ }
251
+ /** Copies kept for `If-None-Match`, by address. */
252
+ export interface ConditionalStore {
253
+ get(url: string): {
254
+ etag: string;
255
+ body: string;
256
+ } | undefined;
257
+ set(url: string, copy: {
258
+ etag: string;
259
+ body: string;
260
+ }): void;
261
+ }
262
+ /** A store of the most recent `limit` copies, in memory. */
263
+ export declare function memoryConditionalStore(limit?: number): ConditionalStore;
264
+ /** Every page and media key in an answer, lower case: items, pickers, links, list entries. */
265
+ export declare function contentKeysIn(body: unknown): string[];
208
266
  export interface RequestOptions {
209
267
  /** A culture name, such as `en-US`; otherwise the client's, then the site's default. */
210
268
  culture?: string;
@@ -214,6 +272,8 @@ export interface RequestOptions {
214
272
  expand?: string;
215
273
  /** Which properties to include: `properties[title,image]`. */
216
274
  fields?: string;
275
+ /** Adds each page's search and sharing details as `seo`. */
276
+ seo?: boolean;
217
277
  signal?: AbortSignal;
218
278
  }
219
279
  export interface ContentQuery extends RequestOptions {
@@ -261,6 +321,21 @@ export interface DeliveryClientOptions {
261
321
  memberToken?: () => string | undefined | Promise<string | undefined>;
262
322
  /** Headers sent with every request. */
263
323
  headers?: Record<string, string>;
324
+ /**
325
+ * Drafts instead of what is published (story 053): `Preview: true`, with the
326
+ * editor's preview token when there is one. Asked for on every request.
327
+ */
328
+ preview?: () => boolean | {
329
+ token?: string;
330
+ } | undefined | Promise<boolean | {
331
+ token?: string;
332
+ } | undefined>;
333
+ /** Told about every answer: its keys and tags, for a cache to tag what it keeps. */
334
+ onAnswer?: (answer: DeliveryAnswer) => void;
335
+ /** Keeps copies and revalidates them with `If-None-Match`; a `304` reuses the copy. */
336
+ conditional?: ConditionalStore;
337
+ /** Where the site sends purge tags; `Surrogate-Key` and `Cache-Tag` are read when unset. */
338
+ tagHeader?: string;
264
339
  /** Defaults to the global `fetch`. */
265
340
  fetch?: (input: string, init: RequestInit) => Promise<Response>;
266
341
  }
@@ -294,6 +369,10 @@ export interface DeliveryClient<Schema extends DeliverySchema = DeliverySchema>
294
369
  route(path: string, options?: Omit<RequestOptions, 'culture' | 'fields'> & {
295
370
  host?: string;
296
371
  }): Promise<RouteAnswer<Schema>>;
372
+ /** The pages `/sitemap.xml` lists for a hostname; undefined when the site serves none. */
373
+ sitemap(host?: string, options?: Pick<RequestOptions, 'signal'>): Promise<Paged<ApiSitemapItem> | undefined>;
374
+ /** A form to draw, by key or alias (story 056); undefined when there is none. */
375
+ form(keyOrAlias: string, options?: Pick<RequestOptions, 'culture' | 'signal'>): Promise<Record<string, unknown> | undefined>;
297
376
  }
298
377
  /** Where a crombolo site serves it; Umbraco's own path, `/umbraco/delivery/api/v2`, answers too. */
299
378
  export declare const DELIVERY_API_PATH = "/crombolo/delivery/api/v2";
package/dist/index.js CHANGED
@@ -2,6 +2,48 @@
2
2
  function isExpanded(reference) {
3
3
  return Object.keys(reference.properties).length > 0;
4
4
  }
5
+ function memoryConditionalStore(limit = 500) {
6
+ const copies = new Map;
7
+ return {
8
+ get(url) {
9
+ const copy = copies.get(url);
10
+ if (copy) {
11
+ copies.delete(url);
12
+ copies.set(url, copy);
13
+ }
14
+ return copy;
15
+ },
16
+ set(url, copy) {
17
+ copies.delete(url);
18
+ copies.set(url, copy);
19
+ while (copies.size > limit)
20
+ copies.delete(copies.keys().next().value);
21
+ }
22
+ };
23
+ }
24
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
25
+ function contentKeysIn(body) {
26
+ const keys = new Set;
27
+ const walk = (value) => {
28
+ if (Array.isArray(value)) {
29
+ for (const item of value)
30
+ walk(item);
31
+ return;
32
+ }
33
+ if (!value || typeof value !== "object")
34
+ return;
35
+ const record = value;
36
+ const id = record.id;
37
+ if (typeof id === "string" && UUID.test(id) && (("contentType" in record) || ("mediaType" in record)))
38
+ keys.add(id.toLowerCase());
39
+ if (typeof record.destinationId === "string" && UUID.test(record.destinationId))
40
+ keys.add(record.destinationId.toLowerCase());
41
+ for (const child of Object.values(record))
42
+ walk(child);
43
+ };
44
+ walk(body);
45
+ return [...keys];
46
+ }
5
47
 
6
48
  class DeliveryError extends Error {
7
49
  status;
@@ -34,11 +76,39 @@ function createDeliveryClient(options) {
34
76
  const token = await options.memberToken?.();
35
77
  if (token)
36
78
  headers.authorization = `Bearer ${token}`;
79
+ const preview = await options.preview?.();
80
+ if (preview) {
81
+ headers.preview = "true";
82
+ if (typeof preview === "object" && preview.token)
83
+ headers["preview-token"] = preview.token;
84
+ }
85
+ const keeps = options.conditional && !token && !preview ? options.conditional : undefined;
86
+ const kept = keeps?.get(url);
87
+ if (kept)
88
+ headers["if-none-match"] = kept.etag;
37
89
  const response = await send(url, { headers, signal: request.signal });
38
90
  if (response.status === 404 && missing === "undefined")
39
91
  return;
40
- const text = await response.text();
92
+ const revalidated = response.status === 304 && kept !== undefined;
93
+ const text = revalidated ? kept.body : await response.text();
41
94
  const body = text ? JSON.parse(text) : null;
95
+ const etag = revalidated ? kept.etag : response.headers.get("etag");
96
+ if (keeps && etag && response.ok && !revalidated)
97
+ keeps.set(url, { etag, body: text });
98
+ if (options.onAnswer) {
99
+ const tagged = response.headers.get(options.tagHeader ?? "surrogate-key") ?? (options.tagHeader ? null : response.headers.get("cache-tag"));
100
+ options.onAnswer({
101
+ url,
102
+ status: revalidated ? 200 : response.status,
103
+ keys: contentKeysIn(body),
104
+ tags: tagged ? tagged.split(/[\s,]+/).filter(Boolean) : [],
105
+ etag,
106
+ cacheControl: response.headers.get("cache-control"),
107
+ revalidated
108
+ });
109
+ }
110
+ if (revalidated)
111
+ return body;
42
112
  if (response.status === 404 && missing === "body")
43
113
  return body;
44
114
  if (!response.ok)
@@ -47,7 +117,8 @@ function createDeliveryClient(options) {
47
117
  }
48
118
  const shaping = (request) => [
49
119
  ...request.expand ? [["expand", request.expand]] : [],
50
- ...request.fields ? [["fields", request.fields]] : []
120
+ ...request.fields ? [["fields", request.fields]] : [],
121
+ ...request.seo ? [["seo", "true"]] : []
51
122
  ];
52
123
  const paging = (query) => [
53
124
  ...list(query.filter).map((filter) => ["filter", filter]),
@@ -72,6 +143,8 @@ function createDeliveryClient(options) {
72
143
  ...request.host ? [["host", request.host]] : [],
73
144
  ...shaping(request)
74
145
  ], request, "body"),
146
+ sitemap: (host, request = {}) => get("sitemap", host ? [["host", host]] : [], request, "undefined"),
147
+ form: (keyOrAlias, request = {}) => get(`forms/${encodeURIComponent(keyOrAlias)}`, [], request, "undefined"),
75
148
  search: (term, search = {}) => get("search", [
76
149
  ["term", term],
77
150
  ...list(search.contentType).map((type) => ["contentType", type]),
@@ -82,6 +155,8 @@ function createDeliveryClient(options) {
82
155
  export {
83
156
  DELIVERY_API_PATH,
84
157
  DeliveryError,
158
+ contentKeysIn,
85
159
  createDeliveryClient,
86
- isExpanded
160
+ isExpanded,
161
+ memoryConditionalStore
87
162
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/delivery-client",
3
- "version": "0.36.2",
3
+ "version": "0.37.0",
4
4
  "description": "A typed client for a crombolo site's Delivery API, for apps in the browser, Node or Bun. No dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",