@crombolo/next-integration 0.0.0-stage → 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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/dist/cache-15.d.ts +2 -0
- package/dist/cache-15.js +22 -0
- package/dist/cache-16.d.ts +3 -0
- package/dist/cache-16.js +27 -0
- package/dist/cache.d.ts +23 -0
- package/dist/cache.js +42 -0
- package/dist/client.d.ts +9 -0
- package/dist/client.js +37 -0
- package/dist/components.d.ts +47 -0
- package/dist/components.js +122 -0
- package/dist/image-loader.d.ts +36 -0
- package/dist/image-loader.js +37 -0
- package/dist/index.d.ts +90 -0
- package/dist/index.js +205 -0
- package/dist/middleware.d.ts +27 -0
- package/dist/middleware.js +138 -0
- package/dist/route-handlers.d.ts +52 -0
- package/dist/route-handlers.js +315 -0
- package/dist/session.d.ts +39 -0
- package/dist/session.js +96 -0
- package/package.json +86 -4
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kineco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
crombolo
|
|
2
|
+
Copyright (c) 2026 Kineco
|
|
3
|
+
|
|
4
|
+
This product includes third-party software. crombolo is an independent project.
|
|
5
|
+
It is not affiliated with, endorsed by, or sponsored by Umbraco A/S, which owns
|
|
6
|
+
the Umbraco trademark.
|
|
7
|
+
|
|
8
|
+
There are three places third-party material is redistributed, rather than merely
|
|
9
|
+
depended on:
|
|
10
|
+
|
|
11
|
+
@crombolo/backoffice-dist the built Umbraco backoffice and the browser
|
|
12
|
+
packages bundled into it, produced by
|
|
13
|
+
`bun run vendor:backoffice`
|
|
14
|
+
@crombolo/backoffice-dist three CSS files copied unmodified from the Umbraco
|
|
15
|
+
client source tree
|
|
16
|
+
@crombolo/contracts the Umbraco Management API OpenAPI document,
|
|
17
|
+
copied unmodified
|
|
18
|
+
|
|
19
|
+
No Umbraco trademark is redistributed. The Umbraco wordmark, the "U" roundel
|
|
20
|
+
favicon and the installer illustration are deliberately not carried in this
|
|
21
|
+
repository: MIT grants copyright permission and says nothing about trademarks, so
|
|
22
|
+
crombolo's own marks are served at those paths instead. See `BRANDED_ASSETS` in
|
|
23
|
+
`@crombolo/backoffice-host`.
|
|
24
|
+
|
|
25
|
+
The Umbraco backoffice
|
|
26
|
+
----------------------
|
|
27
|
+
@umbraco-cms/backoffice 18.2.0 — MIT — https://github.com/umbraco/Umbraco-CMS
|
|
28
|
+
@umbraco-ui/uui 2.0.2 — MIT — https://github.com/umbraco/Umbraco.UI
|
|
29
|
+
|
|
30
|
+
Static files from the Umbraco client source tree
|
|
31
|
+
------------------------------------------------
|
|
32
|
+
css/umb-css.css, css/rte-content.css, css/umbraco-blockgridlayout.css
|
|
33
|
+
— copied unmodified from Umbraco-CMS release-18.2.0 — MIT
|
|
34
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
35
|
+
Shipped in @crombolo/backoffice-dist under `upstream-static/`.
|
|
36
|
+
|
|
37
|
+
`rte-content.css` itself carries Tiptap's default editor styles, copied from
|
|
38
|
+
tiptap v2.11.7 (MIT); the file records this in its own header.
|
|
39
|
+
|
|
40
|
+
The Umbraco Management API contract
|
|
41
|
+
-----------------------------------
|
|
42
|
+
OpenApi.json — copied unmodified from Umbraco-CMS release-18.2.0
|
|
43
|
+
(src/Umbraco.Cms.Api.Management/OpenApi.json) — MIT
|
|
44
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
45
|
+
Shipped in @crombolo/contracts, which also ships the TypeScript types generated
|
|
46
|
+
from it.
|
|
47
|
+
|
|
48
|
+
Browser runtime dependencies bundled into the backoffice build
|
|
49
|
+
--------------------------------------------------------------
|
|
50
|
+
@heximal/expressions 0.1.5 — BSD-3-Clause
|
|
51
|
+
@microsoft/signalr 10.0.11 — MIT
|
|
52
|
+
@tiptap/* 3.31.3 — MIT
|
|
53
|
+
diff 9.0.0 — BSD-3-Clause
|
|
54
|
+
dompurify 3.4.16 — Apache-2.0, elected from "MPL-2.0 OR Apache-2.0"
|
|
55
|
+
element-internals-polyfill 3.0.2 — MIT
|
|
56
|
+
lit 3.3.3 — BSD-3-Clause
|
|
57
|
+
luxon 3.7.2 — MIT
|
|
58
|
+
marked 18.0.14 — MIT
|
|
59
|
+
monaco-editor 0.57.0 — MIT
|
|
60
|
+
rxjs 7.8.2 — Apache-2.0
|
|
61
|
+
uuid 14.0.2 — MIT
|
|
62
|
+
|
|
63
|
+
dompurify is offered under either licence; Apache-2.0 is elected here, so no
|
|
64
|
+
MPL-2.0 obligation attaches to the copy bundled into the backoffice build. Of the
|
|
65
|
+
Apache-2.0 packages above, none ships a NOTICE file of its own, so Apache-2.0
|
|
66
|
+
section 4(d) adds nothing to this file.
|
|
67
|
+
|
|
68
|
+
Each package's own licence text ships inside its npm distribution and applies
|
|
69
|
+
unchanged to the copies bundled here.
|
package/dist/cache-15.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js 15's cache for `createCrombolo` (story 208). Next 15 tags a fetch
|
|
3
|
+
* before it runs, so the keys an answer holds cannot tag it: every answer is
|
|
4
|
+
* kept under the site's tag, and any change clears them all.
|
|
5
|
+
*/
|
|
6
|
+
import { revalidateTag } from 'next/cache';
|
|
7
|
+
import { keptHeaders, SITE_TAG } from './cache.js';
|
|
8
|
+
export const cache15 = {
|
|
9
|
+
major: 15,
|
|
10
|
+
async answer(url, headers) {
|
|
11
|
+
const response = await fetch(url, {
|
|
12
|
+
headers,
|
|
13
|
+
next: { tags: [SITE_TAG], revalidate: false },
|
|
14
|
+
});
|
|
15
|
+
const body = await response.text();
|
|
16
|
+
return { status: response.status, body, headers: keptHeaders(response.headers) };
|
|
17
|
+
},
|
|
18
|
+
revalidate() {
|
|
19
|
+
;
|
|
20
|
+
revalidateTag(SITE_TAG);
|
|
21
|
+
},
|
|
22
|
+
};
|
package/dist/cache-16.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js 16's cache for `createCrombolo` (story 208): `'use cache'`, tagged
|
|
3
|
+
* from each answer with `cacheTag`, so a publish clears exactly what holds the
|
|
4
|
+
* page and its ancestors. Needs `cacheComponents: true` in `next.config.ts`.
|
|
5
|
+
*/
|
|
6
|
+
import { cacheLife, cacheTag, revalidateTag } from 'next/cache';
|
|
7
|
+
import { keptHeaders, MISSING_TAG, tagOf, tagsFor, unkept, } from './cache.js';
|
|
8
|
+
export async function keptAnswer(url, headers) {
|
|
9
|
+
'use cache';
|
|
10
|
+
const response = await fetch(url, { headers });
|
|
11
|
+
const body = await response.text();
|
|
12
|
+
// Kept for as long as nothing changes; the webhook says when something does.
|
|
13
|
+
if (unkept(response.headers))
|
|
14
|
+
cacheLife('seconds');
|
|
15
|
+
else
|
|
16
|
+
cacheLife('max');
|
|
17
|
+
cacheTag(...tagsFor(response.status, body, response.headers));
|
|
18
|
+
return { status: response.status, body, headers: keptHeaders(response.headers) };
|
|
19
|
+
}
|
|
20
|
+
export const cache16 = {
|
|
21
|
+
major: 16,
|
|
22
|
+
answer: keptAnswer,
|
|
23
|
+
revalidate(keys) {
|
|
24
|
+
for (const tag of [MISSING_TAG, ...keys.map(tagOf)])
|
|
25
|
+
revalidateTag(tag, 'max');
|
|
26
|
+
},
|
|
27
|
+
};
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface KeptAnswer {
|
|
2
|
+
status: number;
|
|
3
|
+
body: string;
|
|
4
|
+
headers: Record<string, string>;
|
|
5
|
+
}
|
|
6
|
+
export interface CacheAdapter {
|
|
7
|
+
readonly major: 15 | 16;
|
|
8
|
+
/** The answer at `url`, from Next's cache when it holds one. */
|
|
9
|
+
answer(url: string, headers: Record<string, string>): Promise<KeptAnswer>;
|
|
10
|
+
/** Clears every kept answer holding one of these keys, and every kept not-found. */
|
|
11
|
+
revalidate(keys: readonly string[]): void;
|
|
12
|
+
}
|
|
13
|
+
/** Every adapter's answers carry this, so a whole site can be cleared at once. */
|
|
14
|
+
export declare const SITE_TAG = "crombolo";
|
|
15
|
+
/** Kept not-found answers, cleared on every change: a new page may answer one. */
|
|
16
|
+
export declare const MISSING_TAG = "crombolo:missing";
|
|
17
|
+
export declare const tagOf: (key: string) => string;
|
|
18
|
+
/** The tags an answer is kept under: the site's, and each key it holds or the site named. */
|
|
19
|
+
export declare function tagsFor(status: number, body: string, headers: Headers): string[];
|
|
20
|
+
/** Whether the site said not to keep it: a redirect, a form, anything personal. */
|
|
21
|
+
export declare function unkept(headers: Headers): boolean;
|
|
22
|
+
/** The headers a kept answer needs to be read again. */
|
|
23
|
+
export declare function keptHeaders(headers: Headers): Record<string, string>;
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a caching adapter does for `createCrombolo`, one per Next.js major
|
|
3
|
+
* (story 208): keep the Delivery API's answers in Next's cache, tagged with the
|
|
4
|
+
* content they hold, and clear them when the site says it changed.
|
|
5
|
+
*/
|
|
6
|
+
import { contentKeysIn } from '@crombolo/delivery-client';
|
|
7
|
+
/** Every adapter's answers carry this, so a whole site can be cleared at once. */
|
|
8
|
+
export const SITE_TAG = 'crombolo';
|
|
9
|
+
/** Kept not-found answers, cleared on every change: a new page may answer one. */
|
|
10
|
+
export const MISSING_TAG = 'crombolo:missing';
|
|
11
|
+
/** Next.js keeps at most this many tags on one entry. */
|
|
12
|
+
const MAX_TAGS = 128;
|
|
13
|
+
export const tagOf = (key) => `crombolo:${key.toLowerCase()}`;
|
|
14
|
+
/** The tags an answer is kept under: the site's, and each key it holds or the site named. */
|
|
15
|
+
export function tagsFor(status, body, headers) {
|
|
16
|
+
if (status === 404)
|
|
17
|
+
return [SITE_TAG, MISSING_TAG];
|
|
18
|
+
let parsed = null;
|
|
19
|
+
try {
|
|
20
|
+
parsed = body ? JSON.parse(body) : null;
|
|
21
|
+
}
|
|
22
|
+
catch { }
|
|
23
|
+
const named = (headers.get('surrogate-key') ?? headers.get('cache-tag') ?? '')
|
|
24
|
+
.split(/[\s,]+/)
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
const keys = new Set([...contentKeysIn(parsed), ...named.map((k) => k.toLowerCase())]);
|
|
27
|
+
return [SITE_TAG, ...[...keys].map(tagOf)].slice(0, MAX_TAGS);
|
|
28
|
+
}
|
|
29
|
+
/** Whether the site said not to keep it: a redirect, a form, anything personal. */
|
|
30
|
+
export function unkept(headers) {
|
|
31
|
+
return /\b(no-store|private)\b/i.test(headers.get('cache-control') ?? '');
|
|
32
|
+
}
|
|
33
|
+
/** The headers a kept answer needs to be read again. */
|
|
34
|
+
export function keptHeaders(headers) {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const name of ['content-type', 'etag', 'cache-control', 'surrogate-key', 'cache-tag']) {
|
|
37
|
+
const value = headers.get(name);
|
|
38
|
+
if (value !== null)
|
|
39
|
+
out[name] = value;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** @jsxImportSource react */
|
|
2
|
+
'use client';
|
|
3
|
+
/**
|
|
4
|
+
* `<CromboloLiveReload />` (story 214), which refreshes the page when the CMS's
|
|
5
|
+
* content or schema changes in development.
|
|
6
|
+
*/
|
|
7
|
+
import { useRouter } from 'next/navigation';
|
|
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' }) {
|
|
16
|
+
const router = useRouter();
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (process.env.NODE_ENV !== 'development')
|
|
19
|
+
return;
|
|
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;
|
|
37
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** @jsxImportSource react */
|
|
2
|
+
/**
|
|
3
|
+
* Server components for crombolo values (story 208): rich text, and block lists
|
|
4
|
+
* and grids rendered through a component per element type.
|
|
5
|
+
*/
|
|
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
|
+
};
|
|
21
|
+
/**
|
|
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`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function RichText({ value, components, blocks, className, }: {
|
|
27
|
+
value: RichTextValue | null | undefined;
|
|
28
|
+
components?: RichTextComponents;
|
|
29
|
+
blocks?: BlockComponents;
|
|
30
|
+
className?: string;
|
|
31
|
+
}): import("react").JSX.Element | null;
|
|
32
|
+
/** What a block's component is given. */
|
|
33
|
+
export interface BlockComponentProps<Content = ApiElement, Settings = ApiElement> {
|
|
34
|
+
content: Content;
|
|
35
|
+
settings: Settings | null;
|
|
36
|
+
}
|
|
37
|
+
export type BlockComponents = Record<string, ComponentType<BlockComponentProps<never, never>>>;
|
|
38
|
+
/** A block list, each item through the component for its element type. */
|
|
39
|
+
export declare function BlockList({ value, components, }: {
|
|
40
|
+
value: ApiBlockList | null | undefined;
|
|
41
|
+
components: BlockComponents;
|
|
42
|
+
}): import("react").JSX.Element | null;
|
|
43
|
+
/** A block grid, laid out in its columns, areas included. */
|
|
44
|
+
export declare function BlockGrid({ value, components, }: {
|
|
45
|
+
value: ApiBlockGrid | null | undefined;
|
|
46
|
+
components: BlockComponents;
|
|
47
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,122 @@
|
|
|
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';
|
|
8
|
+
import { draftMode } from 'next/headers';
|
|
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
|
+
}
|
|
64
|
+
/**
|
|
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`.
|
|
68
|
+
*/
|
|
69
|
+
export function RichText({ value, components = {}, blocks = {}, className, }) {
|
|
70
|
+
if (!value?.elements.length)
|
|
71
|
+
return null;
|
|
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 });
|
|
89
|
+
}
|
|
90
|
+
async function Missing({ alias }) {
|
|
91
|
+
if (!(await draftMode()).isEnabled)
|
|
92
|
+
return null;
|
|
93
|
+
return (_jsxs("div", { "data-crombolo-missing-block": alias, style: { border: '1px dashed #c00', padding: '1rem' }, children: ["There is no component for the block \u201C", alias, "\u201D yet."] }));
|
|
94
|
+
}
|
|
95
|
+
function Block({ content, settings, components, }) {
|
|
96
|
+
if (!content)
|
|
97
|
+
return null;
|
|
98
|
+
const Component = components[content.contentType];
|
|
99
|
+
return Component ? (_jsx(Component, { content: content, settings: settings })) : (_jsx(Missing, { alias: content.contentType }));
|
|
100
|
+
}
|
|
101
|
+
/** A block list, each item through the component for its element type. */
|
|
102
|
+
export function BlockList({ value, components, }) {
|
|
103
|
+
if (!value?.items.length)
|
|
104
|
+
return null;
|
|
105
|
+
return (_jsx(_Fragment, { children: value.items.map((item, index) => (_jsx(Block, { content: item.content, settings: item.settings, components: components }, item.content?.id ?? index))) }));
|
|
106
|
+
}
|
|
107
|
+
function GridItems({ items, components, }) {
|
|
108
|
+
return (_jsx(_Fragment, { children: items.map((item, index) => (_jsxs("div", { className: "crombolo-grid-item", style: { gridColumn: `span ${item.columnSpan}`, gridRow: `span ${item.rowSpan}` }, children: [_jsx(Block, { content: item.content, settings: item.settings, components: components }), item.areas.map((area) => (_jsx("div", { className: "crombolo-grid-area", "data-area": area.alias, style: {
|
|
109
|
+
display: 'grid',
|
|
110
|
+
gridTemplateColumns: `repeat(${item.areaGridColumns}, minmax(0, 1fr))`,
|
|
111
|
+
gridColumn: `span ${area.columnSpan}`,
|
|
112
|
+
}, children: _jsx(GridItems, { items: area.items, components: components }) }, area.alias)))] }, item.content?.id ?? index))) }));
|
|
113
|
+
}
|
|
114
|
+
/** A block grid, laid out in its columns, areas included. */
|
|
115
|
+
export function BlockGrid({ value, components, }) {
|
|
116
|
+
if (!value?.items.length)
|
|
117
|
+
return null;
|
|
118
|
+
return (_jsx("div", { className: "crombolo-grid", style: {
|
|
119
|
+
display: 'grid',
|
|
120
|
+
gridTemplateColumns: `repeat(${value.gridColumns}, minmax(0, 1fr))`,
|
|
121
|
+
}, children: _jsx(GridItems, { items: value.items, components: components }) }));
|
|
122
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `next/image` for crombolo media (story 208): the CMS resizes, so the loader
|
|
3
|
+
* writes its imaging query rather than Next's optimiser doing the work.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* // next.config.ts
|
|
7
|
+
* images: { loader: 'custom', loaderFile: './lib/image-loader.ts' }
|
|
8
|
+
* // lib/image-loader.ts
|
|
9
|
+
* export { default } from '@crombolo/next-integration/image-loader'
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import type { ApiCrop, ApiFocalPoint } from '@crombolo/delivery-client';
|
|
13
|
+
export interface ImageLoaderProps {
|
|
14
|
+
src: string;
|
|
15
|
+
width: number;
|
|
16
|
+
quality?: number;
|
|
17
|
+
}
|
|
18
|
+
/** Adds the width, quality and a modern format to a media address, keeping a crop it carries. */
|
|
19
|
+
export default function cromboloImageLoader({ src, width, quality }: ImageLoaderProps): string;
|
|
20
|
+
/** What a picked image needs for `<Image>`: an address carrying its crop or focal point, and its size. */
|
|
21
|
+
export declare function cromboloImage(media: {
|
|
22
|
+
url: string;
|
|
23
|
+
width: number | null;
|
|
24
|
+
height: number | null;
|
|
25
|
+
name: string;
|
|
26
|
+
focalPoint?: ApiFocalPoint | null;
|
|
27
|
+
crops?: ApiCrop[] | null;
|
|
28
|
+
}, options?: {
|
|
29
|
+
crop?: string;
|
|
30
|
+
alt?: string;
|
|
31
|
+
}): {
|
|
32
|
+
src: string;
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
alt: string;
|
|
36
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Adds the width, quality and a modern format to a media address, keeping a crop it carries. */
|
|
2
|
+
export default function cromboloImageLoader({ src, width, quality }) {
|
|
3
|
+
const [path, query = ''] = src.split('?');
|
|
4
|
+
const params = new URLSearchParams(query);
|
|
5
|
+
params.set('width', String(width));
|
|
6
|
+
if (!params.has('quality'))
|
|
7
|
+
params.set('quality', String(quality ?? 75));
|
|
8
|
+
if (!params.has('format'))
|
|
9
|
+
params.set('format', 'webp');
|
|
10
|
+
return `${path}?${params.toString()}`;
|
|
11
|
+
}
|
|
12
|
+
/** What a picked image needs for `<Image>`: an address carrying its crop or focal point, and its size. */
|
|
13
|
+
export function cromboloImage(media, options = {}) {
|
|
14
|
+
const params = new URLSearchParams();
|
|
15
|
+
const crop = options.crop ? media.crops?.find((c) => c.alias === options.crop) : undefined;
|
|
16
|
+
let width = media.width ?? 0;
|
|
17
|
+
let height = media.height ?? 0;
|
|
18
|
+
if (crop) {
|
|
19
|
+
width = crop.width;
|
|
20
|
+
height = crop.height;
|
|
21
|
+
if (crop.coordinates) {
|
|
22
|
+
const { x1, y1, x2, y2 } = crop.coordinates;
|
|
23
|
+
params.set('cc', [x1, y1, x2, y2].join(','));
|
|
24
|
+
}
|
|
25
|
+
else if (media.focalPoint)
|
|
26
|
+
params.set('rxy', `${media.focalPoint.left},${media.focalPoint.top}`);
|
|
27
|
+
}
|
|
28
|
+
else if (media.focalPoint)
|
|
29
|
+
params.set('rxy', `${media.focalPoint.left},${media.focalPoint.top}`);
|
|
30
|
+
const query = params.toString();
|
|
31
|
+
return {
|
|
32
|
+
src: query ? `${media.url}?${query}` : media.url,
|
|
33
|
+
width,
|
|
34
|
+
height,
|
|
35
|
+
alt: options.alt ?? media.name,
|
|
36
|
+
};
|
|
37
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Next.js App Router front end for a crombolo site (story 208).
|
|
3
|
+
*
|
|
4
|
+
* `createCrombolo()` wraps the typed Delivery API client for server
|
|
5
|
+
* components: answers kept in Next's cache and tagged by the content they hold,
|
|
6
|
+
* drafts in Draft Mode, a member's own reads, route lookup as a `Result`, and
|
|
7
|
+
* Next's `Metadata` from a page's search and sharing details.
|
|
8
|
+
* docs/delivery-api.md, "A Next.js front end".
|
|
9
|
+
*/
|
|
10
|
+
import { type Result } from '@crombolo/common';
|
|
11
|
+
import { type ContentItem, type ContentQuery, type DeliveryClient, type DeliverySchema, type RequestOptions } from '@crombolo/delivery-client';
|
|
12
|
+
import type { Metadata } from 'next';
|
|
13
|
+
import type { CacheAdapter } from './cache.ts';
|
|
14
|
+
import { type MemberSession } from './session.ts';
|
|
15
|
+
export { assertSafeLocation, type FieldError, isOk, isOutcome, type MatchHandlers, Ok, Outcome, type OutcomeStatus, ok, Result, } from '@crombolo/common';
|
|
16
|
+
export type { CacheAdapter, KeptAnswer } from './cache.ts';
|
|
17
|
+
export { SITE_TAG, tagOf, tagsFor } from './cache.ts';
|
|
18
|
+
export type { MemberSession } from './session.ts';
|
|
19
|
+
export interface CromboloOptions {
|
|
20
|
+
/** The CMS's address, such as `https://cms.example.com`. */
|
|
21
|
+
url: string;
|
|
22
|
+
/** The Delivery API key, for a site whose API is not public, and for preview. */
|
|
23
|
+
apiKey?: string;
|
|
24
|
+
/** Next's cache, from `./cache-16` or `./cache-15`; without one, every read revalidates by ETag. */
|
|
25
|
+
cache?: CacheAdapter;
|
|
26
|
+
/** `etag` revalidates every read rather than keeping it until the webhook clears it. */
|
|
27
|
+
revalidate?: 'tags' | 'etag';
|
|
28
|
+
/** The hostname routes resolve for, as the site's domains pick a root and culture. */
|
|
29
|
+
host?: string;
|
|
30
|
+
culture?: string;
|
|
31
|
+
startItem?: string;
|
|
32
|
+
/** `CROMBOLO_NEXT_SECRET`, which seals a member's session; read from the environment when unset. */
|
|
33
|
+
secret?: string;
|
|
34
|
+
/** The Delivery API's path, when the site serves it somewhere else. */
|
|
35
|
+
apiPath?: string;
|
|
36
|
+
}
|
|
37
|
+
/** What a page lookup can answer instead of a page. */
|
|
38
|
+
export type PageStatus = 301 | 302 | 303 | 307 | 308 | 401 | 403 | 404;
|
|
39
|
+
/** The reads a page makes, through whichever client the request calls for. */
|
|
40
|
+
export interface CromboloReads<Schema extends DeliverySchema = DeliverySchema> {
|
|
41
|
+
readonly client: DeliveryClient<Schema>;
|
|
42
|
+
/** The page at an address, a redirect, or a refusal, as the site would answer it. */
|
|
43
|
+
page(path: string, options?: {
|
|
44
|
+
host?: string;
|
|
45
|
+
expand?: string;
|
|
46
|
+
}): Promise<Result<ContentItem<Schema>, PageStatus>>;
|
|
47
|
+
/** `page()` ended with Next's own `redirect`, `permanentRedirect`, `notFound`, `unauthorized` or `forbidden`. */
|
|
48
|
+
resolvePage(path: string, options?: {
|
|
49
|
+
host?: string;
|
|
50
|
+
expand?: string;
|
|
51
|
+
}): Promise<ContentItem<Schema>>;
|
|
52
|
+
/** The site's not-found page for a hostname, when it has one. */
|
|
53
|
+
notFoundPage(options?: {
|
|
54
|
+
host?: string;
|
|
55
|
+
}): Promise<ContentItem<Schema> | undefined>;
|
|
56
|
+
item: DeliveryClient<Schema>['item'];
|
|
57
|
+
items: DeliveryClient<Schema>['items'];
|
|
58
|
+
content: DeliveryClient<Schema>['content'];
|
|
59
|
+
mediaItem: DeliveryClient<Schema>['mediaItem'];
|
|
60
|
+
media: DeliveryClient<Schema>['media'];
|
|
61
|
+
menu: DeliveryClient<Schema>['menu'];
|
|
62
|
+
search: DeliveryClient<Schema>['search'];
|
|
63
|
+
form: DeliveryClient<Schema>['form'];
|
|
64
|
+
sitemap: DeliveryClient<Schema>['sitemap'];
|
|
65
|
+
}
|
|
66
|
+
export interface Crombolo<Schema extends DeliverySchema = DeliverySchema> extends CromboloReads<Schema> {
|
|
67
|
+
readonly options: CromboloOptions;
|
|
68
|
+
/** Reads revalidated by ETag on every call, for a page rendered per request. */
|
|
69
|
+
readonly fresh: CromboloReads<Schema>;
|
|
70
|
+
/**
|
|
71
|
+
* The signed-in member's reads, never kept, or undefined for a visitor.
|
|
72
|
+
* Reads the session cookie, so the page renders per request.
|
|
73
|
+
*/
|
|
74
|
+
member(): Promise<(CromboloReads<Schema> & {
|
|
75
|
+
session: MemberSession;
|
|
76
|
+
}) | undefined>;
|
|
77
|
+
/** Next's `Metadata` from a page's search and sharing details (`seo: true`). */
|
|
78
|
+
metadata(item: ContentItem<Schema> | undefined, extra?: Metadata): Metadata;
|
|
79
|
+
/** Clears every kept answer holding these keys. */
|
|
80
|
+
revalidate(keys: readonly string[]): void;
|
|
81
|
+
}
|
|
82
|
+
export declare function createCrombolo<Schema extends DeliverySchema = DeliverySchema>(options: CromboloOptions): Crombolo<Schema>;
|
|
83
|
+
/** Next's `Metadata` from a page's `seo` details; `extra` wins field by field. Pure. */
|
|
84
|
+
export declare function metadataOf(item: {
|
|
85
|
+
name: string;
|
|
86
|
+
seo?: ContentItem['seo'];
|
|
87
|
+
} | undefined, extra?: Metadata): Metadata;
|
|
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
|
+
export type { ContentItem, ContentQuery, DeliverySchema, RequestOptions };
|