@laconius/cms 0.1.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/LICENSE +73 -0
- package/README.md +26 -0
- package/lib/module/adapter.js +136 -0
- package/lib/module/adapter.js.map +1 -0
- package/lib/module/config.js +105 -0
- package/lib/module/config.js.map +1 -0
- package/lib/module/defaults.js +282 -0
- package/lib/module/defaults.js.map +1 -0
- package/lib/module/index.js +10 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/models.js +29 -0
- package/lib/module/models.js.map +1 -0
- package/lib/module/normalizer.js +114 -0
- package/lib/module/normalizer.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/page.js +223 -0
- package/lib/module/page.js.map +1 -0
- package/lib/module/queries.js +54 -0
- package/lib/module/queries.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/adapter.d.ts +25 -0
- package/lib/typescript/src/adapter.d.ts.map +1 -0
- package/lib/typescript/src/config.d.ts +84 -0
- package/lib/typescript/src/config.d.ts.map +1 -0
- package/lib/typescript/src/defaults.d.ts +64 -0
- package/lib/typescript/src/defaults.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +8 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/models.d.ts +123 -0
- package/lib/typescript/src/models.d.ts.map +1 -0
- package/lib/typescript/src/normalizer.d.ts +70 -0
- package/lib/typescript/src/normalizer.d.ts.map +1 -0
- package/lib/typescript/src/page.d.ts +62 -0
- package/lib/typescript/src/page.d.ts.map +1 -0
- package/lib/typescript/src/queries.d.ts +193 -0
- package/lib/typescript/src/queries.d.ts.map +1 -0
- package/package.json +69 -0
- package/src/adapter.ts +159 -0
- package/src/config.ts +119 -0
- package/src/defaults.tsx +281 -0
- package/src/index.ts +82 -0
- package/src/models.ts +120 -0
- package/src/normalizer.ts +159 -0
- package/src/page.tsx +228 -0
- package/src/queries.ts +56 -0
package/src/models.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { MediaContainer } from '@laconius/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CMS models, ported from Spartacus `core-libs/core/src/model/cms.model.ts` and
|
|
5
|
+
* `core-libs/core/src/cms/model/*` (Apache-2.0, see NOTICE). Divergences:
|
|
6
|
+
* - `enum` becomes a string union, so this module emits nothing at runtime.
|
|
7
|
+
* - `CmsStructure.components` is a record keyed by `uid`, not an array: Laconius looks components
|
|
8
|
+
* up by uid at render time instead of feeding an NgRx entity reducer.
|
|
9
|
+
* - `robots` and the rest of the SEO emitters are dropped ([chapter 06](../../../docs/spec/06-cms-engine.md)).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type PageType = 'ContentPage' | 'ProductPage' | 'CategoryPage' | 'CatalogPage';
|
|
13
|
+
|
|
14
|
+
/** The sentinel the backend answers to for the base site's homepage. */
|
|
15
|
+
export const HOME_PAGE_CONTEXT = '__HOMEPAGE__';
|
|
16
|
+
|
|
17
|
+
/** What identifies a page to OCC: a content-page label, or a product / category code. */
|
|
18
|
+
export type PageContext = { id: string; type?: PageType };
|
|
19
|
+
|
|
20
|
+
/** The component reference a slot carries. `key` is the *computed* registry key. */
|
|
21
|
+
export type ContentSlotComponent = {
|
|
22
|
+
uid: string;
|
|
23
|
+
typeCode?: string;
|
|
24
|
+
/** `flexType` for `CMSFlexComponent`, `uid` for `JspIncludeComponent`, else `typeCode`. */
|
|
25
|
+
key: string;
|
|
26
|
+
properties?: Record<string, unknown>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ContentSlot = {
|
|
30
|
+
position: string;
|
|
31
|
+
properties?: Record<string, unknown>;
|
|
32
|
+
components: ContentSlotComponent[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type CmsPageStructure = {
|
|
36
|
+
pageId?: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
type?: string;
|
|
39
|
+
label?: string;
|
|
40
|
+
template?: string;
|
|
41
|
+
title?: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
properties?: Record<string, unknown>;
|
|
44
|
+
/** Keyed by position. Order here is backend order — the warned fallback, not the contract. */
|
|
45
|
+
slots: Record<string, ContentSlot>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** What the page endpoint normalizes to: the structure plus whatever component data came inlined. */
|
|
49
|
+
export type CmsStructure = {
|
|
50
|
+
page: CmsPageStructure;
|
|
51
|
+
components: Record<string, CmsComponent>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Every component payload carries these; the rest is per type. */
|
|
55
|
+
export interface CmsComponent {
|
|
56
|
+
uid?: string;
|
|
57
|
+
typeCode?: string;
|
|
58
|
+
name?: string;
|
|
59
|
+
modifiedTime?: string;
|
|
60
|
+
container?: string;
|
|
61
|
+
styleClasses?: string;
|
|
62
|
+
composition?: { inner?: string[] };
|
|
63
|
+
[key: string]: unknown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CmsLinkData extends CmsComponent {
|
|
67
|
+
linkName?: string;
|
|
68
|
+
url?: string;
|
|
69
|
+
external?: string | boolean;
|
|
70
|
+
contentPage?: string;
|
|
71
|
+
contentPageLabelOrId?: string;
|
|
72
|
+
target?: string | boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface CmsBannerData extends CmsComponent {
|
|
76
|
+
headline?: string;
|
|
77
|
+
content?: string;
|
|
78
|
+
/** A single image, or a group keyed by format code. Absolutised on the way in. */
|
|
79
|
+
media?: MediaContainer;
|
|
80
|
+
urlLink?: string;
|
|
81
|
+
external?: string | boolean;
|
|
82
|
+
contentPage?: string;
|
|
83
|
+
product?: string;
|
|
84
|
+
category?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface CmsParagraphData extends CmsComponent {
|
|
88
|
+
title?: string;
|
|
89
|
+
/** HTML. Stripped to plain text unless `cms.htmlRenderer` is configured. */
|
|
90
|
+
content?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface CmsNavigationNode {
|
|
94
|
+
uid?: string;
|
|
95
|
+
title?: string;
|
|
96
|
+
children?: CmsNavigationNode[];
|
|
97
|
+
entries?: { itemId?: string; itemSuperType?: string; itemType?: string }[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface CmsNavigationData extends CmsComponent {
|
|
101
|
+
navigationNode?: CmsNavigationNode;
|
|
102
|
+
styleClass?: string;
|
|
103
|
+
wrapAfter?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `RotatingImagesComponent`: a space-separated list of banner uids. */
|
|
107
|
+
export interface CmsCarouselData extends CmsComponent {
|
|
108
|
+
banners?: string;
|
|
109
|
+
effect?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Where a CMS link points, resolved from the four mutually exclusive fields OCC uses. Laconius
|
|
114
|
+
* hands this to `cms.onNavigate`; no Laconius package navigates ([chapter 01](../../../docs/spec/01-packages.md)).
|
|
115
|
+
*/
|
|
116
|
+
export type CmsLinkTarget =
|
|
117
|
+
| { kind: 'url'; url: string; external: boolean }
|
|
118
|
+
| { kind: 'contentPage'; label: string }
|
|
119
|
+
| { kind: 'product'; code: string }
|
|
120
|
+
| { kind: 'category'; code: string };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CmsComponent,
|
|
3
|
+
CmsLinkTarget,
|
|
4
|
+
CmsStructure,
|
|
5
|
+
ContentSlot,
|
|
6
|
+
ContentSlotComponent,
|
|
7
|
+
} from './models';
|
|
8
|
+
|
|
9
|
+
const CMS_FLEX_COMPONENT_TYPE = 'CMSFlexComponent';
|
|
10
|
+
const JSP_INCLUDE_COMPONENT_TYPE = 'JspIncludeComponent';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The registry key, **computed** — not `typeCode`
|
|
14
|
+
* (`core-libs/core/src/occ/adapters/cms/converters/occ-cms-page-normalizer.ts:151`). Miss this and
|
|
15
|
+
* every account page renders blank, because those pages are all `CMSFlexComponent`.
|
|
16
|
+
*/
|
|
17
|
+
export function getComponentKey(component: {
|
|
18
|
+
typeCode?: string;
|
|
19
|
+
flexType?: string;
|
|
20
|
+
uid?: string;
|
|
21
|
+
}): string {
|
|
22
|
+
if (component.typeCode === CMS_FLEX_COMPONENT_TYPE) return component.flexType ?? '';
|
|
23
|
+
if (component.typeCode === JSP_INCLUDE_COMPONENT_TYPE) return component.uid ?? '';
|
|
24
|
+
return component.typeCode ?? '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type OccComponent = {
|
|
28
|
+
uid?: string;
|
|
29
|
+
typeCode?: string;
|
|
30
|
+
flexType?: string;
|
|
31
|
+
properties?: Record<string, unknown>;
|
|
32
|
+
modifiedtime?: string;
|
|
33
|
+
modifiedTime?: string;
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type OccSlot = {
|
|
38
|
+
position?: string;
|
|
39
|
+
properties?: Record<string, unknown>;
|
|
40
|
+
components?: { component?: OccComponent | OccComponent[] };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type OccCmsPage = {
|
|
44
|
+
uid?: string;
|
|
45
|
+
name?: string;
|
|
46
|
+
typeCode?: string;
|
|
47
|
+
label?: string;
|
|
48
|
+
template?: string;
|
|
49
|
+
title?: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
properties?: Record<string, unknown>;
|
|
52
|
+
contentSlots?: { contentSlot?: OccSlot | OccSlot[] };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function asArray<T>(value: T | T[] | undefined): T[] {
|
|
56
|
+
if (value === undefined) return [];
|
|
57
|
+
return Array.isArray(value) ? value : [value];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Flattens the OCC page payload into `{ page, components }`.
|
|
62
|
+
*
|
|
63
|
+
* Slot *order* here is backend order, which the layout config overrides
|
|
64
|
+
* ([chapter 06](../../../docs/spec/06-cms-engine.md)). Component `properties` stay on the slot
|
|
65
|
+
* entry and are stripped from the component payload, as in Spartacus.
|
|
66
|
+
*/
|
|
67
|
+
export function normalizeCmsPage(source: OccCmsPage | undefined): CmsStructure {
|
|
68
|
+
const slots: Record<string, ContentSlot> = {};
|
|
69
|
+
const components: Record<string, CmsComponent> = {};
|
|
70
|
+
|
|
71
|
+
for (const slot of asArray(source?.contentSlots?.contentSlot)) {
|
|
72
|
+
if (!slot.position) continue;
|
|
73
|
+
const entries: ContentSlotComponent[] = [];
|
|
74
|
+
|
|
75
|
+
for (const occComponent of asArray(slot.components?.component)) {
|
|
76
|
+
if (!occComponent.uid) continue;
|
|
77
|
+
entries.push({
|
|
78
|
+
uid: occComponent.uid,
|
|
79
|
+
typeCode: occComponent.typeCode,
|
|
80
|
+
key: getComponentKey(occComponent),
|
|
81
|
+
properties: occComponent.properties,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const { properties: _properties, modifiedtime, ...rest } = occComponent;
|
|
85
|
+
components[occComponent.uid] = {
|
|
86
|
+
...(rest as CmsComponent),
|
|
87
|
+
// OCC is out of sync with its own model here, and answers `modifiedtime`.
|
|
88
|
+
modifiedTime: (occComponent.modifiedTime ?? modifiedtime) as string | undefined,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
slots[slot.position] = {
|
|
93
|
+
position: slot.position,
|
|
94
|
+
properties: slot.properties,
|
|
95
|
+
components: entries,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
page: {
|
|
101
|
+
pageId: source?.uid,
|
|
102
|
+
name: source?.name,
|
|
103
|
+
type: source?.typeCode,
|
|
104
|
+
label: source?.label,
|
|
105
|
+
template: source?.template,
|
|
106
|
+
title: source?.title,
|
|
107
|
+
description: source?.description,
|
|
108
|
+
properties: source?.properties,
|
|
109
|
+
slots,
|
|
110
|
+
},
|
|
111
|
+
components,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The four mutually exclusive link fields OCC uses, in Spartacus' own precedence
|
|
117
|
+
* (`cms-components/content/banner/banner.component.ts:64`). `external` arrives as the string
|
|
118
|
+
* `'true'` about as often as a boolean.
|
|
119
|
+
*/
|
|
120
|
+
export function resolveLinkTarget(data: {
|
|
121
|
+
url?: string;
|
|
122
|
+
urlLink?: string;
|
|
123
|
+
external?: string | boolean;
|
|
124
|
+
contentPage?: string;
|
|
125
|
+
contentPageLabelOrId?: string;
|
|
126
|
+
product?: string;
|
|
127
|
+
category?: string;
|
|
128
|
+
}): CmsLinkTarget | undefined {
|
|
129
|
+
const url = data.urlLink ?? data.url;
|
|
130
|
+
if (url) {
|
|
131
|
+
return { kind: 'url', url, external: data.external === true || data.external === 'true' };
|
|
132
|
+
}
|
|
133
|
+
const label = data.contentPage ?? data.contentPageLabelOrId;
|
|
134
|
+
if (label) return { kind: 'contentPage', label };
|
|
135
|
+
if (data.product) return { kind: 'product', code: data.product };
|
|
136
|
+
if (data.category) return { kind: 'category', code: data.category };
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The default HTML story: strip tags, decode the five named entities OCC actually emits, collapse
|
|
142
|
+
* whitespace. Laconius ships no parser — rich text is `cms.htmlRenderer`
|
|
143
|
+
* ([chapter 06](../../../docs/spec/06-cms-engine.md)).
|
|
144
|
+
*/
|
|
145
|
+
export function stripHtml(html: string | undefined): string {
|
|
146
|
+
if (!html) return '';
|
|
147
|
+
return html
|
|
148
|
+
.replace(/<br\s*\/?>|<\/(p|div|li|h[1-6])>/gi, '\n')
|
|
149
|
+
.replace(/<[^>]*>/g, '')
|
|
150
|
+
.replace(/ /g, ' ')
|
|
151
|
+
.replace(/&/g, '&')
|
|
152
|
+
.replace(/</g, '<')
|
|
153
|
+
.replace(/>/g, '>')
|
|
154
|
+
.replace(/"/g, '"')
|
|
155
|
+
.replace(/'/g, "'")
|
|
156
|
+
.replace(/[ \t]+/g, ' ')
|
|
157
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
158
|
+
.trim();
|
|
159
|
+
}
|
package/src/page.tsx
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { useLaconiusConfig } from '@laconius/core';
|
|
2
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
3
|
+
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
CmsComponent,
|
|
7
|
+
CmsStructure,
|
|
8
|
+
ContentSlotComponent,
|
|
9
|
+
PageContext,
|
|
10
|
+
} from './models';
|
|
11
|
+
import { getComponentKey } from './normalizer';
|
|
12
|
+
import { useCmsComponent, useCmsPage } from './queries';
|
|
13
|
+
|
|
14
|
+
const warned = new Set<string>();
|
|
15
|
+
|
|
16
|
+
function warnOnce(message: string): void {
|
|
17
|
+
if (process.env.NODE_ENV === 'production' || warned.has(message)) return;
|
|
18
|
+
warned.add(message);
|
|
19
|
+
console.warn(`[laconius] ${message}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type PageValue = { structure: CmsStructure; context: PageContext };
|
|
23
|
+
|
|
24
|
+
const PageStructureContext = createContext<PageValue | undefined>(undefined);
|
|
25
|
+
const ComponentContext = createContext<
|
|
26
|
+
{ uid: string; typeCode?: string; data: CmsComponent } | undefined
|
|
27
|
+
>(undefined);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The whole component contract: typed data through a hook, never props
|
|
31
|
+
* ([ADR-0007](../../../docs/adr/0007-two-registries-two-contracts.md)). Props would make a
|
|
32
|
+
* registered replacement depend on what Laconius happens to pass it, and substitutability dies.
|
|
33
|
+
*/
|
|
34
|
+
export function useCmsComponentData<T extends CmsComponent>(): T {
|
|
35
|
+
const entry = useContext(ComponentContext);
|
|
36
|
+
if (!entry) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
'[laconius] useCmsComponentData must be used inside a component rendered by the CMS registry.',
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return entry.data as T;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The uid of the component being rendered, for components that key their own state on it. */
|
|
45
|
+
export function useCmsComponentUid(): string {
|
|
46
|
+
const entry = useContext(ComponentContext);
|
|
47
|
+
if (!entry) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
'[laconius] useCmsComponentUid must be used inside a component rendered by the CMS registry.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return entry.uid;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The page a `<CmsSlot>` reads from, and the context its components are scoped to. */
|
|
56
|
+
export function useCmsPageStructure(): PageValue | undefined {
|
|
57
|
+
return useContext(PageStructureContext);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type CmsPageProviderProps = {
|
|
61
|
+
context: PageContext;
|
|
62
|
+
children: ReactNode;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fetches a page and provides it to the `<CmsSlot>`s inside. Suspends: wrap it in `<Suspense>`
|
|
67
|
+
* and an error boundary.
|
|
68
|
+
*
|
|
69
|
+
* A native screen that only *hosts* CMS slots — a PDP, the cart — mounts this around its own
|
|
70
|
+
* layout and places slots where it wants them.
|
|
71
|
+
*/
|
|
72
|
+
export function CmsPageProvider({ context, children }: CmsPageProviderProps) {
|
|
73
|
+
const { data } = useCmsPage(context);
|
|
74
|
+
const value = useMemo(() => ({ structure: data, context }), [data, context]);
|
|
75
|
+
return <PageStructureContext.Provider value={value}>{children}</PageStructureContext.Provider>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type CmsSlotProps = {
|
|
79
|
+
position: string;
|
|
80
|
+
style?: StyleProp<ViewStyle>;
|
|
81
|
+
/** Rendered when the slot is absent or empty. */
|
|
82
|
+
fallback?: ReactNode;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function CmsSlot({ position, style, fallback = null }: CmsSlotProps) {
|
|
86
|
+
const page = useCmsPageStructure();
|
|
87
|
+
const slot = page?.structure.page.slots[position];
|
|
88
|
+
|
|
89
|
+
if (!page) {
|
|
90
|
+
warnOnce(
|
|
91
|
+
`<CmsSlot position="${position}"> rendered outside a page. Wrap the screen in <CmsPageProvider> or <CmsPage>.`,
|
|
92
|
+
);
|
|
93
|
+
return <>{fallback}</>;
|
|
94
|
+
}
|
|
95
|
+
if (!slot || slot.components.length === 0) return <>{fallback}</>;
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<View style={style}>
|
|
99
|
+
{slot.components.map((entry) => (
|
|
100
|
+
<CmsComponentOutlet key={entry.uid} entry={entry} />
|
|
101
|
+
))}
|
|
102
|
+
</View>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type CmsPageProps = {
|
|
107
|
+
/** A content-page label, e.g. `/about-us`. Passed as a prop: no Laconius package routes. */
|
|
108
|
+
pageLabel?: string;
|
|
109
|
+
/** For a page that is not a content page. Takes precedence over `pageLabel`. */
|
|
110
|
+
context?: PageContext;
|
|
111
|
+
style?: StyleProp<ViewStyle>;
|
|
112
|
+
slotStyle?: StyleProp<ViewStyle>;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A whole page, top to bottom, in the slot order of `cms.layouts[template]`. It renders no scroll
|
|
117
|
+
* container: the screen owns scrolling, safe areas and refresh control.
|
|
118
|
+
*/
|
|
119
|
+
export function CmsPage({ pageLabel, context, style, slotStyle }: CmsPageProps) {
|
|
120
|
+
const resolved = useMemo<PageContext>(
|
|
121
|
+
() => context ?? { id: pageLabel ?? '/', type: 'ContentPage' },
|
|
122
|
+
[context, pageLabel],
|
|
123
|
+
);
|
|
124
|
+
return (
|
|
125
|
+
<CmsPageProvider context={resolved}>
|
|
126
|
+
<CmsPageSlots style={style} slotStyle={slotStyle} />
|
|
127
|
+
</CmsPageProvider>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function CmsPageSlots({
|
|
132
|
+
style,
|
|
133
|
+
slotStyle,
|
|
134
|
+
}: {
|
|
135
|
+
style?: StyleProp<ViewStyle>;
|
|
136
|
+
slotStyle?: StyleProp<ViewStyle>;
|
|
137
|
+
}) {
|
|
138
|
+
const config = useLaconiusConfig();
|
|
139
|
+
const page = useCmsPageStructure();
|
|
140
|
+
const template = page?.structure.page.template;
|
|
141
|
+
const configured = template ? config.cms?.layouts?.[template]?.slots : undefined;
|
|
142
|
+
|
|
143
|
+
if (!page) return null;
|
|
144
|
+
if (!configured && template) {
|
|
145
|
+
// Slot order is client configuration; backend key order is a fallback, not the contract.
|
|
146
|
+
warnOnce(
|
|
147
|
+
`No cms.layouts entry for template "${template}"; falling back to the backend slot order.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const positions = configured ?? Object.keys(page.structure.page.slots);
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<View style={style}>
|
|
154
|
+
{positions.map((position) => (
|
|
155
|
+
<CmsSlot key={position} position={position} style={slotStyle} />
|
|
156
|
+
))}
|
|
157
|
+
</View>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Renders one slot entry through the registry, loading its data if the page did not inline it. */
|
|
162
|
+
export function CmsComponentOutlet({ entry }: { entry: ContentSlotComponent }) {
|
|
163
|
+
const config = useLaconiusConfig();
|
|
164
|
+
const page = useCmsPageStructure();
|
|
165
|
+
const inlined = page?.structure.components[entry.uid];
|
|
166
|
+
const query = useCmsComponent(entry.uid, {
|
|
167
|
+
context: page?.context,
|
|
168
|
+
enabled: !inlined,
|
|
169
|
+
});
|
|
170
|
+
const data = inlined ?? query.data;
|
|
171
|
+
const mapping = config.cms?.components?.[entry.key];
|
|
172
|
+
|
|
173
|
+
if (!mapping) return <UnregisteredComponent typeCode={entry.key} uid={entry.uid} />;
|
|
174
|
+
if (!data) return null;
|
|
175
|
+
|
|
176
|
+
const Component = mapping.component;
|
|
177
|
+
return (
|
|
178
|
+
<ComponentContext.Provider value={{ uid: entry.uid, typeCode: entry.typeCode, data }}>
|
|
179
|
+
<Component />
|
|
180
|
+
</ComponentContext.Provider>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Renders a component addressed only by uid — what a carousel does with the banner uids it holds.
|
|
186
|
+
* The registry key comes from the loaded payload, not from a slot entry.
|
|
187
|
+
*/
|
|
188
|
+
export function CmsComponentByUid({ uid }: { uid: string }) {
|
|
189
|
+
const page = useCmsPageStructure();
|
|
190
|
+
const inlined = page?.structure.components[uid];
|
|
191
|
+
const query = useCmsComponent(uid, { context: page?.context, enabled: !inlined });
|
|
192
|
+
const data = inlined ?? query.data;
|
|
193
|
+
if (!data) return null;
|
|
194
|
+
return <CmsComponentOutlet entry={{ uid, typeCode: data.typeCode, key: getComponentKey(data) }} />;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Dev: a visible placeholder plus one warning per type. Prod: silent skip.
|
|
199
|
+
*
|
|
200
|
+
* Spartacus only warns, which is survivable on the web where the gap is visible in a layout. On
|
|
201
|
+
* native an unregistered type is invisible — the prototype hit it on its first run, against the
|
|
202
|
+
* stock homepage's `ProductCarouselComponent`.
|
|
203
|
+
*/
|
|
204
|
+
function UnregisteredComponent({ typeCode, uid }: { typeCode: string; uid: string }) {
|
|
205
|
+
if (process.env.NODE_ENV === 'production') return null;
|
|
206
|
+
warnOnce(`No CMS component registered for "${typeCode}". Add it to cms.components.`);
|
|
207
|
+
return (
|
|
208
|
+
<View style={placeholderStyles.root}>
|
|
209
|
+
<Text style={placeholderStyles.text}>
|
|
210
|
+
{typeCode || '(no type code)'}
|
|
211
|
+
{'\n'}
|
|
212
|
+
{uid}
|
|
213
|
+
</Text>
|
|
214
|
+
</View>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const placeholderStyles = StyleSheet.create({
|
|
219
|
+
root: {
|
|
220
|
+
padding: 12,
|
|
221
|
+
margin: 4,
|
|
222
|
+
borderWidth: 1,
|
|
223
|
+
borderStyle: 'dashed',
|
|
224
|
+
borderColor: '#B00020',
|
|
225
|
+
borderRadius: 4,
|
|
226
|
+
},
|
|
227
|
+
text: { fontSize: 12, color: '#B00020' },
|
|
228
|
+
});
|
package/src/queries.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { getRuntime, laconiusQueryKey } from '@laconius/core';
|
|
2
|
+
import { queryOptions, useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
|
3
|
+
|
|
4
|
+
import { cmsAdapter, loadCmsComponent } from './adapter';
|
|
5
|
+
import type { CmsComponent, CmsNavigationData, PageContext } from './models';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `queryOptions` factories, so an app can `prefetchQuery` a page without forking Laconius.
|
|
9
|
+
*
|
|
10
|
+
* The page key is **per page**, deliberately unlike Spartacus, which collapses every content page
|
|
11
|
+
* under one `ContentPage` key (`core-libs/core/src/cms/utils/cms-utils.ts:29-31`).
|
|
12
|
+
*/
|
|
13
|
+
export const cmsQueries = {
|
|
14
|
+
page: (context: PageContext) =>
|
|
15
|
+
queryOptions({
|
|
16
|
+
queryKey: laconiusQueryKey('cms', 'page', context),
|
|
17
|
+
queryFn: () => {
|
|
18
|
+
const runtime = getRuntime();
|
|
19
|
+
return cmsAdapter(runtime).loadPage(runtime, context);
|
|
20
|
+
},
|
|
21
|
+
}),
|
|
22
|
+
component: (uid: string, context?: PageContext) =>
|
|
23
|
+
queryOptions({
|
|
24
|
+
queryKey: laconiusQueryKey('cms', 'component', uid),
|
|
25
|
+
queryFn: () => loadCmsComponent(getRuntime(), uid, context),
|
|
26
|
+
}),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Suspends. There is no blocking guard: Spartacus' `CmsPageGuard` becomes a non-blocking fetch
|
|
31
|
+
* plus a suspending screen, so the native chrome is on screen while the page loads.
|
|
32
|
+
*/
|
|
33
|
+
export function useCmsPage(context: PageContext) {
|
|
34
|
+
return useSuspenseQuery(cmsQueries.page(context));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One component by uid. Requests made in the same frame go out as one batched call. */
|
|
38
|
+
export function useCmsComponent<T extends CmsComponent>(
|
|
39
|
+
uid: string,
|
|
40
|
+
options: { context?: PageContext; enabled?: boolean } = {},
|
|
41
|
+
) {
|
|
42
|
+
return useQuery({
|
|
43
|
+
...cmsQueries.component(uid, options.context),
|
|
44
|
+
enabled: options.enabled ?? true,
|
|
45
|
+
select: (component) => component as T,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The `navigationNode` tree of a `NavigationComponent` / `FooterNavigationComponent` /
|
|
51
|
+
* `CategoryNavigationComponent`, for apps rendering their own chrome from CMS navigation.
|
|
52
|
+
*/
|
|
53
|
+
export function useCmsNavigation(uid: string) {
|
|
54
|
+
const query = useCmsComponent<CmsNavigationData>(uid);
|
|
55
|
+
return { ...query, node: query.data?.navigationNode };
|
|
56
|
+
}
|