@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 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
- /** Rich text: its markup, with links already this site's URLs. */
121
- export interface ApiRichText {
122
- markup: string;
123
- blocks: unknown[];
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 text = await response.text();
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
- isExpanded
219
+ decodeEntities,
220
+ isExpanded,
221
+ memoryConditionalStore,
222
+ walkRichText
87
223
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/delivery-client",
3
- "version": "0.36.2",
3
+ "version": "0.38.1",
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",