@bettercms-ai/ui 0.20.0 → 0.22.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
@@ -616,6 +616,16 @@ var PT_TYPES = {
616
616
  pullQuote: "pullQuote",
617
617
  /** Key takeaways — see `PortableTextKeyTakeaways`. The V2 kit's `<aside data-rt="takeaways">`. */
618
618
  keyTakeaways: "keyTakeaways",
619
+ /**
620
+ * A third-party embed (X, CodePen, Figma, Maps, Spotify, Podcasts, audio, a link card) — see
621
+ * `PortableTextEmbed` and `embed.ts`. The V2 kit's `<figure data-rt="embed">`. Gated by the
622
+ * `embed` feature; YouTube and Vimeo links still resolve here too, through the video resolver.
623
+ */
624
+ embed: "embed",
625
+ /** A gallery of images, a grid or a carousel — see `PortableTextGallery`. The V2 kit's `<figure data-rt="gallery">`. */
626
+ gallery: "gallery",
627
+ /** A table of contents built from the headings — see `PortableTextTableOfContents`. The V2 kit's `<nav data-rt="toc">`. */
628
+ tableOfContents: "tableOfContents",
619
629
  /**
620
630
  * A table. Spelled `table`, unprefixed, because that is what every other Portable Text
621
631
  * producer calls one — the Sanity-shaped `{_type:"table", rows}` this platform used to refuse
@@ -642,7 +652,10 @@ var STORABLE_BLOCK_TYPES = /* @__PURE__ */ new Set([
642
652
  PT_TYPES.video,
643
653
  PT_TYPES.file,
644
654
  PT_TYPES.pullQuote,
645
- PT_TYPES.keyTakeaways
655
+ PT_TYPES.keyTakeaways,
656
+ PT_TYPES.embed,
657
+ PT_TYPES.gallery,
658
+ PT_TYPES.tableOfContents
646
659
  ]);
647
660
  var EMBEDDED_COMPONENT_KEY = "component";
648
661
 
@@ -1117,16 +1130,51 @@ function keyTakeawaysView(value) {
1117
1130
  return { title, points };
1118
1131
  }
1119
1132
 
1120
- // ../richtext/src/component-copy.ts
1133
+ // ../richtext/src/gallery.ts
1121
1134
  var isRecord3 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
1122
- var fieldsOf = (value) => isRecord3(value) && value.schemaKey === EMBEDDED_COMPONENT_KEY && isRecord3(value.fields) ? value.fields : null;
1135
+ var galleryMediaSrc = (raw) => safeHref(raw) ?? (raw !== "" && !raw.startsWith("//") && !/[:\s]/.test(raw) ? raw : null);
1136
+ var GALLERY_STYLES = {
1137
+ grid: {
1138
+ ul: "display:grid;grid-template-columns:repeat(auto-fill,minmax(12rem,1fr));gap:1rem;list-style:none;margin:0;padding:0",
1139
+ li: "margin:0",
1140
+ figure: "margin:0",
1141
+ img: "display:block;width:100%;height:auto"
1142
+ },
1143
+ carousel: {
1144
+ ul: "display:flex;gap:1rem;overflow-x:auto;scroll-snap-type:x mandatory;list-style:none;margin:0;padding:0",
1145
+ li: "flex:0 0 100%;scroll-snap-align:start;margin:0",
1146
+ figure: "margin:0",
1147
+ img: "display:block;width:100%;height:auto"
1148
+ }
1149
+ };
1150
+ function galleryView(value) {
1151
+ const v = isRecord3(value) ? value : {};
1152
+ const layout = v.layout === "carousel" ? "carousel" : "grid";
1153
+ const images = [];
1154
+ (Array.isArray(v.images) ? v.images : []).forEach((item, i) => {
1155
+ if (!isRecord3(item) || !isRecord3(item.image)) return;
1156
+ const src = galleryMediaSrc(typeof item.image.src === "string" ? item.image.src.trim() : "");
1157
+ if (!src) return;
1158
+ images.push({
1159
+ key: typeof item._key === "string" && item._key ? item._key : `i${i}`,
1160
+ src,
1161
+ alt: typeof item.image.alt === "string" ? item.image.alt : "",
1162
+ caption: typeof item.caption === "string" ? item.caption.trim() : ""
1163
+ });
1164
+ });
1165
+ return { layout, images };
1166
+ }
1167
+
1168
+ // ../richtext/src/component-copy.ts
1169
+ var isRecord4 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
1170
+ var fieldsOf = (value) => isRecord4(value) && value.schemaKey === EMBEDDED_COMPONENT_KEY && isRecord4(value.fields) ? value.fields : null;
1123
1171
  function detachedComponentCopy(value) {
1124
1172
  const f = fieldsOf(value);
1125
1173
  if (!f || f.detached !== true || !Array.isArray(f.blocks)) return null;
1126
1174
  return {
1127
- id: isRecord3(value) && typeof value._key === "string" ? value._key : "",
1175
+ id: isRecord4(value) && typeof value._key === "string" ? value._key : "",
1128
1176
  componentId: typeof f.componentId === "string" ? f.componentId : "",
1129
- props: isRecord3(f.props) ? f.props : {},
1177
+ props: isRecord4(f.props) ? f.props : {},
1130
1178
  blocks: f.blocks,
1131
1179
  ...typeof f.sourcePublishedAt === "string" ? { sourcePublishedAt: f.sourcePublishedAt } : {},
1132
1180
  ...typeof f.variant === "string" ? { variant: f.variant } : {}
@@ -1139,7 +1187,7 @@ function componentCopyLines(copy) {
1139
1187
  const t = v.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
1140
1188
  return t || null;
1141
1189
  }
1142
- if (isRecord3(v)) return line(v.label ?? v.text ?? v.title);
1190
+ if (isRecord4(v)) return line(v.label ?? v.text ?? v.title);
1143
1191
  return null;
1144
1192
  };
1145
1193
  return Object.values(copy.props).map(line).filter((s) => !!s);
@@ -1166,6 +1214,10 @@ function portableTextToPlainText(value) {
1166
1214
  lines.push(...title ? [title] : [], ...points);
1167
1215
  continue;
1168
1216
  }
1217
+ if (node._type === PT_TYPES.gallery) {
1218
+ lines.push(...galleryView(node).images.map((x) => x.caption).filter(Boolean));
1219
+ continue;
1220
+ }
1169
1221
  const table = migrateTable(node);
1170
1222
  if (table) {
1171
1223
  lines.push(...tableLines(table));
@@ -1204,20 +1256,57 @@ var pullQuoteLines = (v) => {
1204
1256
  return [text(v.quote), ...by ? [`\u2014 ${by}`] : []].filter(Boolean);
1205
1257
  };
1206
1258
 
1259
+ // ../richtext/src/toc.ts
1260
+ import { slugify } from "@bettercms-ai/types";
1261
+ var isRecord5 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
1262
+ var headingText = (node) => (Array.isArray(node.children) ? node.children : []).map((c) => isRecord5(c) && typeof c.text === "string" ? c.text : "").join("").replace(/​/g, "").trim();
1263
+ function outlineOf(value) {
1264
+ const items = [];
1265
+ const nodes = /* @__PURE__ */ new WeakMap();
1266
+ const used = /* @__PURE__ */ new Set();
1267
+ for (const node of Array.isArray(value) ? value : []) {
1268
+ if (!isRecord5(node) || node._type !== "block" || node.listItem || node.style !== "h2" && node.style !== "h3") continue;
1269
+ const text = headingText(node);
1270
+ if (!text) continue;
1271
+ const base = slugify(text);
1272
+ let id = base;
1273
+ for (let n = 2; used.has(id); n++) id = `${base}-${n}`;
1274
+ used.add(id);
1275
+ nodes.set(node, id);
1276
+ items.push({ id, text, depth: node.style === "h2" ? 2 : 3 });
1277
+ }
1278
+ return { items, nodes };
1279
+ }
1280
+ function tocTree(items, depth) {
1281
+ const out = [];
1282
+ for (const it of items) {
1283
+ if (it.depth === 3 && depth === "2") continue;
1284
+ const last = out[out.length - 1];
1285
+ if (it.depth === 3 && last) last.children.push(it);
1286
+ else out.push({ ...it, children: [] });
1287
+ }
1288
+ return out;
1289
+ }
1290
+ function tocView(value) {
1291
+ const v = isRecord5(value) ? value : {};
1292
+ const title = typeof v.title === "string" ? v.title.trim() : "";
1293
+ return { title, label: title || "Table of contents", depth: v.depth === "2" ? "2" : "3" };
1294
+ }
1295
+
1207
1296
  // ../richtext/src/read.ts
1208
- var isRecord4 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
1297
+ var isRecord6 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
1209
1298
  function normalizePortableText(value, key = randomKey) {
1210
1299
  if (!Array.isArray(value)) return [];
1211
1300
  return value.flatMap((raw) => {
1212
- if (!isRecord4(raw) || typeof raw._type !== "string") return [];
1301
+ if (!isRecord6(raw) || typeof raw._type !== "string") return [];
1213
1302
  const table = migrateTable(raw, key, (v, cellKey) => normalizePortableText(v, cellKey));
1214
1303
  if (table) return [table];
1215
- const node = { ...raw, _key: isRecord4(raw) && typeof raw._key === "string" && raw._key ? raw._key : key() };
1304
+ const node = { ...raw, _key: isRecord6(raw) && typeof raw._key === "string" && raw._key ? raw._key : key() };
1216
1305
  if (node._type === "block") {
1217
1306
  if (!Array.isArray(node.markDefs)) node.markDefs = [];
1218
1307
  if (typeof node.style !== "string") node.style = "normal";
1219
1308
  node.children = (Array.isArray(node.children) ? node.children : []).map((c) => {
1220
- if (!isRecord4(c)) return c;
1309
+ if (!isRecord6(c)) return c;
1221
1310
  const child = { ...c, _key: typeof c._key === "string" && c._key ? c._key : key() };
1222
1311
  if (child._type === "span" && !Array.isArray(child.marks)) child.marks = [];
1223
1312
  return child;
@@ -1227,12 +1316,219 @@ function normalizePortableText(value, key = randomKey) {
1227
1316
  });
1228
1317
  }
1229
1318
  function readStructuredContent(props) {
1230
- if (!isRecord4(props)) return null;
1319
+ if (!isRecord6(props)) return null;
1231
1320
  const candidate = props.content ?? props.value;
1232
- const arr = Array.isArray(candidate) ? candidate : isRecord4(candidate) ? Array.isArray(candidate.content) ? candidate.content : Array.isArray(candidate.value) ? candidate.value : null : null;
1321
+ const arr = Array.isArray(candidate) ? candidate : isRecord6(candidate) ? Array.isArray(candidate.content) ? candidate.content : Array.isArray(candidate.value) ? candidate.value : null : null;
1233
1322
  return arr && arr.length > 0 ? arr : null;
1234
1323
  }
1235
1324
 
1325
+ // ../richtext/src/video.ts
1326
+ var videoId = (value) => value && /^[a-zA-Z0-9_-]{6,32}$/.test(value) ? value : null;
1327
+ function resolveVideoSource(value) {
1328
+ if (typeof value !== "string" || !value.trim()) return null;
1329
+ let parsed;
1330
+ try {
1331
+ parsed = new URL(value.trim());
1332
+ } catch {
1333
+ return null;
1334
+ }
1335
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
1336
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, "");
1337
+ let id = null;
1338
+ if (host === "youtu.be") id = videoId(parsed.pathname.split("/").filter(Boolean)[0]);
1339
+ if (host === "youtube.com" || host === "m.youtube.com" || host === "youtube-nocookie.com") {
1340
+ id = videoId(
1341
+ parsed.searchParams.get("v") ?? parsed.pathname.match(/^\/(?:embed|shorts)\/([^/?#]+)/)?.[1]
1342
+ );
1343
+ }
1344
+ if (id) {
1345
+ return {
1346
+ kind: "iframe",
1347
+ src: `https://www.youtube-nocookie.com/embed/${id}`,
1348
+ provider: "YouTube"
1349
+ };
1350
+ }
1351
+ if (host === "vimeo.com" || host === "player.vimeo.com") {
1352
+ const segments = parsed.pathname.split("/").filter(Boolean);
1353
+ let vimeoId = null;
1354
+ for (let i = segments.length - 1; i >= 0; i -= 1) {
1355
+ if (/^\d+$/.test(segments[i])) {
1356
+ vimeoId = segments[i];
1357
+ break;
1358
+ }
1359
+ }
1360
+ if (vimeoId) {
1361
+ return {
1362
+ kind: "iframe",
1363
+ src: `https://player.vimeo.com/video/${vimeoId}`,
1364
+ provider: "Vimeo"
1365
+ };
1366
+ }
1367
+ }
1368
+ if (/\.(?:m4v|mp4|og[gv]|webm)$/i.test(parsed.pathname)) {
1369
+ return { kind: "video", src: parsed.toString(), provider: "Video file" };
1370
+ }
1371
+ return null;
1372
+ }
1373
+
1374
+ // ../richtext/src/embed.ts
1375
+ var EMBED_ASPECTS = ["16:9", "4:3", "1:1", "9:16"];
1376
+ var isEmbedAspect = (v) => EMBED_ASPECTS.includes(v);
1377
+ function detectEmbed(url) {
1378
+ const u = url.trim();
1379
+ let host = "";
1380
+ try {
1381
+ host = new URL(u).hostname.replace(/^www\./, "");
1382
+ } catch {
1383
+ host = "";
1384
+ }
1385
+ const is = (re) => re.test(u);
1386
+ if (is(/(?:youtube\.com\/(?:watch\?|embed\/|shorts\/|live\/)|youtu\.be\/)/i)) return { provider: "youtube", label: "YouTube", host };
1387
+ if (is(/vimeo\.com\/(?:video\/)?\d+/i)) return { provider: "vimeo", label: "Vimeo", host };
1388
+ if (is(/(?:twitter|x)\.com\/[^/]+\/status\/\d+/i)) return { provider: "x", label: "X post", host };
1389
+ if (is(/codepen\.io\/[^/]+\/(?:pen|embed)\//i)) return { provider: "codepen", label: "CodePen", host };
1390
+ if (is(/figma\.com\/(?:file|design|proto|board)\//i)) return { provider: "figma", label: "Figma", host };
1391
+ if (is(/(?:google\.[a-z.]+\/maps|maps\.app\.goo\.gl|goo\.gl\/maps)/i)) return { provider: "maps", label: "Google Maps", host };
1392
+ if (is(/open\.spotify\.com\/(?:episode|track|show|playlist|album)\//i)) return { provider: "spotify", label: "Spotify", host };
1393
+ if (is(/podcasts\.apple\.com\//i)) return { provider: "podcast", label: "Apple Podcasts", host };
1394
+ if (is(/\.(?:mp3|m4a|wav|ogg|aac)(?:\?|$)/i)) return { provider: "audio", label: "Audio", host };
1395
+ return { provider: "link", label: host || "Link", host };
1396
+ }
1397
+ function detectMore(url) {
1398
+ const host = url.hostname.toLowerCase().replace(/^www\./, "");
1399
+ const path = url.pathname;
1400
+ if (host === "youtube-nocookie.com" && path.startsWith("/embed/")) return { provider: "youtube", label: "YouTube" };
1401
+ if (host === "open.spotify.com" && /^\/embed\/(?:episode|track|show|playlist|album)\//.test(path)) return { provider: "spotify", label: "Spotify" };
1402
+ if (host === "calendly.com") return { provider: "calendly", label: "Calendly" };
1403
+ if (host.endsWith(".typeform.com") && /^\/to\//.test(path)) return { provider: "typeform", label: "Typeform" };
1404
+ if (host === "loom.com" && /^\/(?:share|embed)\//.test(path)) return { provider: "loom", label: "Loom" };
1405
+ if (host === "airtable.com") return { provider: "airtable", label: "Airtable" };
1406
+ if (host === "docs.google.com") {
1407
+ const kind = path.split("/")[1];
1408
+ const label = { forms: "Google Forms", document: "Google Docs", spreadsheets: "Google Sheets", presentation: "Google Slides" }[kind ?? ""];
1409
+ if (label) return { provider: "google", label };
1410
+ }
1411
+ return null;
1412
+ }
1413
+ var FRAME_SIZE = {
1414
+ "16:9": ["640", "360"],
1415
+ "4:3": ["640", "480"],
1416
+ "1:1": ["640", "640"],
1417
+ "9:16": ["360", "640"]
1418
+ };
1419
+ var ID = /^[A-Za-z0-9_-]{1,64}$/;
1420
+ var FULL_WIDTH_HEIGHT = { calendly: "700", typeform: "500", airtable: "533" };
1421
+ function frameOf(provider, url) {
1422
+ const parts = url.pathname.split("/").filter(Boolean);
1423
+ switch (provider) {
1424
+ case "youtube":
1425
+ case "vimeo": {
1426
+ const video = resolveVideoSource(url.href);
1427
+ return video?.kind === "iframe" ? { src: video.src } : null;
1428
+ }
1429
+ case "codepen": {
1430
+ const [user, mode, id] = parts;
1431
+ return user && ID.test(user) && (mode === "pen" || mode === "embed") && id && ID.test(id) ? { src: `https://codepen.io/${user}/embed/${id}?default-tab=result` } : null;
1432
+ }
1433
+ case "figma": {
1434
+ const [mode, key] = parts;
1435
+ if (!mode || !["file", "design", "proto", "board"].includes(mode) || !key || !ID.test(key)) return null;
1436
+ const frame = new URL(`https://embed.figma.com/${mode}/${key}`);
1437
+ const node = url.searchParams.get("node-id");
1438
+ if (node && /^[0-9A-Za-z:_-]{1,64}$/.test(node)) frame.searchParams.set("node-id", node);
1439
+ frame.searchParams.set("embed-host", "bettercms");
1440
+ return { src: frame.href };
1441
+ }
1442
+ case "maps": {
1443
+ if (!/^(?:www\.)?google\.[a-z.]+$/i.test(url.hostname)) return null;
1444
+ const pb = url.searchParams.get("pb");
1445
+ if (parts[0] === "maps" && parts[1] === "embed" && pb && /^[!A-Za-z0-9._:%+-]{1,2000}$/.test(pb)) {
1446
+ return { src: `https://www.google.com/maps/embed?pb=${encodeURIComponent(pb)}` };
1447
+ }
1448
+ const at = /@(-?\d{1,3}(?:\.\d+)?),(-?\d{1,3}(?:\.\d+)?)/.exec(url.pathname);
1449
+ const place = parts[0] === "maps" && parts[1] === "place" && parts[2] ? decodeURIComponent(parts[2]).replace(/\+/g, " ") : null;
1450
+ const q = at ? `${at[1]},${at[2]}` : url.searchParams.get("q") ?? place;
1451
+ if (!q || q.length > 200) return null;
1452
+ return { src: `https://maps.google.com/maps?q=${encodeURIComponent(q)}&output=embed` };
1453
+ }
1454
+ case "spotify": {
1455
+ const [type, id] = parts[0] === "embed" ? parts.slice(1) : parts;
1456
+ if (!type || !["episode", "track", "show", "playlist", "album"].includes(type) || !id || !ID.test(id)) return null;
1457
+ return { src: `https://open.spotify.com/embed/${type}/${id}`, height: type === "track" || type === "episode" ? "152" : "352" };
1458
+ }
1459
+ case "podcast":
1460
+ return (url.hostname === "podcasts.apple.com" || url.hostname === "embed.podcasts.apple.com") && parts.length >= 2 ? { src: `https://embed.podcasts.apple.com${url.pathname}${url.search}`, height: url.searchParams.has("i") ? "175" : "450" } : null;
1461
+ case "calendly":
1462
+ return parts.length >= 1 && parts.length <= 2 && parts.every((p) => ID.test(p)) ? { src: `https://calendly.com/${parts.join("/")}?embed_type=Inline&hide_gdpr_banner=1` } : null;
1463
+ case "typeform": {
1464
+ const id = parts[0] === "to" ? parts[1] : void 0;
1465
+ return id && /^[A-Za-z0-9]{4,32}$/.test(id) ? { src: `https://form.typeform.com/to/${id}` } : null;
1466
+ }
1467
+ case "loom": {
1468
+ const id = parts[1];
1469
+ return (parts[0] === "share" || parts[0] === "embed") && id && /^[0-9a-f]{32}$/.test(id) ? { src: `https://www.loom.com/embed/${id}` } : null;
1470
+ }
1471
+ case "airtable": {
1472
+ const ids = parts[0] === "embed" ? parts.slice(1) : parts;
1473
+ return ids.length >= 1 && ids.length <= 3 && ids.every((p) => /^(?:app|shr|tbl|viw)[A-Za-z0-9]{14}$/.test(p)) && ids.some((p) => p.startsWith("shr")) ? { src: `https://airtable.com/embed/${ids.join("/")}` } : null;
1474
+ }
1475
+ case "google": {
1476
+ const [kind, d, maybeE, maybeId] = parts;
1477
+ if (d !== "d") return null;
1478
+ const published = maybeE === "e";
1479
+ const id = published ? maybeId : maybeE;
1480
+ if (!id || !/^[A-Za-z0-9_-]{10,128}$/.test(id)) return null;
1481
+ const base = `https://docs.google.com/${kind}/d/${published ? `e/${id}` : id}`;
1482
+ if (kind === "forms") return { src: `${base}/viewform?embedded=true`, height: "800" };
1483
+ if (kind === "document") return { src: published ? `${base}/pub?embedded=true` : `${base}/preview`, height: "600" };
1484
+ if (kind === "spreadsheets") return { src: published ? `${base}/pubhtml?widget=true&headers=false` : `${base}/preview`, height: "600" };
1485
+ if (kind === "presentation") return { src: published ? `${base}/embed` : `${base}/preview` };
1486
+ return null;
1487
+ }
1488
+ default:
1489
+ return null;
1490
+ }
1491
+ }
1492
+ var X_STATUS = /^\/([A-Za-z0-9_]{1,15})\/status\/(\d{1,25})(?:\/|$)/;
1493
+ var X_HOSTS = /^(?:www\.|mobile\.)?(?:twitter|x)\.com$/i;
1494
+ function canonicalEmbedUrl(raw) {
1495
+ const url = typeof raw === "string" ? raw.trim() : "";
1496
+ const safe = url ? safeHref(url) : null;
1497
+ if (!safe || !/^https?:\/\//i.test(safe)) return null;
1498
+ let parsed;
1499
+ try {
1500
+ parsed = new URL(safe);
1501
+ } catch {
1502
+ return null;
1503
+ }
1504
+ const status = X_HOSTS.test(parsed.hostname) ? X_STATUS.exec(parsed.pathname) : null;
1505
+ if (status) return `https://x.com/${status[1].toLowerCase()}/status/${status[2]}`;
1506
+ parsed.hash = "";
1507
+ return parsed.href;
1508
+ }
1509
+ function resolveEmbed(rawUrl, aspect = "16:9") {
1510
+ const url = typeof rawUrl === "string" ? rawUrl.trim() : "";
1511
+ const v2 = detectEmbed(url);
1512
+ const safe = url ? safeHref(url) : null;
1513
+ let parsed = null;
1514
+ try {
1515
+ parsed = safe && /^https?:\/\//i.test(safe) ? new URL(safe) : null;
1516
+ } catch {
1517
+ parsed = null;
1518
+ }
1519
+ const { provider, label } = v2.provider === "link" && parsed ? detectMore(parsed) ?? v2 : v2;
1520
+ const link = { kind: "link", provider, label, url: safe };
1521
+ if (!parsed) return link;
1522
+ if (provider === "x") return { kind: "card", provider: "x", label, url: canonicalEmbedUrl(parsed.href) ?? parsed.href };
1523
+ if (provider === "audio") return parsed.protocol === "https:" ? { kind: "audio", provider: "audio", label, src: parsed.href } : link;
1524
+ const frame = frameOf(provider, parsed);
1525
+ if (!frame) return link;
1526
+ const height = frame.height ?? FULL_WIDTH_HEIGHT[provider];
1527
+ if (height) return { kind: "frame", provider, label, src: frame.src, width: "100%", height };
1528
+ const [width, h] = FRAME_SIZE[isEmbedAspect(aspect) ? aspect : "16:9"];
1529
+ return { kind: "frame", provider, label, src: frame.src, width, height: h };
1530
+ }
1531
+
1236
1532
  // src/blocks/portableText.tsx
1237
1533
  import { Fragment as Fragment2, jsx as jsx14, jsxs as jsxs11 } from "react/jsx-runtime";
1238
1534
  var TOKEN = /^[a-z0-9-]+$/i;
@@ -1364,8 +1660,69 @@ function Table({ value }) {
1364
1660
  ] }) : notes ? /* @__PURE__ */ jsx14("p", { "data-notes": "", id: notesId, children: notes }) : null
1365
1661
  ] }) : table;
1366
1662
  }
1367
- function buildComponents(renderBlock) {
1663
+ function CheckItem({ checked, address, children }) {
1664
+ return /* @__PURE__ */ jsxs11("li", { ...address, "data-checked": String(checked), children: [
1665
+ /* @__PURE__ */ jsx14("input", { type: "checkbox", disabled: true, defaultChecked: checked }),
1666
+ children
1667
+ ] });
1668
+ }
1669
+ function XCard({ url, card }) {
1670
+ return /* @__PURE__ */ jsxs11("blockquote", { className: "twitter-tweet bcms-embed-card", cite: url, children: [
1671
+ /* @__PURE__ */ jsx14("p", { children: card.text }),
1672
+ "\u2014 ",
1673
+ card.authorName,
1674
+ " ",
1675
+ /* @__PURE__ */ jsx14("a", { href: url, children: card.date || "View on X" })
1676
+ ] });
1677
+ }
1678
+ function Toc({ value, outline }) {
1679
+ const { title, label, depth } = tocView(value);
1680
+ const link = (o) => /* @__PURE__ */ jsx14("a", { href: `#${o.id}`, children: o.text });
1681
+ return /* @__PURE__ */ jsxs11("nav", { "data-rt": "toc", "data-depth": depth, "aria-label": label, children: [
1682
+ title ? /* @__PURE__ */ jsx14("p", { "data-title": "", children: title }) : null,
1683
+ /* @__PURE__ */ jsx14("ol", { children: tocTree(outline.items, depth).map((e) => /* @__PURE__ */ jsxs11("li", { children: [
1684
+ link(e),
1685
+ e.children.length ? /* @__PURE__ */ jsx14("ol", { children: e.children.map((c) => /* @__PURE__ */ jsx14("li", { children: link(c) }, c.id)) }) : null
1686
+ ] }, e.id)) })
1687
+ ] });
1688
+ }
1689
+ function Gallery({ value }) {
1690
+ const { layout, images } = galleryView(value);
1691
+ const s = GALLERY_STYLES[layout];
1692
+ return /* @__PURE__ */ jsx14("figure", { "data-rt": "gallery", "data-layout": layout, children: /* @__PURE__ */ jsx14("ul", { style: styleObject(s.ul), tabIndex: layout === "carousel" ? 0 : void 0, children: images.map((x) => /* @__PURE__ */ jsx14("li", { style: styleObject(s.li), children: /* @__PURE__ */ jsxs11("figure", { style: styleObject(s.figure), children: [
1693
+ /* @__PURE__ */ jsx14("img", { src: x.src, alt: x.alt, style: styleObject(s.img), loading: "lazy" }),
1694
+ x.caption ? /* @__PURE__ */ jsx14("figcaption", { children: x.caption }) : null
1695
+ ] }) }, x.key)) }) });
1696
+ }
1697
+ function Embed({ value, embeds }) {
1698
+ const v = value ?? {};
1699
+ const url = typeof v.url === "string" ? v.url.trim() : "";
1700
+ const aspect = isEmbedAspect(v.aspect) ? v.aspect : "16:9";
1701
+ const title = typeof v.title === "string" ? v.title.trim() : "";
1702
+ const r = resolveEmbed(url, aspect);
1703
+ const label = title || r.label;
1704
+ const card = r.kind === "card" ? embeds.cards?.[r.url] : void 0;
1705
+ let body = null;
1706
+ if (r.kind === "frame") {
1707
+ body = /* @__PURE__ */ jsx14("iframe", { src: r.src, title: label, width: r.width, height: r.height, loading: "lazy", referrerPolicy: "strict-origin-when-cross-origin", allowFullScreen: true });
1708
+ } else if (r.kind === "audio") {
1709
+ body = /* @__PURE__ */ jsx14("audio", { controls: true, preload: "none", src: r.src, title: label });
1710
+ } else if (r.kind === "card" && card) {
1711
+ body = /* @__PURE__ */ jsx14(XCard, { url: r.url, card });
1712
+ } else if (r.url) {
1713
+ body = /* @__PURE__ */ jsx14("a", { href: r.url, children: label });
1714
+ }
1715
+ return /* @__PURE__ */ jsx14("figure", { "data-rt": "embed", "data-url": url, "data-provider": r.provider, "data-aspect": aspect, "aria-label": title, children: body });
1716
+ }
1717
+ function buildComponents(renderBlock, embeds = {}, outline = { items: [], nodes: /* @__PURE__ */ new WeakMap() }) {
1368
1718
  return {
1719
+ // RT5: every top-level h2 and h3 carries its outline id, as html.ts writes it.
1720
+ block: {
1721
+ h2: ({ value, children }) => /* @__PURE__ */ jsx14("h2", { id: outline.nodes.get(value), children }),
1722
+ h3: ({ value, children }) => /* @__PURE__ */ jsx14("h3", { id: outline.nodes.get(value), children })
1723
+ },
1724
+ list: { check: ({ children }) => /* @__PURE__ */ jsx14("ul", { "data-task": "true", children }) },
1725
+ listItem: { check: ({ value, children }) => /* @__PURE__ */ jsx14(CheckItem, { checked: value.checked === true, children }) },
1369
1726
  types: {
1370
1727
  [PT_TYPES.divider]: () => /* @__PURE__ */ jsx14("hr", {}),
1371
1728
  [PT_TYPES.table]: ({ value }) => /* @__PURE__ */ jsx14(Table, { value }),
@@ -1415,6 +1772,9 @@ function buildComponents(renderBlock) {
1415
1772
  ] }) : null
1416
1773
  ] });
1417
1774
  },
1775
+ [PT_TYPES.embed]: ({ value }) => /* @__PURE__ */ jsx14(Embed, { value, embeds }),
1776
+ [PT_TYPES.gallery]: ({ value }) => /* @__PURE__ */ jsx14(Gallery, { value }),
1777
+ [PT_TYPES.tableOfContents]: ({ value }) => /* @__PURE__ */ jsx14(Toc, { value, outline }),
1418
1778
  /**
1419
1779
  * Key takeaways — the React half of `portableTextToHtml`'s, and the same DOM: the V2 kit's
1420
1780
  * `<aside data-rt="takeaways">`, read through the one `keyTakeawaysView` both renderers share.
@@ -1442,7 +1802,7 @@ function buildComponents(renderBlock) {
1442
1802
  const href = typeof v.href === "string" ? safeHref(v.href) : null;
1443
1803
  if (!href) return /* @__PURE__ */ jsx14(Fragment2, { children });
1444
1804
  const blank = v.target === "_blank";
1445
- return /* @__PURE__ */ jsx14("a", { href, ...blank ? { target: "_blank", rel: "noopener noreferrer" } : {}, children });
1805
+ return /* @__PURE__ */ jsx14("a", { ...v.button === true ? { "data-button": "true" } : {}, href, ...blank ? { target: "_blank", rel: "noopener noreferrer" } : {}, children });
1446
1806
  },
1447
1807
  /**
1448
1808
  * The site's own inline markup, rendered as itself — the React half of
@@ -1531,15 +1891,22 @@ function calloutOrToggle(value) {
1531
1891
  }
1532
1892
  return null;
1533
1893
  }
1534
- function PortableText({ value, bindingBase, renderBlock }) {
1894
+ function PortableText({ value, bindingBase, renderBlock, embeds }) {
1535
1895
  const blocks = React6.useMemo(() => normalizePortableText(readValue(value)), [value]);
1536
- const components = React6.useMemo(() => buildComponents(renderBlock), [renderBlock]);
1896
+ const outline = React6.useMemo(() => outlineOf(blocks), [blocks]);
1897
+ const components = React6.useMemo(() => buildComponents(renderBlock, embeds, outline), [renderBlock, embeds, outline]);
1537
1898
  const listComponents = React6.useMemo(() => {
1538
1899
  if (!bindingBase) return null;
1539
1900
  const keys = new Set(blocks.map((b) => b._key));
1540
1901
  return {
1541
1902
  ...components,
1542
- 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 })
1903
+ listItem: ({ value: item, children }) => {
1904
+ const address = keys.has(item._key) ? { "data-bcms-field": `${bindingBase}#${item._key}`, "data-bcms-kind": "richtext" } : void 0;
1905
+ if (item.listItem === "check") {
1906
+ return /* @__PURE__ */ jsx14(CheckItem, { checked: item.checked === true, address, children });
1907
+ }
1908
+ return /* @__PURE__ */ jsx14("li", { ...address, children });
1909
+ }
1543
1910
  };
1544
1911
  }, [bindingBase, blocks, components]);
1545
1912
  if (blocks.length === 0) return null;