@bettercms-ai/ui 0.15.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -29,1053 +29,1205 @@ function Heading({
29
29
  }
30
30
 
31
31
  // src/blocks/portableText.tsx
32
- import React from "react";
32
+ import React6 from "react";
33
33
  import { PortableText as PT } from "@portabletext/react";
34
34
 
35
- // ../richtext/src/types.ts
36
- var PT_TYPES = {
37
- /** A generic embedded block: `{schemaKey, schemaLabel, fields}`, the successor to Lexical's BlockNode. */
38
- block: "bcmsBlock",
39
- /** An inline mention. A PT inline object is natively void, which is what Lexical's `token` mode was faking. */
40
- mention: "bcmsMention",
41
- divider: "bcmsDivider",
42
- code: "bcmsCode",
43
- /**
44
- * A video — an uploaded file or a provider URL. DEDICATED rather than another `schemaKey` on
45
- * the generic block, because a video is the one media node whose rendition depends on its own
46
- * payload: the same field is a `<video>` for an mp4 and a provider `<iframe>` for a YouTube
47
- * link (@see resolveVideoSource). The generic block carries a reference this package cannot
48
- * resolve; a video carries a URL it can.
49
- */
50
- video: "video",
51
- /** A downloadable document — a PDF, a spec sheet. `<a href download>`, nothing more. */
52
- file: "file",
53
- /**
54
- * A table. Spelled `table`, unprefixed, because that is what every other Portable Text
55
- * producer calls one — the Sanity-shaped `{_type:"table", rows}` this platform used to refuse
56
- * as foreign content now lands on OUR type and renders, rather than being data nothing draws.
57
- */
58
- table: "table",
59
- /** The annotation carrying a hyperlink's `href`. Spelled `link` because that is the spec's convention. */
60
- link: "link",
61
- /**
62
- * The annotation carrying a site's OWN inline markup — `<span class>`, `<small>`, `<time
63
- * datetime>`. Unprefixed, like `table`, because it describes HTML rather than a BetterCMS
64
- * concept. See `styled.ts` for what it may hold and why the list is that short.
65
- */
66
- styled: "styled"
67
- };
68
- var BUILTIN_TABLE_KEY = "builtin:table";
69
- var STORABLE_BLOCK_TYPES = /* @__PURE__ */ new Set([
70
- "block",
71
- PT_TYPES.block,
72
- PT_TYPES.table,
73
- PT_TYPES.code,
74
- PT_TYPES.divider,
75
- PT_TYPES.mention,
76
- PT_TYPES.video,
77
- PT_TYPES.file
78
- ]);
35
+ // src/renderer.tsx
36
+ import { RESPONSIVE_CSS, hiddenEverywhere, hideOnAttr, splitButtonStyle, usesHideOn, visibleBlocks, windowState } from "@bettercms-ai/types";
79
37
 
80
- // ../richtext/src/styled.ts
81
- var STYLED_TAGS = [
82
- "span",
83
- "small",
84
- "sup",
85
- "sub",
86
- "kbd",
87
- "abbr",
88
- "time",
89
- "strong",
90
- "b",
91
- "em",
92
- "i",
93
- "u",
94
- "s",
95
- "mark",
96
- "code",
97
- "q",
98
- "cite",
99
- "dfn",
100
- "var",
101
- "samp",
102
- "ins",
103
- "del"
104
- ];
105
- var TAG_SET = new Set(STYLED_TAGS);
106
- var isStyledTag = (tag) => typeof tag === "string" && TAG_SET.has(tag);
107
- var ATTR_NAMES = /* @__PURE__ */ new Set(["title", "datetime", "style", "aria-label"]);
108
- var DATA_ATTR = /^data-[a-z][a-z0-9-]*$/;
109
- var isStyledAttr = (name) => ATTR_NAMES.has(name) || DATA_ATTR.test(name) && !name.startsWith("data-bcms-");
110
- var STYLE_PROPS = /* @__PURE__ */ new Set([
111
- "color",
112
- "background-color",
113
- "opacity",
114
- "font",
115
- "font-family",
116
- "font-size",
117
- "font-style",
118
- "font-weight",
119
- "font-variant",
120
- "font-feature-settings",
121
- "font-variation-settings",
122
- "letter-spacing",
123
- "line-height",
124
- "word-spacing",
125
- "white-space",
126
- "word-break",
127
- "text-align",
128
- "text-decoration",
129
- "text-decoration-color",
130
- "text-decoration-line",
131
- "text-decoration-style",
132
- "text-transform",
133
- "text-underline-offset",
134
- "vertical-align"
135
- ]);
136
- var STYLE_VALUE_BAN = /url\(|expression\(|@import|\\|\/\*|[<>]/i;
137
- function safeStyle(css) {
138
- const kept = [];
139
- for (const declaration of css.split(";")) {
140
- const at = declaration.indexOf(":");
141
- if (at < 0) continue;
142
- const name = declaration.slice(0, at).trim().toLowerCase();
143
- const value = declaration.slice(at + 1).trim();
144
- if (!name || !value) continue;
145
- if (!name.startsWith("--") && !STYLE_PROPS.has(name)) continue;
146
- if (STYLE_VALUE_BAN.test(value)) continue;
147
- kept.push(`${name}:${value}`);
148
- }
149
- return kept.length ? kept.join(";") : void 0;
150
- }
151
- function styledAttrEntries(attrs) {
152
- if (!attrs || typeof attrs !== "object" || Array.isArray(attrs)) return [];
153
- const out = [];
154
- for (const [name, raw] of Object.entries(attrs)) {
155
- if (!isStyledAttr(name) || typeof raw !== "string") continue;
156
- const value = name === "style" ? safeStyle(raw) : raw;
157
- if (value === void 0) continue;
158
- out.push([name, value]);
159
- }
160
- return out;
38
+ // src/lib/utils.ts
39
+ import { clsx } from "clsx";
40
+ import { twMerge } from "tailwind-merge";
41
+ function cn(...inputs) {
42
+ return twMerge(clsx(inputs));
161
43
  }
162
44
 
163
- // ../richtext/src/href.ts
164
- var SAFE_HREF_SCHEMES = ["http:", "https:", "mailto:", "tel:"];
165
- function safeHref(raw) {
166
- const trimmed = raw.trim();
167
- if (trimmed.startsWith("#")) return /^#[^\s<>"'`]*$/.test(trimmed) ? trimmed : null;
168
- if (trimmed.startsWith("/") && !trimmed.startsWith("//")) return trimmed;
169
- try {
170
- return SAFE_HREF_SCHEMES.includes(new URL(trimmed).protocol) ? trimmed : null;
171
- } catch {
172
- return null;
173
- }
45
+ // src/blocks/Image.tsx
46
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
47
+ function Image({
48
+ src,
49
+ alt,
50
+ width,
51
+ height,
52
+ caption,
53
+ className = "",
54
+ sizes,
55
+ "data-bcms-field": field,
56
+ "data-bcms-kind": kind
57
+ }) {
58
+ const sized = typeof width === "number" && width > 0;
59
+ return /* @__PURE__ */ jsxs("figure", { className: cn(sized ? "max-w-full" : "w-full", className), children: [
60
+ /* @__PURE__ */ jsx2(
61
+ "img",
62
+ {
63
+ src,
64
+ alt,
65
+ width,
66
+ height,
67
+ sizes,
68
+ "data-bcms-field": field,
69
+ "data-bcms-kind": kind,
70
+ style: sized ? { width: `${width}px`, maxWidth: "100%", height: "auto" } : void 0,
71
+ className: cn(!sized && "w-full", "h-auto rounded-md object-cover"),
72
+ loading: "lazy"
73
+ }
74
+ ),
75
+ caption && /* @__PURE__ */ jsx2("figcaption", { className: "mt-2 text-sm text-muted-foreground text-center italic", children: caption })
76
+ ] });
174
77
  }
175
78
 
176
- // ../richtext/src/key.ts
177
- var ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
178
- var KEY_PATTERN = /^[A-Za-z0-9-]{1,32}$/;
179
- var isValidKey = (k) => typeof k === "string" && KEY_PATTERN.test(k);
180
- function randomKey(length = 12) {
181
- let out = "";
182
- for (let i = 0; i < length; i += 1) {
183
- out += ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
184
- }
185
- return out;
186
- }
187
- function deterministicKeys(prefix = "k") {
188
- let n = 0;
189
- return () => {
190
- n += 1;
191
- return `${prefix}${n}`;
79
+ // src/blocks/Button.tsx
80
+ import { LINK_RELS, buttonIconOf, buttonIconSvg, buttonVariantOf, isScriptHref } from "@bettercms-ai/types";
81
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
82
+ function Button({
83
+ text,
84
+ href,
85
+ variant = "primary",
86
+ size = "md",
87
+ fullWidth = false,
88
+ arrow = false,
89
+ icon: iconProp,
90
+ newTab = false,
91
+ rel,
92
+ action,
93
+ align = "left",
94
+ className = "",
95
+ style,
96
+ "data-bcms-field": field,
97
+ "data-bcms-kind": kind
98
+ }) {
99
+ const baseStyles = "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50";
100
+ const variantStyles = {
101
+ primary: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
102
+ secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
103
+ tertiary: "bg-transparent text-primary hover:bg-primary/10",
104
+ brand: "bg-accent text-accent-foreground shadow hover:bg-accent/90",
105
+ dark: "bg-foreground text-background shadow hover:bg-foreground/90",
106
+ light: "border bg-background text-foreground shadow-sm hover:bg-muted",
107
+ outline: "border border-current bg-transparent text-primary hover:bg-primary/10",
108
+ link: "h-auto bg-transparent p-0 text-primary underline underline-offset-4"
192
109
  };
110
+ const icon = buttonIconOf(iconProp);
111
+ const alignClass = {
112
+ left: "justify-start",
113
+ center: "justify-center",
114
+ right: "justify-end"
115
+ }[align];
116
+ const sizeStyles = {
117
+ sm: "px-3 py-1.5 text-xs",
118
+ md: "px-4 py-2",
119
+ lg: "px-6 py-3 text-base"
120
+ };
121
+ const sizeClass = size === "sm" || size === "lg" ? sizeStyles[size] : sizeStyles.md;
122
+ const fullClass = fullWidth === true ? "flex w-full justify-center" : "";
123
+ const relTokens = [...newTab === true ? ["noopener", "noreferrer"] : [], ...LINK_RELS.includes(rel) ? [rel] : []];
124
+ if (action === "code") {
125
+ return /* @__PURE__ */ jsxs2("button", { type: "button", "data-bcms-field": field, "data-bcms-kind": kind, style, className: cn(baseStyles, variantStyles[buttonVariantOf(variant)], alignClass, sizeClass, fullClass, className), children: [
126
+ icon && /* @__PURE__ */ jsx3("span", { className: "mr-2 inline-flex", dangerouslySetInnerHTML: { __html: buttonIconSvg(icon, "bcms-btn-icon") } }),
127
+ text,
128
+ arrow === true && /* @__PURE__ */ jsx3("span", { className: "ml-2 inline-flex", dangerouslySetInnerHTML: { __html: buttonIconSvg("arrow-right", "bcms-btn-arrow") } })
129
+ ] });
130
+ }
131
+ return /* @__PURE__ */ jsxs2(
132
+ "a",
133
+ {
134
+ href: typeof href === "string" && isScriptHref(href) ? "#" : href,
135
+ ...newTab === true ? { target: "_blank" } : {},
136
+ ...relTokens.length ? { rel: relTokens.join(" ") } : {},
137
+ "data-bcms-field": field,
138
+ "data-bcms-kind": kind,
139
+ style,
140
+ className: cn(baseStyles, variantStyles[buttonVariantOf(variant)], alignClass, sizeClass, fullClass, className),
141
+ children: [
142
+ icon && /* @__PURE__ */ jsx3("span", { className: "mr-2 inline-flex", dangerouslySetInnerHTML: { __html: buttonIconSvg(icon, "bcms-btn-icon") } }),
143
+ text,
144
+ arrow === true && /* @__PURE__ */ jsx3("span", { className: "ml-2 inline-flex", dangerouslySetInnerHTML: { __html: buttonIconSvg("arrow-right", "bcms-btn-arrow") } })
145
+ ]
146
+ }
147
+ );
193
148
  }
194
149
 
195
- // ../richtext/src/table.ts
196
- var isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
197
- var isPortableTextTable = (n) => isRecord(n) && n._type === PT_TYPES.table && Array.isArray(n.rows);
198
- var isBuiltInTableBlock = (n) => isRecord(n) && n._type === PT_TYPES.block && n.schemaKey === BUILTIN_TABLE_KEY;
199
- var asIs = (v) => Array.isArray(v) ? v : [];
200
- function keyMinter(base, reserved) {
201
- const prefix = (base.match(/[A-Za-z0-9-]/g) ?? []).join("").slice(0, 16) || "t";
202
- return (...parts) => {
203
- const candidate = `${prefix}-${parts.join("")}`.slice(0, 30);
204
- let key = candidate;
205
- for (let n = 1; reserved.has(key); n += 1) key = `${candidate}-${n}`;
206
- reserved.add(key);
207
- return key;
208
- };
150
+ // src/blocks/Spacer.tsx
151
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
152
+ function Spacer({ height = 32, className = "" }) {
153
+ return /* @__PURE__ */ jsx4(
154
+ "div",
155
+ {
156
+ className: `w-full border border-dashed border-muted-foreground/20 rounded flex items-center justify-center ${className}`,
157
+ style: { height: `${height}px` },
158
+ "aria-hidden": "true",
159
+ children: /* @__PURE__ */ jsxs3("span", { className: "text-xs text-muted-foreground/40 select-none", children: [
160
+ height,
161
+ "px"
162
+ ] })
163
+ }
164
+ );
209
165
  }
210
- function reservedKeys(rows) {
211
- const out = /* @__PURE__ */ new Set();
212
- for (const row of rows) {
213
- if (!isRecord(row)) continue;
214
- if (isValidKey(row._key)) out.add(row._key);
215
- for (const cell of Array.isArray(row.cells) ? row.cells : []) {
216
- if (isRecord(cell) && isValidKey(cell._key)) out.add(cell._key);
166
+
167
+ // src/blocks/Columns.tsx
168
+ import "react";
169
+ import { jsx as jsx5 } from "react/jsx-runtime";
170
+ function Columns({
171
+ columns,
172
+ gap = 16,
173
+ className = "",
174
+ renderBlock
175
+ }) {
176
+ return /* @__PURE__ */ jsx5(
177
+ "div",
178
+ {
179
+ className: cn("grid w-full", className),
180
+ style: {
181
+ gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))`,
182
+ gap: `${gap}px`
183
+ },
184
+ children: columns.map((columnBlocks, colIndex) => /* @__PURE__ */ jsx5("div", { className: "flex flex-col gap-4 min-w-0", children: columnBlocks.map((block) => /* @__PURE__ */ jsx5("span", { children: renderBlock ? renderBlock(block) : null }, block.id)) }, colIndex))
217
185
  }
218
- }
219
- return out;
186
+ );
220
187
  }
221
- var textContent = (text, key) => text === "" ? [] : [{
222
- _type: "block",
223
- _key: key(),
224
- style: "normal",
225
- markDefs: [],
226
- children: [{ _type: "span", _key: key(), text, marks: [] }]
227
- }];
228
- var spanOf = (value) => {
229
- const n = typeof value === "number" ? value : Number(value);
230
- return Number.isInteger(n) && n > 1 ? n : void 0;
231
- };
232
- function normalizeCell(raw, at, mint, read) {
233
- const [row, col] = at;
234
- const key = isRecord(raw) && isValidKey(raw._key) ? raw._key : mint("r", row, "c", col);
235
- const inner = deterministicKeys(`${key}-`);
236
- if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") {
237
- return { _key: key, content: textContent(String(raw), inner) };
188
+
189
+ // src/blocks/Video.tsx
190
+ import { useState } from "react";
191
+ import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
192
+ function getEmbedUrl(url) {
193
+ const ytMatch = url.match(
194
+ /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/
195
+ );
196
+ if (ytMatch) {
197
+ return {
198
+ embedUrl: `https://www.youtube.com/embed/${ytMatch[1]}`,
199
+ provider: "youtube"
200
+ };
238
201
  }
239
- if (!isRecord(raw)) return { _key: key, content: [] };
240
- const span = spanOf(raw.span ?? raw.colSpan ?? raw.colspan);
241
- const rowSpan = spanOf(raw.rowSpan ?? raw.rowspan);
242
- return {
243
- _key: key,
244
- // `text` is the spelling a string matrix uses once its cells have been given objects; it is
245
- // read here so a half-migrated value cannot lose its words.
246
- content: Array.isArray(raw.content) ? read(raw.content, inner) : textContent(typeof raw.text === "string" ? raw.text : "", inner),
247
- ...raw.header === true ? { header: true } : {},
248
- ...span === void 0 ? {} : { span },
249
- ...rowSpan === void 0 ? {} : { rowSpan }
250
- };
251
- }
252
- function normalizeRow(raw, at, mint, read) {
253
- const cells = Array.isArray(raw) ? raw : isRecord(raw) && Array.isArray(raw.cells) ? raw.cells : [];
254
- return {
255
- _key: isRecord(raw) && isValidKey(raw._key) ? raw._key : mint("r", at),
256
- cells: cells.map((c, col) => normalizeCell(c, [at, col], mint, read)),
257
- ...isRecord(raw) && raw.head === true ? { head: true } : {}
258
- };
259
- }
260
- function builtInTableToTable(node, key = randomKey) {
261
- if (!isBuiltInTableBlock(node)) return null;
262
- const fields = node.fields;
263
- const raw = isRecord(fields) && Array.isArray(fields.rows) ? fields.rows : [];
264
- const hasHeader = isRecord(fields) ? fields.hasHeader !== false : true;
265
- const own = node._key;
266
- const tableKey = isValidKey(own) ? own : key();
267
- const mint = keyMinter(tableKey, reservedKeys(raw));
268
- const rows = raw.map((row, i) => {
269
- const normalized = normalizeRow(row, i, mint, asIs);
270
- if (!(hasHeader && i === 0)) return normalized;
202
+ const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
203
+ if (vimeoMatch) {
271
204
  return {
272
- ...normalized,
273
- head: true,
274
- cells: normalized.cells.map((c) => ({ ...c, header: true }))
205
+ embedUrl: `https://player.vimeo.com/video/${vimeoMatch[1]}`,
206
+ provider: "vimeo"
275
207
  };
276
- });
277
- const width = rows.reduce((max, r) => Math.max(max, r.cells.length), 0);
278
- for (const [i, row] of rows.entries()) {
279
- while (row.cells.length < width) {
280
- row.cells.push({
281
- _key: mint("r", i, "c", row.cells.length),
282
- content: [],
283
- ...row.head === true ? { header: true } : {}
284
- });
285
- }
286
208
  }
287
- return { _type: PT_TYPES.table, _key: tableKey, rows, legacy: true };
209
+ return { embedUrl: url, provider: "unknown" };
288
210
  }
289
- function migrateTable(node, key = randomKey, read = asIs) {
290
- const builtIn = builtInTableToTable(node, key);
291
- if (builtIn) return builtIn;
292
- if (!isPortableTextTable(node)) return null;
293
- const rows = node.rows;
294
- const tableKey = isValidKey(node._key) ? node._key : key();
295
- const mint = keyMinter(tableKey, reservedKeys(rows));
296
- return {
297
- _type: PT_TYPES.table,
298
- _key: tableKey,
299
- rows: rows.map((r, i) => normalizeRow(r, i, mint, read)),
300
- // Carried through: a migrated table that an author saves back is still the same table, and
301
- // dropping the flag on that save would change the page the next time it renders.
302
- ...node.legacy === true ? { legacy: true } : {}
303
- };
211
+ function Video({
212
+ url,
213
+ poster,
214
+ autoplay = false,
215
+ loop = false,
216
+ className = ""
217
+ }) {
218
+ const [isLoaded, setIsLoaded] = useState(false);
219
+ const { embedUrl, provider } = getEmbedUrl(url);
220
+ if (provider === "unknown") {
221
+ return /* @__PURE__ */ jsx6(
222
+ "video",
223
+ {
224
+ src: url,
225
+ poster,
226
+ autoPlay: autoplay,
227
+ loop,
228
+ controls: true,
229
+ className,
230
+ playsInline: true
231
+ }
232
+ );
233
+ }
234
+ return /* @__PURE__ */ jsxs4("div", { className: cn("w-full overflow-hidden rounded-md bg-black aspect-video", className), children: [
235
+ !isLoaded && poster && // eslint-disable-next-line @next/next/no-img-element
236
+ /* @__PURE__ */ jsx6(
237
+ "img",
238
+ {
239
+ src: poster,
240
+ alt: "Video thumbnail",
241
+ className: "w-full h-full object-cover absolute inset-0"
242
+ }
243
+ ),
244
+ /* @__PURE__ */ jsx6(
245
+ "iframe",
246
+ {
247
+ src: `${embedUrl}?autoplay=${autoplay ? 1 : 0}&loop=${loop ? 1 : 0}&mute=${autoplay ? 1 : 0}`,
248
+ allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
249
+ allowFullScreen: true,
250
+ loading: "lazy",
251
+ onLoad: () => setIsLoaded(true),
252
+ className: "w-full h-full"
253
+ }
254
+ )
255
+ ] });
304
256
  }
305
257
 
306
- // ../richtext/src/read.ts
307
- var isRecord2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
308
- function normalizePortableText(value, key = randomKey) {
309
- if (!Array.isArray(value)) return [];
310
- return value.flatMap((raw) => {
311
- if (!isRecord2(raw) || typeof raw._type !== "string") return [];
312
- const table = migrateTable(raw, key, (v, cellKey) => normalizePortableText(v, cellKey));
313
- if (table) return [table];
314
- const node = { ...raw, _key: isRecord2(raw) && typeof raw._key === "string" && raw._key ? raw._key : key() };
315
- if (node._type === "block") {
316
- if (!Array.isArray(node.markDefs)) node.markDefs = [];
317
- if (typeof node.style !== "string") node.style = "normal";
318
- node.children = (Array.isArray(node.children) ? node.children : []).map((c) => {
319
- if (!isRecord2(c)) return c;
320
- const child = { ...c, _key: typeof c._key === "string" && c._key ? c._key : key() };
321
- if (child._type === "span" && !Array.isArray(child.marks)) child.marks = [];
322
- return child;
323
- });
258
+ // src/blocks/Section.tsx
259
+ import "react";
260
+ import { jsx as jsx7 } from "react/jsx-runtime";
261
+ function Section({
262
+ children,
263
+ background,
264
+ paddingY,
265
+ maxWidth,
266
+ className = "",
267
+ renderBlock
268
+ }) {
269
+ const inner = (children ?? []).map((block) => /* @__PURE__ */ jsx7("span", { children: renderBlock ? renderBlock(block) : null }, block.id));
270
+ return /* @__PURE__ */ jsx7(
271
+ "section",
272
+ {
273
+ className: cn("w-full", className),
274
+ style: { background, paddingTop: paddingY, paddingBottom: paddingY },
275
+ children: maxWidth ? /* @__PURE__ */ jsx7("div", { style: { maxWidth, marginInline: "auto" }, children: inner }) : inner
324
276
  }
325
- return [node];
326
- });
277
+ );
278
+ }
279
+
280
+ // src/blocks/Navbar.tsx
281
+ import { jsx as jsx8, jsxs as jsxs5 } from "react/jsx-runtime";
282
+ function Navbar({ logo, links, cta, className = "" }) {
283
+ return /* @__PURE__ */ jsxs5("nav", { className: cn("flex items-center gap-6 py-4", className), children: [
284
+ logo?.src ? /* @__PURE__ */ jsx8("a", { href: logo.href ?? "/", className: "shrink-0", children: /* @__PURE__ */ jsx8("img", { src: logo.src, alt: logo.alt, className: "h-8 w-auto" }) }) : null,
285
+ /* @__PURE__ */ jsx8("div", { className: "flex flex-1 gap-5", children: (links ?? []).map((l, i) => /* @__PURE__ */ jsx8("a", { href: l.href, className: "text-inherit no-underline", children: l.label }, i)) }),
286
+ cta ? /* @__PURE__ */ jsx8(
287
+ "a",
288
+ {
289
+ href: cta.href,
290
+ className: "rounded-lg bg-black px-4 py-2 font-semibold text-white no-underline",
291
+ children: cta.text
292
+ }
293
+ ) : null
294
+ ] });
327
295
  }
328
- function readStructuredContent(props) {
329
- if (!isRecord2(props)) return null;
330
- const candidate = props.content ?? props.value;
331
- const arr = Array.isArray(candidate) ? candidate : isRecord2(candidate) ? Array.isArray(candidate.content) ? candidate.content : Array.isArray(candidate.value) ? candidate.value : null : null;
332
- return arr && arr.length > 0 ? arr : null;
296
+
297
+ // src/blocks/Footer.tsx
298
+ import { jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
299
+ function Footer({ columns, copyright, className = "" }) {
300
+ return /* @__PURE__ */ jsxs6("footer", { className: cn("mt-12 border-t pt-8", className), children: [
301
+ /* @__PURE__ */ jsx9("div", { className: "flex flex-wrap gap-12", children: (columns ?? []).map((col, i) => /* @__PURE__ */ jsxs6("div", { className: "flex flex-col gap-1.5", children: [
302
+ col.heading ? /* @__PURE__ */ jsx9("h4", { className: "mb-2 font-semibold", children: col.heading }) : null,
303
+ (col.links ?? []).map((l, j) => /* @__PURE__ */ jsx9("a", { href: l.href, className: "text-neutral-500 no-underline", children: l.label }, j))
304
+ ] }, i)) }),
305
+ copyright ? /* @__PURE__ */ jsx9("p", { className: "mt-6 text-sm text-neutral-500", children: copyright }) : null
306
+ ] });
333
307
  }
334
308
 
335
- // src/blocks/portableText.tsx
336
- import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
337
- var TOKEN = /^[a-z0-9-]+$/i;
338
- function styleObject(css) {
339
- const out = {};
340
- for (const declaration of css.split(";")) {
341
- const at = declaration.indexOf(":");
342
- if (at < 0) continue;
343
- const name = declaration.slice(0, at).trim();
344
- const value = declaration.slice(at + 1).trim();
345
- if (!name || !value) continue;
346
- out[name.startsWith("--") ? name : name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value;
347
- }
348
- return out;
309
+ // src/blocks/Slider.tsx
310
+ import { useState as useState2 } from "react";
311
+ import { jsx as jsx10, jsxs as jsxs7 } from "react/jsx-runtime";
312
+ function Slider({ slides, className = "", renderBlock }) {
313
+ const [i, setI] = useState2(0);
314
+ const list = slides ?? [];
315
+ const go = (n) => setI((n + list.length) % (list.length || 1));
316
+ const btn = "absolute top-1/2 -translate-y-1/2 flex h-9 w-9 items-center justify-center rounded-full border-0 bg-black/50 text-white cursor-pointer";
317
+ return /* @__PURE__ */ jsxs7("div", { className: cn("relative w-full", className), children: [
318
+ list.map((s, idx) => /* @__PURE__ */ jsx10("div", { hidden: idx !== i, className: "flex flex-col gap-4", children: (s.children ?? []).map((b) => /* @__PURE__ */ jsx10("span", { children: renderBlock ? renderBlock(b) : null }, b.id)) }, s.id)),
319
+ /* @__PURE__ */ jsx10("button", { type: "button", "aria-label": "Previous", onClick: () => go(i - 1), className: cn(btn, "left-2"), children: "\u2039" }),
320
+ /* @__PURE__ */ jsx10("button", { type: "button", "aria-label": "Next", onClick: () => go(i + 1), className: cn(btn, "right-2"), children: "\u203A" })
321
+ ] });
349
322
  }
350
- function Bound({
351
- base,
352
- nodeKey,
353
- children
323
+
324
+ // src/blocks/Tabs.tsx
325
+ import { useState as useState3 } from "react";
326
+ import { jsx as jsx11, jsxs as jsxs8 } from "react/jsx-runtime";
327
+ function Tabs({ tabs, className = "", renderBlock }) {
328
+ const [active, setActive] = useState3(0);
329
+ const list = tabs ?? [];
330
+ return /* @__PURE__ */ jsxs8("div", { className: cn("w-full", className), children: [
331
+ /* @__PURE__ */ jsx11("div", { role: "tablist", className: "mb-4 flex gap-1 border-b", children: list.map((t, idx) => /* @__PURE__ */ jsx11(
332
+ "button",
333
+ {
334
+ role: "tab",
335
+ type: "button",
336
+ "aria-selected": idx === active,
337
+ onClick: () => setActive(idx),
338
+ className: cn(
339
+ "cursor-pointer border-b-2 border-transparent bg-transparent px-4 py-2",
340
+ idx === active && "border-black font-semibold"
341
+ ),
342
+ children: t.label
343
+ },
344
+ t.id
345
+ )) }),
346
+ list.map((t, idx) => /* @__PURE__ */ jsx11("div", { role: "tabpanel", hidden: idx !== active, className: "flex flex-col gap-4", children: (t.children ?? []).map((b) => /* @__PURE__ */ jsx11("span", { children: renderBlock ? renderBlock(b) : null }, b.id)) }, t.id))
347
+ ] });
348
+ }
349
+
350
+ // src/blocks/Placeholders.tsx
351
+ import { Fragment, jsx as jsx12, jsxs as jsxs9 } from "react/jsx-runtime";
352
+ var box = "rounded border border-dashed border-neutral-300 bg-neutral-50 p-4 text-sm text-neutral-500";
353
+ function ComponentInstance({
354
+ componentId,
355
+ className = ""
354
356
  }) {
355
- if (!base || !nodeKey) return /* @__PURE__ */ jsx2(Fragment, { children });
356
- return /* @__PURE__ */ jsx2("div", { "data-bcms-field": `${base}#${nodeKey}`, "data-bcms-kind": "richtext", children });
357
+ return /* @__PURE__ */ jsxs9("div", { className: cn(box, className), children: [
358
+ "Component instance: ",
359
+ /* @__PURE__ */ jsx12("code", { children: componentId })
360
+ ] });
357
361
  }
358
- function Table({ value }) {
359
- const components = React.useMemo(buildComponents, []);
360
- const inline = React.useMemo(
361
- () => ({ ...components, block: { normal: ({ children }) => /* @__PURE__ */ jsx2(Fragment, { children }) } }),
362
- [components]
363
- );
364
- const rows = Array.isArray(value?.rows) ? value.rows : [];
365
- if (rows.length === 0) return null;
366
- const cells = (row) => (Array.isArray(row?.cells) ? row.cells : []).map((cell) => {
367
- const Tag = cell?.header === true ? "th" : "td";
368
- const span = typeof cell?.span === "number" && cell.span > 1 ? cell.span : void 0;
369
- const rowSpan = typeof cell?.rowSpan === "number" && cell.rowSpan > 1 ? cell.rowSpan : void 0;
370
- const content = Array.isArray(cell?.content) ? cell.content : [];
371
- const lone = content.length === 1 && content[0]._type === "block" && content[0].style === "normal" && !content[0].listItem;
372
- return /* @__PURE__ */ jsx2(Tag, { ...span ? { colSpan: span } : {}, ...rowSpan ? { rowSpan } : {}, children: /* @__PURE__ */ jsx2(PT, { value: content, components: lone ? inline : components }) }, cell?._key);
373
- });
374
- const rendered = rows.map((row) => /* @__PURE__ */ jsx2("tr", { children: cells(row) }, row?._key));
375
- const headCount = rows.findIndex((r) => r?.head !== true);
376
- const head = headCount === -1 ? rows.length : headCount;
377
- return /* @__PURE__ */ jsxs("table", { ...value.legacy === true ? { className: "bcms-table" } : {}, children: [
378
- head > 0 && /* @__PURE__ */ jsx2("thead", { children: rendered.slice(0, head) }),
379
- head < rows.length && /* @__PURE__ */ jsx2("tbody", { children: rendered.slice(head) })
362
+ function FormPlaceholder({ formId, className = "" }) {
363
+ return /* @__PURE__ */ jsxs9("div", { className: cn(box, className), children: [
364
+ "Form: ",
365
+ /* @__PURE__ */ jsx12("code", { children: formId })
380
366
  ] });
381
367
  }
382
- function buildComponents(renderBlock) {
383
- return {
384
- types: {
385
- [PT_TYPES.divider]: () => /* @__PURE__ */ jsx2("hr", {}),
386
- [PT_TYPES.table]: ({ value }) => /* @__PURE__ */ jsx2(Table, { value }),
387
- [PT_TYPES.code]: ({ value }) => {
388
- const v = value;
389
- const lang = typeof v.language === "string" && TOKEN.test(v.language) ? v.language : void 0;
390
- return /* @__PURE__ */ jsx2("pre", { children: /* @__PURE__ */ jsx2("code", { className: lang ? `language-${lang}` : void 0, children: typeof v.code === "string" ? v.code : "" }) });
391
- },
392
- /**
393
- * A generic embedded block renders as a marker, not as content: its payload references
394
- * something (a component, a media id) that has to be RESOLVED against the project, and
395
- * this renderer has no project. A marker keeps the node visible and addressable rather
396
- * than rendering nothing at all, which is how embedded content used to vanish. A caller
397
- * that CAN resolve it passes `renderBlock`.
398
- */
399
- [PT_TYPES.block]: ({ value }) => {
400
- const rendered = renderBlock?.(value);
401
- if (rendered !== void 0) return /* @__PURE__ */ jsx2(Fragment, { children: rendered });
402
- const v = value;
403
- const key = typeof v.schemaKey === "string" ? v.schemaKey : "block";
404
- return /* @__PURE__ */ jsx2("div", { className: "bcms-block", "data-bcms-schema-key": key });
405
- },
406
- [PT_TYPES.mention]: ({ value }) => {
407
- const v = value;
408
- return /* @__PURE__ */ jsx2("span", { className: "bcms-mention", children: typeof v.label === "string" ? v.label : "" });
409
- }
410
- },
411
- marks: {
412
- /**
413
- * The only attribute this renderer writes from author data. An unsafe href renders the
414
- * LABEL rather than deleting it — blocking a link must not lose the words.
415
- */
416
- [PT_TYPES.link]: ({ value, children }) => {
417
- const v = value ?? {};
418
- const href = typeof v.href === "string" ? safeHref(v.href) : null;
419
- if (!href) return /* @__PURE__ */ jsx2(Fragment, { children });
420
- const blank = v.target === "_blank";
421
- return /* @__PURE__ */ jsx2("a", { href, ...blank ? { target: "_blank", rel: "noopener noreferrer" } : {}, children });
422
- },
423
- /**
424
- * The site's own inline markup, rendered as itself — the React half of
425
- * `portableTextToHtml`'s `styled` mark, and it has to stay the same shape: Astro and the
426
- * editor canvas render through that serialiser while a Next site renders through this
427
- * one, and two renderers of one value that disagree is the whole bug class.
428
- *
429
- * The tag is re-checked against `STYLED_TAGS` and the attribute bag re-filtered, because
430
- * a markDef is DATA and arrives from imports, the Management API and AI writes. React
431
- * escapes the values it writes; the allowlist is what keeps a `href` or an `onClick` out
432
- * of the props entirely. An unrecognised tag renders its CHILDREN — the words are the
433
- * author's, the wrapper is only the design.
434
- */
435
- [PT_TYPES.styled]: ({ value, children }) => {
436
- const v = value ?? {};
437
- const tag = typeof v.tag === "string" ? v.tag.toLowerCase() : "";
438
- if (!isStyledTag(tag)) return /* @__PURE__ */ jsx2(Fragment, { children });
439
- const Tag = tag;
440
- const className = typeof v.className === "string" && v.className ? v.className : void 0;
441
- const attrs = styledAttrEntries(v.attrs);
442
- const style = attrs.find(([name]) => name === "style")?.[1];
443
- return /* @__PURE__ */ jsx2(
444
- Tag,
445
- {
446
- className,
447
- ...Object.fromEntries(attrs.filter(([name]) => name !== "style")),
448
- ...style ? { style: styleObject(style) } : {},
449
- children
450
- }
451
- );
452
- },
453
- highlight: ({ children }) => /* @__PURE__ */ jsx2("mark", { children }),
454
- "strike-through": ({ children }) => /* @__PURE__ */ jsx2("s", { children }),
455
- /**
456
- * A colour mark carries a TOKEN name (`color:brand-accent`), never a baked hex, so the
457
- * consuming site styles it and a rebrand does not orphan the content.
458
- */
459
- color: ({ children, markType }) => {
460
- const token = markType.startsWith("color:") ? markType.slice(6) : "";
461
- if (!TOKEN.test(token)) return /* @__PURE__ */ jsx2(Fragment, { children });
462
- return /* @__PURE__ */ jsx2("span", { className: `bcms-color-${token}`, children });
463
- }
464
- },
465
- /** Unknown marks and types render their children. Never swallow content. */
466
- unknownMark: ({ children }) => /* @__PURE__ */ jsx2(Fragment, { children }),
467
- unknownType: ({ children }) => /* @__PURE__ */ jsx2(Fragment, { children: children ?? null }),
468
- unknownBlockStyle: ({ children }) => /* @__PURE__ */ jsx2("p", { children })
469
- };
368
+ function CollectionPlaceholder({
369
+ cardComponentId,
370
+ className = ""
371
+ }) {
372
+ return /* @__PURE__ */ jsxs9("div", { className: cn(box, className), children: [
373
+ "Collection: entries of this page",
374
+ cardComponentId ? /* @__PURE__ */ jsxs9(Fragment, { children: [
375
+ " as ",
376
+ /* @__PURE__ */ jsx12("code", { children: cardComponentId })
377
+ ] }) : /* @__PURE__ */ jsx12(Fragment, { children: " (default card)" })
378
+ ] });
470
379
  }
471
- var isListItem = (node) => node._type === "block" && typeof node.listItem === "string" && !!node.listItem;
472
- function runsOf(blocks) {
473
- const runs = [];
474
- for (const node of blocks) {
475
- const last = runs.at(-1);
476
- if (last && isListItem(node) && isListItem(last[0])) last.push(node);
477
- else runs.push([node]);
380
+
381
+ // src/registry.ts
382
+ var BLOCK_REGISTRY = {
383
+ // The entries of the page this block sits on. No preview here by design: the list is
384
+ // fetched per request by the site renderer, so the builder has nothing to show.
385
+ collection: {
386
+ component: CollectionPlaceholder,
387
+ label: "Collection",
388
+ icon: "list"
389
+ },
390
+ heading: {
391
+ component: Heading,
392
+ label: "Heading",
393
+ icon: "type"
394
+ },
395
+ text: {
396
+ component: Text,
397
+ label: "Text",
398
+ icon: "align-left"
399
+ },
400
+ // Same payload, same renderer — deliberately. `richtext` differs from `text` only in how
401
+ // an author EDITS it (a formatting surface vs a plain field); at delivery both are an HTML
402
+ // string, so duplicating the component would be two things to keep in sync for no gain.
403
+ richtext: {
404
+ component: Text,
405
+ label: "Rich text",
406
+ icon: "pilcrow"
407
+ },
408
+ image: {
409
+ component: Image,
410
+ label: "Image",
411
+ icon: "image"
412
+ },
413
+ button: {
414
+ component: Button,
415
+ label: "Button",
416
+ icon: "square"
417
+ },
418
+ spacer: {
419
+ component: Spacer,
420
+ label: "Spacer",
421
+ icon: "minus"
422
+ },
423
+ columns: {
424
+ component: Columns,
425
+ label: "Columns",
426
+ icon: "columns"
427
+ },
428
+ video: {
429
+ component: Video,
430
+ label: "Video",
431
+ icon: "play"
432
+ },
433
+ section: {
434
+ component: Section,
435
+ label: "Section",
436
+ icon: "layout"
437
+ },
438
+ navbar: {
439
+ component: Navbar,
440
+ label: "Navbar",
441
+ icon: "menu"
442
+ },
443
+ footer: {
444
+ component: Footer,
445
+ label: "Footer",
446
+ icon: "panel-bottom"
447
+ },
448
+ slider: {
449
+ component: Slider,
450
+ label: "Slider",
451
+ icon: "gallery-horizontal"
452
+ },
453
+ tabs: {
454
+ component: Tabs,
455
+ label: "Tabs",
456
+ icon: "folder"
457
+ },
458
+ component: {
459
+ component: ComponentInstance,
460
+ label: "Component",
461
+ icon: "component"
462
+ },
463
+ form: {
464
+ component: FormPlaceholder,
465
+ label: "Form",
466
+ icon: "clipboard-list"
478
467
  }
479
- return runs;
468
+ };
469
+ var BLOCK_TYPES = Object.keys(BLOCK_REGISTRY);
470
+
471
+ // src/blockStyle.ts
472
+ import { blockStyleToCss } from "@bettercms-ai/types";
473
+ function blockStyleToReactStyle(style) {
474
+ return blockStyleToCss(style);
480
475
  }
481
- function PortableText({ value, bindingBase, renderBlock }) {
482
- const blocks = React.useMemo(() => normalizePortableText(readValue(value)), [value]);
483
- const components = React.useMemo(() => buildComponents(renderBlock), [renderBlock]);
484
- const listComponents = React.useMemo(() => {
485
- if (!bindingBase) return null;
486
- const keys = new Set(blocks.map((b) => b._key));
487
- return {
488
- ...components,
489
- listItem: ({ value: item, children }) => keys.has(item._key) ? /* @__PURE__ */ jsx2("li", { "data-bcms-field": `${bindingBase}#${item._key}`, "data-bcms-kind": "richtext", children }) : /* @__PURE__ */ jsx2("li", { children })
490
- };
491
- }, [bindingBase, blocks, components]);
492
- if (blocks.length === 0) return null;
493
- if (!bindingBase || !listComponents) return /* @__PURE__ */ jsx2(PT, { value: blocks, components });
494
- return /* @__PURE__ */ jsx2(Fragment, { children: runsOf(blocks).map(
495
- (run) => isListItem(run[0]) ? /* @__PURE__ */ jsx2(PT, { value: run, components: listComponents }, run[0]._key) : /* @__PURE__ */ jsx2(Bound, { base: bindingBase, nodeKey: run[0]._key, children: /* @__PURE__ */ jsx2(PT, { value: run, components }) }, run[0]._key)
496
- ) });
476
+
477
+ // src/renderer.tsx
478
+ import "react";
479
+ import { jsx as jsx13, jsxs as jsxs10 } from "react/jsx-runtime";
480
+ var CONTAINER_TYPES = /* @__PURE__ */ new Set(["columns", "section", "slider", "tabs"]);
481
+ function styleOf(block) {
482
+ if (block.type !== "button") return { wrapper: blockStyleToReactStyle(block.style), paint: {} };
483
+ const { placement, paint } = splitButtonStyle(block.style);
484
+ return { wrapper: blockStyleToReactStyle(placement), paint: paint ? { style: paint } : {} };
497
485
  }
498
- function readValue(value) {
499
- if (Array.isArray(value)) return value;
500
- if (value && typeof value === "object") {
501
- const o = value;
502
- if (Array.isArray(o.value)) return o.value;
503
- if (Array.isArray(o.content)) return o.content;
486
+ function withHide(block, wrapperStyle, el, stampAt) {
487
+ const hide = hideOnAttr(block.style);
488
+ const state = stampAt ? windowState(block, stampAt) : "live";
489
+ const hidden = !!stampAt && hiddenEverywhere(block);
490
+ if (!wrapperStyle && !hide && state === "live" && !hidden) return el;
491
+ return /* @__PURE__ */ jsx13("div", { style: wrapperStyle, ...hide ? { "data-bcms-hide": hide } : {}, ...state === "live" ? {} : { "data-bcms-window": state }, ...hidden ? { "data-bcms-hidden": "all" } : {}, children: el });
492
+ }
493
+ function bindingProps(block, annotate) {
494
+ if (!annotate || !block.id) return {};
495
+ switch (block.type) {
496
+ case "heading":
497
+ case "button":
498
+ return { "data-bcms-field": `${block.id}__text`, "data-bcms-kind": "text" };
499
+ case "text":
500
+ case "richtext":
501
+ return { "data-bcms-field": `${block.id}__html`, "data-bcms-kind": "richtext" };
502
+ case "image":
503
+ return { "data-bcms-field": `${block.id}__src`, "data-bcms-kind": "image" };
504
+ default:
505
+ return {};
504
506
  }
505
- return [];
506
507
  }
507
-
508
- // src/blocks/Text.tsx
509
- import { jsx as jsx3 } from "react/jsx-runtime";
510
- function Text({
511
- html,
512
- content,
513
- align = "left",
508
+ function resolveBlock(block, className, annotate = false, stampAt) {
509
+ const entry = BLOCK_REGISTRY[block.type];
510
+ if (!entry) {
511
+ return /* @__PURE__ */ jsxs10(
512
+ "div",
513
+ {
514
+ className: `w-full p-4 border border-dashed border-red-400 bg-red-50 text-red-600 text-sm rounded ${className ?? ""}`,
515
+ children: [
516
+ "Unknown block type: ",
517
+ /* @__PURE__ */ jsx13("code", { children: block.type })
518
+ ]
519
+ }
520
+ );
521
+ }
522
+ const Component = entry.component;
523
+ const { wrapper: blockStyle, paint } = styleOf(block);
524
+ const el = /* @__PURE__ */ jsx13(Component, { ...block.props, ...paint, className, ...bindingProps(block, annotate) });
525
+ return withHide(block, blockStyle, el, stampAt);
526
+ }
527
+ function BlockRenderer({
528
+ block,
514
529
  className = "",
515
- "data-bcms-field": field,
516
- "data-bcms-kind": kind
530
+ renderBlock,
531
+ annotate = false,
532
+ stampAt
517
533
  }) {
518
- const alignClass = {
519
- left: "text-left",
520
- center: "text-center",
521
- right: "text-right"
522
- }[align];
523
- const cls = `text-muted-foreground leading-relaxed ${alignClass} ${className}`;
524
- const structured = readStructuredContent({ content });
525
- return /* @__PURE__ */ jsx3(
526
- "div",
534
+ const entry = BLOCK_REGISTRY[block.type];
535
+ if (!entry) {
536
+ return /* @__PURE__ */ jsxs10(
537
+ "div",
538
+ {
539
+ className: `w-full p-4 border border-dashed border-red-400 bg-red-50 text-red-600 text-sm rounded ${className}`,
540
+ children: [
541
+ "Unknown block type: ",
542
+ /* @__PURE__ */ jsx13("code", { children: block.type })
543
+ ]
544
+ }
545
+ );
546
+ }
547
+ const Component = entry.component;
548
+ const containerProps = CONTAINER_TYPES.has(block.type) ? { renderBlock: renderBlock ?? ((child) => resolveBlock(child, void 0, annotate, stampAt)) } : {};
549
+ const { wrapper: blockStyle, paint } = styleOf(block);
550
+ const el = /* @__PURE__ */ jsx13(
551
+ Component,
527
552
  {
528
- className: cls,
529
- ...structured ? {} : { "data-bcms-field": field, "data-bcms-kind": kind },
530
- children: structured ? /* @__PURE__ */ jsx3(PortableText, { value: structured, bindingBase: field }) : (
531
- // eslint-disable-next-line react/no-danger
532
- /* @__PURE__ */ jsx3("span", { dangerouslySetInnerHTML: { __html: html } })
533
- )
553
+ ...block.props,
554
+ ...paint,
555
+ className,
556
+ ...containerProps,
557
+ ...bindingProps(block, annotate)
534
558
  }
535
559
  );
560
+ return withHide(block, blockStyle, el, stampAt);
561
+ }
562
+ function BlocksRenderer({
563
+ blocks: all,
564
+ className = "",
565
+ renderBlock,
566
+ annotate = false,
567
+ now
568
+ }) {
569
+ const at = now ?? /* @__PURE__ */ new Date();
570
+ const blocks = annotate ? all : visibleBlocks(all, at);
571
+ if (blocks.length === 0) return null;
572
+ return /* @__PURE__ */ jsxs10("div", { className, children: [
573
+ usesHideOn(blocks) && /* @__PURE__ */ jsx13("style", { dangerouslySetInnerHTML: { __html: RESPONSIVE_CSS } }),
574
+ blocks.map((block) => /* @__PURE__ */ jsx13(
575
+ BlockRenderer,
576
+ {
577
+ block,
578
+ renderBlock,
579
+ annotate,
580
+ stampAt: annotate ? at : void 0
581
+ },
582
+ block.id
583
+ ))
584
+ ] });
585
+ }
586
+
587
+ // ../richtext/src/types.ts
588
+ function pullQuoteHref(link) {
589
+ if (typeof link === "string") return link.trim();
590
+ if (link && typeof link === "object" && typeof link.href === "string") {
591
+ return link.href.trim();
592
+ }
593
+ return "";
594
+ }
595
+ var PT_TYPES = {
596
+ /** A generic embedded block: `{schemaKey, schemaLabel, fields}`, the successor to Lexical's BlockNode. */
597
+ block: "bcmsBlock",
598
+ /** An inline mention. A PT inline object is natively void, which is what Lexical's `token` mode was faking. */
599
+ mention: "bcmsMention",
600
+ divider: "bcmsDivider",
601
+ code: "bcmsCode",
602
+ /**
603
+ * A video — an uploaded file or a provider URL. DEDICATED rather than another `schemaKey` on
604
+ * the generic block, because a video is the one media node whose rendition depends on its own
605
+ * payload: the same field is a `<video>` for an mp4 and a provider `<iframe>` for a YouTube
606
+ * link (@see resolveVideoSource). The generic block carries a reference this package cannot
607
+ * resolve; a video carries a URL it can.
608
+ */
609
+ video: "video",
610
+ /** A downloadable document — a PDF, a spec sheet. `<a href download>`, nothing more. */
611
+ file: "file",
612
+ /** A pull quote — see `PortableTextPullQuote`. The V2 kit's `<figure data-rt="pullquote">`. */
613
+ pullQuote: "pullQuote",
614
+ /** Key takeaways — see `PortableTextKeyTakeaways`. The V2 kit's `<aside data-rt="takeaways">`. */
615
+ keyTakeaways: "keyTakeaways",
616
+ /**
617
+ * A table. Spelled `table`, unprefixed, because that is what every other Portable Text
618
+ * producer calls one — the Sanity-shaped `{_type:"table", rows}` this platform used to refuse
619
+ * as foreign content now lands on OUR type and renders, rather than being data nothing draws.
620
+ */
621
+ table: "table",
622
+ /** The annotation carrying a hyperlink's `href`. Spelled `link` because that is the spec's convention. */
623
+ link: "link",
624
+ /**
625
+ * The annotation carrying a site's OWN inline markup — `<span class>`, `<small>`, `<time
626
+ * datetime>`. Unprefixed, like `table`, because it describes HTML rather than a BetterCMS
627
+ * concept. See `styled.ts` for what it may hold and why the list is that short.
628
+ */
629
+ styled: "styled"
630
+ };
631
+ var BUILTIN_TABLE_KEY = "builtin:table";
632
+ var STORABLE_BLOCK_TYPES = /* @__PURE__ */ new Set([
633
+ "block",
634
+ PT_TYPES.block,
635
+ PT_TYPES.table,
636
+ PT_TYPES.code,
637
+ PT_TYPES.divider,
638
+ PT_TYPES.mention,
639
+ PT_TYPES.video,
640
+ PT_TYPES.file,
641
+ PT_TYPES.pullQuote,
642
+ PT_TYPES.keyTakeaways
643
+ ]);
644
+ var EMBEDDED_COMPONENT_KEY = "component";
645
+
646
+ // ../richtext/src/styled.ts
647
+ var STYLED_TAGS = [
648
+ "span",
649
+ "small",
650
+ "sup",
651
+ "sub",
652
+ "kbd",
653
+ "abbr",
654
+ "time",
655
+ "strong",
656
+ "b",
657
+ "em",
658
+ "i",
659
+ "u",
660
+ "s",
661
+ "mark",
662
+ "code",
663
+ "q",
664
+ "cite",
665
+ "dfn",
666
+ "var",
667
+ "samp",
668
+ "ins",
669
+ "del"
670
+ ];
671
+ var TAG_SET = new Set(STYLED_TAGS);
672
+ var isStyledTag = (tag) => typeof tag === "string" && TAG_SET.has(tag);
673
+ var ATTR_NAMES = /* @__PURE__ */ new Set(["title", "datetime", "style", "aria-label"]);
674
+ var DATA_ATTR = /^data-[a-z][a-z0-9-]*$/;
675
+ var isStyledAttr = (name) => ATTR_NAMES.has(name) || DATA_ATTR.test(name) && !name.startsWith("data-bcms-");
676
+ var STYLE_PROPS = /* @__PURE__ */ new Set([
677
+ "color",
678
+ "background-color",
679
+ "opacity",
680
+ "font",
681
+ "font-family",
682
+ "font-size",
683
+ "font-style",
684
+ "font-weight",
685
+ "font-variant",
686
+ "font-feature-settings",
687
+ "font-variation-settings",
688
+ "letter-spacing",
689
+ "line-height",
690
+ "word-spacing",
691
+ "white-space",
692
+ "word-break",
693
+ "text-align",
694
+ "text-decoration",
695
+ "text-decoration-color",
696
+ "text-decoration-line",
697
+ "text-decoration-style",
698
+ "text-transform",
699
+ "text-underline-offset",
700
+ "vertical-align"
701
+ ]);
702
+ var STYLE_VALUE_BAN = /url\(|expression\(|@import|\\|\/\*|[<>]/i;
703
+ function safeStyle(css) {
704
+ const kept = [];
705
+ for (const declaration of css.split(";")) {
706
+ const at = declaration.indexOf(":");
707
+ if (at < 0) continue;
708
+ const name = declaration.slice(0, at).trim().toLowerCase();
709
+ const value = declaration.slice(at + 1).trim();
710
+ if (!name || !value) continue;
711
+ if (!name.startsWith("--") && !STYLE_PROPS.has(name)) continue;
712
+ if (STYLE_VALUE_BAN.test(value)) continue;
713
+ kept.push(`${name}:${value}`);
714
+ }
715
+ return kept.length ? kept.join(";") : void 0;
716
+ }
717
+ function styledAttrEntries(attrs) {
718
+ if (!attrs || typeof attrs !== "object" || Array.isArray(attrs)) return [];
719
+ const out = [];
720
+ for (const [name, raw] of Object.entries(attrs)) {
721
+ if (!isStyledAttr(name) || typeof raw !== "string") continue;
722
+ const value = name === "style" ? safeStyle(raw) : raw;
723
+ if (value === void 0) continue;
724
+ out.push([name, value]);
725
+ }
726
+ return out;
536
727
  }
537
728
 
538
- // src/lib/utils.ts
539
- import { clsx } from "clsx";
540
- import { twMerge } from "tailwind-merge";
541
- function cn(...inputs) {
542
- return twMerge(clsx(inputs));
729
+ // ../richtext/src/href.ts
730
+ var SAFE_HREF_SCHEMES = ["http:", "https:", "mailto:", "tel:"];
731
+ function safeHref(raw) {
732
+ const trimmed = raw.trim();
733
+ if (trimmed.startsWith("#")) return /^#[^\s<>"'`]*$/.test(trimmed) ? trimmed : null;
734
+ if (trimmed.startsWith("/") && !trimmed.startsWith("//")) return trimmed;
735
+ try {
736
+ return SAFE_HREF_SCHEMES.includes(new URL(trimmed).protocol) ? trimmed : null;
737
+ } catch {
738
+ return null;
739
+ }
543
740
  }
544
741
 
545
- // src/blocks/Image.tsx
546
- import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
547
- function Image({
548
- src,
549
- alt,
550
- width,
551
- height,
552
- caption,
553
- className = "",
554
- sizes,
555
- "data-bcms-field": field,
556
- "data-bcms-kind": kind
557
- }) {
558
- const sized = typeof width === "number" && width > 0;
559
- return /* @__PURE__ */ jsxs2("figure", { className: cn(sized ? "max-w-full" : "w-full", className), children: [
560
- /* @__PURE__ */ jsx4(
561
- "img",
562
- {
563
- src,
564
- alt,
565
- width,
566
- height,
567
- sizes,
568
- "data-bcms-field": field,
569
- "data-bcms-kind": kind,
570
- style: sized ? { width: `${width}px`, maxWidth: "100%", height: "auto" } : void 0,
571
- className: cn(!sized && "w-full", "h-auto rounded-md object-cover"),
572
- loading: "lazy"
573
- }
574
- ),
575
- caption && /* @__PURE__ */ jsx4("figcaption", { className: "mt-2 text-sm text-muted-foreground text-center italic", children: caption })
576
- ] });
742
+ // ../richtext/src/key.ts
743
+ var ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
744
+ var KEY_PATTERN = /^[A-Za-z0-9-]{1,32}$/;
745
+ var isValidKey = (k) => typeof k === "string" && KEY_PATTERN.test(k);
746
+ function randomKey(length = 12) {
747
+ let out = "";
748
+ for (let i = 0; i < length; i += 1) {
749
+ out += ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
750
+ }
751
+ return out;
577
752
  }
578
-
579
- // src/blocks/Button.tsx
580
- import { LINK_RELS, buttonIconOf, buttonIconSvg, buttonVariantOf, isScriptHref } from "@bettercms-ai/types";
581
- import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
582
- function Button({
583
- text,
584
- href,
585
- variant = "primary",
586
- size = "md",
587
- fullWidth = false,
588
- arrow = false,
589
- icon: iconProp,
590
- newTab = false,
591
- rel,
592
- align = "left",
593
- className = "",
594
- style,
595
- "data-bcms-field": field,
596
- "data-bcms-kind": kind
597
- }) {
598
- const baseStyles = "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50";
599
- const variantStyles = {
600
- primary: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
601
- secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
602
- tertiary: "bg-transparent text-primary hover:bg-primary/10",
603
- brand: "bg-accent text-accent-foreground shadow hover:bg-accent/90",
604
- dark: "bg-foreground text-background shadow hover:bg-foreground/90",
605
- light: "border bg-background text-foreground shadow-sm hover:bg-muted",
606
- outline: "border border-current bg-transparent text-primary hover:bg-primary/10",
607
- link: "h-auto bg-transparent p-0 text-primary underline underline-offset-4"
608
- };
609
- const icon = buttonIconOf(iconProp);
610
- const alignClass = {
611
- left: "justify-start",
612
- center: "justify-center",
613
- right: "justify-end"
614
- }[align];
615
- const sizeStyles = {
616
- sm: "px-3 py-1.5 text-xs",
617
- md: "px-4 py-2",
618
- lg: "px-6 py-3 text-base"
753
+ function deterministicKeys(prefix = "k") {
754
+ let n = 0;
755
+ return () => {
756
+ n += 1;
757
+ return `${prefix}${n}`;
619
758
  };
620
- const sizeClass = size === "sm" || size === "lg" ? sizeStyles[size] : sizeStyles.md;
621
- const fullClass = fullWidth === true ? "flex w-full justify-center" : "";
622
- const relTokens = [...newTab === true ? ["noopener", "noreferrer"] : [], ...LINK_RELS.includes(rel) ? [rel] : []];
623
- return /* @__PURE__ */ jsxs3(
624
- "a",
625
- {
626
- href: typeof href === "string" && isScriptHref(href) ? "#" : href,
627
- ...newTab === true ? { target: "_blank" } : {},
628
- ...relTokens.length ? { rel: relTokens.join(" ") } : {},
629
- "data-bcms-field": field,
630
- "data-bcms-kind": kind,
631
- style,
632
- className: cn(baseStyles, variantStyles[buttonVariantOf(variant)], alignClass, sizeClass, fullClass, className),
633
- children: [
634
- icon && /* @__PURE__ */ jsx5("span", { className: "mr-2 inline-flex", dangerouslySetInnerHTML: { __html: buttonIconSvg(icon, "bcms-btn-icon") } }),
635
- text,
636
- arrow === true && /* @__PURE__ */ jsx5("span", { className: "ml-2 inline-flex", dangerouslySetInnerHTML: { __html: buttonIconSvg("arrow-right", "bcms-btn-arrow") } })
637
- ]
638
- }
639
- );
640
759
  }
641
760
 
642
- // src/blocks/Spacer.tsx
643
- import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
644
- function Spacer({ height = 32, className = "" }) {
645
- return /* @__PURE__ */ jsx6(
646
- "div",
647
- {
648
- className: `w-full border border-dashed border-muted-foreground/20 rounded flex items-center justify-center ${className}`,
649
- style: { height: `${height}px` },
650
- "aria-hidden": "true",
651
- children: /* @__PURE__ */ jsxs4("span", { className: "text-xs text-muted-foreground/40 select-none", children: [
652
- height,
653
- "px"
654
- ] })
655
- }
656
- );
761
+ // ../richtext/src/table.ts
762
+ var isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
763
+ var isPortableTextTable = (n) => isRecord(n) && n._type === PT_TYPES.table && Array.isArray(n.rows);
764
+ var isBuiltInTableBlock = (n) => isRecord(n) && n._type === PT_TYPES.block && n.schemaKey === BUILTIN_TABLE_KEY;
765
+ var asIs = (v) => Array.isArray(v) ? v : [];
766
+ function keyMinter(base, reserved) {
767
+ const prefix = (base.match(/[A-Za-z0-9-]/g) ?? []).join("").slice(0, 16) || "t";
768
+ return (...parts) => {
769
+ const candidate = `${prefix}-${parts.join("")}`.slice(0, 30);
770
+ let key = candidate;
771
+ for (let n = 1; reserved.has(key); n += 1) key = `${candidate}-${n}`;
772
+ reserved.add(key);
773
+ return key;
774
+ };
657
775
  }
658
-
659
- // src/blocks/Columns.tsx
660
- import "react";
661
- import { jsx as jsx7 } from "react/jsx-runtime";
662
- function Columns({
663
- columns,
664
- gap = 16,
665
- className = "",
666
- renderBlock
667
- }) {
668
- return /* @__PURE__ */ jsx7(
669
- "div",
670
- {
671
- className: cn("grid w-full", className),
672
- style: {
673
- gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))`,
674
- gap: `${gap}px`
675
- },
676
- children: columns.map((columnBlocks, colIndex) => /* @__PURE__ */ jsx7("div", { className: "flex flex-col gap-4 min-w-0", children: columnBlocks.map((block) => /* @__PURE__ */ jsx7("span", { children: renderBlock ? renderBlock(block) : null }, block.id)) }, colIndex))
776
+ function reservedKeys(rows) {
777
+ const out = /* @__PURE__ */ new Set();
778
+ for (const row of rows) {
779
+ if (!isRecord(row)) continue;
780
+ if (isValidKey(row._key)) out.add(row._key);
781
+ for (const cell of Array.isArray(row.cells) ? row.cells : []) {
782
+ if (isRecord(cell) && isValidKey(cell._key)) out.add(cell._key);
677
783
  }
678
- );
679
- }
680
-
681
- // src/blocks/Video.tsx
682
- import { useState } from "react";
683
- import { jsx as jsx8, jsxs as jsxs5 } from "react/jsx-runtime";
684
- function getEmbedUrl(url) {
685
- const ytMatch = url.match(
686
- /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/
687
- );
688
- if (ytMatch) {
689
- return {
690
- embedUrl: `https://www.youtube.com/embed/${ytMatch[1]}`,
691
- provider: "youtube"
692
- };
693
- }
694
- const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
695
- if (vimeoMatch) {
696
- return {
697
- embedUrl: `https://player.vimeo.com/video/${vimeoMatch[1]}`,
698
- provider: "vimeo"
699
- };
700
784
  }
701
- return { embedUrl: url, provider: "unknown" };
785
+ return out;
702
786
  }
703
- function Video({
704
- url,
705
- poster,
706
- autoplay = false,
707
- loop = false,
708
- className = ""
709
- }) {
710
- const [isLoaded, setIsLoaded] = useState(false);
711
- const { embedUrl, provider } = getEmbedUrl(url);
712
- if (provider === "unknown") {
713
- return /* @__PURE__ */ jsx8(
714
- "video",
715
- {
716
- src: url,
717
- poster,
718
- autoPlay: autoplay,
719
- loop,
720
- controls: true,
721
- className,
722
- playsInline: true
723
- }
724
- );
725
- }
726
- return /* @__PURE__ */ jsxs5("div", { className: cn("w-full overflow-hidden rounded-md bg-black aspect-video", className), children: [
727
- !isLoaded && poster && // eslint-disable-next-line @next/next/no-img-element
728
- /* @__PURE__ */ jsx8(
729
- "img",
730
- {
731
- src: poster,
732
- alt: "Video thumbnail",
733
- className: "w-full h-full object-cover absolute inset-0"
734
- }
735
- ),
736
- /* @__PURE__ */ jsx8(
737
- "iframe",
738
- {
739
- src: `${embedUrl}?autoplay=${autoplay ? 1 : 0}&loop=${loop ? 1 : 0}&mute=${autoplay ? 1 : 0}`,
740
- allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
741
- allowFullScreen: true,
742
- loading: "lazy",
743
- onLoad: () => setIsLoaded(true),
744
- className: "w-full h-full"
745
- }
746
- )
747
- ] });
787
+ var textContent = (text, key) => text === "" ? [] : [{
788
+ _type: "block",
789
+ _key: key(),
790
+ style: "normal",
791
+ markDefs: [],
792
+ children: [{ _type: "span", _key: key(), text, marks: [] }]
793
+ }];
794
+ var spanOf = (value) => {
795
+ const n = typeof value === "number" ? value : Number(value);
796
+ return Number.isInteger(n) && n > 1 ? n : void 0;
797
+ };
798
+ function normalizeCell(raw, at, mint, read) {
799
+ const [row, col] = at;
800
+ const key = isRecord(raw) && isValidKey(raw._key) ? raw._key : mint("r", row, "c", col);
801
+ const inner = deterministicKeys(`${key}-`);
802
+ if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") {
803
+ return { _key: key, content: textContent(String(raw), inner) };
804
+ }
805
+ if (!isRecord(raw)) return { _key: key, content: [] };
806
+ const span = spanOf(raw.span ?? raw.colSpan ?? raw.colspan);
807
+ const rowSpan = spanOf(raw.rowSpan ?? raw.rowspan);
808
+ return {
809
+ _key: key,
810
+ // `text` is the spelling a string matrix uses once its cells have been given objects; it is
811
+ // read here so a half-migrated value cannot lose its words.
812
+ content: Array.isArray(raw.content) ? read(raw.content, inner) : textContent(typeof raw.text === "string" ? raw.text : "", inner),
813
+ ...raw.header === true ? { header: true } : {},
814
+ ...span === void 0 ? {} : { span },
815
+ ...rowSpan === void 0 ? {} : { rowSpan },
816
+ ...typeof raw.group === "string" ? { group: raw.group } : {}
817
+ };
748
818
  }
749
-
750
- // src/blocks/Section.tsx
751
- import "react";
752
- import { jsx as jsx9 } from "react/jsx-runtime";
753
- function Section({
754
- children,
755
- background,
756
- paddingY,
757
- maxWidth,
758
- className = "",
759
- renderBlock
760
- }) {
761
- const inner = (children ?? []).map((block) => /* @__PURE__ */ jsx9("span", { children: renderBlock ? renderBlock(block) : null }, block.id));
762
- return /* @__PURE__ */ jsx9(
763
- "section",
764
- {
765
- className: cn("w-full", className),
766
- style: { background, paddingTop: paddingY, paddingBottom: paddingY },
767
- children: maxWidth ? /* @__PURE__ */ jsx9("div", { style: { maxWidth, marginInline: "auto" }, children: inner }) : inner
819
+ function normalizeRow(raw, at, mint, read) {
820
+ const cells = Array.isArray(raw) ? raw : isRecord(raw) && Array.isArray(raw.cells) ? raw.cells : [];
821
+ return {
822
+ _key: isRecord(raw) && isValidKey(raw._key) ? raw._key : mint("r", at),
823
+ cells: cells.map((c, col) => normalizeCell(c, [at, col], mint, read)),
824
+ ...isRecord(raw) && raw.head === true ? { head: true } : {},
825
+ ...isRecord(raw) && typeof raw.group === "string" ? { group: raw.group } : {}
826
+ };
827
+ }
828
+ function builtInTableToTable(node, key = randomKey) {
829
+ if (!isBuiltInTableBlock(node)) return null;
830
+ const fields = node.fields;
831
+ const raw = isRecord(fields) && Array.isArray(fields.rows) ? fields.rows : [];
832
+ const hasHeader = isRecord(fields) ? fields.hasHeader !== false : true;
833
+ const own = node._key;
834
+ const tableKey = isValidKey(own) ? own : key();
835
+ const mint = keyMinter(tableKey, reservedKeys(raw));
836
+ const rows = raw.map((row, i) => {
837
+ const normalized = normalizeRow(row, i, mint, asIs);
838
+ if (!(hasHeader && i === 0)) return normalized;
839
+ return {
840
+ ...normalized,
841
+ head: true,
842
+ cells: normalized.cells.map((c) => ({ ...c, header: true }))
843
+ };
844
+ });
845
+ const width = rows.reduce((max, r) => Math.max(max, r.cells.length), 0);
846
+ for (const [i, row] of rows.entries()) {
847
+ while (row.cells.length < width) {
848
+ row.cells.push({
849
+ _key: mint("r", i, "c", row.cells.length),
850
+ content: [],
851
+ ...row.head === true ? { header: true } : {}
852
+ });
768
853
  }
769
- );
854
+ }
855
+ return { _type: PT_TYPES.table, _key: tableKey, rows, legacy: true };
770
856
  }
771
-
772
- // src/blocks/Navbar.tsx
773
- import { jsx as jsx10, jsxs as jsxs6 } from "react/jsx-runtime";
774
- function Navbar({ logo, links, cta, className = "" }) {
775
- return /* @__PURE__ */ jsxs6("nav", { className: cn("flex items-center gap-6 py-4", className), children: [
776
- logo?.src ? /* @__PURE__ */ jsx10("a", { href: logo.href ?? "/", className: "shrink-0", children: /* @__PURE__ */ jsx10("img", { src: logo.src, alt: logo.alt, className: "h-8 w-auto" }) }) : null,
777
- /* @__PURE__ */ jsx10("div", { className: "flex flex-1 gap-5", children: (links ?? []).map((l, i) => /* @__PURE__ */ jsx10("a", { href: l.href, className: "text-inherit no-underline", children: l.label }, i)) }),
778
- cta ? /* @__PURE__ */ jsx10(
779
- "a",
780
- {
781
- href: cta.href,
782
- className: "rounded-lg bg-black px-4 py-2 font-semibold text-white no-underline",
783
- children: cta.text
784
- }
785
- ) : null
786
- ] });
857
+ function migrateTable(node, key = randomKey, read = asIs) {
858
+ const builtIn = builtInTableToTable(node, key);
859
+ if (builtIn) return builtIn;
860
+ if (!isPortableTextTable(node)) return null;
861
+ const rows = node.rows;
862
+ const tableKey = isValidKey(node._key) ? node._key : key();
863
+ const mint = keyMinter(tableKey, reservedKeys(rows));
864
+ return {
865
+ _type: PT_TYPES.table,
866
+ _key: tableKey,
867
+ rows: rows.map((r, i) => normalizeRow(r, i, mint, read)),
868
+ // Carried through: a migrated table that an author saves back is still the same table, and
869
+ // dropping the flag on that save would change the page the next time it renders.
870
+ ...node.legacy === true ? { legacy: true } : {},
871
+ ...tableExtras(node)
872
+ };
787
873
  }
788
-
789
- // src/blocks/Footer.tsx
790
- import { jsx as jsx11, jsxs as jsxs7 } from "react/jsx-runtime";
791
- function Footer({ columns, copyright, className = "" }) {
792
- return /* @__PURE__ */ jsxs7("footer", { className: cn("mt-12 border-t pt-8", className), children: [
793
- /* @__PURE__ */ jsx11("div", { className: "flex flex-wrap gap-12", children: (columns ?? []).map((col, i) => /* @__PURE__ */ jsxs7("div", { className: "flex flex-col gap-1.5", children: [
794
- col.heading ? /* @__PURE__ */ jsx11("h4", { className: "mb-2 font-semibold", children: col.heading }) : null,
795
- (col.links ?? []).map((l, j) => /* @__PURE__ */ jsx11("a", { href: l.href, className: "text-neutral-500 no-underline", children: l.label }, j))
796
- ] }, i)) }),
797
- copyright ? /* @__PURE__ */ jsx11("p", { className: "mt-6 text-sm text-neutral-500", children: copyright }) : null
798
- ] });
874
+ function tableExtras(node) {
875
+ const groups = (list) => Array.isArray(list) ? list.filter((g) => isRecord(g) && typeof g._key === "string" && g._key !== "").map((g) => ({ _key: g._key, label: typeof g.label === "string" ? g.label : "" })) : void 0;
876
+ const rowGroups = groups(node.rowGroups);
877
+ const columnGroups = groups(node.columnGroups);
878
+ return {
879
+ ...typeof node.caption === "string" ? { caption: node.caption } : {},
880
+ ...typeof node.notes === "string" ? { notes: node.notes } : {},
881
+ ...rowGroups ? { rowGroups } : {},
882
+ ...columnGroups ? { columnGroups } : {}
883
+ };
799
884
  }
800
-
801
- // src/blocks/Slider.tsx
802
- import { useState as useState2 } from "react";
803
- import { jsx as jsx12, jsxs as jsxs8 } from "react/jsx-runtime";
804
- function Slider({ slides, className = "", renderBlock }) {
805
- const [i, setI] = useState2(0);
806
- const list = slides ?? [];
807
- const go = (n) => setI((n + list.length) % (list.length || 1));
808
- const btn = "absolute top-1/2 -translate-y-1/2 flex h-9 w-9 items-center justify-center rounded-full border-0 bg-black/50 text-white cursor-pointer";
809
- return /* @__PURE__ */ jsxs8("div", { className: cn("relative w-full", className), children: [
810
- list.map((s, idx) => /* @__PURE__ */ jsx12("div", { hidden: idx !== i, className: "flex flex-col gap-4", children: (s.children ?? []).map((b) => /* @__PURE__ */ jsx12("span", { children: renderBlock ? renderBlock(b) : null }, b.id)) }, s.id)),
811
- /* @__PURE__ */ jsx12("button", { type: "button", "aria-label": "Previous", onClick: () => go(i - 1), className: cn(btn, "left-2"), children: "\u2039" }),
812
- /* @__PURE__ */ jsx12("button", { type: "button", "aria-label": "Next", onClick: () => go(i + 1), className: cn(btn, "right-2"), children: "\u203A" })
813
- ] });
885
+ function tableGroupLayout(table) {
886
+ const rows = Array.isArray(table.rows) ? table.rows : [];
887
+ const labels = (list) => new Map(
888
+ (Array.isArray(list) ? list : []).filter((g) => isRecord(g) && typeof g._key === "string").map((g) => [g._key, typeof g.label === "string" ? g.label : ""])
889
+ );
890
+ const cellsOf = (row) => isRecord(row) && Array.isArray(row.cells) ? row.cells : [];
891
+ const spanOf2 = (cell) => isRecord(cell) && typeof cell.span === "number" && Number.isInteger(cell.span) && cell.span > 1 ? cell.span : 1;
892
+ const width = rows.reduce((max, row) => Math.max(max, cellsOf(row).reduce((n, c) => n + spanOf2(c), 0)), 0);
893
+ const colLabels = labels(table.columnGroups);
894
+ const columns = [];
895
+ for (const cell of cellsOf(rows[0])) {
896
+ const key = isRecord(cell) && typeof cell.group === "string" && colLabels.has(cell.group) ? cell.group : void 0;
897
+ const last = columns[columns.length - 1];
898
+ if (last && last.key === key) last.width += spanOf2(cell);
899
+ else columns.push({ width: spanOf2(cell), ...key === void 0 ? {} : { key, label: colLabels.get(key) } });
900
+ }
901
+ const rowLabels = labels(table.rowGroups);
902
+ const headCount = rows.findIndex((r) => !isRecord(r) || r.head !== true);
903
+ const body = headCount === -1 ? [] : rows.slice(headCount);
904
+ const runs = [];
905
+ for (const row of body) {
906
+ const key = isRecord(row) && typeof row.group === "string" && rowLabels.has(row.group) ? row.group : void 0;
907
+ const last = runs[runs.length - 1];
908
+ if (last && last.key === key) last.rows.push(row);
909
+ else runs.push({ rows: [row], ...key === void 0 ? {} : { key, label: rowLabels.get(key) } });
910
+ }
911
+ return {
912
+ columns: columns.some((r) => r.key !== void 0) ? columns : [],
913
+ rows: runs.some((r) => r.key !== void 0) ? runs : [],
914
+ width
915
+ };
814
916
  }
815
917
 
816
- // src/blocks/Tabs.tsx
817
- import { useState as useState3 } from "react";
818
- import { jsx as jsx13, jsxs as jsxs9 } from "react/jsx-runtime";
819
- function Tabs({ tabs, className = "", renderBlock }) {
820
- const [active, setActive] = useState3(0);
821
- const list = tabs ?? [];
822
- return /* @__PURE__ */ jsxs9("div", { className: cn("w-full", className), children: [
823
- /* @__PURE__ */ jsx13("div", { role: "tablist", className: "mb-4 flex gap-1 border-b", children: list.map((t, idx) => /* @__PURE__ */ jsx13(
824
- "button",
825
- {
826
- role: "tab",
827
- type: "button",
828
- "aria-selected": idx === active,
829
- onClick: () => setActive(idx),
830
- className: cn(
831
- "cursor-pointer border-b-2 border-transparent bg-transparent px-4 py-2",
832
- idx === active && "border-black font-semibold"
833
- ),
834
- children: t.label
835
- },
836
- t.id
837
- )) }),
838
- list.map((t, idx) => /* @__PURE__ */ jsx13("div", { role: "tabpanel", hidden: idx !== active, className: "flex flex-col gap-4", children: (t.children ?? []).map((b) => /* @__PURE__ */ jsx13("span", { children: renderBlock ? renderBlock(b) : null }, b.id)) }, t.id))
839
- ] });
918
+ // ../richtext/src/key-takeaways.ts
919
+ var isRecord2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
920
+ function keyTakeawaysView(value) {
921
+ const v = isRecord2(value) ? value : {};
922
+ const title = typeof v.title === "string" ? v.title.trim() : "";
923
+ const points = (Array.isArray(v.points) ? v.points : []).map((p) => typeof p === "string" ? p.trim() : "").filter(Boolean);
924
+ return { title, points };
840
925
  }
841
926
 
842
- // src/blocks/Placeholders.tsx
843
- import { Fragment as Fragment2, jsx as jsx14, jsxs as jsxs10 } from "react/jsx-runtime";
844
- var box = "rounded border border-dashed border-neutral-300 bg-neutral-50 p-4 text-sm text-neutral-500";
845
- function ComponentInstance({
846
- componentId,
847
- className = ""
848
- }) {
849
- return /* @__PURE__ */ jsxs10("div", { className: cn(box, className), children: [
850
- "Component instance: ",
851
- /* @__PURE__ */ jsx14("code", { children: componentId })
852
- ] });
853
- }
854
- function FormPlaceholder({ formId, className = "" }) {
855
- return /* @__PURE__ */ jsxs10("div", { className: cn(box, className), children: [
856
- "Form: ",
857
- /* @__PURE__ */ jsx14("code", { children: formId })
858
- ] });
859
- }
860
- function CollectionPlaceholder({
861
- cardComponentId,
862
- className = ""
863
- }) {
864
- return /* @__PURE__ */ jsxs10("div", { className: cn(box, className), children: [
865
- "Collection: entries of this page",
866
- cardComponentId ? /* @__PURE__ */ jsxs10(Fragment2, { children: [
867
- " as ",
868
- /* @__PURE__ */ jsx14("code", { children: cardComponentId })
869
- ] }) : /* @__PURE__ */ jsx14(Fragment2, { children: " (default card)" })
870
- ] });
927
+ // ../richtext/src/component-copy.ts
928
+ var isRecord3 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
929
+ var fieldsOf = (value) => isRecord3(value) && value.schemaKey === EMBEDDED_COMPONENT_KEY && isRecord3(value.fields) ? value.fields : null;
930
+ function detachedComponentCopy(value) {
931
+ const f = fieldsOf(value);
932
+ if (!f || f.detached !== true || !Array.isArray(f.blocks)) return null;
933
+ return {
934
+ id: isRecord3(value) && typeof value._key === "string" ? value._key : "",
935
+ componentId: typeof f.componentId === "string" ? f.componentId : "",
936
+ props: isRecord3(f.props) ? f.props : {},
937
+ blocks: f.blocks,
938
+ ...typeof f.sourcePublishedAt === "string" ? { sourcePublishedAt: f.sourcePublishedAt } : {},
939
+ ...typeof f.variant === "string" ? { variant: f.variant } : {}
940
+ };
871
941
  }
872
942
 
873
- // src/registry.ts
874
- var BLOCK_REGISTRY = {
875
- // The entries of the page this block sits on. No preview here by design: the list is
876
- // fetched per request by the site renderer, so the builder has nothing to show.
877
- collection: {
878
- component: CollectionPlaceholder,
879
- label: "Collection",
880
- icon: "list"
881
- },
882
- heading: {
883
- component: Heading,
884
- label: "Heading",
885
- icon: "type"
886
- },
887
- text: {
888
- component: Text,
889
- label: "Text",
890
- icon: "align-left"
891
- },
892
- // Same payload, same renderer — deliberately. `richtext` differs from `text` only in how
893
- // an author EDITS it (a formatting surface vs a plain field); at delivery both are an HTML
894
- // string, so duplicating the component would be two things to keep in sync for no gain.
895
- richtext: {
896
- component: Text,
897
- label: "Rich text",
898
- icon: "pilcrow"
899
- },
900
- image: {
901
- component: Image,
902
- label: "Image",
903
- icon: "image"
904
- },
905
- button: {
906
- component: Button,
907
- label: "Button",
908
- icon: "square"
909
- },
910
- spacer: {
911
- component: Spacer,
912
- label: "Spacer",
913
- icon: "minus"
914
- },
915
- columns: {
916
- component: Columns,
917
- label: "Columns",
918
- icon: "columns"
919
- },
920
- video: {
921
- component: Video,
922
- label: "Video",
923
- icon: "play"
924
- },
925
- section: {
926
- component: Section,
927
- label: "Section",
928
- icon: "layout"
929
- },
930
- navbar: {
931
- component: Navbar,
932
- label: "Navbar",
933
- icon: "menu"
934
- },
935
- footer: {
936
- component: Footer,
937
- label: "Footer",
938
- icon: "panel-bottom"
939
- },
940
- slider: {
941
- component: Slider,
942
- label: "Slider",
943
- icon: "gallery-horizontal"
944
- },
945
- tabs: {
946
- component: Tabs,
947
- label: "Tabs",
948
- icon: "folder"
949
- },
950
- component: {
951
- component: ComponentInstance,
952
- label: "Component",
953
- icon: "component"
954
- },
955
- form: {
956
- component: FormPlaceholder,
957
- label: "Form",
958
- icon: "clipboard-list"
959
- }
960
- };
961
- var BLOCK_TYPES = Object.keys(BLOCK_REGISTRY);
962
-
963
- // src/renderer.tsx
964
- import { RESPONSIVE_CSS, hideOnAttr, splitButtonStyle, usesHideOn, visibleBlocks, windowState } from "@bettercms-ai/types";
965
-
966
- // src/blockStyle.ts
967
- import { blockStyleToCss } from "@bettercms-ai/types";
968
- function blockStyleToReactStyle(style) {
969
- return blockStyleToCss(style);
943
+ // ../richtext/src/read.ts
944
+ var isRecord4 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
945
+ function normalizePortableText(value, key = randomKey) {
946
+ if (!Array.isArray(value)) return [];
947
+ return value.flatMap((raw) => {
948
+ if (!isRecord4(raw) || typeof raw._type !== "string") return [];
949
+ const table = migrateTable(raw, key, (v, cellKey) => normalizePortableText(v, cellKey));
950
+ if (table) return [table];
951
+ const node = { ...raw, _key: isRecord4(raw) && typeof raw._key === "string" && raw._key ? raw._key : key() };
952
+ if (node._type === "block") {
953
+ if (!Array.isArray(node.markDefs)) node.markDefs = [];
954
+ if (typeof node.style !== "string") node.style = "normal";
955
+ node.children = (Array.isArray(node.children) ? node.children : []).map((c) => {
956
+ if (!isRecord4(c)) return c;
957
+ const child = { ...c, _key: typeof c._key === "string" && c._key ? c._key : key() };
958
+ if (child._type === "span" && !Array.isArray(child.marks)) child.marks = [];
959
+ return child;
960
+ });
961
+ }
962
+ return [node];
963
+ });
964
+ }
965
+ function readStructuredContent(props) {
966
+ if (!isRecord4(props)) return null;
967
+ const candidate = props.content ?? props.value;
968
+ const arr = Array.isArray(candidate) ? candidate : isRecord4(candidate) ? Array.isArray(candidate.content) ? candidate.content : Array.isArray(candidate.value) ? candidate.value : null : null;
969
+ return arr && arr.length > 0 ? arr : null;
970
970
  }
971
971
 
972
- // src/renderer.tsx
973
- import "react";
974
- import { jsx as jsx15, jsxs as jsxs11 } from "react/jsx-runtime";
975
- var CONTAINER_TYPES = /* @__PURE__ */ new Set(["columns", "section", "slider", "tabs"]);
976
- function styleOf(block) {
977
- if (block.type !== "button") return { wrapper: blockStyleToReactStyle(block.style), paint: {} };
978
- const { placement, paint } = splitButtonStyle(block.style);
979
- return { wrapper: blockStyleToReactStyle(placement), paint: paint ? { style: paint } : {} };
972
+ // src/blocks/portableText.tsx
973
+ import { Fragment as Fragment2, jsx as jsx14, jsxs as jsxs11 } from "react/jsx-runtime";
974
+ var TOKEN = /^[a-z0-9-]+$/i;
975
+ function styleObject(css) {
976
+ const out = {};
977
+ for (const declaration of css.split(";")) {
978
+ const at = declaration.indexOf(":");
979
+ if (at < 0) continue;
980
+ const name = declaration.slice(0, at).trim();
981
+ const value = declaration.slice(at + 1).trim();
982
+ if (!name || !value) continue;
983
+ out[name.startsWith("--") ? name : name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value;
984
+ }
985
+ return out;
980
986
  }
981
- function withHide(block, wrapperStyle, el, stampAt) {
982
- const hide = hideOnAttr(block.style);
983
- const state = stampAt ? windowState(block, stampAt) : "live";
984
- if (!wrapperStyle && !hide && state === "live") return el;
985
- return /* @__PURE__ */ jsx15("div", { style: wrapperStyle, ...hide ? { "data-bcms-hide": hide } : {}, ...state === "live" ? {} : { "data-bcms-window": state }, children: el });
987
+ function Bound({
988
+ base,
989
+ nodeKey,
990
+ children
991
+ }) {
992
+ if (!base || !nodeKey) return /* @__PURE__ */ jsx14(Fragment2, { children });
993
+ return /* @__PURE__ */ jsx14("div", { "data-bcms-field": `${base}#${nodeKey}`, "data-bcms-kind": "richtext", children });
986
994
  }
987
- function bindingProps(block, annotate) {
988
- if (!annotate || !block.id) return {};
989
- switch (block.type) {
990
- case "heading":
991
- case "button":
992
- return { "data-bcms-field": `${block.id}__text`, "data-bcms-kind": "text" };
993
- case "text":
994
- case "richtext":
995
- return { "data-bcms-field": `${block.id}__html`, "data-bcms-kind": "richtext" };
996
- case "image":
997
- return { "data-bcms-field": `${block.id}__src`, "data-bcms-kind": "image" };
998
- default:
999
- return {};
1000
- }
995
+ function Table({ value }) {
996
+ const components = React6.useMemo(buildComponents, []);
997
+ const inline = React6.useMemo(
998
+ () => ({ ...components, block: { normal: ({ children }) => /* @__PURE__ */ jsx14(Fragment2, { children }) } }),
999
+ [components]
1000
+ );
1001
+ const rows = Array.isArray(value?.rows) ? value.rows : [];
1002
+ if (rows.length === 0) return null;
1003
+ const cells = (row) => (Array.isArray(row?.cells) ? row.cells : []).map((cell) => {
1004
+ const Tag = cell?.header === true ? "th" : "td";
1005
+ const span2 = typeof cell?.span === "number" && cell.span > 1 ? cell.span : void 0;
1006
+ const rowSpan = typeof cell?.rowSpan === "number" && cell.rowSpan > 1 ? cell.rowSpan : void 0;
1007
+ const content = Array.isArray(cell?.content) ? cell.content : [];
1008
+ const lone = content.length === 1 && content[0]._type === "block" && content[0].style === "normal" && !content[0].listItem;
1009
+ return /* @__PURE__ */ jsx14(Tag, { ...span2 ? { colSpan: span2 } : {}, ...rowSpan ? { rowSpan } : {}, children: /* @__PURE__ */ jsx14(PT, { value: content, components: lone ? inline : components }) }, cell?._key);
1010
+ });
1011
+ const rowEl = (row) => /* @__PURE__ */ jsx14("tr", { children: cells(row) }, row?._key);
1012
+ const headCount = rows.findIndex((r) => r?.head !== true);
1013
+ const head = headCount === -1 ? rows.length : headCount;
1014
+ const layout = tableGroupLayout(value);
1015
+ const caption = typeof value.caption === "string" ? value.caption.trim() : "";
1016
+ const notes = typeof value.notes === "string" ? value.notes.trim() : "";
1017
+ const notesId = notes && typeof value._key === "string" && value._key ? `${value._key}-notes` : void 0;
1018
+ const span = (n) => n > 1 ? n : void 0;
1019
+ const groupRow = layout.columns.length ? /* @__PURE__ */ jsx14("tr", { children: layout.columns.flatMap(
1020
+ (run, i) => run.label === void 0 ? Array.from({ length: run.width }, (_, k) => /* @__PURE__ */ jsx14("td", {}, `${i}-${k}`)) : [/* @__PURE__ */ jsx14("th", { scope: "colgroup", colSpan: span(run.width), "data-group": run.key, children: run.label }, i)]
1021
+ ) }) : null;
1022
+ const bodies = layout.rows.length ? layout.rows.map((run, i) => /* @__PURE__ */ jsxs11("tbody", { "data-key": run.key, children: [
1023
+ run.label !== void 0 && /* @__PURE__ */ jsx14("tr", { children: /* @__PURE__ */ jsx14("th", { scope: "rowgroup", colSpan: span(layout.width), children: run.label }) }),
1024
+ run.rows.map(rowEl)
1025
+ ] }, i)) : head < rows.length && /* @__PURE__ */ jsx14("tbody", { children: rows.slice(head).map(rowEl) });
1026
+ const table = /* @__PURE__ */ jsxs11("table", { ...value.legacy === true ? { className: "bcms-table" } : {}, ...notesId ? { "aria-describedby": notesId } : {}, children: [
1027
+ caption && /* @__PURE__ */ jsx14("caption", { children: caption }),
1028
+ layout.columns.map((run, i) => /* @__PURE__ */ jsx14("colgroup", { span: span(run.width) }, i)),
1029
+ (groupRow || head > 0) && /* @__PURE__ */ jsxs11("thead", { children: [
1030
+ groupRow,
1031
+ rows.slice(0, head).map(rowEl)
1032
+ ] }),
1033
+ bodies
1034
+ ] });
1035
+ return notes ? /* @__PURE__ */ jsxs11("figure", { "data-rt": "table", children: [
1036
+ table,
1037
+ /* @__PURE__ */ jsx14("p", { "data-notes": "", id: notesId, children: notes })
1038
+ ] }) : table;
1001
1039
  }
1002
- function resolveBlock(block, className, annotate = false, stampAt) {
1003
- const entry = BLOCK_REGISTRY[block.type];
1004
- if (!entry) {
1005
- return /* @__PURE__ */ jsxs11(
1006
- "div",
1007
- {
1008
- className: `w-full p-4 border border-dashed border-red-400 bg-red-50 text-red-600 text-sm rounded ${className ?? ""}`,
1009
- children: [
1010
- "Unknown block type: ",
1011
- /* @__PURE__ */ jsx15("code", { children: block.type })
1012
- ]
1040
+ function buildComponents(renderBlock) {
1041
+ return {
1042
+ types: {
1043
+ [PT_TYPES.divider]: () => /* @__PURE__ */ jsx14("hr", {}),
1044
+ [PT_TYPES.table]: ({ value }) => /* @__PURE__ */ jsx14(Table, { value }),
1045
+ [PT_TYPES.code]: ({ value }) => {
1046
+ const v = value;
1047
+ const lang = typeof v.language === "string" && TOKEN.test(v.language) ? v.language : void 0;
1048
+ return /* @__PURE__ */ jsx14("pre", { children: /* @__PURE__ */ jsx14("code", { className: lang ? `language-${lang}` : void 0, children: typeof v.code === "string" ? v.code : "" }) });
1049
+ },
1050
+ /**
1051
+ * A generic embedded block renders as a marker, not as content: its payload references
1052
+ * something (a component, a media id) that has to be RESOLVED against the project, and
1053
+ * this renderer has no project. A marker keeps the node visible and addressable rather
1054
+ * than rendering nothing at all, which is how embedded content used to vanish. A caller
1055
+ * that CAN resolve it passes `renderBlock`.
1056
+ */
1057
+ [PT_TYPES.block]: ({ value }) => {
1058
+ const rendered = renderBlock?.(value);
1059
+ if (rendered !== void 0) return /* @__PURE__ */ jsx14(Fragment2, { children: rendered });
1060
+ const copy = detachedComponentCopy(value);
1061
+ if (copy) {
1062
+ return /* @__PURE__ */ jsx14("div", { className: "bcms-block bcms-block--component", "data-bcms-component-copy": copy.componentId, "data-bcms-instance": copy.id, children: /* @__PURE__ */ jsx14(BlocksRenderer, { blocks: copy.blocks }) });
1063
+ }
1064
+ const v = value;
1065
+ const key = typeof v.schemaKey === "string" ? v.schemaKey : "block";
1066
+ return /* @__PURE__ */ jsx14("div", { className: "bcms-block", "data-bcms-schema-key": key });
1067
+ },
1068
+ /**
1069
+ * A pull quote — the React half of `portableTextToHtml`'s, and the same DOM: the V2 kit's
1070
+ * `<figure data-rt="pullquote">`, the name linked (and the quote cited) only through
1071
+ * `safeHref`. pull-quote.test.tsx compares the two.
1072
+ */
1073
+ [PT_TYPES.pullQuote]: ({ value }) => {
1074
+ const v = value;
1075
+ const text = (x) => typeof x === "string" ? x.trim() : "";
1076
+ const quote = typeof v.quote === "string" ? v.quote : "";
1077
+ const name = text(v.name);
1078
+ const role = text(v.role);
1079
+ const href = safeHref(pullQuoteHref(v.link)) ?? void 0;
1080
+ return /* @__PURE__ */ jsxs11("figure", { "data-rt": "pullquote", children: [
1081
+ /* @__PURE__ */ jsx14("blockquote", { cite: href, children: /* @__PURE__ */ jsx14("p", { children: quote }) }),
1082
+ name || role ? /* @__PURE__ */ jsxs11("figcaption", { children: [
1083
+ name ? href ? /* @__PURE__ */ jsx14("a", { href, "data-name": "", children: name }) : /* @__PURE__ */ jsx14("span", { "data-name": "", children: name }) : null,
1084
+ name && role ? ", " : null,
1085
+ role ? /* @__PURE__ */ jsx14("span", { "data-role": "", children: role }) : null
1086
+ ] }) : null
1087
+ ] });
1088
+ },
1089
+ /**
1090
+ * Key takeaways — the React half of `portableTextToHtml`'s, and the same DOM: the V2 kit's
1091
+ * `<aside data-rt="takeaways">`, read through the one `keyTakeawaysView` both renderers share.
1092
+ * portable-text-key-takeaways.test.tsx compares the two.
1093
+ */
1094
+ [PT_TYPES.keyTakeaways]: ({ value }) => {
1095
+ const { title, points } = keyTakeawaysView(value);
1096
+ return /* @__PURE__ */ jsxs11("aside", { "data-rt": "takeaways", children: [
1097
+ title ? /* @__PURE__ */ jsx14("p", { "data-title": "", children: title }) : null,
1098
+ /* @__PURE__ */ jsx14("ul", { children: points.map((p, i) => /* @__PURE__ */ jsx14("li", { children: p }, i)) })
1099
+ ] });
1100
+ },
1101
+ [PT_TYPES.mention]: ({ value }) => {
1102
+ const v = value;
1103
+ return /* @__PURE__ */ jsx14("span", { className: "bcms-mention", children: typeof v.label === "string" ? v.label : "" });
1013
1104
  }
1014
- );
1105
+ },
1106
+ marks: {
1107
+ /**
1108
+ * The only attribute this renderer writes from author data. An unsafe href renders the
1109
+ * LABEL rather than deleting it — blocking a link must not lose the words.
1110
+ */
1111
+ [PT_TYPES.link]: ({ value, children }) => {
1112
+ const v = value ?? {};
1113
+ const href = typeof v.href === "string" ? safeHref(v.href) : null;
1114
+ if (!href) return /* @__PURE__ */ jsx14(Fragment2, { children });
1115
+ const blank = v.target === "_blank";
1116
+ return /* @__PURE__ */ jsx14("a", { href, ...blank ? { target: "_blank", rel: "noopener noreferrer" } : {}, children });
1117
+ },
1118
+ /**
1119
+ * The site's own inline markup, rendered as itself — the React half of
1120
+ * `portableTextToHtml`'s `styled` mark, and it has to stay the same shape: Astro and the
1121
+ * editor canvas render through that serialiser while a Next site renders through this
1122
+ * one, and two renderers of one value that disagree is the whole bug class.
1123
+ *
1124
+ * The tag is re-checked against `STYLED_TAGS` and the attribute bag re-filtered, because
1125
+ * a markDef is DATA and arrives from imports, the Management API and AI writes. React
1126
+ * escapes the values it writes; the allowlist is what keeps a `href` or an `onClick` out
1127
+ * of the props entirely. An unrecognised tag renders its CHILDREN — the words are the
1128
+ * author's, the wrapper is only the design.
1129
+ */
1130
+ [PT_TYPES.styled]: ({ value, children }) => {
1131
+ const v = value ?? {};
1132
+ const tag = typeof v.tag === "string" ? v.tag.toLowerCase() : "";
1133
+ if (!isStyledTag(tag)) return /* @__PURE__ */ jsx14(Fragment2, { children });
1134
+ const Tag = tag;
1135
+ const className = typeof v.className === "string" && v.className ? v.className : void 0;
1136
+ const attrs = styledAttrEntries(v.attrs);
1137
+ const style = attrs.find(([name]) => name === "style")?.[1];
1138
+ return /* @__PURE__ */ jsx14(
1139
+ Tag,
1140
+ {
1141
+ className,
1142
+ ...Object.fromEntries(attrs.filter(([name]) => name !== "style")),
1143
+ ...style ? { style: styleObject(style) } : {},
1144
+ children
1145
+ }
1146
+ );
1147
+ },
1148
+ highlight: ({ children }) => /* @__PURE__ */ jsx14("mark", { children }),
1149
+ "strike-through": ({ children }) => /* @__PURE__ */ jsx14("s", { children }),
1150
+ /**
1151
+ * A colour mark carries a TOKEN name (`color:brand-accent`), never a baked hex, so the
1152
+ * consuming site styles it and a rebrand does not orphan the content.
1153
+ */
1154
+ color: ({ children, markType }) => {
1155
+ const token = markType.startsWith("color:") ? markType.slice(6) : "";
1156
+ if (!TOKEN.test(token)) return /* @__PURE__ */ jsx14(Fragment2, { children });
1157
+ return /* @__PURE__ */ jsx14("span", { className: `bcms-color-${token}`, children });
1158
+ }
1159
+ },
1160
+ /** Unknown marks and types render their children. Never swallow content. */
1161
+ unknownMark: ({ children }) => /* @__PURE__ */ jsx14(Fragment2, { children }),
1162
+ unknownType: ({ children }) => /* @__PURE__ */ jsx14(Fragment2, { children: children ?? null }),
1163
+ unknownBlockStyle: ({ children }) => /* @__PURE__ */ jsx14("p", { children })
1164
+ };
1165
+ }
1166
+ var isListItem = (node) => node._type === "block" && typeof node.listItem === "string" && !!node.listItem;
1167
+ function runsOf(blocks) {
1168
+ const runs = [];
1169
+ for (const node of blocks) {
1170
+ const last = runs.at(-1);
1171
+ if (last && isListItem(node) && isListItem(last[0])) last.push(node);
1172
+ else runs.push([node]);
1015
1173
  }
1016
- const Component = entry.component;
1017
- const { wrapper: blockStyle, paint } = styleOf(block);
1018
- const el = /* @__PURE__ */ jsx15(Component, { ...block.props, ...paint, className, ...bindingProps(block, annotate) });
1019
- return withHide(block, blockStyle, el, stampAt);
1174
+ return runs;
1020
1175
  }
1021
- function BlockRenderer({
1022
- block,
1176
+ function PortableText({ value, bindingBase, renderBlock }) {
1177
+ const blocks = React6.useMemo(() => normalizePortableText(readValue(value)), [value]);
1178
+ const components = React6.useMemo(() => buildComponents(renderBlock), [renderBlock]);
1179
+ const listComponents = React6.useMemo(() => {
1180
+ if (!bindingBase) return null;
1181
+ const keys = new Set(blocks.map((b) => b._key));
1182
+ return {
1183
+ ...components,
1184
+ listItem: ({ value: item, children }) => keys.has(item._key) ? /* @__PURE__ */ jsx14("li", { "data-bcms-field": `${bindingBase}#${item._key}`, "data-bcms-kind": "richtext", children }) : /* @__PURE__ */ jsx14("li", { children })
1185
+ };
1186
+ }, [bindingBase, blocks, components]);
1187
+ if (blocks.length === 0) return null;
1188
+ if (!bindingBase || !listComponents) return /* @__PURE__ */ jsx14(PT, { value: blocks, components });
1189
+ return /* @__PURE__ */ jsx14(Fragment2, { children: runsOf(blocks).map(
1190
+ (run) => isListItem(run[0]) ? /* @__PURE__ */ jsx14(PT, { value: run, components: listComponents }, run[0]._key) : /* @__PURE__ */ jsx14(Bound, { base: bindingBase, nodeKey: run[0]._key, children: /* @__PURE__ */ jsx14(PT, { value: run, components }) }, run[0]._key)
1191
+ ) });
1192
+ }
1193
+ function readValue(value) {
1194
+ if (Array.isArray(value)) return value;
1195
+ if (value && typeof value === "object") {
1196
+ const o = value;
1197
+ if (Array.isArray(o.value)) return o.value;
1198
+ if (Array.isArray(o.content)) return o.content;
1199
+ }
1200
+ return [];
1201
+ }
1202
+
1203
+ // src/blocks/Text.tsx
1204
+ import { jsx as jsx15 } from "react/jsx-runtime";
1205
+ function Text({
1206
+ html,
1207
+ content,
1208
+ align = "left",
1023
1209
  className = "",
1024
- renderBlock,
1025
- annotate = false,
1026
- stampAt
1210
+ "data-bcms-field": field,
1211
+ "data-bcms-kind": kind
1027
1212
  }) {
1028
- const entry = BLOCK_REGISTRY[block.type];
1029
- if (!entry) {
1030
- return /* @__PURE__ */ jsxs11(
1031
- "div",
1032
- {
1033
- className: `w-full p-4 border border-dashed border-red-400 bg-red-50 text-red-600 text-sm rounded ${className}`,
1034
- children: [
1035
- "Unknown block type: ",
1036
- /* @__PURE__ */ jsx15("code", { children: block.type })
1037
- ]
1038
- }
1039
- );
1040
- }
1041
- const Component = entry.component;
1042
- const containerProps = CONTAINER_TYPES.has(block.type) ? { renderBlock: renderBlock ?? ((child) => resolveBlock(child, void 0, annotate, stampAt)) } : {};
1043
- const { wrapper: blockStyle, paint } = styleOf(block);
1044
- const el = /* @__PURE__ */ jsx15(
1045
- Component,
1213
+ const alignClass = {
1214
+ left: "text-left",
1215
+ center: "text-center",
1216
+ right: "text-right"
1217
+ }[align];
1218
+ const cls = `text-muted-foreground leading-relaxed ${alignClass} ${className}`;
1219
+ const structured = readStructuredContent({ content });
1220
+ return /* @__PURE__ */ jsx15(
1221
+ "div",
1046
1222
  {
1047
- ...block.props,
1048
- ...paint,
1049
- className,
1050
- ...containerProps,
1051
- ...bindingProps(block, annotate)
1223
+ className: cls,
1224
+ ...structured ? {} : { "data-bcms-field": field, "data-bcms-kind": kind },
1225
+ children: structured ? /* @__PURE__ */ jsx15(PortableText, { value: structured, bindingBase: field }) : (
1226
+ // eslint-disable-next-line react/no-danger
1227
+ /* @__PURE__ */ jsx15("span", { dangerouslySetInnerHTML: { __html: html } })
1228
+ )
1052
1229
  }
1053
1230
  );
1054
- return withHide(block, blockStyle, el, stampAt);
1055
- }
1056
- function BlocksRenderer({
1057
- blocks: all,
1058
- className = "",
1059
- renderBlock,
1060
- annotate = false,
1061
- now
1062
- }) {
1063
- const at = now ?? /* @__PURE__ */ new Date();
1064
- const blocks = annotate ? all : visibleBlocks(all, at);
1065
- if (blocks.length === 0) return null;
1066
- return /* @__PURE__ */ jsxs11("div", { className, children: [
1067
- usesHideOn(blocks) && /* @__PURE__ */ jsx15("style", { dangerouslySetInnerHTML: { __html: RESPONSIVE_CSS } }),
1068
- blocks.map((block) => /* @__PURE__ */ jsx15(
1069
- BlockRenderer,
1070
- {
1071
- block,
1072
- renderBlock,
1073
- annotate,
1074
- stampAt: annotate ? at : void 0
1075
- },
1076
- block.id
1077
- ))
1078
- ] });
1079
1231
  }
1080
1232
  export {
1081
1233
  BLOCK_REGISTRY,