@crombolo/next-integration 0.37.0 → 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/client.d.ts CHANGED
@@ -1,5 +1,9 @@
1
- import type { ReactNode } from 'react';
2
- export declare function RichTextLinks({ children, className, }: {
3
- children: ReactNode;
4
- className?: string;
5
- }): import("react").JSX.Element;
1
+ /**
2
+ * In development, refreshes the page's server components when the CMS's
3
+ * content or schema changes, or once this app has revalidated what a webhook
4
+ * named, keeping client state. Renders nothing; does nothing in production.
5
+ * Listens on the route `liveReloadHandler` answers.
6
+ */
7
+ export declare function CromboloLiveReload({ path }: {
8
+ path?: string;
9
+ }): null;
package/dist/client.js CHANGED
@@ -1,26 +1,37 @@
1
1
  /** @jsxImportSource react */
2
2
  'use client';
3
- import { jsx as _jsx } from "react/jsx-runtime";
4
3
  /**
5
- * The browser half of `<RichText>` (story 208): a click on a link to a page of
6
- * this site navigates with Next's router rather than reloading the page.
4
+ * `<CromboloLiveReload />` (story 214), which refreshes the page when the CMS's
5
+ * content or schema changes in development.
7
6
  */
8
7
  import { useRouter } from 'next/navigation';
9
- export function RichTextLinks({ children, className, }) {
8
+ import { useEffect } from 'react';
9
+ /**
10
+ * In development, refreshes the page's server components when the CMS's
11
+ * content or schema changes, or once this app has revalidated what a webhook
12
+ * named, keeping client state. Renders nothing; does nothing in production.
13
+ * Listens on the route `liveReloadHandler` answers.
14
+ */
15
+ export function CromboloLiveReload({ path = '/api/crombolo/live' }) {
10
16
  const router = useRouter();
11
- const onClick = (event) => {
12
- if (event.defaultPrevented || event.button !== 0)
13
- return;
14
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
15
- return;
16
- const link = event.target.closest('a');
17
- const href = link?.getAttribute('href');
18
- if (!link || !href || link.target || link.hasAttribute('download'))
19
- return;
20
- if (!href.startsWith('/') || href.startsWith('//') || href.startsWith('/media/'))
17
+ useEffect(() => {
18
+ if (process.env.NODE_ENV !== 'development')
21
19
  return;
22
- event.preventDefault();
23
- router.push(href);
24
- };
25
- return (_jsx("div", { className: className, onClick: onClick, children: children }));
20
+ const events = new EventSource(path);
21
+ let boot;
22
+ events.addEventListener('hello', (event) => {
23
+ const id = JSON.parse(event.data).boot;
24
+ if (boot !== undefined && boot !== id)
25
+ router.refresh();
26
+ boot = id;
27
+ });
28
+ events.addEventListener('reload', (event) => {
29
+ const { reasons } = JSON.parse(event.data);
30
+ if (reasons?.some((reason) => reason === 'content' || reason === 'schema'))
31
+ router.refresh();
32
+ });
33
+ events.addEventListener('revalidated', () => router.refresh());
34
+ return () => events.close();
35
+ }, [path, router]);
36
+ return null;
26
37
  }
@@ -3,14 +3,30 @@
3
3
  * Server components for crombolo values (story 208): rich text, and block lists
4
4
  * and grids rendered through a component per element type.
5
5
  */
6
- import type { ApiBlockGrid, ApiBlockList, ApiElement, ApiRichText } from '@crombolo/delivery-client';
7
- import type { ComponentType } from 'react';
6
+ import { type ApiBlockGrid, type ApiBlockList, type ApiElement, type RichTextGenericElement, type RichTextTags, type RichTextValue } from '@crombolo/delivery-client';
7
+ import { type ComponentType, type ReactNode } from 'react';
8
+ /** What a tag's component in `<RichText components>` is given. */
9
+ export interface RichTextTagProps<Node = RichTextGenericElement> {
10
+ node: Node;
11
+ /** The element's attributes, ready to spread onto the tag. */
12
+ props: Record<string, unknown>;
13
+ children?: ReactNode;
14
+ }
15
+ /** A component per tag, in place of the tag itself; `a`, `img` and the block tags are typed. */
16
+ export type RichTextComponents = {
17
+ [Tag in keyof RichTextTags]?: ComponentType<RichTextTagProps<RichTextTags[Tag]>>;
18
+ } & {
19
+ [tag: string]: ComponentType<RichTextTagProps<never>> | undefined;
20
+ };
8
21
  /**
9
- * Rich text as the CMS wrote it, with links to its pages navigating in the app.
10
- * The markup is the CMS's own, already sanitised there.
22
+ * Rich text, rendered from the Delivery API's tree (story 216): each tag as
23
+ * itself or through `components`, a link to a page as a Next link, and each
24
+ * block through the component for its element type in `blocks`.
11
25
  */
12
- export declare function RichText({ value, className, }: {
13
- value: ApiRichText | null | undefined;
26
+ export declare function RichText({ value, components, blocks, className, }: {
27
+ value: RichTextValue | null | undefined;
28
+ components?: RichTextComponents;
29
+ blocks?: BlockComponents;
14
30
  className?: string;
15
31
  }): import("react").JSX.Element | null;
16
32
  /** What a block's component is given. */
@@ -1,14 +1,91 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** @jsxImportSource react */
3
+ /**
4
+ * Server components for crombolo values (story 208): rich text, and block lists
5
+ * and grids rendered through a component per element type.
6
+ */
7
+ import { decodeEntities, walkRichText, } from '@crombolo/delivery-client';
2
8
  import { draftMode } from 'next/headers';
3
- import { RichTextLinks } from './client.js';
9
+ import Link from 'next/link';
10
+ import { createElement, Fragment } from 'react';
11
+ const VOID = new Set(['area', 'br', 'col', 'embed', 'hr', 'img', 'input', 'source', 'track', 'wbr']);
12
+ const REACT_NAMES = {
13
+ class: 'className',
14
+ for: 'htmlFor',
15
+ colspan: 'colSpan',
16
+ rowspan: 'rowSpan',
17
+ tabindex: 'tabIndex',
18
+ srcset: 'srcSet',
19
+ crossorigin: 'crossOrigin',
20
+ referrerpolicy: 'referrerPolicy',
21
+ allowfullscreen: 'allowFullScreen',
22
+ frameborder: 'frameBorder',
23
+ maxlength: 'maxLength',
24
+ readonly: 'readOnly',
25
+ };
26
+ /** What the tree adds to describe a link or block, not attributes of the tag. */
27
+ const DESCRIBING = new Set(['destinationId', 'destinationType', 'linkType', 'route', 'content-id']);
28
+ function styleOf(css) {
29
+ const style = {};
30
+ for (const rule of css.split(';')) {
31
+ const colon = rule.indexOf(':');
32
+ if (colon === -1)
33
+ continue;
34
+ const name = rule.slice(0, colon).trim();
35
+ const value = rule.slice(colon + 1).trim();
36
+ if (!name || !value)
37
+ continue;
38
+ const key = name.startsWith('--')
39
+ ? name
40
+ : name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
41
+ style[key] = value;
42
+ }
43
+ return style;
44
+ }
45
+ /** A tree element's attributes as React props: decoded, renamed, and never a handler. */
46
+ function propsOf(node) {
47
+ const props = {};
48
+ for (const [name, value] of Object.entries(node.attributes)) {
49
+ if (DESCRIBING.has(name) || typeof value !== 'string' || /^on/i.test(name))
50
+ continue;
51
+ const text = decodeEntities(value);
52
+ if (name === 'style')
53
+ props.style = styleOf(text);
54
+ else
55
+ props[REACT_NAMES[name] ?? name] = text;
56
+ }
57
+ return props;
58
+ }
59
+ /** Where a link to a page goes in this app: its route, with any query or anchor. */
60
+ function routeHref(node) {
61
+ const route = node.attributes.route;
62
+ return route ? `${route.path}${route.queryString ?? ''}` : undefined;
63
+ }
4
64
  /**
5
- * Rich text as the CMS wrote it, with links to its pages navigating in the app.
6
- * The markup is the CMS's own, already sanitised there.
65
+ * Rich text, rendered from the Delivery API's tree (story 216): each tag as
66
+ * itself or through `components`, a link to a page as a Next link, and each
67
+ * block through the component for its element type in `blocks`.
7
68
  */
8
- export function RichText({ value, className, }) {
9
- if (!value?.markup)
69
+ export function RichText({ value, components = {}, blocks = {}, className, }) {
70
+ if (!value?.elements.length)
10
71
  return null;
11
- return (_jsx(RichTextLinks, { className: className, children: _jsx("div", { dangerouslySetInnerHTML: { __html: value.markup } }) }));
72
+ let key = 0;
73
+ const nodes = walkRichText(value, {
74
+ text: (node) => _jsx(Fragment, { children: decodeEntities(node.text) }, key++),
75
+ block: (item) => item ? (_jsx(Block, { content: item.content, settings: item.settings, components: blocks }, key++)) : null,
76
+ element: (node, children) => {
77
+ const props = propsOf(node);
78
+ const Own = components[node.tag];
79
+ const inside = VOID.has(node.tag) || children.length === 0 ? undefined : children;
80
+ if (Own)
81
+ return (_jsx(Own, { node: node, props: props, children: inside }, key++));
82
+ const href = node.tag === 'a' ? routeHref(node) : undefined;
83
+ if (href)
84
+ return (_jsx(Link, { ...props, href: href, children: inside }, key++));
85
+ return createElement(node.tag, { ...props, key: key++ }, inside);
86
+ },
87
+ });
88
+ return _jsx("div", { className: className, children: nodes });
12
89
  }
13
90
  async function Missing({ alias }) {
14
91
  if (!(await draftMode()).isEnabled)
package/dist/index.d.ts CHANGED
@@ -85,6 +85,6 @@ export declare function metadataOf(item: {
85
85
  name: string;
86
86
  seo?: ContentItem['seo'];
87
87
  } | undefined, extra?: Metadata): Metadata;
88
- export type { ApiBlockGrid, ApiBlockGridItem, ApiBlockList, ApiBlockListItem, ApiContent, ApiContentReference, ApiCrop, ApiElement, ApiFocalPoint, ApiLink, ApiMedia, ApiMediaReference, ApiMediaWithCrops, ApiMenu, ApiMenuItem, ApiRichText, ApiRoute, ApiSeo, MediaItem, Paged, SearchHit, } from '@crombolo/delivery-client';
89
- export { isExpanded } from '@crombolo/delivery-client';
88
+ export type { ApiBlockGrid, ApiBlockGridItem, ApiBlockList, ApiBlockListItem, ApiContent, ApiContentReference, ApiCrop, ApiElement, ApiFocalPoint, ApiLink, ApiMedia, ApiMediaReference, ApiMediaWithCrops, ApiMenu, ApiMenuItem, ApiRoute, ApiSeo, MediaItem, Paged, RichTextElement, RichTextGenericElement, RichTextTextElement, RichTextValue, SearchHit, } from '@crombolo/delivery-client';
89
+ export { decodeEntities, isExpanded, walkRichText } from '@crombolo/delivery-client';
90
90
  export type { ContentItem, ContentQuery, DeliverySchema, RequestOptions };
package/dist/index.js CHANGED
@@ -202,4 +202,4 @@ export function metadataOf(item, extra = {}) {
202
202
  ...extra,
203
203
  };
204
204
  }
205
- export { isExpanded } from '@crombolo/delivery-client';
205
+ export { decodeEntities, isExpanded, walkRichText } from '@crombolo/delivery-client';
@@ -42,3 +42,11 @@ export declare function memberHandlers(crombolo: Crombolo, options: MemberHandle
42
42
  * `web/app/media/[...path]/route.ts`.
43
43
  */
44
44
  export declare function mediaHandler(crombolo: Crombolo): (request: Request) => Promise<Response>;
45
+ /** Where the CMS streams its changes in development. */
46
+ export declare const CMS_LIVE_EVENTS_PATH = "/crombolo/dev/events";
47
+ /**
48
+ * `GET` for `<CromboloLiveReload />` in development: the CMS's change stream,
49
+ * and a `revalidated` event once this app has cleared what a webhook named, so
50
+ * a refresh after it shows the new content. 404 outside development.
51
+ */
52
+ export declare function liveReloadHandler(crombolo: Crombolo): (request: Request) => Promise<Response>;
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Route handlers a Next.js app mounts (story 208): Draft Mode on and off from
3
3
  * the CMS's Preview button, revalidation from its webhook, a member's sign-in
4
- * and sign-out with PKCE, and media fetched with the member's token.
4
+ * and sign-out with PKCE, media fetched with the member's token, and the CMS's
5
+ * changes for live reload in development (story 214).
5
6
  *
6
7
  * ```ts
7
8
  * // web/app/api/crombolo/preview/route.ts
@@ -91,6 +92,7 @@ export function revalidateHandler(crombolo, options = {}) {
91
92
  if (keys.length === 0)
92
93
  return new Response('The payload names no item.', { status: 400 });
93
94
  crombolo.revalidate(keys);
95
+ announceRevalidation(keys);
94
96
  return Response.json({ revalidated: keys });
95
97
  };
96
98
  }
@@ -234,3 +236,80 @@ export function mediaHandler(crombolo) {
234
236
  return new Response(answer.body, { status: answer.status, headers: out });
235
237
  };
236
238
  }
239
+ /** Where the CMS streams its changes in development. */
240
+ export const CMS_LIVE_EVENTS_PATH = '/crombolo/dev/events';
241
+ /**
242
+ * On the global object: Next.js may bundle this module once per route, and
243
+ * the revalidate route must reach the live route's listeners.
244
+ */
245
+ const REVALIDATED = Symbol.for('crombolo.next.revalidated');
246
+ function revalidationListeners() {
247
+ const holder = globalThis;
248
+ holder[REVALIDATED] ??= new Set();
249
+ return holder[REVALIDATED];
250
+ }
251
+ function announceRevalidation(keys) {
252
+ for (const listener of revalidationListeners())
253
+ listener(keys);
254
+ }
255
+ const encoder = new TextEncoder();
256
+ /**
257
+ * `GET` for `<CromboloLiveReload />` in development: the CMS's change stream,
258
+ * and a `revalidated` event once this app has cleared what a webhook named, so
259
+ * a refresh after it shows the new content. 404 outside development.
260
+ */
261
+ export function liveReloadHandler(crombolo) {
262
+ return async (request) => {
263
+ if (process.env.NODE_ENV !== 'development')
264
+ return new Response('Not Found', { status: 404 });
265
+ const upstream = await fetch(new URL(CMS_LIVE_EVENTS_PATH, crombolo.options.url), {
266
+ headers: { accept: 'text/event-stream' },
267
+ signal: request.signal,
268
+ }).catch(() => undefined);
269
+ if (!upstream?.ok || !upstream.body)
270
+ return new Response('The CMS is not streaming its changes.', { status: 502 });
271
+ const reader = upstream.body.getReader();
272
+ const decoder = new TextDecoder();
273
+ let listener;
274
+ const body = new ReadableStream({
275
+ async start(controller) {
276
+ listener = (keys) => controller.enqueue(encoder.encode(`event: revalidated\ndata: ${JSON.stringify({ keys })}\n\n`));
277
+ revalidationListeners().add(listener);
278
+ // Whole frames only, so a `revalidated` event never lands inside one of the CMS's.
279
+ let buffered = '';
280
+ try {
281
+ for (;;) {
282
+ const { done, value } = await reader.read();
283
+ if (done)
284
+ break;
285
+ buffered += decoder.decode(value, { stream: true });
286
+ const end = buffered.lastIndexOf('\n\n');
287
+ if (end < 0)
288
+ continue;
289
+ controller.enqueue(encoder.encode(buffered.slice(0, end + 2)));
290
+ buffered = buffered.slice(end + 2);
291
+ }
292
+ }
293
+ catch {
294
+ // The page left, or the CMS restarted; the browser reconnects either way.
295
+ }
296
+ finally {
297
+ if (listener)
298
+ revalidationListeners().delete(listener);
299
+ try {
300
+ controller.close();
301
+ }
302
+ catch { }
303
+ }
304
+ },
305
+ cancel() {
306
+ if (listener)
307
+ revalidationListeners().delete(listener);
308
+ void reader.cancel().catch(() => { });
309
+ },
310
+ });
311
+ return new Response(body, {
312
+ headers: { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-store' },
313
+ });
314
+ };
315
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/next-integration",
3
- "version": "0.37.0",
3
+ "version": "0.38.1",
4
4
  "description": "A Next.js App Router front end for a crombolo site: pages, metadata, preview, revalidation, members, images and content components.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -69,8 +69,8 @@
69
69
  "NOTICE"
70
70
  ],
71
71
  "dependencies": {
72
- "@crombolo/common": "0.37.0",
73
- "@crombolo/delivery-client": "0.37.0"
72
+ "@crombolo/common": "0.38.1",
73
+ "@crombolo/delivery-client": "0.38.1"
74
74
  },
75
75
  "peerDependencies": {
76
76
  "next": ">=15.5 <17",