@crombolo/next-integration 0.37.0 → 0.39.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/client.d.ts +9 -5
- package/dist/client.js +29 -18
- package/dist/components.d.ts +22 -6
- package/dist/components.js +83 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/route-handlers.d.ts +8 -0
- package/dist/route-handlers.js +80 -1
- package/package.json +3 -3
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
if (
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
}
|
package/dist/components.d.ts
CHANGED
|
@@ -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
|
|
7
|
-
import type
|
|
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
|
|
10
|
-
*
|
|
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:
|
|
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. */
|
package/dist/components.js
CHANGED
|
@@ -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
|
|
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
|
|
6
|
-
*
|
|
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?.
|
|
69
|
+
export function RichText({ value, components = {}, blocks = {}, className, }) {
|
|
70
|
+
if (!value?.elements.length)
|
|
10
71
|
return null;
|
|
11
|
-
|
|
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,
|
|
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
package/dist/route-handlers.d.ts
CHANGED
|
@@ -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>;
|
package/dist/route-handlers.js
CHANGED
|
@@ -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,
|
|
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.
|
|
3
|
+
"version": "0.39.0",
|
|
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.
|
|
73
|
-
"@crombolo/delivery-client": "0.
|
|
72
|
+
"@crombolo/common": "0.39.0",
|
|
73
|
+
"@crombolo/delivery-client": "0.39.0"
|
|
74
74
|
},
|
|
75
75
|
"peerDependencies": {
|
|
76
76
|
"next": ">=15.5 <17",
|