cms-renderer 0.1.1 → 0.1.3
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/chunk-6QR5B5KQ.js +287 -0
- package/dist/chunk-6QR5B5KQ.js.map +1 -0
- package/dist/lib/block-renderer.d.ts +53 -13
- package/dist/lib/block-renderer.js +5 -3
- package/dist/lib/block-toolbar.d.ts +13 -0
- package/dist/lib/block-toolbar.js +100 -0
- package/dist/lib/block-toolbar.js.map +1 -0
- package/dist/lib/image/lazy-load.js +1 -0
- package/dist/lib/image/lazy-load.js.map +1 -1
- package/dist/lib/proxy.d.ts +52 -0
- package/dist/lib/proxy.js +143 -0
- package/dist/lib/proxy.js.map +1 -0
- package/dist/lib/renderer.d.ts +5 -2
- package/dist/lib/renderer.js +43 -4
- package/dist/lib/renderer.js.map +1 -1
- package/package.json +9 -1
- package/dist/chunk-RPM73PQZ.js +0 -17
- package/dist/chunk-RPM73PQZ.js.map +0 -1
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// lib/block-renderer.tsx
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { BlockToolbar } from "./block-toolbar.js";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
function walkReactNode(node, visitors, ctx = {}) {
|
|
6
|
+
const path = ctx.path ?? [];
|
|
7
|
+
if (node == null || typeof node === "boolean") return node;
|
|
8
|
+
if (typeof node === "string" || typeof node === "number") {
|
|
9
|
+
const value = String(node);
|
|
10
|
+
return visitors.onText ? visitors.onText({ value, path, parentType: ctx.parentType, key: ctx.key }) : node;
|
|
11
|
+
}
|
|
12
|
+
if (Array.isArray(node)) {
|
|
13
|
+
return node.map((child, i) => {
|
|
14
|
+
const childKey = child?.key ?? null;
|
|
15
|
+
const result = walkReactNode(child, visitors, {
|
|
16
|
+
path: [...path, i],
|
|
17
|
+
parentType: ctx.parentType,
|
|
18
|
+
key: childKey
|
|
19
|
+
});
|
|
20
|
+
if (React.isValidElement(result) && result.key == null) {
|
|
21
|
+
return React.cloneElement(result, { key: childKey ?? `arr-${path.join("-")}-${i}` });
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (React.isValidElement(node)) {
|
|
27
|
+
const el = node;
|
|
28
|
+
const elProps = el.props;
|
|
29
|
+
const hasChildren = elProps && "children" in elProps;
|
|
30
|
+
const nextChildren = hasChildren ? React.Children.map(elProps.children, (child, i) => {
|
|
31
|
+
const childKey = child?.key ?? null;
|
|
32
|
+
const result = walkReactNode(child, visitors, {
|
|
33
|
+
path: [...path, "children", i],
|
|
34
|
+
parentType: el.type,
|
|
35
|
+
key: childKey
|
|
36
|
+
});
|
|
37
|
+
if (React.isValidElement(result) && result.key == null) {
|
|
38
|
+
return React.cloneElement(result, { key: childKey ?? `child-${path.join("-")}-${i}` });
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}) : elProps?.children;
|
|
42
|
+
const cloned = hasChildren ? React.cloneElement(el, void 0, nextChildren) : el;
|
|
43
|
+
return visitors.onElement ? visitors.onElement({ element: cloned, path }) : cloned;
|
|
44
|
+
}
|
|
45
|
+
return node;
|
|
46
|
+
}
|
|
47
|
+
function extractContentValues(content, basePath = []) {
|
|
48
|
+
const map = /* @__PURE__ */ new Map();
|
|
49
|
+
function walk(obj, path) {
|
|
50
|
+
if (typeof obj === "string" && obj.trim() !== "") {
|
|
51
|
+
const contentPath = path.join(".");
|
|
52
|
+
const existing = map.get(obj) || [];
|
|
53
|
+
existing.push({ contentPath, value: obj });
|
|
54
|
+
map.set(obj, existing);
|
|
55
|
+
} else if (Array.isArray(obj)) {
|
|
56
|
+
for (let index = 0; index < obj.length; index++) {
|
|
57
|
+
walk(obj[index], [...path, String(index)]);
|
|
58
|
+
}
|
|
59
|
+
} else if (obj && typeof obj === "object") {
|
|
60
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
61
|
+
walk(value, [...path, key]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
walk(content, basePath);
|
|
66
|
+
return map;
|
|
67
|
+
}
|
|
68
|
+
function renderToWalkableTree(node, keyPrefix = "") {
|
|
69
|
+
if (node == null || typeof node === "boolean") return node;
|
|
70
|
+
if (typeof node === "string" || typeof node === "number") return node;
|
|
71
|
+
if (Array.isArray(node)) {
|
|
72
|
+
return node.map((child, i) => {
|
|
73
|
+
const result = renderToWalkableTree(child, `${keyPrefix}${i}-`);
|
|
74
|
+
if (React.isValidElement(result) && result.key == null) {
|
|
75
|
+
const existingKey = child?.key;
|
|
76
|
+
return React.cloneElement(result, { key: existingKey ?? `${keyPrefix}${i}` });
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (React.isValidElement(node)) {
|
|
82
|
+
const el = node;
|
|
83
|
+
const elProps = el.props;
|
|
84
|
+
if (typeof el.type === "function") {
|
|
85
|
+
try {
|
|
86
|
+
const rendered = el.type(el.props);
|
|
87
|
+
return renderToWalkableTree(rendered, keyPrefix);
|
|
88
|
+
} catch {
|
|
89
|
+
return node;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (elProps && "children" in elProps) {
|
|
93
|
+
const newChildren = renderToWalkableTree(elProps.children, keyPrefix);
|
|
94
|
+
return React.cloneElement(el, void 0, newChildren);
|
|
95
|
+
}
|
|
96
|
+
return node;
|
|
97
|
+
}
|
|
98
|
+
return node;
|
|
99
|
+
}
|
|
100
|
+
function BlockRenderer({ block, registry, disableEditable }) {
|
|
101
|
+
const Component = registry[block.type];
|
|
102
|
+
if (!Component) {
|
|
103
|
+
if (process.env.NODE_ENV === "development") {
|
|
104
|
+
console.warn(`[BlockRenderer] Unknown block type: ${block.type}`);
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const component = /* @__PURE__ */ jsx(Component, { content: block.content });
|
|
109
|
+
if (disableEditable) {
|
|
110
|
+
return component;
|
|
111
|
+
}
|
|
112
|
+
const renderedTree = renderToWalkableTree(component);
|
|
113
|
+
const contentValueMap = extractContentValues(block.content);
|
|
114
|
+
const usedPaths = /* @__PURE__ */ new Set();
|
|
115
|
+
let isRoot = true;
|
|
116
|
+
const wrappedComponent = walkReactNode(renderedTree, {
|
|
117
|
+
onText: ({ value, key, path }) => {
|
|
118
|
+
const matches = contentValueMap.get(value);
|
|
119
|
+
if (!matches || matches.length === 0) {
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
const match = matches.find((m) => !usedPaths.has(m.contentPath)) ?? matches[0];
|
|
123
|
+
if (!match) return value;
|
|
124
|
+
usedPaths.add(match.contentPath);
|
|
125
|
+
const spanKey = key ?? `${block.id}-${match.contentPath}-${path.join("-")}`;
|
|
126
|
+
return /* @__PURE__ */ jsx(
|
|
127
|
+
"span",
|
|
128
|
+
{
|
|
129
|
+
"data-cms-editable": true,
|
|
130
|
+
"data-block-id": block.id,
|
|
131
|
+
"data-block-type": block.type,
|
|
132
|
+
"data-content-path": match.contentPath,
|
|
133
|
+
children: value
|
|
134
|
+
},
|
|
135
|
+
spanKey
|
|
136
|
+
);
|
|
137
|
+
},
|
|
138
|
+
onElement: ({ element, path }) => {
|
|
139
|
+
if (isRoot && path.length === 0) {
|
|
140
|
+
isRoot = false;
|
|
141
|
+
const elProps = element.props;
|
|
142
|
+
const existingChildren = elProps?.children;
|
|
143
|
+
return React.cloneElement(
|
|
144
|
+
element,
|
|
145
|
+
{
|
|
146
|
+
"data-cms-block": true,
|
|
147
|
+
"data-block-id": block.id,
|
|
148
|
+
"data-block-type": block.type
|
|
149
|
+
},
|
|
150
|
+
existingChildren,
|
|
151
|
+
/* @__PURE__ */ jsx(BlockToolbar, { blockId: block.id }, "cms-toolbar")
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return element;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
return /* @__PURE__ */ jsxs("div", { style: { display: "contents" }, children: [
|
|
158
|
+
/* @__PURE__ */ jsx("style", { children: `
|
|
159
|
+
[data-cms-block] {
|
|
160
|
+
position: relative;
|
|
161
|
+
}
|
|
162
|
+
[data-cms-block]:hover {
|
|
163
|
+
outline: 2px solid #3b82f6;
|
|
164
|
+
outline-offset: 4px;
|
|
165
|
+
}
|
|
166
|
+
[data-cms-editable] {
|
|
167
|
+
cursor: pointer;
|
|
168
|
+
border-radius: 2px;
|
|
169
|
+
}
|
|
170
|
+
[data-cms-editable]:hover {
|
|
171
|
+
outline: 2px solid #3b82f6;
|
|
172
|
+
outline-offset: 2px;
|
|
173
|
+
}
|
|
174
|
+
.cms-block-toolbar {
|
|
175
|
+
position: absolute;
|
|
176
|
+
bottom: -16px;
|
|
177
|
+
left: 50%;
|
|
178
|
+
transform: translateX(-50%);
|
|
179
|
+
display: flex;
|
|
180
|
+
gap: 4px;
|
|
181
|
+
background: #1f2937;
|
|
182
|
+
border-radius: 6px;
|
|
183
|
+
padding: 4px;
|
|
184
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
185
|
+
opacity: 0;
|
|
186
|
+
pointer-events: none;
|
|
187
|
+
transition: opacity 0.15s ease;
|
|
188
|
+
z-index: 1000;
|
|
189
|
+
}
|
|
190
|
+
[data-cms-block]:hover .cms-block-toolbar {
|
|
191
|
+
opacity: 1;
|
|
192
|
+
pointer-events: auto;
|
|
193
|
+
}
|
|
194
|
+
.cms-block-toolbar button {
|
|
195
|
+
display: flex;
|
|
196
|
+
align-items: center;
|
|
197
|
+
justify-content: center;
|
|
198
|
+
width: 28px;
|
|
199
|
+
height: 28px;
|
|
200
|
+
border: none;
|
|
201
|
+
background: transparent;
|
|
202
|
+
color: #9ca3af;
|
|
203
|
+
border-radius: 4px;
|
|
204
|
+
cursor: pointer;
|
|
205
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
206
|
+
}
|
|
207
|
+
.cms-block-toolbar button:hover {
|
|
208
|
+
background: #374151;
|
|
209
|
+
color: #fff;
|
|
210
|
+
}
|
|
211
|
+
.cms-block-toolbar button.delete:hover {
|
|
212
|
+
background: #dc2626;
|
|
213
|
+
color: #fff;
|
|
214
|
+
}
|
|
215
|
+
.cms-block-toolbar button:disabled {
|
|
216
|
+
opacity: 0.4;
|
|
217
|
+
cursor: not-allowed;
|
|
218
|
+
}
|
|
219
|
+
.cms-block-toolbar button:disabled:hover {
|
|
220
|
+
background: transparent;
|
|
221
|
+
color: #9ca3af;
|
|
222
|
+
}
|
|
223
|
+
.cms-block-toolbar svg {
|
|
224
|
+
width: 16px;
|
|
225
|
+
height: 16px;
|
|
226
|
+
}
|
|
227
|
+
` }),
|
|
228
|
+
/* @__PURE__ */ jsx(
|
|
229
|
+
"script",
|
|
230
|
+
{
|
|
231
|
+
dangerouslySetInnerHTML: {
|
|
232
|
+
__html: `
|
|
233
|
+
(function() {
|
|
234
|
+
if (window.__cmsEditableInitialized) return;
|
|
235
|
+
window.__cmsEditableInitialized = true;
|
|
236
|
+
|
|
237
|
+
document.addEventListener('click', function(e) {
|
|
238
|
+
// Ignore toolbar clicks
|
|
239
|
+
if (e.target.closest('.cms-block-toolbar')) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Check for editable text click first (more specific)
|
|
244
|
+
var editableTarget = e.target.closest('[data-cms-editable]');
|
|
245
|
+
if (editableTarget) {
|
|
246
|
+
var message = {
|
|
247
|
+
type: 'cms-editable-click',
|
|
248
|
+
blockId: editableTarget.getAttribute('data-block-id'),
|
|
249
|
+
blockType: editableTarget.getAttribute('data-block-type'),
|
|
250
|
+
contentPath: editableTarget.getAttribute('data-content-path')
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
if (window.parent && window.parent !== window) {
|
|
254
|
+
window.parent.postMessage(message, '*');
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Check for block-level click
|
|
260
|
+
var blockTarget = e.target.closest('[data-cms-block]');
|
|
261
|
+
if (blockTarget) {
|
|
262
|
+
var message = {
|
|
263
|
+
type: 'cms-editable-click',
|
|
264
|
+
blockId: blockTarget.getAttribute('data-block-id'),
|
|
265
|
+
blockType: blockTarget.getAttribute('data-block-type'),
|
|
266
|
+
contentPath: null
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
if (window.parent && window.parent !== window) {
|
|
270
|
+
window.parent.postMessage(message, '*');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
})();
|
|
275
|
+
`
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
),
|
|
279
|
+
wrappedComponent
|
|
280
|
+
] }, block.id);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export {
|
|
284
|
+
walkReactNode,
|
|
285
|
+
BlockRenderer
|
|
286
|
+
};
|
|
287
|
+
//# sourceMappingURL=chunk-6QR5B5KQ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../lib/block-renderer.tsx"],"sourcesContent":["/**\n * Block Renderer Component\n *\n * Dispatches block data to the appropriate component using the ComponentMap pattern.\n * This is the main entry point for rendering blocks from the CMS.\n */\n\nimport React from 'react';\nimport { BlockToolbar } from './block-toolbar';\nimport type { BlockComponentRegistry, BlockData } from './types';\n\ntype TextInfo = {\n value: string;\n path: Array<string | number>;\n parentType?: React.ElementType;\n key?: React.Key | null;\n};\n\ntype ElementInfo = {\n element: React.ReactElement;\n path: Array<string | number>;\n};\n\ntype WalkVisitors = {\n /**\n * Called for every string/number child encountered.\n * Return:\n * - same string (or modified)\n * - a ReactNode (e.g. wrap in <span/>)\n */\n onText?: (info: TextInfo) => React.ReactNode;\n\n /**\n * Called for every ReactElement encountered (after children are processed).\n * Return:\n * - same element\n * - a cloned/modified element\n */\n onElement?: (info: ElementInfo) => React.ReactElement;\n};\n\n/**\n * Recursively maps a ReactNode tree, allowing transformations of text nodes and/or elements.\n * SSR-safe: does not touch DOM APIs.\n */\nexport function walkReactNode(\n node: React.ReactNode,\n visitors: WalkVisitors,\n ctx: {\n path?: Array<string | number>;\n parentType?: React.ElementType;\n key?: React.Key | null;\n } = {}\n): React.ReactNode {\n const path = ctx.path ?? [];\n\n // Fast-path primitives\n if (node == null || typeof node === 'boolean') return node;\n\n if (typeof node === 'string' || typeof node === 'number') {\n const value = String(node);\n return visitors.onText\n ? visitors.onText({ value, path, parentType: ctx.parentType, key: ctx.key })\n : node;\n }\n\n // Arrays\n if (Array.isArray(node)) {\n return node.map((child, i) => {\n // biome-ignore lint/suspicious/noExplicitAny: React child key access\n const childKey = (child as any)?.key ?? null;\n const result = walkReactNode(child, visitors, {\n path: [...path, i],\n parentType: ctx.parentType,\n key: childKey,\n });\n // Ensure array children have keys\n if (React.isValidElement(result) && result.key == null) {\n return React.cloneElement(result, { key: childKey ?? `arr-${path.join('-')}-${i}` });\n }\n return result;\n });\n }\n\n // ReactElement (including Fragment)\n if (React.isValidElement(node)) {\n // biome-ignore lint/suspicious/noExplicitAny: React element props access\n const el = node as React.ReactElement<any>;\n const elProps = el.props as Record<string, unknown> | null;\n\n // Recurse into children (if any)\n const hasChildren = elProps && 'children' in elProps;\n const nextChildren = hasChildren\n ? React.Children.map(elProps.children as React.ReactNode, (child, i) => {\n // biome-ignore lint/suspicious/noExplicitAny: React child key access\n const childKey = (child as any)?.key ?? null;\n const result = walkReactNode(child, visitors, {\n path: [...path, 'children', i],\n parentType: el.type as React.ElementType,\n key: childKey,\n });\n // Ensure children have keys\n if (React.isValidElement(result) && result.key == null) {\n return React.cloneElement(result, { key: childKey ?? `child-${path.join('-')}-${i}` });\n }\n return result;\n })\n : (elProps?.children as React.ReactNode);\n\n // Only clone if children changed (or if you want to force a clone)\n const cloned = hasChildren\n ? React.cloneElement(el, undefined, nextChildren as React.ReactNode)\n : el;\n\n return visitors.onElement ? visitors.onElement({ element: cloned, path }) : cloned;\n }\n\n // Functions, symbols, portals, etc. are rare here; return as-is\n return node;\n}\n\n// -----------------------------------------------------------------------------\n// Content Value Extraction\n// -----------------------------------------------------------------------------\n\ntype ContentMatch = {\n contentPath: string;\n value: string;\n};\n\n/**\n * Extracts all string values from a content object with their paths.\n * Returns a Map where keys are string values and values are arrays of content paths.\n */\nfunction extractContentValues(\n content: Record<string, unknown>,\n basePath: string[] = []\n): Map<string, ContentMatch[]> {\n const map = new Map<string, ContentMatch[]>();\n\n function walk(obj: unknown, path: string[]) {\n if (typeof obj === 'string' && obj.trim() !== '') {\n const contentPath = path.join('.');\n const existing = map.get(obj) || [];\n existing.push({ contentPath, value: obj });\n map.set(obj, existing);\n } else if (Array.isArray(obj)) {\n for (let index = 0; index < obj.length; index++) {\n walk(obj[index], [...path, String(index)]);\n }\n } else if (obj && typeof obj === 'object') {\n for (const [key, value] of Object.entries(obj)) {\n walk(value, [...path, key]);\n }\n }\n }\n\n walk(content, basePath);\n return map;\n}\n\n// -----------------------------------------------------------------------------\n// Props\n// -----------------------------------------------------------------------------\n\ninterface BlockRendererProps {\n /**\n * The block data to render.\n * Must have a `type` field that maps to a registered component.\n */\n block: BlockData;\n registry: Partial<BlockComponentRegistry>;\n /**\n * If true, renders the component without any tree walking or editable wrappers.\n */\n disableEditable?: boolean;\n}\n\n// -----------------------------------------------------------------------------\n// Component\n// -----------------------------------------------------------------------------\n\n/**\n * Recursively renders a React node, invoking function components to get their output.\n * This allows us to walk the full rendered tree, not just the element wrappers.\n */\nfunction renderToWalkableTree(node: React.ReactNode, keyPrefix = ''): React.ReactNode {\n if (node == null || typeof node === 'boolean') return node;\n if (typeof node === 'string' || typeof node === 'number') return node;\n\n if (Array.isArray(node)) {\n return node.map((child, i) => {\n const result = renderToWalkableTree(child, `${keyPrefix}${i}-`);\n // Ensure array children have keys\n if (React.isValidElement(result) && result.key == null) {\n // biome-ignore lint/suspicious/noExplicitAny: Adding key to element\n const existingKey = (child as any)?.key;\n return React.cloneElement(result, { key: existingKey ?? `${keyPrefix}${i}` });\n }\n return result;\n });\n }\n\n if (React.isValidElement(node)) {\n // biome-ignore lint/suspicious/noExplicitAny: React element props access\n const el = node as React.ReactElement<any>;\n const elProps = el.props as Record<string, unknown> | null;\n\n // If it's a function component, invoke it to get the rendered output\n if (typeof el.type === 'function') {\n try {\n // biome-ignore lint/complexity/noBannedTypes: Need to invoke React function component\n const rendered = (el.type as Function)(el.props);\n return renderToWalkableTree(rendered, keyPrefix);\n } catch {\n // If component throws (e.g., uses hooks), return as-is\n return node;\n }\n }\n\n // For host elements (div, span, etc.), recurse into children\n if (elProps && 'children' in elProps) {\n const newChildren = renderToWalkableTree(elProps.children as React.ReactNode, keyPrefix);\n return React.cloneElement(el, undefined, newChildren);\n }\n\n return node;\n }\n\n return node;\n}\n\n/**\n * Renders a single block by dispatching to the appropriate component.\n *\n * Uses the ComponentMap pattern: the block's `type` field determines which\n * component renders the block's `content`.\n *\n * Internally, it:\n * 1. Renders the component tree by invoking function components\n * 2. Extracts all string values from block.content\n * 3. Walks the rendered tree and wraps matching text nodes with spans\n */\nexport function BlockRenderer({ block, registry, disableEditable }: BlockRendererProps) {\n const Component = registry[block.type];\n\n if (!Component) {\n // Log warning in development, render nothing in production\n if (process.env.NODE_ENV === 'development') {\n console.warn(`[BlockRenderer] Unknown block type: ${block.type}`);\n }\n return null;\n }\n\n // biome-ignore lint/suspicious/noExplicitAny: Type safety ensured by BlockData discriminated union\n const component = <Component content={block.content as any} />;\n\n if (disableEditable) {\n return component;\n }\n\n // Render the component tree to get the actual DOM structure with all children\n const renderedTree = renderToWalkableTree(component);\n\n // Extract all string values from content with their paths\n const contentValueMap = extractContentValues(block.content as Record<string, unknown>);\n\n // Track which content paths have been used to handle duplicates\n const usedPaths = new Set<string>();\n\n // Track if we've processed the root element\n let isRoot = true;\n\n // Walk the tree: wrap matching text nodes and add attributes to root element\n const wrappedComponent = walkReactNode(renderedTree, {\n onText: ({ value, key, path }) => {\n const matches = contentValueMap.get(value);\n\n if (!matches || matches.length === 0) {\n return value;\n }\n\n // Find the first unused match, or use the first one if all are used\n const match = matches.find((m) => !usedPaths.has(m.contentPath)) ?? matches[0];\n if (!match) return value;\n\n usedPaths.add(match.contentPath);\n\n // Generate a unique key using the original key, content path, or path\n const spanKey = key ?? `${block.id}-${match.contentPath}-${path.join('-')}`;\n\n return (\n <span\n key={spanKey}\n data-cms-editable\n data-block-id={block.id}\n data-block-type={block.type}\n data-content-path={match.contentPath}\n >\n {value}\n </span>\n );\n },\n onElement: ({ element, path }) => {\n // Add wrapper attributes and toolbar to the root element\n if (isRoot && path.length === 0) {\n isRoot = false;\n // Get existing children\n // biome-ignore lint/suspicious/noExplicitAny: React element props access\n const elProps = (element as React.ReactElement<any>).props as Record<\n string,\n unknown\n > | null;\n const existingChildren = elProps?.children;\n // Clone with new attributes and inject toolbar as last child\n return React.cloneElement(\n element,\n {\n 'data-cms-block': true,\n 'data-block-id': block.id,\n 'data-block-type': block.type,\n } as React.Attributes & Record<string, unknown>,\n existingChildren as React.ReactNode,\n <BlockToolbar key=\"cms-toolbar\" blockId={block.id} />\n );\n }\n return element;\n },\n });\n\n return (\n <div key={block.id} style={{ display: 'contents' }}>\n <style>{`\n [data-cms-block] {\n position: relative;\n }\n [data-cms-block]:hover {\n outline: 2px solid #3b82f6;\n outline-offset: 4px;\n }\n [data-cms-editable] {\n cursor: pointer;\n border-radius: 2px;\n }\n [data-cms-editable]:hover {\n outline: 2px solid #3b82f6;\n outline-offset: 2px;\n }\n .cms-block-toolbar {\n position: absolute;\n bottom: -16px;\n left: 50%;\n transform: translateX(-50%);\n display: flex;\n gap: 4px;\n background: #1f2937;\n border-radius: 6px;\n padding: 4px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);\n opacity: 0;\n pointer-events: none;\n transition: opacity 0.15s ease;\n z-index: 1000;\n }\n [data-cms-block]:hover .cms-block-toolbar {\n opacity: 1;\n pointer-events: auto;\n }\n .cms-block-toolbar button {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n border: none;\n background: transparent;\n color: #9ca3af;\n border-radius: 4px;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .cms-block-toolbar button:hover {\n background: #374151;\n color: #fff;\n }\n .cms-block-toolbar button.delete:hover {\n background: #dc2626;\n color: #fff;\n }\n .cms-block-toolbar button:disabled {\n opacity: 0.4;\n cursor: not-allowed;\n }\n .cms-block-toolbar button:disabled:hover {\n background: transparent;\n color: #9ca3af;\n }\n .cms-block-toolbar svg {\n width: 16px;\n height: 16px;\n }\n `}</style>\n <script\n // biome-ignore lint/security/noDangerouslySetInnerHtml: Inline script for iframe postMessage\n dangerouslySetInnerHTML={{\n __html: `\n (function() {\n if (window.__cmsEditableInitialized) return;\n window.__cmsEditableInitialized = true;\n\n document.addEventListener('click', function(e) {\n // Ignore toolbar clicks\n if (e.target.closest('.cms-block-toolbar')) {\n return;\n }\n\n // Check for editable text click first (more specific)\n var editableTarget = e.target.closest('[data-cms-editable]');\n if (editableTarget) {\n var message = {\n type: 'cms-editable-click',\n blockId: editableTarget.getAttribute('data-block-id'),\n blockType: editableTarget.getAttribute('data-block-type'),\n contentPath: editableTarget.getAttribute('data-content-path')\n };\n\n if (window.parent && window.parent !== window) {\n window.parent.postMessage(message, '*');\n }\n return;\n }\n\n // Check for block-level click\n var blockTarget = e.target.closest('[data-cms-block]');\n if (blockTarget) {\n var message = {\n type: 'cms-editable-click',\n blockId: blockTarget.getAttribute('data-block-id'),\n blockType: blockTarget.getAttribute('data-block-type'),\n contentPath: null\n };\n\n if (window.parent && window.parent !== window) {\n window.parent.postMessage(message, '*');\n }\n }\n });\n })();\n `,\n }}\n />\n {wrappedComponent}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,7 +1,49 @@
|
|
|
1
|
-
import
|
|
1
|
+
import React__default from 'react';
|
|
2
2
|
import { BlockData, BlockComponentRegistry } from './types.js';
|
|
3
3
|
import '@repo/cms-schema/blocks';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Block Renderer Component
|
|
7
|
+
*
|
|
8
|
+
* Dispatches block data to the appropriate component using the ComponentMap pattern.
|
|
9
|
+
* This is the main entry point for rendering blocks from the CMS.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type TextInfo = {
|
|
13
|
+
value: string;
|
|
14
|
+
path: Array<string | number>;
|
|
15
|
+
parentType?: React__default.ElementType;
|
|
16
|
+
key?: React__default.Key | null;
|
|
17
|
+
};
|
|
18
|
+
type ElementInfo = {
|
|
19
|
+
element: React__default.ReactElement;
|
|
20
|
+
path: Array<string | number>;
|
|
21
|
+
};
|
|
22
|
+
type WalkVisitors = {
|
|
23
|
+
/**
|
|
24
|
+
* Called for every string/number child encountered.
|
|
25
|
+
* Return:
|
|
26
|
+
* - same string (or modified)
|
|
27
|
+
* - a ReactNode (e.g. wrap in <span/>)
|
|
28
|
+
*/
|
|
29
|
+
onText?: (info: TextInfo) => React__default.ReactNode;
|
|
30
|
+
/**
|
|
31
|
+
* Called for every ReactElement encountered (after children are processed).
|
|
32
|
+
* Return:
|
|
33
|
+
* - same element
|
|
34
|
+
* - a cloned/modified element
|
|
35
|
+
*/
|
|
36
|
+
onElement?: (info: ElementInfo) => React__default.ReactElement;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Recursively maps a ReactNode tree, allowing transformations of text nodes and/or elements.
|
|
40
|
+
* SSR-safe: does not touch DOM APIs.
|
|
41
|
+
*/
|
|
42
|
+
declare function walkReactNode(node: React__default.ReactNode, visitors: WalkVisitors, ctx?: {
|
|
43
|
+
path?: Array<string | number>;
|
|
44
|
+
parentType?: React__default.ElementType;
|
|
45
|
+
key?: React__default.Key | null;
|
|
46
|
+
}): React__default.ReactNode;
|
|
5
47
|
interface BlockRendererProps {
|
|
6
48
|
/**
|
|
7
49
|
* The block data to render.
|
|
@@ -9,6 +51,10 @@ interface BlockRendererProps {
|
|
|
9
51
|
*/
|
|
10
52
|
block: BlockData;
|
|
11
53
|
registry: Partial<BlockComponentRegistry>;
|
|
54
|
+
/**
|
|
55
|
+
* If true, renders the component without any tree walking or editable wrappers.
|
|
56
|
+
*/
|
|
57
|
+
disableEditable?: boolean;
|
|
12
58
|
}
|
|
13
59
|
/**
|
|
14
60
|
* Renders a single block by dispatching to the appropriate component.
|
|
@@ -16,17 +62,11 @@ interface BlockRendererProps {
|
|
|
16
62
|
* Uses the ComponentMap pattern: the block's `type` field determines which
|
|
17
63
|
* component renders the block's `content`.
|
|
18
64
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* // Render an array of blocks
|
|
25
|
-
* {page.blocks.map((block, index) => (
|
|
26
|
-
* <BlockRenderer key={index} block={block} />
|
|
27
|
-
* ))}
|
|
28
|
-
* ```
|
|
65
|
+
* Internally, it:
|
|
66
|
+
* 1. Renders the component tree by invoking function components
|
|
67
|
+
* 2. Extracts all string values from block.content
|
|
68
|
+
* 3. Walks the rendered tree and wraps matching text nodes with spans
|
|
29
69
|
*/
|
|
30
|
-
declare function BlockRenderer({ block, registry }: BlockRendererProps):
|
|
70
|
+
declare function BlockRenderer({ block, registry, disableEditable }: BlockRendererProps): React__default.JSX.Element | null;
|
|
31
71
|
|
|
32
|
-
export { BlockRenderer };
|
|
72
|
+
export { BlockRenderer, walkReactNode };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Block Toolbar Component
|
|
5
|
+
*
|
|
6
|
+
* Provides move up/down and delete controls for blocks in edit mode.
|
|
7
|
+
* This is a Client Component because it requires onClick handlers.
|
|
8
|
+
*/
|
|
9
|
+
declare function BlockToolbar({ blockId }: {
|
|
10
|
+
blockId: string;
|
|
11
|
+
}): react.JSX.Element;
|
|
12
|
+
|
|
13
|
+
export { BlockToolbar };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
// lib/block-toolbar.tsx
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
function BlockToolbar({ blockId }) {
|
|
7
|
+
const handleAction = (action) => {
|
|
8
|
+
if (typeof window !== "undefined" && window.parent && window.parent !== window) {
|
|
9
|
+
window.parent.postMessage({ type: "cms-block-action", action, blockId }, "*");
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
return /* @__PURE__ */ jsxs("div", { className: "cms-block-toolbar", "data-cms-toolbar": "true", children: [
|
|
13
|
+
/* @__PURE__ */ jsx(
|
|
14
|
+
"button",
|
|
15
|
+
{
|
|
16
|
+
type: "button",
|
|
17
|
+
title: "Move up",
|
|
18
|
+
"data-action": "move-up",
|
|
19
|
+
onClick: (e) => {
|
|
20
|
+
e.stopPropagation();
|
|
21
|
+
handleAction("move-up");
|
|
22
|
+
},
|
|
23
|
+
children: /* @__PURE__ */ jsx(
|
|
24
|
+
"svg",
|
|
25
|
+
{
|
|
26
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
27
|
+
viewBox: "0 0 24 24",
|
|
28
|
+
fill: "none",
|
|
29
|
+
stroke: "currentColor",
|
|
30
|
+
strokeWidth: "2",
|
|
31
|
+
strokeLinecap: "round",
|
|
32
|
+
strokeLinejoin: "round",
|
|
33
|
+
children: /* @__PURE__ */ jsx("path", { d: "m18 15-6-6-6 6" })
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
),
|
|
38
|
+
/* @__PURE__ */ jsx(
|
|
39
|
+
"button",
|
|
40
|
+
{
|
|
41
|
+
type: "button",
|
|
42
|
+
title: "Move down",
|
|
43
|
+
"data-action": "move-down",
|
|
44
|
+
onClick: (e) => {
|
|
45
|
+
e.stopPropagation();
|
|
46
|
+
handleAction("move-down");
|
|
47
|
+
},
|
|
48
|
+
children: /* @__PURE__ */ jsx(
|
|
49
|
+
"svg",
|
|
50
|
+
{
|
|
51
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
52
|
+
viewBox: "0 0 24 24",
|
|
53
|
+
fill: "none",
|
|
54
|
+
stroke: "currentColor",
|
|
55
|
+
strokeWidth: "2",
|
|
56
|
+
strokeLinecap: "round",
|
|
57
|
+
strokeLinejoin: "round",
|
|
58
|
+
children: /* @__PURE__ */ jsx("path", { d: "m6 9 6 6 6-6" })
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
),
|
|
63
|
+
/* @__PURE__ */ jsx(
|
|
64
|
+
"button",
|
|
65
|
+
{
|
|
66
|
+
type: "button",
|
|
67
|
+
className: "delete",
|
|
68
|
+
title: "Delete block",
|
|
69
|
+
"data-action": "delete",
|
|
70
|
+
onClick: (e) => {
|
|
71
|
+
e.stopPropagation();
|
|
72
|
+
handleAction("delete");
|
|
73
|
+
},
|
|
74
|
+
children: /* @__PURE__ */ jsxs(
|
|
75
|
+
"svg",
|
|
76
|
+
{
|
|
77
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
78
|
+
viewBox: "0 0 24 24",
|
|
79
|
+
fill: "none",
|
|
80
|
+
stroke: "currentColor",
|
|
81
|
+
strokeWidth: "2",
|
|
82
|
+
strokeLinecap: "round",
|
|
83
|
+
strokeLinejoin: "round",
|
|
84
|
+
children: [
|
|
85
|
+
/* @__PURE__ */ jsx("path", { d: "M3 6h18" }),
|
|
86
|
+
/* @__PURE__ */ jsx("path", { d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" }),
|
|
87
|
+
/* @__PURE__ */ jsx("path", { d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" }),
|
|
88
|
+
/* @__PURE__ */ jsx("line", { x1: "10", x2: "10", y1: "11", y2: "17" }),
|
|
89
|
+
/* @__PURE__ */ jsx("line", { x1: "14", x2: "14", y1: "11", y2: "17" })
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
] });
|
|
96
|
+
}
|
|
97
|
+
export {
|
|
98
|
+
BlockToolbar
|
|
99
|
+
};
|
|
100
|
+
//# sourceMappingURL=block-toolbar.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../lib/block-toolbar.tsx"],"sourcesContent":["'use client';\n\n/**\n * Block Toolbar Component\n *\n * Provides move up/down and delete controls for blocks in edit mode.\n * This is a Client Component because it requires onClick handlers.\n */\n\nexport function BlockToolbar({ blockId }: { blockId: string }) {\n const handleAction = (action: string) => {\n if (typeof window !== 'undefined' && window.parent && window.parent !== window) {\n window.parent.postMessage({ type: 'cms-block-action', action, blockId }, '*');\n }\n };\n\n return (\n <div className=\"cms-block-toolbar\" data-cms-toolbar=\"true\">\n <button\n type=\"button\"\n title=\"Move up\"\n data-action=\"move-up\"\n onClick={(e) => {\n e.stopPropagation();\n handleAction('move-up');\n }}\n >\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"m18 15-6-6-6 6\" />\n </svg>\n </button>\n <button\n type=\"button\"\n title=\"Move down\"\n data-action=\"move-down\"\n onClick={(e) => {\n e.stopPropagation();\n handleAction('move-down');\n }}\n >\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </button>\n <button\n type=\"button\"\n className=\"delete\"\n title=\"Delete block\"\n data-action=\"delete\"\n onClick={(e) => {\n e.stopPropagation();\n handleAction('delete');\n }}\n >\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M3 6h18\" />\n <path d=\"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6\" />\n <path d=\"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2\" />\n <line x1=\"10\" x2=\"10\" y1=\"11\" y2=\"17\" />\n <line x1=\"14\" x2=\"14\" y1=\"11\" y2=\"17\" />\n </svg>\n </button>\n </div>\n );\n}\n"],"mappings":";;;;AAoCU,cAkCF,YAlCE;AA3BH,SAAS,aAAa,EAAE,QAAQ,GAAwB;AAC7D,QAAM,eAAe,CAAC,WAAmB;AACvC,QAAI,OAAO,WAAW,eAAe,OAAO,UAAU,OAAO,WAAW,QAAQ;AAC9E,aAAO,OAAO,YAAY,EAAE,MAAM,oBAAoB,QAAQ,QAAQ,GAAG,GAAG;AAAA,IAC9E;AAAA,EACF;AAEA,SACE,qBAAC,SAAI,WAAU,qBAAoB,oBAAiB,QAClD;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,OAAM;AAAA,QACN,eAAY;AAAA,QACZ,SAAS,CAAC,MAAM;AACd,YAAE,gBAAgB;AAClB,uBAAa,SAAS;AAAA,QACxB;AAAA,QAEA;AAAA,UAAC;AAAA;AAAA,YACC,OAAM;AAAA,YACN,SAAQ;AAAA,YACR,MAAK;AAAA,YACL,QAAO;AAAA,YACP,aAAY;AAAA,YACZ,eAAc;AAAA,YACd,gBAAe;AAAA,YAEf,8BAAC,UAAK,GAAE,kBAAiB;AAAA;AAAA,QAC3B;AAAA;AAAA,IACF;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,OAAM;AAAA,QACN,eAAY;AAAA,QACZ,SAAS,CAAC,MAAM;AACd,YAAE,gBAAgB;AAClB,uBAAa,WAAW;AAAA,QAC1B;AAAA,QAEA;AAAA,UAAC;AAAA;AAAA,YACC,OAAM;AAAA,YACN,SAAQ;AAAA,YACR,MAAK;AAAA,YACL,QAAO;AAAA,YACP,aAAY;AAAA,YACZ,eAAc;AAAA,YACd,gBAAe;AAAA,YAEf,8BAAC,UAAK,GAAE,gBAAe;AAAA;AAAA,QACzB;AAAA;AAAA,IACF;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,WAAU;AAAA,QACV,OAAM;AAAA,QACN,eAAY;AAAA,QACZ,SAAS,CAAC,MAAM;AACd,YAAE,gBAAgB;AAClB,uBAAa,QAAQ;AAAA,QACvB;AAAA,QAEA;AAAA,UAAC;AAAA;AAAA,YACC,OAAM;AAAA,YACN,SAAQ;AAAA,YACR,MAAK;AAAA,YACL,QAAO;AAAA,YACP,aAAY;AAAA,YACZ,eAAc;AAAA,YACd,gBAAe;AAAA,YAEf;AAAA,kCAAC,UAAK,GAAE,WAAU;AAAA,cAClB,oBAAC,UAAK,GAAE,yCAAwC;AAAA,cAChD,oBAAC,UAAK,GAAE,sCAAqC;AAAA,cAC7C,oBAAC,UAAK,IAAG,MAAK,IAAG,MAAK,IAAG,MAAK,IAAG,MAAK;AAAA,cACtC,oBAAC,UAAK,IAAG,MAAK,IAAG,MAAK,IAAG,MAAK,IAAG,MAAK;AAAA;AAAA;AAAA,QACxC;AAAA;AAAA,IACF;AAAA,KACF;AAEJ;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../lib/image/lazy-load.ts"],"sourcesContent":["/**\n * Lazy Loading Utilities\n *\n * Reusable IntersectionObserver-based lazy loading with\n * connection-aware quality adjustment.\n *\n * This module provides:\n * - useLazyLoad: Hook for deferred loading when elements enter viewport\n * - getConnectionAwareQuality: Adjusts image quality based on network speed\n */\n\n'use client';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface LazyLoadOptions {\n /** Root margin for early loading (default: \"200px\") */\n rootMargin?: string;\n /** Threshold for intersection (default: 0) */\n threshold?: number;\n /** Callback when element enters viewport */\n onEnter?: () => void;\n}\n\nexport interface LazyLoadResult {\n /** Callback ref to attach to the element */\n ref: (node: HTMLElement | null) => void;\n /** Whether the element has entered the viewport */\n inView: boolean;\n}\n\n// =============================================================================\n// useLazyLoad Hook\n// =============================================================================\n\n/**\n * IntersectionObserver-based lazy loading hook.\n *\n * Triggers when an element approaches the viewport. Once triggered,\n * the observer disconnects (one-shot behavior).\n *\n * @param options - Configuration options\n * @returns Object with ref callback and inView state\n *\n * @example\n * ```tsx\n * function LazyComponent() {\n * const { ref, inView } = useLazyLoad({ rootMargin: '200px' });\n *\n * return (\n * <div ref={ref}>\n * {inView && <ExpensiveContent />}\n * </div>\n * );\n * }\n * ```\n */\nexport function useLazyLoad(options: LazyLoadOptions = {}): LazyLoadResult {\n const { rootMargin = '200px', threshold = 0, onEnter } = options;\n const [inView, setInView] = useState(false);\n const observerRef = useRef<IntersectionObserver | null>(null);\n\n const ref = useCallback(\n (node: HTMLElement | null) => {\n // Disconnect previous observer\n if (observerRef.current) {\n observerRef.current.disconnect();\n observerRef.current = null;\n }\n\n // No node to observe\n if (!node) return;\n\n // Create new observer\n observerRef.current = new IntersectionObserver(\n ([entry]) => {\n if (entry?.isIntersecting) {\n setInView(true);\n onEnter?.();\n // Disconnect after first intersection (one-shot)\n observerRef.current?.disconnect();\n observerRef.current = null;\n }\n },\n { rootMargin, threshold }\n );\n\n observerRef.current.observe(node);\n },\n [rootMargin, threshold, onEnter]\n );\n\n // Cleanup on unmount\n useEffect(() => {\n return () => {\n if (observerRef.current) {\n observerRef.current.disconnect();\n observerRef.current = null;\n }\n };\n }, []);\n\n return { ref, inView };\n}\n\n// =============================================================================\n// Connection-Aware Quality\n// =============================================================================\n\n/**\n * Network Information API types (not in standard TypeScript lib).\n * @see https://developer.mozilla.org/en-US/docs/Web/API/Network_Information_API\n */\ninterface NetworkInformation {\n effectiveType?: 'slow-2g' | '2g' | '3g' | '4g';\n downlink?: number;\n saveData?: boolean;\n}\n\ninterface NavigatorWithConnection extends Navigator {\n connection?: NetworkInformation;\n}\n\n/**\n * Quality presets based on connection type.\n */\nconst QUALITY_PRESETS = {\n 'slow-2g': 30,\n '2g': 50,\n '3g': 65,\n '4g': 80,\n saveData: 40,\n default: 80,\n} as const;\n\n/**\n * Returns appropriate image quality based on network conditions.\n *\n * Uses the Network Information API when available to detect:\n * - Data saver mode (returns lowest quality)\n * - Effective connection type (slow-2g, 2g, 3g, 4g)\n *\n * Falls back to default quality (80) when:\n * - Running on server (SSR)\n * - Network Information API not supported\n *\n * @returns Quality value between 30-80\n *\n * @example\n * ```typescript\n * const quality = getConnectionAwareQuality();\n * const imageUrl = `${baseUrl}?q=${quality}`;\n * ```\n */\nexport function getConnectionAwareQuality(): number {\n // SSR fallback\n if (typeof navigator === 'undefined') {\n return QUALITY_PRESETS.default;\n }\n\n const nav = navigator as NavigatorWithConnection;\n const connection = nav.connection;\n\n // No Network Information API support\n if (!connection) {\n return QUALITY_PRESETS.default;\n }\n\n // User has enabled data saver - respect their preference\n if (connection.saveData) {\n return QUALITY_PRESETS.saveData;\n }\n\n // Adjust based on effective connection type\n const effectiveType = connection.effectiveType;\n if (effectiveType && effectiveType in QUALITY_PRESETS) {\n return QUALITY_PRESETS[effectiveType];\n }\n\n return QUALITY_PRESETS.default;\n}\n\n/**\n * Check if the user prefers reduced data usage.\n *\n * @returns true if Save-Data is enabled or connection is slow\n */\nexport function prefersReducedData(): boolean {\n if (typeof navigator === 'undefined') {\n return false;\n }\n\n const nav = navigator as NavigatorWithConnection;\n const connection = nav.connection;\n\n if (!connection) {\n return false;\n }\n\n return (\n connection.saveData === true ||\n connection.effectiveType === 'slow-2g' ||\n connection.effectiveType === '2g'\n );\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"sources":["../../../lib/image/lazy-load.ts"],"sourcesContent":["/**\n * Lazy Loading Utilities\n *\n * Reusable IntersectionObserver-based lazy loading with\n * connection-aware quality adjustment.\n *\n * This module provides:\n * - useLazyLoad: Hook for deferred loading when elements enter viewport\n * - getConnectionAwareQuality: Adjusts image quality based on network speed\n */\n\n'use client';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface LazyLoadOptions {\n /** Root margin for early loading (default: \"200px\") */\n rootMargin?: string;\n /** Threshold for intersection (default: 0) */\n threshold?: number;\n /** Callback when element enters viewport */\n onEnter?: () => void;\n}\n\nexport interface LazyLoadResult {\n /** Callback ref to attach to the element */\n ref: (node: HTMLElement | null) => void;\n /** Whether the element has entered the viewport */\n inView: boolean;\n}\n\n// =============================================================================\n// useLazyLoad Hook\n// =============================================================================\n\n/**\n * IntersectionObserver-based lazy loading hook.\n *\n * Triggers when an element approaches the viewport. Once triggered,\n * the observer disconnects (one-shot behavior).\n *\n * @param options - Configuration options\n * @returns Object with ref callback and inView state\n *\n * @example\n * ```tsx\n * function LazyComponent() {\n * const { ref, inView } = useLazyLoad({ rootMargin: '200px' });\n *\n * return (\n * <div ref={ref}>\n * {inView && <ExpensiveContent />}\n * </div>\n * );\n * }\n * ```\n */\nexport function useLazyLoad(options: LazyLoadOptions = {}): LazyLoadResult {\n const { rootMargin = '200px', threshold = 0, onEnter } = options;\n const [inView, setInView] = useState(false);\n const observerRef = useRef<IntersectionObserver | null>(null);\n\n const ref = useCallback(\n (node: HTMLElement | null) => {\n // Disconnect previous observer\n if (observerRef.current) {\n observerRef.current.disconnect();\n observerRef.current = null;\n }\n\n // No node to observe\n if (!node) return;\n\n // Create new observer\n observerRef.current = new IntersectionObserver(\n ([entry]) => {\n if (entry?.isIntersecting) {\n setInView(true);\n onEnter?.();\n // Disconnect after first intersection (one-shot)\n observerRef.current?.disconnect();\n observerRef.current = null;\n }\n },\n { rootMargin, threshold }\n );\n\n observerRef.current.observe(node);\n },\n [rootMargin, threshold, onEnter]\n );\n\n // Cleanup on unmount\n useEffect(() => {\n return () => {\n if (observerRef.current) {\n observerRef.current.disconnect();\n observerRef.current = null;\n }\n };\n }, []);\n\n return { ref, inView };\n}\n\n// =============================================================================\n// Connection-Aware Quality\n// =============================================================================\n\n/**\n * Network Information API types (not in standard TypeScript lib).\n * @see https://developer.mozilla.org/en-US/docs/Web/API/Network_Information_API\n */\ninterface NetworkInformation {\n effectiveType?: 'slow-2g' | '2g' | '3g' | '4g';\n downlink?: number;\n saveData?: boolean;\n}\n\ninterface NavigatorWithConnection extends Navigator {\n connection?: NetworkInformation;\n}\n\n/**\n * Quality presets based on connection type.\n */\nconst QUALITY_PRESETS = {\n 'slow-2g': 30,\n '2g': 50,\n '3g': 65,\n '4g': 80,\n saveData: 40,\n default: 80,\n} as const;\n\n/**\n * Returns appropriate image quality based on network conditions.\n *\n * Uses the Network Information API when available to detect:\n * - Data saver mode (returns lowest quality)\n * - Effective connection type (slow-2g, 2g, 3g, 4g)\n *\n * Falls back to default quality (80) when:\n * - Running on server (SSR)\n * - Network Information API not supported\n *\n * @returns Quality value between 30-80\n *\n * @example\n * ```typescript\n * const quality = getConnectionAwareQuality();\n * const imageUrl = `${baseUrl}?q=${quality}`;\n * ```\n */\nexport function getConnectionAwareQuality(): number {\n // SSR fallback\n if (typeof navigator === 'undefined') {\n return QUALITY_PRESETS.default;\n }\n\n const nav = navigator as NavigatorWithConnection;\n const connection = nav.connection;\n\n // No Network Information API support\n if (!connection) {\n return QUALITY_PRESETS.default;\n }\n\n // User has enabled data saver - respect their preference\n if (connection.saveData) {\n return QUALITY_PRESETS.saveData;\n }\n\n // Adjust based on effective connection type\n const effectiveType = connection.effectiveType;\n if (effectiveType && effectiveType in QUALITY_PRESETS) {\n return QUALITY_PRESETS[effectiveType];\n }\n\n return QUALITY_PRESETS.default;\n}\n\n/**\n * Check if the user prefers reduced data usage.\n *\n * @returns true if Save-Data is enabled or connection is slow\n */\nexport function prefersReducedData(): boolean {\n if (typeof navigator === 'undefined') {\n return false;\n }\n\n const nav = navigator as NavigatorWithConnection;\n const connection = nav.connection;\n\n if (!connection) {\n return false;\n }\n\n return (\n connection.saveData === true ||\n connection.effectiveType === 'slow-2g' ||\n connection.effectiveType === '2g'\n );\n}\n"],"mappings":";;;;AAaA,SAAS,aAAa,WAAW,QAAQ,gBAAgB;AAgDlD,SAAS,YAAY,UAA2B,CAAC,GAAmB;AACzE,QAAM,EAAE,aAAa,SAAS,YAAY,GAAG,QAAQ,IAAI;AACzD,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,KAAK;AAC1C,QAAM,cAAc,OAAoC,IAAI;AAE5D,QAAM,MAAM;AAAA,IACV,CAAC,SAA6B;AAE5B,UAAI,YAAY,SAAS;AACvB,oBAAY,QAAQ,WAAW;AAC/B,oBAAY,UAAU;AAAA,MACxB;AAGA,UAAI,CAAC,KAAM;AAGX,kBAAY,UAAU,IAAI;AAAA,QACxB,CAAC,CAAC,KAAK,MAAM;AACX,cAAI,OAAO,gBAAgB;AACzB,sBAAU,IAAI;AACd,sBAAU;AAEV,wBAAY,SAAS,WAAW;AAChC,wBAAY,UAAU;AAAA,UACxB;AAAA,QACF;AAAA,QACA,EAAE,YAAY,UAAU;AAAA,MAC1B;AAEA,kBAAY,QAAQ,QAAQ,IAAI;AAAA,IAClC;AAAA,IACA,CAAC,YAAY,WAAW,OAAO;AAAA,EACjC;AAGA,YAAU,MAAM;AACd,WAAO,MAAM;AACX,UAAI,YAAY,SAAS;AACvB,oBAAY,QAAQ,WAAW;AAC/B,oBAAY,UAAU;AAAA,MACxB;AAAA,IACF;AAAA,EACF,GAAG,CAAC,CAAC;AAEL,SAAO,EAAE,KAAK,OAAO;AACvB;AAuBA,IAAM,kBAAkB;AAAA,EACtB,WAAW;AAAA,EACX,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,UAAU;AAAA,EACV,SAAS;AACX;AAqBO,SAAS,4BAAoC;AAElD,MAAI,OAAO,cAAc,aAAa;AACpC,WAAO,gBAAgB;AAAA,EACzB;AAEA,QAAM,MAAM;AACZ,QAAM,aAAa,IAAI;AAGvB,MAAI,CAAC,YAAY;AACf,WAAO,gBAAgB;AAAA,EACzB;AAGA,MAAI,WAAW,UAAU;AACvB,WAAO,gBAAgB;AAAA,EACzB;AAGA,QAAM,gBAAgB,WAAW;AACjC,MAAI,iBAAiB,iBAAiB,iBAAiB;AACrD,WAAO,gBAAgB,aAAa;AAAA,EACtC;AAEA,SAAO,gBAAgB;AACzB;AAOO,SAAS,qBAA8B;AAC5C,MAAI,OAAO,cAAc,aAAa;AACpC,WAAO;AAAA,EACT;AAEA,QAAM,MAAM;AACZ,QAAM,aAAa,IAAI;AAEvB,MAAI,CAAC,YAAY;AACf,WAAO;AAAA,EACT;AAEA,SACE,WAAW,aAAa,QACxB,WAAW,kBAAkB,aAC7B,WAAW,kBAAkB;AAEjC;","names":[]}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Configuration options for the CMS proxy.
|
|
5
|
+
*/
|
|
6
|
+
interface ProxyConfig {
|
|
7
|
+
/**
|
|
8
|
+
* The upstream CMS server URL (e.g., 'https://cms.example.com').
|
|
9
|
+
* Defaults to ADMIN_UPSTREAM_ORIGIN environment variable.
|
|
10
|
+
*/
|
|
11
|
+
upstream?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Additional path prefixes to proxy (beyond /admin, /api, /auth).
|
|
14
|
+
*/
|
|
15
|
+
additionalPaths?: string[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Creates a proxy middleware function for Next.js.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* // middleware.ts
|
|
23
|
+
* import { createCmsProxy } from 'cms-renderer/lib/proxy';
|
|
24
|
+
*
|
|
25
|
+
* const cmsProxy = createCmsProxy({
|
|
26
|
+
* upstream: process.env.ADMIN_UPSTREAM_ORIGIN,
|
|
27
|
+
* });
|
|
28
|
+
*
|
|
29
|
+
* export async function middleware(request: NextRequest) {
|
|
30
|
+
* return cmsProxy(request);
|
|
31
|
+
* }
|
|
32
|
+
*
|
|
33
|
+
* export const config = {
|
|
34
|
+
* matcher: cmsProxyMatcher,
|
|
35
|
+
* };
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
declare function createCmsProxy(config?: ProxyConfig): (request: NextRequest) => Promise<NextResponse>;
|
|
39
|
+
/**
|
|
40
|
+
* Default matcher configuration for the CMS proxy middleware.
|
|
41
|
+
* Use this in your middleware.ts config export.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* export const config = {
|
|
46
|
+
* matcher: cmsProxyMatcher,
|
|
47
|
+
* };
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
declare const cmsProxyMatcher: string[];
|
|
51
|
+
|
|
52
|
+
export { type ProxyConfig, cmsProxyMatcher, createCmsProxy };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// lib/proxy.ts
|
|
2
|
+
import { NextResponse } from "next/server";
|
|
3
|
+
var STATIC_FILE_REGEX = /\.(css|js|map|png|jpg|jpeg|gif|svg|ico|webp|avif|woff|woff2|ttf|eot|txt|xml)$/;
|
|
4
|
+
function isFromAdminPage(request) {
|
|
5
|
+
const referer = request.headers.get("referer");
|
|
6
|
+
if (!referer) return false;
|
|
7
|
+
try {
|
|
8
|
+
const refererUrl = new URL(referer);
|
|
9
|
+
return refererUrl.pathname.startsWith("/admin");
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
async function proxyToUpstream(request, pathname, upstream) {
|
|
15
|
+
const upstreamUrl = new URL(pathname, upstream);
|
|
16
|
+
upstreamUrl.search = request.nextUrl.search;
|
|
17
|
+
const headers = new Headers(request.headers);
|
|
18
|
+
headers.set("x-forwarded-host", request.headers.get("host") ?? "");
|
|
19
|
+
headers.set("x-forwarded-proto", request.nextUrl.protocol.replace(":", ""));
|
|
20
|
+
headers.set("x-forwarded-for", request.headers.get("x-forwarded-for") ?? "");
|
|
21
|
+
const response = await fetch(upstreamUrl.toString(), {
|
|
22
|
+
method: request.method,
|
|
23
|
+
headers,
|
|
24
|
+
body: request.body,
|
|
25
|
+
// @ts-expect-error - duplex is required for streaming bodies
|
|
26
|
+
duplex: "half",
|
|
27
|
+
redirect: "manual"
|
|
28
|
+
// Don't follow redirects, let the client handle them
|
|
29
|
+
});
|
|
30
|
+
const responseHeaders = new Headers();
|
|
31
|
+
const upstreamUrlObj = new URL(upstream);
|
|
32
|
+
const upstreamOrigin = upstreamUrlObj.origin;
|
|
33
|
+
const currentOrigin = request.nextUrl.origin;
|
|
34
|
+
response.headers.forEach((value, key) => {
|
|
35
|
+
const lowerKey = key.toLowerCase();
|
|
36
|
+
if (lowerKey === "set-cookie") {
|
|
37
|
+
let modifiedCookie = value;
|
|
38
|
+
modifiedCookie = modifiedCookie.replace(/;\s*Domain=[^;]*/gi, "");
|
|
39
|
+
if (!/;\s*Path=/i.test(modifiedCookie)) {
|
|
40
|
+
modifiedCookie += "; Path=/";
|
|
41
|
+
}
|
|
42
|
+
if (!/;\s*SameSite=/i.test(modifiedCookie)) {
|
|
43
|
+
modifiedCookie += "; SameSite=Lax";
|
|
44
|
+
}
|
|
45
|
+
responseHeaders.append(key, modifiedCookie);
|
|
46
|
+
} else if (lowerKey === "location") {
|
|
47
|
+
try {
|
|
48
|
+
const locationUrl = new URL(value, upstream);
|
|
49
|
+
if (locationUrl.origin === upstreamOrigin) {
|
|
50
|
+
const newLocation = `${currentOrigin}${locationUrl.pathname}${locationUrl.search}`;
|
|
51
|
+
responseHeaders.set(key, newLocation);
|
|
52
|
+
} else {
|
|
53
|
+
let finalLocation = value;
|
|
54
|
+
const redirectUri = locationUrl.searchParams.get("redirect_uri");
|
|
55
|
+
if (redirectUri) {
|
|
56
|
+
try {
|
|
57
|
+
const redirectUriUrl = new URL(redirectUri);
|
|
58
|
+
if (redirectUriUrl.host === upstreamUrlObj.host) {
|
|
59
|
+
const newRedirectUri = `${currentOrigin}${redirectUriUrl.pathname}${redirectUriUrl.search}`;
|
|
60
|
+
locationUrl.searchParams.set("redirect_uri", newRedirectUri);
|
|
61
|
+
finalLocation = locationUrl.toString();
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
responseHeaders.set(key, finalLocation);
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
responseHeaders.set(key, value);
|
|
70
|
+
}
|
|
71
|
+
} else if (lowerKey !== "transfer-encoding" && lowerKey !== "content-encoding" && lowerKey !== "content-length") {
|
|
72
|
+
responseHeaders.set(key, value);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
responseHeaders.set("x-proxied-by", "cms-proxy");
|
|
76
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
77
|
+
if (contentType.includes("text/html") && response.body) {
|
|
78
|
+
let text = await response.text();
|
|
79
|
+
const upstreamHost = upstreamUrlObj.host;
|
|
80
|
+
text = text.replaceAll(upstreamOrigin, currentOrigin);
|
|
81
|
+
text = text.replaceAll(`//${upstreamHost}`, `//${request.nextUrl.host}`);
|
|
82
|
+
text = text.replaceAll(`https://${upstreamHost}`, currentOrigin);
|
|
83
|
+
text = text.replaceAll(`http://${upstreamHost}`, currentOrigin);
|
|
84
|
+
return new NextResponse(text, {
|
|
85
|
+
status: response.status,
|
|
86
|
+
statusText: response.statusText,
|
|
87
|
+
headers: responseHeaders
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return new NextResponse(response.body, {
|
|
91
|
+
status: response.status,
|
|
92
|
+
statusText: response.statusText,
|
|
93
|
+
headers: responseHeaders
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
function createCmsProxy(config = {}) {
|
|
97
|
+
const upstream = (config.upstream ?? process.env.ADMIN_UPSTREAM_ORIGIN ?? "").replace(/\/$/, "");
|
|
98
|
+
const additionalPaths = config.additionalPaths ?? [];
|
|
99
|
+
if (!upstream) {
|
|
100
|
+
console.warn(
|
|
101
|
+
"[cms-proxy] No upstream URL configured. Set ADMIN_UPSTREAM_ORIGIN or pass upstream option."
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return async function cmsProxy(request) {
|
|
105
|
+
if (!upstream) {
|
|
106
|
+
return NextResponse.next();
|
|
107
|
+
}
|
|
108
|
+
const { pathname } = request.nextUrl;
|
|
109
|
+
if (pathname.startsWith("/admin")) {
|
|
110
|
+
return proxyToUpstream(request, pathname, upstream);
|
|
111
|
+
}
|
|
112
|
+
if (pathname.startsWith("/api")) {
|
|
113
|
+
return proxyToUpstream(request, pathname, upstream);
|
|
114
|
+
}
|
|
115
|
+
if (pathname.startsWith("/auth")) {
|
|
116
|
+
return proxyToUpstream(request, pathname, upstream);
|
|
117
|
+
}
|
|
118
|
+
for (const pathPrefix of additionalPaths) {
|
|
119
|
+
if (pathname.startsWith(pathPrefix)) {
|
|
120
|
+
return proxyToUpstream(request, pathname, upstream);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (isFromAdminPage(request)) {
|
|
124
|
+
if (pathname.startsWith("/_next") || STATIC_FILE_REGEX.test(pathname)) {
|
|
125
|
+
return proxyToUpstream(request, pathname, upstream);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return NextResponse.next();
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
var cmsProxyMatcher = [
|
|
132
|
+
"/admin",
|
|
133
|
+
"/admin/:path*",
|
|
134
|
+
"/api/:path*",
|
|
135
|
+
"/auth/:path*",
|
|
136
|
+
"/_next/:path*",
|
|
137
|
+
"/((?:.*\\.(?:css|js|map|png|jpg|jpeg|gif|svg|ico|webp|avif|woff|woff2|ttf|eot|txt|xml))$)"
|
|
138
|
+
];
|
|
139
|
+
export {
|
|
140
|
+
cmsProxyMatcher,
|
|
141
|
+
createCmsProxy
|
|
142
|
+
};
|
|
143
|
+
//# sourceMappingURL=proxy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../lib/proxy.ts"],"sourcesContent":["import { type NextRequest, NextResponse } from 'next/server';\n\n/**\n * Configuration options for the CMS proxy.\n */\nexport interface ProxyConfig {\n /**\n * The upstream CMS server URL (e.g., 'https://cms.example.com').\n * Defaults to ADMIN_UPSTREAM_ORIGIN environment variable.\n */\n upstream?: string;\n /**\n * Additional path prefixes to proxy (beyond /admin, /api, /auth).\n */\n additionalPaths?: string[];\n}\n\n// Static file extensions to proxy to upstream\nconst STATIC_FILE_REGEX =\n /\\.(css|js|map|png|jpg|jpeg|gif|svg|ico|webp|avif|woff|woff2|ttf|eot|txt|xml)$/;\n\n/**\n * Check if the request originates from an admin page (via Referer header).\n */\nfunction isFromAdminPage(request: NextRequest): boolean {\n const referer = request.headers.get('referer');\n if (!referer) return false;\n\n try {\n const refererUrl = new URL(referer);\n return refererUrl.pathname.startsWith('/admin');\n } catch {\n return false;\n }\n}\n\n/**\n * Proxy a request to the upstream CMS server with proper cookie handling.\n */\nasync function proxyToUpstream(\n request: NextRequest,\n pathname: string,\n upstream: string\n): Promise<NextResponse> {\n const upstreamUrl = new URL(pathname, upstream);\n upstreamUrl.search = request.nextUrl.search;\n\n // Clone all headers from the request\n const headers = new Headers(request.headers);\n\n // Keep the original host header so the upstream app knows the real origin\n // This is important for auth redirects (WorkOS) to use the correct domain\n // The x-forwarded-* headers provide additional context\n headers.set('x-forwarded-host', request.headers.get('host') ?? '');\n headers.set('x-forwarded-proto', request.nextUrl.protocol.replace(':', ''));\n headers.set('x-forwarded-for', request.headers.get('x-forwarded-for') ?? '');\n\n const response = await fetch(upstreamUrl.toString(), {\n method: request.method,\n headers,\n body: request.body,\n // @ts-expect-error - duplex is required for streaming bodies\n duplex: 'half',\n redirect: 'manual', // Don't follow redirects, let the client handle them\n });\n\n // Create response with proper header handling\n const responseHeaders = new Headers();\n\n const upstreamUrlObj = new URL(upstream);\n const upstreamOrigin = upstreamUrlObj.origin;\n const currentOrigin = request.nextUrl.origin;\n\n // Copy headers from upstream response\n response.headers.forEach((value, key) => {\n const lowerKey = key.toLowerCase();\n\n // Handle Set-Cookie specially - rewrite domain to current host\n if (lowerKey === 'set-cookie') {\n let modifiedCookie = value;\n\n // Remove Domain attribute so cookie defaults to current host\n modifiedCookie = modifiedCookie.replace(/;\\s*Domain=[^;]*/gi, '');\n\n // Ensure Path is set (usually /admin or /)\n if (!/;\\s*Path=/i.test(modifiedCookie)) {\n modifiedCookie += '; Path=/';\n }\n\n // For secure cookies in production, ensure SameSite is appropriate\n if (!/;\\s*SameSite=/i.test(modifiedCookie)) {\n modifiedCookie += '; SameSite=Lax';\n }\n\n responseHeaders.append(key, modifiedCookie);\n }\n // Handle Location header - rewrite upstream URLs to current host\n else if (lowerKey === 'location') {\n try {\n // Parse the location (handles both absolute and relative URLs)\n const locationUrl = new URL(value, upstream);\n\n // If redirect points to upstream, rewrite to current origin\n if (locationUrl.origin === upstreamOrigin) {\n const newLocation = `${currentOrigin}${locationUrl.pathname}${locationUrl.search}`;\n responseHeaders.set(key, newLocation);\n } else {\n // External redirect (e.g., to WorkOS)\n // Rewrite redirect_uri parameter if it points to upstream\n let finalLocation = value;\n\n // Check if this is a WorkOS/OAuth redirect with redirect_uri parameter\n const redirectUri = locationUrl.searchParams.get('redirect_uri');\n if (redirectUri) {\n try {\n const redirectUriUrl = new URL(redirectUri);\n // If redirect_uri points to upstream, rewrite to current origin\n if (redirectUriUrl.host === upstreamUrlObj.host) {\n const newRedirectUri = `${currentOrigin}${redirectUriUrl.pathname}${redirectUriUrl.search}`;\n locationUrl.searchParams.set('redirect_uri', newRedirectUri);\n finalLocation = locationUrl.toString();\n }\n } catch {\n // If redirect_uri parsing fails, keep original\n }\n }\n\n responseHeaders.set(key, finalLocation);\n }\n } catch {\n // If URL parsing fails, keep original\n responseHeaders.set(key, value);\n }\n }\n // Skip headers that cause issues after fetch decompresses the body\n else if (\n lowerKey !== 'transfer-encoding' &&\n lowerKey !== 'content-encoding' &&\n lowerKey !== 'content-length'\n ) {\n responseHeaders.set(key, value);\n }\n });\n\n // Add debug header to verify middleware is running\n responseHeaders.set('x-proxied-by', 'cms-proxy');\n\n // For HTML responses, rewrite upstream URLs in the body\n const contentType = response.headers.get('content-type') ?? '';\n if (contentType.includes('text/html') && response.body) {\n let text = await response.text();\n\n // Get the upstream host for more comprehensive replacement\n const upstreamHost = upstreamUrlObj.host;\n\n // Replace full origin (https://cms.example.com)\n text = text.replaceAll(upstreamOrigin, currentOrigin);\n\n // Replace protocol-relative URLs (//cms.example.com)\n text = text.replaceAll(`//${upstreamHost}`, `//${request.nextUrl.host}`);\n\n // Replace any remaining absolute URLs with the upstream host\n // This catches cases where protocol might differ\n text = text.replaceAll(`https://${upstreamHost}`, currentOrigin);\n text = text.replaceAll(`http://${upstreamHost}`, currentOrigin);\n\n return new NextResponse(text, {\n status: response.status,\n statusText: response.statusText,\n headers: responseHeaders,\n });\n }\n\n return new NextResponse(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers: responseHeaders,\n });\n}\n\n/**\n * Creates a proxy middleware function for Next.js.\n *\n * @example\n * ```ts\n * // middleware.ts\n * import { createCmsProxy } from 'cms-renderer/lib/proxy';\n *\n * const cmsProxy = createCmsProxy({\n * upstream: process.env.ADMIN_UPSTREAM_ORIGIN,\n * });\n *\n * export async function middleware(request: NextRequest) {\n * return cmsProxy(request);\n * }\n *\n * export const config = {\n * matcher: cmsProxyMatcher,\n * };\n * ```\n */\nexport function createCmsProxy(config: ProxyConfig = {}) {\n const upstream = (config.upstream ?? process.env.ADMIN_UPSTREAM_ORIGIN ?? '').replace(/\\/$/, '');\n const additionalPaths = config.additionalPaths ?? [];\n\n if (!upstream) {\n console.warn(\n '[cms-proxy] No upstream URL configured. Set ADMIN_UPSTREAM_ORIGIN or pass upstream option.'\n );\n }\n\n return async function cmsProxy(request: NextRequest): Promise<NextResponse> {\n if (!upstream) {\n return NextResponse.next();\n }\n\n const { pathname } = request.nextUrl;\n\n // Proxy /admin routes to the upstream CMS\n if (pathname.startsWith('/admin')) {\n return proxyToUpstream(request, pathname, upstream);\n }\n\n // Proxy /api routes to the upstream CMS\n if (pathname.startsWith('/api')) {\n return proxyToUpstream(request, pathname, upstream);\n }\n\n // Proxy auth routes to the upstream CMS (WorkOS callbacks, signin, etc.)\n if (pathname.startsWith('/auth')) {\n return proxyToUpstream(request, pathname, upstream);\n }\n\n // Proxy additional custom paths\n for (const pathPrefix of additionalPaths) {\n if (pathname.startsWith(pathPrefix)) {\n return proxyToUpstream(request, pathname, upstream);\n }\n }\n\n // Only proxy /_next and static files if the request comes from an admin page\n // This prevents breaking the web app's own assets\n if (isFromAdminPage(request)) {\n if (pathname.startsWith('/_next') || STATIC_FILE_REGEX.test(pathname)) {\n return proxyToUpstream(request, pathname, upstream);\n }\n }\n\n return NextResponse.next();\n };\n}\n\n/**\n * Default matcher configuration for the CMS proxy middleware.\n * Use this in your middleware.ts config export.\n *\n * @example\n * ```ts\n * export const config = {\n * matcher: cmsProxyMatcher,\n * };\n * ```\n */\nexport const cmsProxyMatcher = [\n '/admin',\n '/admin/:path*',\n '/api/:path*',\n '/auth/:path*',\n '/_next/:path*',\n '/((?:.*\\\\.(?:css|js|map|png|jpg|jpeg|gif|svg|ico|webp|avif|woff|woff2|ttf|eot|txt|xml))$)',\n];\n"],"mappings":"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","names":[]}
|
package/dist/lib/renderer.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as
|
|
1
|
+
import * as React from 'react';
|
|
2
2
|
import { Metadata } from 'next';
|
|
3
3
|
import { BlockComponentRegistry } from './types.js';
|
|
4
4
|
import '@repo/cms-schema/blocks';
|
|
@@ -7,6 +7,9 @@ type PageProps = {
|
|
|
7
7
|
params: Promise<{
|
|
8
8
|
slug: string[];
|
|
9
9
|
}>;
|
|
10
|
+
searchParams?: Promise<{
|
|
11
|
+
[key: string]: string | string[] | undefined;
|
|
12
|
+
}>;
|
|
10
13
|
/** CMS API base URL (e.g., 'http://localhost:3000') */
|
|
11
14
|
cmsUrl: string;
|
|
12
15
|
registry?: Partial<BlockComponentRegistry>;
|
|
@@ -29,7 +32,7 @@ declare const dynamic = "force-dynamic";
|
|
|
29
32
|
*
|
|
30
33
|
* Reconstructs the full path and fetches route via tRPC.
|
|
31
34
|
*/
|
|
32
|
-
declare function ParametricRoutePage({ params, registry, apiKey, cmsUrl, websiteId: providedWebsiteId, }: PageProps): Promise<
|
|
35
|
+
declare function ParametricRoutePage({ params, searchParams, registry, apiKey, cmsUrl, websiteId: providedWebsiteId, }: PageProps): Promise<React.JSX.Element>;
|
|
33
36
|
/**
|
|
34
37
|
* Generate metadata for the page.
|
|
35
38
|
* Uses Next.js 15+ async params pattern.
|
package/dist/lib/renderer.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BlockRenderer
|
|
3
|
-
} from "../chunk-
|
|
3
|
+
} from "../chunk-6QR5B5KQ.js";
|
|
4
4
|
import {
|
|
5
5
|
getCmsClient
|
|
6
6
|
} from "../chunk-JHKDRASN.js";
|
|
@@ -267,6 +267,7 @@ function getWebsiteId(providedWebsiteId) {
|
|
|
267
267
|
var dynamic = "force-dynamic";
|
|
268
268
|
async function ParametricRoutePage({
|
|
269
269
|
params,
|
|
270
|
+
searchParams,
|
|
270
271
|
registry,
|
|
271
272
|
apiKey,
|
|
272
273
|
cmsUrl,
|
|
@@ -275,6 +276,20 @@ async function ParametricRoutePage({
|
|
|
275
276
|
unstable_noStore();
|
|
276
277
|
const websiteId = getWebsiteId(providedWebsiteId);
|
|
277
278
|
const { slug } = await params;
|
|
279
|
+
const resolvedSearchParams = await searchParams;
|
|
280
|
+
let aiPreviewIndex = null;
|
|
281
|
+
const aiPreviewParam = resolvedSearchParams?.ai_preview;
|
|
282
|
+
if (aiPreviewParam) {
|
|
283
|
+
const paramValue = Array.isArray(aiPreviewParam) ? aiPreviewParam[0] : aiPreviewParam;
|
|
284
|
+
if (paramValue) {
|
|
285
|
+
const parsed = parseInt(paramValue, 10);
|
|
286
|
+
if (!Number.isNaN(parsed)) {
|
|
287
|
+
aiPreviewIndex = parsed;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const editModeParam = resolvedSearchParams?.edit_mode;
|
|
292
|
+
const editMode = editModeParam === "true" || editModeParam === "1";
|
|
278
293
|
const rawPath = `/${slug.join("/")}`;
|
|
279
294
|
const path = normalizePath(rawPath);
|
|
280
295
|
const client = getCmsClient({ apiKey, cmsUrl });
|
|
@@ -293,11 +308,27 @@ async function ParametricRoutePage({
|
|
|
293
308
|
return null;
|
|
294
309
|
}
|
|
295
310
|
});
|
|
296
|
-
const
|
|
311
|
+
const generatedBlocksPromise = aiPreviewIndex !== null ? client.block.getGeneratedByBlockIds.query({ websiteId, blockIds: route.block_ids }).catch((error) => {
|
|
312
|
+
console.error("Failed to fetch generated blocks:", error);
|
|
313
|
+
return { generatedBlocks: {} };
|
|
314
|
+
}) : Promise.resolve({ generatedBlocks: {} });
|
|
315
|
+
const [blockResults, { generatedBlocks }] = await Promise.all([
|
|
316
|
+
Promise.all(blockPromises),
|
|
317
|
+
generatedBlocksPromise
|
|
318
|
+
]);
|
|
297
319
|
const blocks = [];
|
|
298
320
|
for (const block of blockResults) {
|
|
299
321
|
if (!block || block.published_content === null) continue;
|
|
300
|
-
|
|
322
|
+
let content = null;
|
|
323
|
+
if (aiPreviewIndex !== null) {
|
|
324
|
+
const generatedBlock = generatedBlocks[block.id];
|
|
325
|
+
const variantIndex = aiPreviewIndex - 1;
|
|
326
|
+
const variants = generatedBlock?.generated_content;
|
|
327
|
+
if (variants && Array.isArray(variants) && variants[variantIndex]) {
|
|
328
|
+
content = variants[variantIndex].content;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
content = content ?? block.published_content;
|
|
301
332
|
if (!content) continue;
|
|
302
333
|
if (block.schema_name === "article") {
|
|
303
334
|
const article = normalizeArticleContent(content);
|
|
@@ -316,7 +347,15 @@ async function ParametricRoutePage({
|
|
|
316
347
|
content
|
|
317
348
|
});
|
|
318
349
|
}
|
|
319
|
-
return /* @__PURE__ */ jsx("main", { children: blocks.map((block) => /* @__PURE__ */ jsx(
|
|
350
|
+
return /* @__PURE__ */ jsx("main", { children: blocks.map((block) => /* @__PURE__ */ jsx(
|
|
351
|
+
BlockRenderer,
|
|
352
|
+
{
|
|
353
|
+
block,
|
|
354
|
+
registry: registry ?? {},
|
|
355
|
+
disableEditable: !editMode
|
|
356
|
+
},
|
|
357
|
+
block.id
|
|
358
|
+
)) });
|
|
320
359
|
} catch (error) {
|
|
321
360
|
console.error(`Route fetch error for path: ${path}`, error);
|
|
322
361
|
const errorCode = error instanceof Error && "data" in error ? error.data?.code : error instanceof Error && "code" in error ? error.code : void 0;
|
package/dist/lib/renderer.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../packages/cms-schema/src/blocks/article.ts","../../../../packages/cms-schema/src/validation/image.ts","../../../../packages/cms-schema/src/blocks/schemas/article-block.ts","../../../../packages/cms-schema/src/blocks/schemas/cta-block.ts","../../../../packages/cms-schema/src/blocks/schemas/features-block.ts","../../../../packages/cms-schema/src/blocks/schemas/hero-block.ts","../../../../packages/cms-schema/src/fields/complex/media.ts","../../../../packages/cms-schema/src/blocks/schemas/logo-trust-block.ts","../../../../packages/cms-schema/src/blocks/registry.ts","../../lib/renderer.tsx"],"sourcesContent":["/**\n * Article block normalization helpers shared across CMS and website apps.\n */\n\nexport interface ArticleContent {\n headline: string;\n author?: string;\n publishedAt?: string;\n body: string;\n tags?: readonly string[];\n status?: string;\n}\n\nexport interface ArticleRow {\n id: string;\n published_content: unknown;\n}\n\nexport interface NormalizedArticleRow extends ArticleContent {\n id: string;\n tags: string[];\n status: string;\n}\n\n/**\n * Normalize article content coming from Supabase blocks.\n * Returns null when required fields are missing or invalid.\n */\nexport function normalizeArticleContent(payload: unknown): ArticleContent | null {\n if (!payload || typeof payload !== 'object') {\n return null;\n }\n\n const record = payload as Record<string, unknown>;\n const headline = typeof record.headline === 'string' ? record.headline : null;\n const body = typeof record.body === 'string' ? record.body : null;\n\n if (!headline || !body) {\n return null;\n }\n\n const author = typeof record.author === 'string' ? record.author : undefined;\n const publishedAt = typeof record.publishedAt === 'string' ? record.publishedAt : undefined;\n const tags = Array.isArray(record.tags) ? record.tags.map((tag) => String(tag)) : undefined;\n const statusRaw = typeof record.status === 'string' ? record.status.trim() : undefined;\n\n return {\n headline,\n body,\n author,\n publishedAt,\n tags,\n status: statusRaw || undefined,\n };\n}\n\n/**\n * Normalize an article block row, applying defaults for tags/status.\n */\nexport function normalizeArticleRow(row: ArticleRow): NormalizedArticleRow | null {\n const article = normalizeArticleContent(row.published_content);\n if (!article) {\n return null;\n }\n\n return {\n ...article,\n id: row.id,\n tags: article.tags ? [...article.tags] : [],\n status: article.status ?? 'published',\n };\n}\n\n/**\n * Ensure the article is published.\n */\nexport function isArticlePublished(article: ArticleContent): boolean {\n const now = new Date();\n const publishedAt = article.publishedAt ? new Date(article.publishedAt) : null;\n\n return (\n article.status === 'published' &&\n publishedAt !== null &&\n !Number.isNaN(publishedAt.getTime()) &&\n publishedAt <= now\n );\n}\n","/**\n * Image Validation Utilities\n *\n * Provides validation for:\n * - File type (MIME type checking)\n * - File size (configurable limits)\n * - Image dimensions (width/height constraints)\n */\n\nimport { z } from 'zod';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nexport const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const;\nexport type ImageMimeType = (typeof ALLOWED_MIME_TYPES)[number];\n\nexport const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB\nexport const MIN_FILE_SIZE = 1024; // 1KB (avoid empty/corrupt files)\n\nexport const MAX_DIMENSION = 8192; // 8K resolution\nexport const MIN_DIMENSION = 10; // Minimum useful size\n\n// =============================================================================\n// Zod Schemas\n// =============================================================================\n\nexport const MimeTypeSchema = z.enum(ALLOWED_MIME_TYPES);\n\nexport const FileSizeSchema = z\n .number()\n .int()\n .min(MIN_FILE_SIZE, `File too small. Minimum: ${MIN_FILE_SIZE} bytes`)\n .max(MAX_FILE_SIZE, `File too large. Maximum: ${MAX_FILE_SIZE / 1024 / 1024}MB`);\n\nexport const DimensionSchema = z\n .number()\n .int()\n .min(MIN_DIMENSION, `Dimension too small. Minimum: ${MIN_DIMENSION}px`)\n .max(MAX_DIMENSION, `Dimension too large. Maximum: ${MAX_DIMENSION}px`);\n\n/**\n * Schema for upload request validation.\n */\nexport const UploadRequestSchema = z.object({\n filename: z\n .string()\n .min(1, 'Filename is required')\n .max(255, 'Filename too long')\n .regex(/^[^<>:\"/\\\\|?*]+$/, 'Filename contains invalid characters'),\n mimeType: MimeTypeSchema,\n fileSize: FileSizeSchema,\n});\n\nexport type UploadRequest = z.infer<typeof UploadRequestSchema>;\n\n/**\n * Schema for confirming upload with dimensions.\n * assetId is optional - if not provided, the server will generate a UUID.\n */\nexport const ConfirmUploadSchema = z.object({\n assetId: z.uuid().optional(),\n width: DimensionSchema,\n height: DimensionSchema,\n});\n\nexport type ConfirmUpload = z.infer<typeof ConfirmUploadSchema>;\n\n// =============================================================================\n// Validation Functions\n// =============================================================================\n\nexport interface ValidationResult {\n valid: boolean;\n error?: string;\n}\n\n/**\n * Validate file type by MIME type.\n */\nexport function validateMimeType(mimeType: string): ValidationResult {\n const result = MimeTypeSchema.safeParse(mimeType);\n if (result.success) {\n return { valid: true };\n }\n return {\n valid: false,\n error: `File type not supported. Use JPEG, PNG, or WebP.`,\n };\n}\n\n/**\n * Validate file size.\n */\nexport function validateFileSize(size: number): ValidationResult {\n if (size < MIN_FILE_SIZE) {\n return {\n valid: false,\n error: `File appears to be empty or corrupt.`,\n };\n }\n if (size > MAX_FILE_SIZE) {\n const sizeMB = (size / 1024 / 1024).toFixed(1);\n return {\n valid: false,\n error: `File too large (${sizeMB}MB). Maximum: 10 MB.`,\n };\n }\n return { valid: true };\n}\n\n/**\n * Validate image dimensions.\n */\nexport function validateDimensions(width: number, height: number): ValidationResult {\n if (width < MIN_DIMENSION || height < MIN_DIMENSION) {\n return {\n valid: false,\n error: `Image too small. Minimum: ${MIN_DIMENSION}x${MIN_DIMENSION}px.`,\n };\n }\n if (width > MAX_DIMENSION || height > MAX_DIMENSION) {\n return {\n valid: false,\n error: `Image too large. Maximum: ${MAX_DIMENSION}x${MAX_DIMENSION}px.`,\n };\n }\n return { valid: true };\n}\n\n/**\n * Validate complete upload request.\n */\nexport function validateUploadRequest(\n request: unknown\n): { valid: true; data: UploadRequest } | { valid: false; error: string } {\n const result = UploadRequestSchema.safeParse(request);\n if (result.success) {\n return { valid: true, data: result.data };\n }\n const firstIssue = result.error.issues[0];\n return {\n valid: false,\n error: firstIssue?.message ?? 'Invalid upload request',\n };\n}\n\n// =============================================================================\n// File Extension Utilities\n// =============================================================================\n\nconst MIME_TO_EXT: Record<ImageMimeType, string> = {\n 'image/jpeg': 'jpg',\n 'image/png': 'png',\n 'image/webp': 'webp',\n};\n\nconst EXT_TO_MIME: Record<string, ImageMimeType> = {\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n png: 'image/png',\n webp: 'image/webp',\n};\n\n/**\n * Get file extension from MIME type.\n */\nexport function getExtensionFromMime(mimeType: ImageMimeType | string): string {\n return MIME_TO_EXT[mimeType as ImageMimeType] ?? 'bin';\n}\n\n/**\n * Get MIME type from file extension.\n */\nexport function getMimeFromExtension(ext: string): ImageMimeType | undefined {\n return EXT_TO_MIME[ext.toLowerCase()];\n}\n\n/**\n * Extract extension from filename.\n */\nexport function getExtensionFromFilename(filename: string): string {\n const parts = filename.split('.');\n return parts.pop()?.toLowerCase?.() ?? '';\n}\n\n/**\n * Sanitize filename for storage.\n * Removes special characters, preserves extension.\n */\nexport function sanitizeFilename(filename: string): string {\n // Get extension\n const ext = getExtensionFromFilename(filename);\n\n // Get base name without extension\n const base = filename.slice(0, filename.length - ext.length - 1);\n\n // Sanitize: lowercase, replace spaces and special chars\n const sanitized = base\n .toLowerCase()\n .replace(/[^a-z0-9]/g, '-')\n .replace(/-+/g, '-')\n .replace(/^-|-$/g, '')\n .slice(0, 50); // Limit length\n\n return ext ? `${sanitized}.${ext}` : sanitized;\n}\n","/**\n * ArticleBlock Schema.\n *\n * Defines the structure and validation for ArticleBlock content.\n *\n * ArticleBlock represents a block of content focused on articles,\n * including fields such as headline, author, publication date, body content, tags, and status.\n *\n * Includes:\n * - Zod schema for validation\n * - TypeScript type inference\n * - Default values\n * - Validation function\n * - Function to create default content\n */\nimport { z } from 'zod';\n\n// Article block content schema with validation rules\nexport const ArticleBlockContentSchema = z.object({\n headline: z.string().min(1, 'Article headline is required').max(300, 'Headline too long').trim(),\n author: z.string().max(100, 'Article author too long').trim().optional(),\n publishedAt: z.iso\n .datetime({ message: 'Article publishedAt must be a valid ISO 8601 datetime string.' })\n .optional(),\n body: z.string().min(1, 'Article body content is required'),\n tags: z.array(z.string()).optional(),\n status: z.enum(['draft', 'review', 'published']),\n});\n\n// Inferred type for components\nexport type ArticleBlockContent = z.infer<typeof ArticleBlockContentSchema>;\n\n// Schema name constant\nexport const ARTICLE_BLOCK_SCHEMA_NAME = 'article';\n\n// Default values for a new ArticleBlock\nexport const ArticleBlockDefaults: Partial<ArticleBlockContent> = {\n status: 'draft',\n};\n\n// Validate ArticleBlock content\nexport function validateArticleBlockContent(content: unknown): {\n valid: boolean;\n data?: ArticleBlockContent;\n errors?: z.ZodError;\n} {\n const result = ArticleBlockContentSchema.safeParse(content);\n if (result.success) {\n return { valid: true, data: result.data };\n }\n return { valid: false, errors: result.error };\n}\n\n// Create default ArticleBlock content\nexport function createDefaultArticleContent(): ArticleBlockContent {\n return {\n headline: 'untitled article',\n author: '',\n publishedAt: new Date().toISOString(),\n body: '',\n tags: [],\n status: 'draft',\n };\n}\n","/**\n * CTABlock Schema.\n *\n * A call-to-action section with headline, description, and action buttons.\n * Supports primary and optional secondary CTA buttons.\n */\n\nimport { z } from 'zod';\n\n// CTA button schema\n// URL can be either a full URL (http://...) or a relative path (/path)\nconst CTAButtonSchema = z.object({\n text: z.string().min(1, 'Button text is required').max(50, 'Button text too long'),\n url: z.string().refine(\n (val) => {\n // Accept full URLs\n if (val.startsWith('http://') || val.startsWith('https://')) {\n try {\n new URL(val);\n return true;\n } catch {\n return false;\n }\n }\n // Accept relative paths starting with /\n if (val.startsWith('/')) {\n return true;\n }\n // Accept hash/anchor links\n if (val.startsWith('#')) {\n return true;\n }\n return false;\n },\n {\n message:\n 'URL must be a valid full URL (http://... or https://...), relative path (/path), or anchor (#anchor)',\n }\n ),\n});\n\nexport type CTAButton = z.infer<typeof CTAButtonSchema>;\n\n// CTABlock content schema with validation rules\nexport const CTABlockContentSchema = z.object({\n headline: z.string().min(1, 'Headline is required').max(100, 'Headline too long'),\n description: z.string().max(500, 'Description too long').optional(),\n primaryButton: CTAButtonSchema,\n secondaryButton: CTAButtonSchema.optional(),\n});\n\n// Inferred type for components\nexport type CTABlockContent = z.infer<typeof CTABlockContentSchema>;\n\n// Schema name constant\nexport const CTA_BLOCK_SCHEMA_NAME = 'cta-block';\n\n// Default values for a new CTABlock\nexport const CTABlockDefaults: Partial<CTABlockContent> = {\n primaryButton: {\n text: 'Get Started',\n url: '#',\n },\n};\n\n// Validate CTABlock content\nexport function validateCTABlockContent(content: unknown): {\n valid: boolean;\n data?: CTABlockContent;\n errors?: z.ZodError;\n} {\n const result = CTABlockContentSchema.safeParse(content);\n if (result.success) {\n return { valid: true, data: result.data };\n }\n return { valid: false, errors: result.error };\n}\n\n// Create default CTABlock content\nexport function createDefaultCTAContent(): CTABlockContent {\n return {\n headline: '',\n description: undefined,\n primaryButton: {\n text: 'Get Started',\n url: '#',\n },\n secondaryButton: undefined,\n };\n}\n","/**\n * FeaturesBlock Schema.\n *\n * A section displaying a list of features with icons, titles, and descriptions.\n * Supports different layouts (grid, list, carousel).\n */\n\nimport { z } from 'zod';\n\n// Layout options for features display\nexport const FeaturesLayout = ['grid', 'list', 'carousel'] as const;\nexport type FeaturesLayoutType = (typeof FeaturesLayout)[number];\n\n// Single feature item schema\nexport const FeatureItemSchema = z.object({\n icon: z.string().max(50, 'Icon name too long').optional(),\n title: z.string().min(1, 'Title is required').max(100, 'Title too long'),\n description: z.string().max(500, 'Description too long').optional(),\n});\n\nexport type FeatureItem = z.infer<typeof FeatureItemSchema>;\n\n// FeaturesBlock content schema with array validation\nexport const FeaturesBlockContentSchema = z.object({\n title: z.string().min(1, 'Section title is required').max(100, 'Title too long'),\n subtitle: z.string().max(200, 'Subtitle too long').optional(),\n features: z\n .array(FeatureItemSchema)\n .min(1, 'At least one feature is required')\n .max(6, 'Maximum 6 features allowed'),\n layout: z.enum(FeaturesLayout).default('grid'),\n});\n\n// Inferred type for components\nexport type FeaturesBlockContent = z.infer<typeof FeaturesBlockContentSchema>;\n\n// Schema name constant\nexport const FEATURES_BLOCK_SCHEMA_NAME = 'features-block';\n\n// Default values for a new FeaturesBlock\nexport const FeaturesBlockDefaults: Partial<FeaturesBlockContent> = {\n layout: 'grid',\n features: [\n {\n icon: 'star',\n title: 'Feature Title',\n description: 'Describe this feature',\n },\n ],\n};\n\n// Validate FeaturesBlock content\nexport function validateFeaturesBlockContent(content: unknown): {\n valid: boolean;\n data?: FeaturesBlockContent;\n errors?: z.ZodError;\n} {\n const result = FeaturesBlockContentSchema.safeParse(content);\n if (result.success) {\n return { valid: true, data: result.data };\n }\n return { valid: false, errors: result.error };\n}\n\n// Create default FeaturesBlock content\nexport function createDefaultFeaturesContent(): FeaturesBlockContent {\n return {\n title: '',\n subtitle: undefined,\n features: [],\n layout: 'grid',\n };\n}\n\n// Add a feature to the content\nexport function addFeature(\n content: FeaturesBlockContent,\n feature: FeatureItem\n): FeaturesBlockContent {\n if (content.features.length >= 6) {\n throw new Error('Maximum 6 features allowed');\n }\n return {\n ...content,\n features: [...content.features, feature],\n };\n}\n\n// Remove a feature by index\nexport function removeFeature(content: FeaturesBlockContent, index: number): FeaturesBlockContent {\n if (index < 0 || index >= content.features.length) {\n throw new Error('Invalid feature index');\n }\n if (content.features.length <= 1) {\n throw new Error('At least one feature is required');\n }\n return {\n ...content,\n features: content.features.filter((_, i) => i !== index),\n };\n}\n\n// Update a feature by index\nexport function updateFeature(\n content: FeaturesBlockContent,\n index: number,\n updates: Partial<FeatureItem>\n): FeaturesBlockContent {\n if (index < 0 || index >= content.features.length) {\n throw new Error('Invalid feature index');\n }\n return {\n ...content,\n features: content.features.map((feature, i) =>\n i === index ? { ...feature, ...updates } : feature\n ),\n };\n}\n","/**\n * HeroBlock Schema.\n *\n * A full-width hero section typically used at the top of pages.\n * Supports headline, subheadline, CTA button, background image, and alignment.\n */\n\nimport { z } from 'zod';\nimport { ImageReferenceSchema } from '../../fields/complex/media';\n\n// Alignment options for the hero content\nexport const HeroAlignment = ['left', 'center', 'right'] as const;\nexport type HeroAlignmentType = (typeof HeroAlignment)[number];\n\n// HeroBlock content schema with validation rules\nexport const HeroBlockContentSchema = z.object({\n headline: z.string().min(1, 'Headline is required').max(100, 'Headline too long'),\n subheadline: z.string().max(200, 'Subheadline too long').optional(),\n ctaText: z.string().max(50, 'CTA text too long').optional(),\n ctaUrl: z\n .string()\n .refine(\n (val) => {\n // Empty string is valid\n if (val === '') return true;\n // Accept full URLs\n if (val.startsWith('http://') || val.startsWith('https://')) {\n try {\n new URL(val);\n return true;\n } catch {\n return false;\n }\n }\n // Accept relative paths starting with /\n if (val.startsWith('/')) {\n return true;\n }\n // Accept hash/anchor links\n if (val.startsWith('#')) {\n return true;\n }\n return false;\n },\n {\n message:\n 'URL must be a valid full URL (http://... or https://...), relative path (/path), anchor (#anchor), or empty string',\n }\n )\n .optional()\n .or(z.literal('')),\n backgroundImage: ImageReferenceSchema.nullable().optional(),\n alignment: z.enum(HeroAlignment).default('center'),\n});\n\n// Inferred type for components\nexport type HeroBlockContent = z.infer<typeof HeroBlockContentSchema>;\n\n// Schema name constant\nexport const HERO_BLOCK_SCHEMA_NAME = 'hero-block';\n\n// Default values for a new HeroBlock\nexport const HeroBlockDefaults: Partial<HeroBlockContent> = {\n alignment: 'center',\n};\n\n// Validate HeroBlock content\nexport function validateHeroBlockContent(content: unknown): {\n valid: boolean;\n data?: HeroBlockContent;\n errors?: z.ZodError;\n} {\n const result = HeroBlockContentSchema.safeParse(content);\n if (result.success) {\n return { valid: true, data: result.data };\n }\n return { valid: false, errors: result.error };\n}\n\n// Create default HeroBlock content\nexport function createDefaultHeroContent(): HeroBlockContent {\n return {\n headline: '',\n subheadline: undefined,\n ctaText: undefined,\n ctaUrl: undefined,\n backgroundImage: undefined,\n alignment: 'center',\n };\n}\n","/**\n * Image and file field factories.\n *\n * The image() factory creates an ImageReference schema that:\n * - References an ImageAsset by ID\n * - Stores context-specific alt text (required for accessibility)\n * - Supports optional hotspot (focal point) and crop settings\n */\n\nimport { z } from 'zod';\n\nimport type { FieldMeta } from '../../types';\nimport {\n DimensionSchema,\n FileSizeSchema,\n MAX_DIMENSION,\n MAX_FILE_SIZE,\n MIN_DIMENSION,\n MimeTypeSchema,\n} from '../../validation/image';\n\n// =============================================================================\n// ImageAsset Schema (Public API type)\n// =============================================================================\n\n/**\n * Public API schema for image assets.\n *\n * This is the camelCase API representation. The database uses snake_case\n * (ImageAssetRow in db/image-asset-types.ts). Conversion between formats\n * should happen at the API boundary.\n *\n * Reuses validation schemas from validation/image.ts to ensure consistency.\n */\nexport const ImageAssetSchema = z.object({\n /** UUID primary key */\n id: z.uuid(),\n /** R2/S3 storage URL for the original file */\n url: z.url(),\n /** Image width in pixels */\n width: DimensionSchema,\n /** Image height in pixels */\n height: DimensionSchema,\n /** Original filename from upload */\n originalFilename: z.string().min(1).max(255),\n /** MIME type (only web-safe formats allowed) */\n mimeType: MimeTypeSchema,\n /** File size in bytes */\n fileSize: FileSizeSchema,\n /** Base64-encoded tiny preview for blur-up loading */\n lqip: z.string().optional(),\n});\n\nexport type ImageAsset = z.infer<typeof ImageAssetSchema>;\n\n// =============================================================================\n// ImageReference Schema (Asset-reference model)\n// =============================================================================\n\n/**\n * Hotspot defines the focal point of an image.\n * Coordinates are fractions (0-1) from top-left.\n */\nexport const HotspotSchema = z.object({\n x: z.number().min(0).max(1),\n y: z.number().min(0).max(1),\n});\n\nexport type Hotspot = z.infer<typeof HotspotSchema>;\n\n/**\n * Crop defines the region to extract from the image.\n * - x, y: Top-left coordinate of the crop region in pixels\n * - width, height: Size of the crop region in pixels\n * All values are positive integers representing pixel coordinates/dimensions.\n */\nexport const CropSchema = z.object({\n /** X coordinate of top-left corner in pixels */\n x: z.number().int().nonnegative(),\n /** Y coordinate of top-left corner in pixels */\n y: z.number().int().nonnegative(),\n /** Width of crop region in pixels (must be > 0) */\n width: z.number().int().positive(),\n /** Height of crop region in pixels (must be > 0) */\n height: z.number().int().positive(),\n});\n\nexport type Crop = z.infer<typeof CropSchema>;\n\n/**\n * ImageReference is what blocks store.\n * It points to an ImageAsset and adds context-specific metadata.\n */\nexport const ImageReferenceSchema = z.object({\n // Alt text is REQUIRED for accessibility\n alt: z.string().min(1, 'Alt text is required for accessibility'),\n\n // Optional metadata\n caption: z.string().max(500).optional(),\n attribution: z.string().max(255).optional(),\n\n // Reference to the ImageAsset with stored transformation\n _asset: z.object({\n id: z.uuid(),\n transformation: z.string().nullable().optional(),\n }),\n});\n\nexport type ImageReference = z.infer<typeof ImageReferenceSchema>;\n\n// =============================================================================\n// Image Field Factory\n// =============================================================================\n\nexport interface ImageFieldOptions {\n label: string;\n description?: string;\n required?: boolean;\n accept?: string;\n maxSize?: number;\n minWidth?: number;\n minHeight?: number;\n maxWidth?: number;\n maxHeight?: number;\n aspectRatio?: number;\n}\n\n/**\n * Create an image field with metadata.\n *\n * @example\n * const featuredImage = image({\n * label: 'Featured Image',\n * required: true,\n * accept: 'image/jpeg, image/png',\n * maxSize: 5 * 1024 * 1024,\n * });\n *\n * // Type of the value:\n * // {\n * // alt: string;\n * // caption?: string;\n * // attribution?: string;\n * // _asset: { id: string; transformation?: string | null };\n * // }\n */\nexport function image(options: ImageFieldOptions) {\n const meta: FieldMeta = {\n label: options.label,\n component: 'image-uploader',\n required: options.required,\n description: options.description,\n options: {\n accept: options.accept ?? 'image/jpeg, image/png, image/webp',\n maxSize: options.maxSize ?? MAX_FILE_SIZE, // 10MB default\n minWidth: options.minWidth ?? MIN_DIMENSION,\n minHeight: options.minHeight ?? MIN_DIMENSION,\n maxWidth: options.maxWidth ?? MAX_DIMENSION,\n maxHeight: options.maxHeight ?? MAX_DIMENSION,\n aspectRatio: options.aspectRatio,\n },\n };\n\n // Use the new ImageReferenceSchema instead of URL-only\n const result = options.required ? ImageReferenceSchema : ImageReferenceSchema.optional();\n\n return result.meta(meta);\n}\n\n// =============================================================================\n// File Field\n// =============================================================================\n\nexport interface FileFieldOptions {\n label: string;\n description?: string;\n required?: boolean;\n accept?: string;\n maxSize?: number;\n}\n\nconst fileSchema = z.object({\n url: z.url(),\n name: z.string(),\n size: z.number().int().positive(),\n type: z.string(),\n});\n\n/**\n * Create a file field with metadata.\n */\nexport function file(options: FileFieldOptions) {\n const meta: FieldMeta = {\n label: options.label,\n component: 'file-uploader',\n required: options.required,\n description: options.description,\n options: {\n accept: options.accept ?? '*/*',\n maxSize: options.maxSize,\n },\n };\n\n const result = options.required ? fileSchema : fileSchema.optional();\n return result.meta(meta);\n}\n","/**\n * LogoTrustBlock Schema.\n *\n * A section displaying customer/partner logos for trust and credibility.\n * Supports an optional title and an array of logo images.\n *\n * Each logo uses ImageReference to integrate with the image manager.\n */\n\nimport { z } from 'zod';\n\nimport { ImageReferenceSchema } from '../../fields/complex/media';\n\n// Logo item schema - uses ImageReference for image manager integration\nexport const LogoItemSchema = z.object({\n /** Unique ID for this logo item */\n id: z.uuid(),\n /** Image reference (alt + _asset with transformation) */\n image: ImageReferenceSchema,\n /** Optional company/brand name to display */\n name: z.string().max(100, 'Name too long').optional(),\n});\n\nexport type LogoItem = z.infer<typeof LogoItemSchema>;\n\n/**\n * Legacy logo format schema for backwards compatibility.\n * Used for logos stored with direct URL before the ImageReference migration.\n */\nexport const LegacyLogoItemSchema = z.object({\n /** Direct URL to the logo image */\n url: z.string(),\n /** Alt text for the image */\n alt: z.string(),\n /** Optional company/brand name */\n name: z.string().optional(),\n});\n\nexport type LegacyLogoItem = z.infer<typeof LegacyLogoItemSchema>;\n\n// LogoTrustBlock content schema with validation rules\nexport const LogoTrustBlockContentSchema = z.object({\n title: z.string().max(100, 'Title too long').optional(),\n logos: z.array(LogoItemSchema).max(20, 'Maximum 20 logos allowed'),\n});\n\n// Inferred type for components\nexport type LogoTrustBlockContent = z.infer<typeof LogoTrustBlockContentSchema>;\n\n// Schema name constant\nexport const LOGO_TRUST_BLOCK_SCHEMA_NAME = 'logo-trust-block';\n\n// Default values for a new LogoTrustBlock\nexport const LogoTrustBlockDefaults: Partial<LogoTrustBlockContent> = {\n logos: [],\n};\n\n// Validate LogoTrustBlock content\nexport function validateLogoTrustBlockContent(content: unknown): {\n valid: boolean;\n data?: LogoTrustBlockContent;\n errors?: z.ZodError;\n} {\n const result = LogoTrustBlockContentSchema.safeParse(content);\n if (result.success) {\n return { valid: true, data: result.data };\n }\n return { valid: false, errors: result.error };\n}\n\n// Create default LogoTrustBlock content\nexport function createDefaultLogoTrustContent(): LogoTrustBlockContent {\n return {\n title: undefined,\n logos: [],\n };\n}\n","/**\n * Block Schema Registry.\n *\n * Centralizes registration for all block schemas.\n * Call registerAllBlockSchemas() during application initialization.\n */\n\nimport { clearSchemaRegistry, registerBlockSchema } from '../validation';\nimport { ARTICLE_BLOCK_SCHEMA_NAME, ArticleBlockContentSchema } from './schemas/article-block';\nimport { CTA_BLOCK_SCHEMA_NAME, CTABlockContentSchema } from './schemas/cta-block';\nimport { FEATURES_BLOCK_SCHEMA_NAME, FeaturesBlockContentSchema } from './schemas/features-block';\nimport { HERO_BLOCK_SCHEMA_NAME, HeroBlockContentSchema } from './schemas/hero-block';\nimport {\n LOGO_TRUST_BLOCK_SCHEMA_NAME,\n LogoTrustBlockContentSchema,\n} from './schemas/logo-trust-block';\n\n// All registered block schema names\nexport const BLOCK_SCHEMA_NAMES = [\n ARTICLE_BLOCK_SCHEMA_NAME,\n HERO_BLOCK_SCHEMA_NAME,\n FEATURES_BLOCK_SCHEMA_NAME,\n CTA_BLOCK_SCHEMA_NAME,\n LOGO_TRUST_BLOCK_SCHEMA_NAME,\n] as const;\n\n// Union type for type-safe schema name usage\nexport type BlockSchemaName = (typeof BLOCK_SCHEMA_NAMES)[number];\n\n// Register all block schemas\nexport function registerAllBlockSchemas(): void {\n registerBlockSchema(ARTICLE_BLOCK_SCHEMA_NAME, ArticleBlockContentSchema);\n registerBlockSchema(HERO_BLOCK_SCHEMA_NAME, HeroBlockContentSchema);\n registerBlockSchema(FEATURES_BLOCK_SCHEMA_NAME, FeaturesBlockContentSchema);\n registerBlockSchema(CTA_BLOCK_SCHEMA_NAME, CTABlockContentSchema);\n registerBlockSchema(LOGO_TRUST_BLOCK_SCHEMA_NAME, LogoTrustBlockContentSchema);\n}\n\n// Clear and re-register all block schemas (for testing)\nexport function resetBlockSchemas(): void {\n clearSchemaRegistry();\n registerAllBlockSchemas();\n}\n\n// Type guard for schema names\nexport function isValidBlockSchemaName(name: string): name is BlockSchemaName {\n return BLOCK_SCHEMA_NAMES.includes(name as BlockSchemaName);\n}\n","/**\n * Catch-all Route Handler for Parametric Routes\n *\n * Handles routes with multiple segments like /us/en/products.\n * Uses the CMS API to fetch and render blocks.\n */\n\nimport {\n isArticlePublished,\n isValidBlockSchemaName,\n normalizeArticleContent,\n} from '@repo/cms-schema/blocks';\nimport type { Metadata } from 'next';\nimport { unstable_noStore } from 'next/cache';\nimport { notFound } from 'next/navigation';\nimport { BlockRenderer } from './block-renderer';\nimport { getCmsClient } from './cms-api';\nimport type { BlockComponentRegistry, BlockData } from './types';\n\ntype PageProps = {\n params: Promise<{ slug: string[] }>;\n /** CMS API base URL (e.g., 'http://localhost:3000') */\n cmsUrl: string;\n registry?: Partial<BlockComponentRegistry>;\n /** API key for CMS API authentication */\n apiKey?: string;\n /** Website ID (required if not using env variables) */\n websiteId?: string;\n};\n\nfunction getWebsiteId(providedWebsiteId?: string): string {\n const websiteId =\n providedWebsiteId ??\n process.env.NEXT_PUBLIC_WEBSITE_ID ??\n process.env.WEBSITE_ID ??\n process.env.CMS_WEBSITE_ID;\n\n if (!websiteId) {\n throw new Error(\n 'Missing websiteId for website renderer. Either pass websiteId prop or set NEXT_PUBLIC_WEBSITE_ID (or WEBSITE_ID/CMS_WEBSITE_ID) to a valid UUID.'\n );\n }\n\n const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n if (!uuidRegex.test(websiteId)) {\n throw new Error(\n `Invalid websiteId \"${websiteId}\". Provide a valid UUID via prop or set NEXT_PUBLIC_WEBSITE_ID (or WEBSITE_ID/CMS_WEBSITE_ID).`\n );\n }\n\n return websiteId;\n}\n\n/**\n * Force dynamic rendering to ensure routes are always fresh.\n * This prevents Next.js from caching pages when routes are published.\n */\nexport const dynamic = 'force-dynamic';\n\n/**\n * Catch-all route handler for parametric routes.\n *\n * Handles paths like:\n * - /us/en/products -> slug = ['us', 'en', 'products']\n * - /about -> slug = ['about']\n *\n * Reconstructs the full path and fetches route via tRPC.\n */\nexport default async function ParametricRoutePage({\n params,\n registry,\n apiKey,\n cmsUrl,\n websiteId: providedWebsiteId,\n}: PageProps) {\n // Prevent any caching - ensure we always fetch fresh route data\n unstable_noStore();\n\n const websiteId = getWebsiteId(providedWebsiteId);\n const { slug } = await params;\n\n // Reconstruct full path from slug segments and normalize it\n const rawPath = `/${slug.join('/')}`;\n const path = normalizePath(rawPath);\n\n // Get CMS API client with optional API key and custom URL\n const client = getCmsClient({ apiKey, cmsUrl });\n\n try {\n // Fetch route by path via CMS API\n const { route } = await client.route.getByPath.query({ websiteId, path });\n\n // Only show Live routes on public website\n if (route.state !== 'Live') {\n console.error(`Route found but not Live. Path: ${path}, State: ${route.state}`);\n notFound();\n }\n\n // Fetch all blocks by ID via CMS API (skip any that fail)\n const blockPromises = route.block_ids.map(async (blockId) => {\n try {\n const result = await client.block.getById.query({ websiteId, id: blockId });\n return result.block;\n } catch (error) {\n // Log error but don't fail the entire page\n console.error(`Failed to fetch block ${blockId}:`, error);\n return null;\n }\n });\n const blockResults = await Promise.all(blockPromises);\n\n // Transform blocks to BlockData format for BlockRenderer\n // Filter out any blocks that failed to load\n const blocks: BlockData[] = [];\n\n for (const block of blockResults) {\n if (!block || block.published_content === null) continue;\n\n const content = block.published_content as Record<string, unknown> | null;\n if (!content) continue;\n\n // Handle 'article' blocks separately before checking schema type\n if (block.schema_name === 'article') {\n const article = normalizeArticleContent(content);\n const isPublished = article ? isArticlePublished(article) : null;\n if (article && isPublished) {\n blocks.push({ id: block.id, type: 'article', content: article });\n }\n continue;\n }\n\n // Skip blocks with invalid schema names (after handling 'article')\n if (!isValidBlockSchemaName(block.schema_name)) {\n continue;\n }\n\n // For all block types, map schema_name to type and include content\n // Image references are automatically resolved by block.getById\n blocks.push({\n id: block.id,\n type: block.schema_name,\n content,\n } as BlockData);\n }\n\n return (\n <main>\n {blocks.map((block) => (\n <BlockRenderer registry={registry ?? {}} key={block.id} block={block} />\n ))}\n </main>\n );\n } catch (error) {\n // Log error for debugging\n console.error(`Route fetch error for path: ${path}`, error);\n\n // If route not found or param validation fails, show 404\n // TRPCClientError has data.code for the error code\n const errorCode =\n error instanceof Error && 'data' in error\n ? (error as { data?: { code?: string } }).data?.code\n : error instanceof Error && 'code' in error\n ? (error as { code: string }).code\n : undefined;\n\n if (errorCode === 'NOT_FOUND' || errorCode === 'P0002') {\n notFound();\n }\n\n // Re-throw other errors\n throw error;\n }\n}\n\n// -----------------------------------------------------------------------------\n// Metadata\n// -----------------------------------------------------------------------------\n\n/**\n * Generate metadata for the page.\n * Uses Next.js 15+ async params pattern.\n */\nexport async function generateMetadata({\n params,\n apiKey,\n cmsUrl,\n websiteId: providedWebsiteId,\n}: PageProps): Promise<Metadata> {\n const websiteId = getWebsiteId(providedWebsiteId);\n const { slug } = await params;\n const rawPath = `/${slug.join('/')}`;\n const path = normalizePath(rawPath);\n const client = getCmsClient({ apiKey, cmsUrl });\n\n try {\n const { route } = await client.route.getByPath.query({ websiteId, path });\n return {\n title: `${route.path} | Website`,\n description: `Content page: ${route.path}`,\n };\n } catch {\n return {\n title: 'Page Not Found | Website',\n description: 'The requested page could not be found.',\n };\n }\n}\n\nexport function normalizePath(path: string): string {\n if (!path || path === '/') {\n return '/';\n }\n\n // Remove trailing slashes, ensure leading slash\n let normalized = path.trim();\n\n // Remove trailing slashes (but keep root \"/\")\n normalized = normalized.replace(/\\/+$/, '');\n\n // Ensure leading slash\n if (!normalized.startsWith('/')) {\n normalized = `/${normalized}`;\n }\n\n // Collapse multiple consecutive slashes to single slash\n normalized = normalized.replace(/\\/+/g, '/');\n\n return normalized;\n}\n"],"mappings":"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","names":["z","z","z","z","z","z","z","z","z","z"]}
|
|
1
|
+
{"version":3,"sources":["../../../../packages/cms-schema/src/blocks/article.ts","../../../../packages/cms-schema/src/validation/image.ts","../../../../packages/cms-schema/src/blocks/schemas/article-block.ts","../../../../packages/cms-schema/src/blocks/schemas/cta-block.ts","../../../../packages/cms-schema/src/blocks/schemas/features-block.ts","../../../../packages/cms-schema/src/blocks/schemas/hero-block.ts","../../../../packages/cms-schema/src/fields/complex/media.ts","../../../../packages/cms-schema/src/blocks/schemas/logo-trust-block.ts","../../../../packages/cms-schema/src/blocks/registry.ts","../../lib/renderer.tsx"],"sourcesContent":["/**\n * Article block normalization helpers shared across CMS and website apps.\n */\n\nexport interface ArticleContent {\n headline: string;\n author?: string;\n publishedAt?: string;\n body: string;\n tags?: readonly string[];\n status?: string;\n}\n\nexport interface ArticleRow {\n id: string;\n published_content: unknown;\n}\n\nexport interface NormalizedArticleRow extends ArticleContent {\n id: string;\n tags: string[];\n status: string;\n}\n\n/**\n * Normalize article content coming from Supabase blocks.\n * Returns null when required fields are missing or invalid.\n */\nexport function normalizeArticleContent(payload: unknown): ArticleContent | null {\n if (!payload || typeof payload !== 'object') {\n return null;\n }\n\n const record = payload as Record<string, unknown>;\n const headline = typeof record.headline === 'string' ? record.headline : null;\n const body = typeof record.body === 'string' ? record.body : null;\n\n if (!headline || !body) {\n return null;\n }\n\n const author = typeof record.author === 'string' ? record.author : undefined;\n const publishedAt = typeof record.publishedAt === 'string' ? record.publishedAt : undefined;\n const tags = Array.isArray(record.tags) ? record.tags.map((tag) => String(tag)) : undefined;\n const statusRaw = typeof record.status === 'string' ? record.status.trim() : undefined;\n\n return {\n headline,\n body,\n author,\n publishedAt,\n tags,\n status: statusRaw || undefined,\n };\n}\n\n/**\n * Normalize an article block row, applying defaults for tags/status.\n */\nexport function normalizeArticleRow(row: ArticleRow): NormalizedArticleRow | null {\n const article = normalizeArticleContent(row.published_content);\n if (!article) {\n return null;\n }\n\n return {\n ...article,\n id: row.id,\n tags: article.tags ? [...article.tags] : [],\n status: article.status ?? 'published',\n };\n}\n\n/**\n * Ensure the article is published.\n */\nexport function isArticlePublished(article: ArticleContent): boolean {\n const now = new Date();\n const publishedAt = article.publishedAt ? new Date(article.publishedAt) : null;\n\n return (\n article.status === 'published' &&\n publishedAt !== null &&\n !Number.isNaN(publishedAt.getTime()) &&\n publishedAt <= now\n );\n}\n","/**\n * Image Validation Utilities\n *\n * Provides validation for:\n * - File type (MIME type checking)\n * - File size (configurable limits)\n * - Image dimensions (width/height constraints)\n */\n\nimport { z } from 'zod';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nexport const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const;\nexport type ImageMimeType = (typeof ALLOWED_MIME_TYPES)[number];\n\nexport const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB\nexport const MIN_FILE_SIZE = 1024; // 1KB (avoid empty/corrupt files)\n\nexport const MAX_DIMENSION = 8192; // 8K resolution\nexport const MIN_DIMENSION = 10; // Minimum useful size\n\n// =============================================================================\n// Zod Schemas\n// =============================================================================\n\nexport const MimeTypeSchema = z.enum(ALLOWED_MIME_TYPES);\n\nexport const FileSizeSchema = z\n .number()\n .int()\n .min(MIN_FILE_SIZE, `File too small. Minimum: ${MIN_FILE_SIZE} bytes`)\n .max(MAX_FILE_SIZE, `File too large. Maximum: ${MAX_FILE_SIZE / 1024 / 1024}MB`);\n\nexport const DimensionSchema = z\n .number()\n .int()\n .min(MIN_DIMENSION, `Dimension too small. Minimum: ${MIN_DIMENSION}px`)\n .max(MAX_DIMENSION, `Dimension too large. Maximum: ${MAX_DIMENSION}px`);\n\n/**\n * Schema for upload request validation.\n */\nexport const UploadRequestSchema = z.object({\n filename: z\n .string()\n .min(1, 'Filename is required')\n .max(255, 'Filename too long')\n .regex(/^[^<>:\"/\\\\|?*]+$/, 'Filename contains invalid characters'),\n mimeType: MimeTypeSchema,\n fileSize: FileSizeSchema,\n});\n\nexport type UploadRequest = z.infer<typeof UploadRequestSchema>;\n\n/**\n * Schema for confirming upload with dimensions.\n * assetId is optional - if not provided, the server will generate a UUID.\n */\nexport const ConfirmUploadSchema = z.object({\n assetId: z.uuid().optional(),\n width: DimensionSchema,\n height: DimensionSchema,\n});\n\nexport type ConfirmUpload = z.infer<typeof ConfirmUploadSchema>;\n\n// =============================================================================\n// Validation Functions\n// =============================================================================\n\nexport interface ValidationResult {\n valid: boolean;\n error?: string;\n}\n\n/**\n * Validate file type by MIME type.\n */\nexport function validateMimeType(mimeType: string): ValidationResult {\n const result = MimeTypeSchema.safeParse(mimeType);\n if (result.success) {\n return { valid: true };\n }\n return {\n valid: false,\n error: `File type not supported. Use JPEG, PNG, or WebP.`,\n };\n}\n\n/**\n * Validate file size.\n */\nexport function validateFileSize(size: number): ValidationResult {\n if (size < MIN_FILE_SIZE) {\n return {\n valid: false,\n error: `File appears to be empty or corrupt.`,\n };\n }\n if (size > MAX_FILE_SIZE) {\n const sizeMB = (size / 1024 / 1024).toFixed(1);\n return {\n valid: false,\n error: `File too large (${sizeMB}MB). Maximum: 10 MB.`,\n };\n }\n return { valid: true };\n}\n\n/**\n * Validate image dimensions.\n */\nexport function validateDimensions(width: number, height: number): ValidationResult {\n if (width < MIN_DIMENSION || height < MIN_DIMENSION) {\n return {\n valid: false,\n error: `Image too small. Minimum: ${MIN_DIMENSION}x${MIN_DIMENSION}px.`,\n };\n }\n if (width > MAX_DIMENSION || height > MAX_DIMENSION) {\n return {\n valid: false,\n error: `Image too large. Maximum: ${MAX_DIMENSION}x${MAX_DIMENSION}px.`,\n };\n }\n return { valid: true };\n}\n\n/**\n * Validate complete upload request.\n */\nexport function validateUploadRequest(\n request: unknown\n): { valid: true; data: UploadRequest } | { valid: false; error: string } {\n const result = UploadRequestSchema.safeParse(request);\n if (result.success) {\n return { valid: true, data: result.data };\n }\n const firstIssue = result.error.issues[0];\n return {\n valid: false,\n error: firstIssue?.message ?? 'Invalid upload request',\n };\n}\n\n// =============================================================================\n// File Extension Utilities\n// =============================================================================\n\nconst MIME_TO_EXT: Record<ImageMimeType, string> = {\n 'image/jpeg': 'jpg',\n 'image/png': 'png',\n 'image/webp': 'webp',\n};\n\nconst EXT_TO_MIME: Record<string, ImageMimeType> = {\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n png: 'image/png',\n webp: 'image/webp',\n};\n\n/**\n * Get file extension from MIME type.\n */\nexport function getExtensionFromMime(mimeType: ImageMimeType | string): string {\n return MIME_TO_EXT[mimeType as ImageMimeType] ?? 'bin';\n}\n\n/**\n * Get MIME type from file extension.\n */\nexport function getMimeFromExtension(ext: string): ImageMimeType | undefined {\n return EXT_TO_MIME[ext.toLowerCase()];\n}\n\n/**\n * Extract extension from filename.\n */\nexport function getExtensionFromFilename(filename: string): string {\n const parts = filename.split('.');\n return parts.pop()?.toLowerCase?.() ?? '';\n}\n\n/**\n * Sanitize filename for storage.\n * Removes special characters, preserves extension.\n */\nexport function sanitizeFilename(filename: string): string {\n // Get extension\n const ext = getExtensionFromFilename(filename);\n\n // Get base name without extension\n const base = filename.slice(0, filename.length - ext.length - 1);\n\n // Sanitize: lowercase, replace spaces and special chars\n const sanitized = base\n .toLowerCase()\n .replace(/[^a-z0-9]/g, '-')\n .replace(/-+/g, '-')\n .replace(/^-|-$/g, '')\n .slice(0, 50); // Limit length\n\n return ext ? `${sanitized}.${ext}` : sanitized;\n}\n","/**\n * ArticleBlock Schema.\n *\n * Defines the structure and validation for ArticleBlock content.\n *\n * ArticleBlock represents a block of content focused on articles,\n * including fields such as headline, author, publication date, body content, tags, and status.\n *\n * Includes:\n * - Zod schema for validation\n * - TypeScript type inference\n * - Default values\n * - Validation function\n * - Function to create default content\n */\nimport { z } from 'zod';\n\n// Article block content schema with validation rules\nexport const ArticleBlockContentSchema = z.object({\n headline: z.string().min(1, 'Article headline is required').max(300, 'Headline too long').trim(),\n author: z.string().max(100, 'Article author too long').trim().optional(),\n publishedAt: z.iso\n .datetime({ message: 'Article publishedAt must be a valid ISO 8601 datetime string.' })\n .optional(),\n body: z.string().min(1, 'Article body content is required'),\n tags: z.array(z.string()).optional(),\n status: z.enum(['draft', 'review', 'published']),\n});\n\n// Inferred type for components\nexport type ArticleBlockContent = z.infer<typeof ArticleBlockContentSchema>;\n\n// Schema name constant\nexport const ARTICLE_BLOCK_SCHEMA_NAME = 'article';\n\n// Default values for a new ArticleBlock\nexport const ArticleBlockDefaults: Partial<ArticleBlockContent> = {\n status: 'draft',\n};\n\n// Validate ArticleBlock content\nexport function validateArticleBlockContent(content: unknown): {\n valid: boolean;\n data?: ArticleBlockContent;\n errors?: z.ZodError;\n} {\n const result = ArticleBlockContentSchema.safeParse(content);\n if (result.success) {\n return { valid: true, data: result.data };\n }\n return { valid: false, errors: result.error };\n}\n\n// Create default ArticleBlock content\nexport function createDefaultArticleContent(): ArticleBlockContent {\n return {\n headline: 'untitled article',\n author: '',\n publishedAt: new Date().toISOString(),\n body: '',\n tags: [],\n status: 'draft',\n };\n}\n","/**\n * CTABlock Schema.\n *\n * A call-to-action section with headline, description, and action buttons.\n * Supports primary and optional secondary CTA buttons.\n */\n\nimport { z } from 'zod';\n\n// CTA button schema\n// URL can be either a full URL (http://...) or a relative path (/path)\nconst CTAButtonSchema = z.object({\n text: z.string().min(1, 'Button text is required').max(50, 'Button text too long'),\n url: z.string().refine(\n (val) => {\n // Accept full URLs\n if (val.startsWith('http://') || val.startsWith('https://')) {\n try {\n new URL(val);\n return true;\n } catch {\n return false;\n }\n }\n // Accept relative paths starting with /\n if (val.startsWith('/')) {\n return true;\n }\n // Accept hash/anchor links\n if (val.startsWith('#')) {\n return true;\n }\n return false;\n },\n {\n message:\n 'URL must be a valid full URL (http://... or https://...), relative path (/path), or anchor (#anchor)',\n }\n ),\n});\n\nexport type CTAButton = z.infer<typeof CTAButtonSchema>;\n\n// CTABlock content schema with validation rules\nexport const CTABlockContentSchema = z.object({\n headline: z.string().min(1, 'Headline is required').max(100, 'Headline too long'),\n description: z.string().max(500, 'Description too long').optional(),\n primaryButton: CTAButtonSchema,\n secondaryButton: CTAButtonSchema.optional(),\n});\n\n// Inferred type for components\nexport type CTABlockContent = z.infer<typeof CTABlockContentSchema>;\n\n// Schema name constant\nexport const CTA_BLOCK_SCHEMA_NAME = 'cta-block';\n\n// Default values for a new CTABlock\nexport const CTABlockDefaults: Partial<CTABlockContent> = {\n primaryButton: {\n text: 'Get Started',\n url: '#',\n },\n};\n\n// Validate CTABlock content\nexport function validateCTABlockContent(content: unknown): {\n valid: boolean;\n data?: CTABlockContent;\n errors?: z.ZodError;\n} {\n const result = CTABlockContentSchema.safeParse(content);\n if (result.success) {\n return { valid: true, data: result.data };\n }\n return { valid: false, errors: result.error };\n}\n\n// Create default CTABlock content\nexport function createDefaultCTAContent(): CTABlockContent {\n return {\n headline: '',\n description: undefined,\n primaryButton: {\n text: 'Get Started',\n url: '#',\n },\n secondaryButton: undefined,\n };\n}\n","/**\n * FeaturesBlock Schema.\n *\n * A section displaying a list of features with icons, titles, and descriptions.\n * Supports different layouts (grid, list, carousel).\n */\n\nimport { z } from 'zod';\n\n// Layout options for features display\nexport const FeaturesLayout = ['grid', 'list', 'carousel'] as const;\nexport type FeaturesLayoutType = (typeof FeaturesLayout)[number];\n\n// Single feature item schema\nexport const FeatureItemSchema = z.object({\n icon: z.string().max(50, 'Icon name too long').optional(),\n title: z.string().min(1, 'Title is required').max(100, 'Title too long'),\n description: z.string().max(500, 'Description too long').optional(),\n});\n\nexport type FeatureItem = z.infer<typeof FeatureItemSchema>;\n\n// FeaturesBlock content schema with array validation\nexport const FeaturesBlockContentSchema = z.object({\n title: z.string().min(1, 'Section title is required').max(100, 'Title too long'),\n subtitle: z.string().max(200, 'Subtitle too long').optional(),\n features: z\n .array(FeatureItemSchema)\n .min(1, 'At least one feature is required')\n .max(6, 'Maximum 6 features allowed'),\n layout: z.enum(FeaturesLayout).default('grid'),\n});\n\n// Inferred type for components\nexport type FeaturesBlockContent = z.infer<typeof FeaturesBlockContentSchema>;\n\n// Schema name constant\nexport const FEATURES_BLOCK_SCHEMA_NAME = 'features-block';\n\n// Default values for a new FeaturesBlock\nexport const FeaturesBlockDefaults: Partial<FeaturesBlockContent> = {\n layout: 'grid',\n features: [\n {\n icon: 'star',\n title: 'Feature Title',\n description: 'Describe this feature',\n },\n ],\n};\n\n// Validate FeaturesBlock content\nexport function validateFeaturesBlockContent(content: unknown): {\n valid: boolean;\n data?: FeaturesBlockContent;\n errors?: z.ZodError;\n} {\n const result = FeaturesBlockContentSchema.safeParse(content);\n if (result.success) {\n return { valid: true, data: result.data };\n }\n return { valid: false, errors: result.error };\n}\n\n// Create default FeaturesBlock content\nexport function createDefaultFeaturesContent(): FeaturesBlockContent {\n return {\n title: '',\n subtitle: undefined,\n features: [],\n layout: 'grid',\n };\n}\n\n// Add a feature to the content\nexport function addFeature(\n content: FeaturesBlockContent,\n feature: FeatureItem\n): FeaturesBlockContent {\n if (content.features.length >= 6) {\n throw new Error('Maximum 6 features allowed');\n }\n return {\n ...content,\n features: [...content.features, feature],\n };\n}\n\n// Remove a feature by index\nexport function removeFeature(content: FeaturesBlockContent, index: number): FeaturesBlockContent {\n if (index < 0 || index >= content.features.length) {\n throw new Error('Invalid feature index');\n }\n if (content.features.length <= 1) {\n throw new Error('At least one feature is required');\n }\n return {\n ...content,\n features: content.features.filter((_, i) => i !== index),\n };\n}\n\n// Update a feature by index\nexport function updateFeature(\n content: FeaturesBlockContent,\n index: number,\n updates: Partial<FeatureItem>\n): FeaturesBlockContent {\n if (index < 0 || index >= content.features.length) {\n throw new Error('Invalid feature index');\n }\n return {\n ...content,\n features: content.features.map((feature, i) =>\n i === index ? { ...feature, ...updates } : feature\n ),\n };\n}\n","/**\n * HeroBlock Schema.\n *\n * A full-width hero section typically used at the top of pages.\n * Supports headline, subheadline, CTA button, background image, and alignment.\n */\n\nimport { z } from 'zod';\nimport { ImageReferenceSchema } from '../../fields/complex/media';\n\n// Alignment options for the hero content\nexport const HeroAlignment = ['left', 'center', 'right'] as const;\nexport type HeroAlignmentType = (typeof HeroAlignment)[number];\n\n// HeroBlock content schema with validation rules\nexport const HeroBlockContentSchema = z.object({\n headline: z.string().min(1, 'Headline is required').max(100, 'Headline too long'),\n subheadline: z.string().max(200, 'Subheadline too long').optional(),\n ctaText: z.string().max(50, 'CTA text too long').optional(),\n ctaUrl: z\n .string()\n .refine(\n (val) => {\n // Empty string is valid\n if (val === '') return true;\n // Accept full URLs\n if (val.startsWith('http://') || val.startsWith('https://')) {\n try {\n new URL(val);\n return true;\n } catch {\n return false;\n }\n }\n // Accept relative paths starting with /\n if (val.startsWith('/')) {\n return true;\n }\n // Accept hash/anchor links\n if (val.startsWith('#')) {\n return true;\n }\n return false;\n },\n {\n message:\n 'URL must be a valid full URL (http://... or https://...), relative path (/path), anchor (#anchor), or empty string',\n }\n )\n .optional()\n .or(z.literal('')),\n backgroundImage: ImageReferenceSchema.nullable().optional(),\n alignment: z.enum(HeroAlignment).default('center'),\n});\n\n// Inferred type for components\nexport type HeroBlockContent = z.infer<typeof HeroBlockContentSchema>;\n\n// Schema name constant\nexport const HERO_BLOCK_SCHEMA_NAME = 'hero-block';\n\n// Default values for a new HeroBlock\nexport const HeroBlockDefaults: Partial<HeroBlockContent> = {\n alignment: 'center',\n};\n\n// Validate HeroBlock content\nexport function validateHeroBlockContent(content: unknown): {\n valid: boolean;\n data?: HeroBlockContent;\n errors?: z.ZodError;\n} {\n const result = HeroBlockContentSchema.safeParse(content);\n if (result.success) {\n return { valid: true, data: result.data };\n }\n return { valid: false, errors: result.error };\n}\n\n// Create default HeroBlock content\nexport function createDefaultHeroContent(): HeroBlockContent {\n return {\n headline: '',\n subheadline: undefined,\n ctaText: undefined,\n ctaUrl: undefined,\n backgroundImage: undefined,\n alignment: 'center',\n };\n}\n","/**\n * Image and file field factories.\n *\n * The image() factory creates an ImageReference schema that:\n * - References an ImageAsset by ID\n * - Stores context-specific alt text (required for accessibility)\n * - Supports optional hotspot (focal point) and crop settings\n */\n\nimport { z } from 'zod';\n\nimport type { FieldMeta } from '../../types';\nimport {\n DimensionSchema,\n FileSizeSchema,\n MAX_DIMENSION,\n MAX_FILE_SIZE,\n MIN_DIMENSION,\n MimeTypeSchema,\n} from '../../validation/image';\n\n// =============================================================================\n// ImageAsset Schema (Public API type)\n// =============================================================================\n\n/**\n * Public API schema for image assets.\n *\n * This is the camelCase API representation. The database uses snake_case\n * (ImageAssetRow in db/image-asset-types.ts). Conversion between formats\n * should happen at the API boundary.\n *\n * Reuses validation schemas from validation/image.ts to ensure consistency.\n */\nexport const ImageAssetSchema = z.object({\n /** UUID primary key */\n id: z.uuid(),\n /** R2/S3 storage URL for the original file */\n url: z.url(),\n /** Image width in pixels */\n width: DimensionSchema,\n /** Image height in pixels */\n height: DimensionSchema,\n /** Original filename from upload */\n originalFilename: z.string().min(1).max(255),\n /** MIME type (only web-safe formats allowed) */\n mimeType: MimeTypeSchema,\n /** File size in bytes */\n fileSize: FileSizeSchema,\n /** Base64-encoded tiny preview for blur-up loading */\n lqip: z.string().optional(),\n});\n\nexport type ImageAsset = z.infer<typeof ImageAssetSchema>;\n\n// =============================================================================\n// ImageReference Schema (Asset-reference model)\n// =============================================================================\n\n/**\n * Hotspot defines the focal point of an image.\n * Coordinates are fractions (0-1) from top-left.\n */\nexport const HotspotSchema = z.object({\n x: z.number().min(0).max(1),\n y: z.number().min(0).max(1),\n});\n\nexport type Hotspot = z.infer<typeof HotspotSchema>;\n\n/**\n * Crop defines the region to extract from the image.\n * - x, y: Top-left coordinate of the crop region in pixels\n * - width, height: Size of the crop region in pixels\n * All values are positive integers representing pixel coordinates/dimensions.\n */\nexport const CropSchema = z.object({\n /** X coordinate of top-left corner in pixels */\n x: z.number().int().nonnegative(),\n /** Y coordinate of top-left corner in pixels */\n y: z.number().int().nonnegative(),\n /** Width of crop region in pixels (must be > 0) */\n width: z.number().int().positive(),\n /** Height of crop region in pixels (must be > 0) */\n height: z.number().int().positive(),\n});\n\nexport type Crop = z.infer<typeof CropSchema>;\n\n/**\n * ImageReference is what blocks store.\n * It points to an ImageAsset and adds context-specific metadata.\n */\nexport const ImageReferenceSchema = z.object({\n // Alt text is REQUIRED for accessibility\n alt: z.string().min(1, 'Alt text is required for accessibility'),\n\n // Optional metadata\n caption: z.string().max(500).optional(),\n attribution: z.string().max(255).optional(),\n\n // Reference to the ImageAsset with stored transformation\n _asset: z.object({\n id: z.uuid(),\n transformation: z.string().nullable().optional(),\n }),\n});\n\nexport type ImageReference = z.infer<typeof ImageReferenceSchema>;\n\n// =============================================================================\n// Image Field Factory\n// =============================================================================\n\nexport interface ImageFieldOptions {\n label: string;\n description?: string;\n required?: boolean;\n accept?: string;\n maxSize?: number;\n minWidth?: number;\n minHeight?: number;\n maxWidth?: number;\n maxHeight?: number;\n aspectRatio?: number;\n}\n\n/**\n * Create an image field with metadata.\n *\n * @example\n * const featuredImage = image({\n * label: 'Featured Image',\n * required: true,\n * accept: 'image/jpeg, image/png',\n * maxSize: 5 * 1024 * 1024,\n * });\n *\n * // Type of the value:\n * // {\n * // alt: string;\n * // caption?: string;\n * // attribution?: string;\n * // _asset: { id: string; transformation?: string | null };\n * // }\n */\nexport function image(options: ImageFieldOptions) {\n const meta: FieldMeta = {\n label: options.label,\n component: 'image-uploader',\n required: options.required,\n description: options.description,\n options: {\n accept: options.accept ?? 'image/jpeg, image/png, image/webp',\n maxSize: options.maxSize ?? MAX_FILE_SIZE, // 10MB default\n minWidth: options.minWidth ?? MIN_DIMENSION,\n minHeight: options.minHeight ?? MIN_DIMENSION,\n maxWidth: options.maxWidth ?? MAX_DIMENSION,\n maxHeight: options.maxHeight ?? MAX_DIMENSION,\n aspectRatio: options.aspectRatio,\n },\n };\n\n // Use the new ImageReferenceSchema instead of URL-only\n const result = options.required ? ImageReferenceSchema : ImageReferenceSchema.optional();\n\n return result.meta(meta);\n}\n\n// =============================================================================\n// File Field\n// =============================================================================\n\nexport interface FileFieldOptions {\n label: string;\n description?: string;\n required?: boolean;\n accept?: string;\n maxSize?: number;\n}\n\nconst fileSchema = z.object({\n url: z.url(),\n name: z.string(),\n size: z.number().int().positive(),\n type: z.string(),\n});\n\n/**\n * Create a file field with metadata.\n */\nexport function file(options: FileFieldOptions) {\n const meta: FieldMeta = {\n label: options.label,\n component: 'file-uploader',\n required: options.required,\n description: options.description,\n options: {\n accept: options.accept ?? '*/*',\n maxSize: options.maxSize,\n },\n };\n\n const result = options.required ? fileSchema : fileSchema.optional();\n return result.meta(meta);\n}\n","/**\n * LogoTrustBlock Schema.\n *\n * A section displaying customer/partner logos for trust and credibility.\n * Supports an optional title and an array of logo images.\n *\n * Each logo uses ImageReference to integrate with the image manager.\n */\n\nimport { z } from 'zod';\n\nimport { ImageReferenceSchema } from '../../fields/complex/media';\n\n// Logo item schema - uses ImageReference for image manager integration\nexport const LogoItemSchema = z.object({\n /** Unique ID for this logo item */\n id: z.uuid(),\n /** Image reference (alt + _asset with transformation) */\n image: ImageReferenceSchema,\n /** Optional company/brand name to display */\n name: z.string().max(100, 'Name too long').optional(),\n});\n\nexport type LogoItem = z.infer<typeof LogoItemSchema>;\n\n/**\n * Legacy logo format schema for backwards compatibility.\n * Used for logos stored with direct URL before the ImageReference migration.\n */\nexport const LegacyLogoItemSchema = z.object({\n /** Direct URL to the logo image */\n url: z.string(),\n /** Alt text for the image */\n alt: z.string(),\n /** Optional company/brand name */\n name: z.string().optional(),\n});\n\nexport type LegacyLogoItem = z.infer<typeof LegacyLogoItemSchema>;\n\n// LogoTrustBlock content schema with validation rules\nexport const LogoTrustBlockContentSchema = z.object({\n title: z.string().max(100, 'Title too long').optional(),\n logos: z.array(LogoItemSchema).max(20, 'Maximum 20 logos allowed'),\n});\n\n// Inferred type for components\nexport type LogoTrustBlockContent = z.infer<typeof LogoTrustBlockContentSchema>;\n\n// Schema name constant\nexport const LOGO_TRUST_BLOCK_SCHEMA_NAME = 'logo-trust-block';\n\n// Default values for a new LogoTrustBlock\nexport const LogoTrustBlockDefaults: Partial<LogoTrustBlockContent> = {\n logos: [],\n};\n\n// Validate LogoTrustBlock content\nexport function validateLogoTrustBlockContent(content: unknown): {\n valid: boolean;\n data?: LogoTrustBlockContent;\n errors?: z.ZodError;\n} {\n const result = LogoTrustBlockContentSchema.safeParse(content);\n if (result.success) {\n return { valid: true, data: result.data };\n }\n return { valid: false, errors: result.error };\n}\n\n// Create default LogoTrustBlock content\nexport function createDefaultLogoTrustContent(): LogoTrustBlockContent {\n return {\n title: undefined,\n logos: [],\n };\n}\n","/**\n * Block Schema Registry.\n *\n * Centralizes registration for all block schemas.\n * Call registerAllBlockSchemas() during application initialization.\n */\n\nimport { clearSchemaRegistry, registerBlockSchema } from '../validation';\nimport { ARTICLE_BLOCK_SCHEMA_NAME, ArticleBlockContentSchema } from './schemas/article-block';\nimport { CTA_BLOCK_SCHEMA_NAME, CTABlockContentSchema } from './schemas/cta-block';\nimport { FEATURES_BLOCK_SCHEMA_NAME, FeaturesBlockContentSchema } from './schemas/features-block';\nimport { HERO_BLOCK_SCHEMA_NAME, HeroBlockContentSchema } from './schemas/hero-block';\nimport {\n LOGO_TRUST_BLOCK_SCHEMA_NAME,\n LogoTrustBlockContentSchema,\n} from './schemas/logo-trust-block';\n\n// All registered block schema names\nexport const BLOCK_SCHEMA_NAMES = [\n ARTICLE_BLOCK_SCHEMA_NAME,\n HERO_BLOCK_SCHEMA_NAME,\n FEATURES_BLOCK_SCHEMA_NAME,\n CTA_BLOCK_SCHEMA_NAME,\n LOGO_TRUST_BLOCK_SCHEMA_NAME,\n] as const;\n\n// Union type for type-safe schema name usage\nexport type BlockSchemaName = (typeof BLOCK_SCHEMA_NAMES)[number];\n\n// Register all block schemas\nexport function registerAllBlockSchemas(): void {\n registerBlockSchema(ARTICLE_BLOCK_SCHEMA_NAME, ArticleBlockContentSchema);\n registerBlockSchema(HERO_BLOCK_SCHEMA_NAME, HeroBlockContentSchema);\n registerBlockSchema(FEATURES_BLOCK_SCHEMA_NAME, FeaturesBlockContentSchema);\n registerBlockSchema(CTA_BLOCK_SCHEMA_NAME, CTABlockContentSchema);\n registerBlockSchema(LOGO_TRUST_BLOCK_SCHEMA_NAME, LogoTrustBlockContentSchema);\n}\n\n// Clear and re-register all block schemas (for testing)\nexport function resetBlockSchemas(): void {\n clearSchemaRegistry();\n registerAllBlockSchemas();\n}\n\n// Type guard for schema names\nexport function isValidBlockSchemaName(name: string): name is BlockSchemaName {\n return BLOCK_SCHEMA_NAMES.includes(name as BlockSchemaName);\n}\n","/**\n * Catch-all Route Handler for Parametric Routes\n *\n * Handles routes with multiple segments like /us/en/products.\n * Uses the CMS API to fetch and render blocks.\n */\n\nimport {\n isArticlePublished,\n isValidBlockSchemaName,\n normalizeArticleContent,\n} from '@repo/cms-schema/blocks';\nimport type { Metadata } from 'next';\nimport { unstable_noStore } from 'next/cache';\nimport { notFound } from 'next/navigation';\nimport { BlockRenderer } from './block-renderer';\nimport { getCmsClient } from './cms-api';\nimport type { BlockComponentRegistry, BlockData } from './types';\n\ntype PageProps = {\n params: Promise<{ slug: string[] }>;\n searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;\n /** CMS API base URL (e.g., 'http://localhost:3000') */\n cmsUrl: string;\n registry?: Partial<BlockComponentRegistry>;\n /** API key for CMS API authentication */\n apiKey?: string;\n /** Website ID (required if not using env variables) */\n websiteId?: string;\n};\n\nfunction getWebsiteId(providedWebsiteId?: string): string {\n const websiteId =\n providedWebsiteId ??\n process.env.NEXT_PUBLIC_WEBSITE_ID ??\n process.env.WEBSITE_ID ??\n process.env.CMS_WEBSITE_ID;\n\n if (!websiteId) {\n throw new Error(\n 'Missing websiteId for website renderer. Either pass websiteId prop or set NEXT_PUBLIC_WEBSITE_ID (or WEBSITE_ID/CMS_WEBSITE_ID) to a valid UUID.'\n );\n }\n\n const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n if (!uuidRegex.test(websiteId)) {\n throw new Error(\n `Invalid websiteId \"${websiteId}\". Provide a valid UUID via prop or set NEXT_PUBLIC_WEBSITE_ID (or WEBSITE_ID/CMS_WEBSITE_ID).`\n );\n }\n\n return websiteId;\n}\n\n/**\n * Force dynamic rendering to ensure routes are always fresh.\n * This prevents Next.js from caching pages when routes are published.\n */\nexport const dynamic = 'force-dynamic';\n\n/**\n * Catch-all route handler for parametric routes.\n *\n * Handles paths like:\n * - /us/en/products -> slug = ['us', 'en', 'products']\n * - /about -> slug = ['about']\n *\n * Reconstructs the full path and fetches route via tRPC.\n */\nexport default async function ParametricRoutePage({\n params,\n searchParams,\n registry,\n apiKey,\n cmsUrl,\n websiteId: providedWebsiteId,\n}: PageProps) {\n // Prevent any caching - ensure we always fetch fresh route data\n unstable_noStore();\n\n const websiteId = getWebsiteId(providedWebsiteId);\n const { slug } = await params;\n const resolvedSearchParams = await searchParams;\n\n // Check for ai_preview query param to determine if we should show generated blocks\n let aiPreviewIndex: number | null = null;\n const aiPreviewParam = resolvedSearchParams?.ai_preview;\n if (aiPreviewParam) {\n const paramValue = Array.isArray(aiPreviewParam) ? aiPreviewParam[0] : aiPreviewParam;\n if (paramValue) {\n const parsed = parseInt(paramValue, 10);\n if (!Number.isNaN(parsed)) {\n aiPreviewIndex = parsed;\n }\n }\n }\n\n // Check for edit_mode query param to enable editable wrappers\n const editModeParam = resolvedSearchParams?.edit_mode;\n const editMode = editModeParam === 'true' || editModeParam === '1';\n\n // Reconstruct full path from slug segments and normalize it\n const rawPath = `/${slug.join('/')}`;\n const path = normalizePath(rawPath);\n\n // Get CMS API client with optional API key and custom URL\n const client = getCmsClient({ apiKey, cmsUrl });\n\n try {\n // Fetch route by path via CMS API\n const { route } = await client.route.getByPath.query({ websiteId, path });\n\n // Only show Live routes on public website\n if (route.state !== 'Live') {\n console.error(`Route found but not Live. Path: ${path}, State: ${route.state}`);\n notFound();\n }\n\n // Fetch all blocks by ID via CMS API (skip any that fail)\n const blockPromises = route.block_ids.map(async (blockId) => {\n try {\n const result = await client.block.getById.query({ websiteId, id: blockId });\n return result.block;\n } catch (error) {\n // Log error but don't fail the entire page\n console.error(`Failed to fetch block ${blockId}:`, error);\n return null;\n }\n });\n\n // Fetch generated blocks for preview only if ai_preview param is set\n // generated_content is an array of variants: [{ description, content }, ...]\n type BlockVariant = {\n description: string;\n content: Record<string, unknown>;\n };\n type GeneratedBlockData = {\n id: string;\n generated_content: BlockVariant[];\n generated_layout: Array<{ description: string; layout: Record<string, unknown> }> | null;\n schema_name: string;\n status: string;\n };\n const generatedBlocksPromise =\n aiPreviewIndex !== null\n ? client.block.getGeneratedByBlockIds\n .query({ websiteId, blockIds: route.block_ids })\n .catch((error) => {\n console.error('Failed to fetch generated blocks:', error);\n return { generatedBlocks: {} as Record<string, GeneratedBlockData> };\n })\n : Promise.resolve({ generatedBlocks: {} as Record<string, GeneratedBlockData> });\n\n const [blockResults, { generatedBlocks }] = await Promise.all([\n Promise.all(blockPromises),\n generatedBlocksPromise,\n ]);\n\n // Transform blocks to BlockData format for BlockRenderer\n // Filter out any blocks that failed to load\n // Use generated content only when ai_preview param is set\n const blocks: BlockData[] = [];\n\n for (const block of blockResults) {\n if (!block || block.published_content === null) continue;\n\n // Use generated content only if ai_preview is set and generated block exists\n // ai_preview is 1-indexed, so subtract 1 to get array index\n let content: Record<string, unknown> | null = null;\n if (aiPreviewIndex !== null) {\n const generatedBlock = generatedBlocks[block.id] as GeneratedBlockData | undefined;\n const variantIndex = aiPreviewIndex - 1; // Convert to 0-indexed\n const variants = generatedBlock?.generated_content;\n if (variants && Array.isArray(variants) && variants[variantIndex]) {\n content = variants[variantIndex].content;\n }\n }\n // Fall back to published content if no variant found\n content = content ?? (block.published_content as Record<string, unknown> | null);\n if (!content) continue;\n\n // Handle 'article' blocks separately before checking schema type\n if (block.schema_name === 'article') {\n const article = normalizeArticleContent(content);\n const isPublished = article ? isArticlePublished(article) : null;\n if (article && isPublished) {\n blocks.push({ id: block.id, type: 'article', content: article });\n }\n continue;\n }\n\n // Skip blocks with invalid schema names (after handling 'article')\n if (!isValidBlockSchemaName(block.schema_name)) {\n continue;\n }\n\n // For all block types, map schema_name to type and include content\n // Image references are automatically resolved by block.getById\n blocks.push({\n id: block.id,\n type: block.schema_name,\n content,\n } as BlockData);\n }\n\n return (\n <main>\n {blocks.map((block) => (\n <BlockRenderer\n key={block.id}\n block={block}\n registry={registry ?? {}}\n disableEditable={!editMode}\n />\n ))}\n </main>\n );\n } catch (error) {\n // Log error for debugging\n console.error(`Route fetch error for path: ${path}`, error);\n\n // If route not found or param validation fails, show 404\n // TRPCClientError has data.code for the error code\n const errorCode =\n error instanceof Error && 'data' in error\n ? (error as { data?: { code?: string } }).data?.code\n : error instanceof Error && 'code' in error\n ? (error as { code: string }).code\n : undefined;\n\n if (errorCode === 'NOT_FOUND' || errorCode === 'P0002') {\n notFound();\n }\n\n // Re-throw other errors\n throw error;\n }\n}\n\n// -----------------------------------------------------------------------------\n// Metadata\n// -----------------------------------------------------------------------------\n\n/**\n * Generate metadata for the page.\n * Uses Next.js 15+ async params pattern.\n */\nexport async function generateMetadata({\n params,\n apiKey,\n cmsUrl,\n websiteId: providedWebsiteId,\n}: PageProps): Promise<Metadata> {\n const websiteId = getWebsiteId(providedWebsiteId);\n const { slug } = await params;\n const rawPath = `/${slug.join('/')}`;\n const path = normalizePath(rawPath);\n const client = getCmsClient({ apiKey, cmsUrl });\n\n try {\n const { route } = await client.route.getByPath.query({ websiteId, path });\n return {\n title: `${route.path} | Website`,\n description: `Content page: ${route.path}`,\n };\n } catch {\n return {\n title: 'Page Not Found | Website',\n description: 'The requested page could not be found.',\n };\n }\n}\n\nexport function normalizePath(path: string): string {\n if (!path || path === '/') {\n return '/';\n }\n\n // Remove trailing slashes, ensure leading slash\n let normalized = path.trim();\n\n // Remove trailing slashes (but keep root \"/\")\n normalized = normalized.replace(/\\/+$/, '');\n\n // Ensure leading slash\n if (!normalized.startsWith('/')) {\n normalized = `/${normalized}`;\n }\n\n // Collapse multiple consecutive slashes to single slash\n normalized = normalized.replace(/\\/+/g, '/');\n\n return normalized;\n}\n"],"mappings":"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","names":["z","z","z","z","z","z","z","z","z","z"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cms-renderer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
"types": "./dist/lib/block-renderer.d.ts",
|
|
9
9
|
"import": "./dist/lib/block-renderer.js"
|
|
10
10
|
},
|
|
11
|
+
"./lib/block-toolbar": {
|
|
12
|
+
"types": "./dist/lib/block-toolbar.d.ts",
|
|
13
|
+
"import": "./dist/lib/block-toolbar.js"
|
|
14
|
+
},
|
|
11
15
|
"./lib/cms-api": {
|
|
12
16
|
"types": "./dist/lib/cms-api.d.ts",
|
|
13
17
|
"import": "./dist/lib/cms-api.js"
|
|
@@ -43,6 +47,10 @@
|
|
|
43
47
|
"./lib/image/lazy-load": {
|
|
44
48
|
"types": "./dist/lib/image/lazy-load.d.ts",
|
|
45
49
|
"import": "./dist/lib/image/lazy-load.js"
|
|
50
|
+
},
|
|
51
|
+
"./lib/proxy": {
|
|
52
|
+
"types": "./dist/lib/proxy.d.ts",
|
|
53
|
+
"import": "./dist/lib/proxy.js"
|
|
46
54
|
}
|
|
47
55
|
},
|
|
48
56
|
"files": [
|
package/dist/chunk-RPM73PQZ.js
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
// lib/block-renderer.tsx
|
|
2
|
-
import { jsx } from "react/jsx-runtime";
|
|
3
|
-
function BlockRenderer({ block, registry }) {
|
|
4
|
-
const Component = registry[block.type];
|
|
5
|
-
if (!Component) {
|
|
6
|
-
if (process.env.NODE_ENV === "development") {
|
|
7
|
-
console.warn(`[BlockRenderer] Unknown block type: ${block.type}`);
|
|
8
|
-
}
|
|
9
|
-
return null;
|
|
10
|
-
}
|
|
11
|
-
return /* @__PURE__ */ jsx(Component, { content: block.content });
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export {
|
|
15
|
-
BlockRenderer
|
|
16
|
-
};
|
|
17
|
-
//# sourceMappingURL=chunk-RPM73PQZ.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../lib/block-renderer.tsx"],"sourcesContent":["/**\n * Block Renderer Component\n *\n * Dispatches block data to the appropriate component using the ComponentMap pattern.\n * This is the main entry point for rendering blocks from the CMS.\n */\n\nimport type { BlockComponentRegistry, BlockData } from './types';\n\n// -----------------------------------------------------------------------------\n// Props\n// -----------------------------------------------------------------------------\n\ninterface BlockRendererProps {\n /**\n * The block data to render.\n * Must have a `type` field that maps to a registered component.\n */\n block: BlockData;\n registry: Partial<BlockComponentRegistry>;\n}\n\n// -----------------------------------------------------------------------------\n// Component\n// -----------------------------------------------------------------------------\n\n/**\n * Renders a single block by dispatching to the appropriate component.\n *\n * Uses the ComponentMap pattern: the block's `type` field determines which\n * component renders the block's `content`.\n *\n * @example\n * ```tsx\n * // Render a single block\n * <BlockRenderer block={{ type: 'header', content: { headline: 'Hello' } }} />\n *\n * // Render an array of blocks\n * {page.blocks.map((block, index) => (\n * <BlockRenderer key={index} block={block} />\n * ))}\n * ```\n */\nexport function BlockRenderer({ block, registry }: BlockRendererProps) {\n const Component = registry[block.type];\n\n if (!Component) {\n // Log warning in development, render nothing in production\n if (process.env.NODE_ENV === 'development') {\n console.warn(`[BlockRenderer] Unknown block type: ${block.type}`);\n }\n return null;\n }\n\n // TypeScript cannot narrow the content type through the component lookup,\n // so we use a type assertion here. Runtime safety is guaranteed by the\n // discriminated union and the blockComponents registry.\n // biome-ignore lint/suspicious/noExplicitAny: Type safety ensured by BlockData discriminated union\n return <Component content={block.content as any} />;\n}\n"],"mappings":";AA0DS;AAfF,SAAS,cAAc,EAAE,OAAO,SAAS,GAAuB;AACrE,QAAM,YAAY,SAAS,MAAM,IAAI;AAErC,MAAI,CAAC,WAAW;AAEd,QAAI,QAAQ,IAAI,aAAa,eAAe;AAC1C,cAAQ,KAAK,uCAAuC,MAAM,IAAI,EAAE;AAAA,IAClE;AACA,WAAO;AAAA,EACT;AAMA,SAAO,oBAAC,aAAU,SAAS,MAAM,SAAgB;AACnD;","names":[]}
|