@asteroidcms/core-utils 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -356,18 +356,25 @@ getContentReadTime(article.body, {
356
356
 
357
357
  ## `<RichTextContent>`
358
358
 
359
- Render Asteroid CMS rich-text JSON/HTML with syntax-highlighted code blocks (via `highlight.js`).
359
+ Render Asteroid CMS rich-text HTML with syntax-highlighted code blocks (via `highlight.js`), self-healing enhancements (copy buttons, blockquote decorations, callout chips), terminal/diff code-block variants, and slugified heading IDs out of the box.
360
360
 
361
361
  ```tsx
362
- import { RichTextContent } from "@asteroidcms/core-utils";
362
+ import { RichTextContent } from "@asteroidcms/core-utils/client";
363
+ import { Info } from "lucide-react";
363
364
 
364
365
  <RichTextContent
365
- content={article.body}
366
+ html={article.body}
367
+ as="article"
368
+ className="prose"
366
369
  classMap={{ p: "my-2 leading-relaxed", h2: "text-2xl font-bold" }}
370
+ onReady={(root) => console.log("hydrated", root.querySelectorAll("h2").length)}
371
+ calloutIcons={{ info: <Info size={14} strokeWidth={2.4} /> }}
367
372
  />;
368
373
  ```
369
374
 
370
- Or use the parser directly:
375
+ Additional props: `contentRef` (forwards the wrapper element, useful for `useTableOfContents`), `onReady` (fires once enhancements have run), and `calloutIcons` (per-variant icon override for `<aside data-callout data-icon>` blocks).
376
+
377
+ Or use the parser directly (server-safe, no `highlight.js`):
371
378
 
372
379
  ```ts
373
380
  import { parseRichText } from "@asteroidcms/core-utils";
@@ -379,6 +386,59 @@ const html = parseRichText(article.body, {
379
386
  });
380
387
  ```
381
388
 
389
+ ### Table of contents
390
+
391
+ Build a live, scroll-tracked ToC from rendered content with `useTableOfContents`. Pair it with `RichTextContent`'s `contentRef` prop:
392
+
393
+ ```tsx
394
+ import { useRef } from "react";
395
+ import {
396
+ RichTextContent,
397
+ useTableOfContents,
398
+ } from "@asteroidcms/core-utils/client";
399
+
400
+ function Article({ slug, html }: { slug: string; html: string }) {
401
+ const contentRef = useRef<HTMLElement | null>(null);
402
+ const { items, activeId } = useTableOfContents(contentRef, {
403
+ levels: [2, 3],
404
+ contentKey: slug, // re-collect when content swaps
405
+ activationOffset: 96, // px from viewport top
406
+ });
407
+
408
+ return (
409
+ <div className="flex gap-8">
410
+ <RichTextContent
411
+ html={html}
412
+ as="article"
413
+ className="prose"
414
+ contentRef={contentRef}
415
+ />
416
+ <nav>
417
+ {items.map((it) => (
418
+ <a
419
+ key={it.id}
420
+ href={`#${it.id}`}
421
+ className={it.id === activeId ? "font-semibold" : ""}
422
+ >
423
+ {it.text}
424
+ </a>
425
+ ))}
426
+ </nav>
427
+ </div>
428
+ );
429
+ }
430
+ ```
431
+
432
+ For a static, server-side outline (RSC layouts, sitemaps, RSS), use `extractHeadingsFromHtml`:
433
+
434
+ ```ts
435
+ import { extractHeadingsFromHtml } from "@asteroidcms/core-utils";
436
+
437
+ const toc = extractHeadingsFromHtml(article.body, { levels: [2, 3] });
438
+ ```
439
+
440
+ See the [full rich-text docs](./docs/web-sdk-react/10-rich-text.md) for `classMap` variants, parser options, and `useTableOfContents` tuning.
441
+
382
442
  ---
383
443
 
384
444
  ## Advanced - bring your own Apollo client
package/dist/client.cjs CHANGED
@@ -7,8 +7,8 @@ var client = require('@apollo/client');
7
7
  var context = require('@apollo/client/link/context');
8
8
  var error = require('@apollo/client/link/error');
9
9
  var jsxRuntime = require('react/jsx-runtime');
10
+ var reactDom = require('react-dom');
10
11
  var hljs = require('highlight.js/lib/common');
11
- require('highlight.js/styles/tokyo-night-dark.css');
12
12
 
13
13
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
14
14
 
@@ -371,7 +371,9 @@ var DEFAULT_ALLOWLIST = [
371
371
  "section",
372
372
  "article",
373
373
  "div",
374
- "span"
374
+ "span",
375
+ "details",
376
+ "summary"
375
377
  ];
376
378
  var ALLOWED_ATTRS = {
377
379
  a: ["href", "title", "target", "rel"],
@@ -381,7 +383,8 @@ var ALLOWED_ATTRS = {
381
383
  col: ["span", "width"],
382
384
  colgroup: ["span"],
383
385
  table: ["border", "cellpadding", "cellspacing"],
384
- span: ["style"]
386
+ span: ["style"],
387
+ details: ["open"]
385
388
  };
386
389
  var ALLOWED_STYLE_PROPS = {
387
390
  span: ["font-size"]
@@ -415,7 +418,8 @@ var GLOBAL_ALLOWED_ATTRS = [
415
418
  "data-title",
416
419
  "data-callout-title",
417
420
  "data-language",
418
- "data-filename"
421
+ "data-filename",
422
+ "data-icon"
419
423
  ];
420
424
  var URL_ATTRS = /* @__PURE__ */ new Set(["href", "src"]);
421
425
  function parseRichText(html, options = {}) {
@@ -425,7 +429,36 @@ function parseRichText(html, options = {}) {
425
429
  working = upgradeStandaloneImages(working);
426
430
  working = upgradeAuthoredBlockquotes(working);
427
431
  working = flattenTableCellParagraphs(working);
428
- return sanitizeAndStyle(working, options);
432
+ working = sanitizeAndStyle(working, options);
433
+ if (options.autoHeadingIds !== false) {
434
+ working = injectHeadingIds(working);
435
+ }
436
+ return working;
437
+ }
438
+ function injectHeadingIds(html) {
439
+ const used = /* @__PURE__ */ new Map();
440
+ const existingRe = /<h[1-6]\b[^>]*\bid\s*=\s*("([^"]*)"|'([^']*)'|(\S+))/gi;
441
+ let em;
442
+ while ((em = existingRe.exec(html)) !== null) {
443
+ const id = em[2] ?? em[3] ?? em[4] ?? "";
444
+ if (id) used.set(id, (used.get(id) ?? 0) + 1);
445
+ }
446
+ return html.replace(
447
+ /<(h[1-6])\b([^>]*)>([\s\S]*?)<\/\1>/gi,
448
+ (full, tag, attrs, inner) => {
449
+ if (/\bid\s*=/i.test(attrs)) return full;
450
+ const text = inner.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/\s+/g, " ").trim();
451
+ if (!text) return full;
452
+ const base = slugifyHeading(text) || tag;
453
+ const n = used.get(base) ?? 0;
454
+ used.set(base, n + 1);
455
+ const id = n === 0 ? base : `${base}-${n}`;
456
+ return `<${tag}${attrs} id="${id}">${inner}</${tag}>`;
457
+ }
458
+ );
459
+ }
460
+ function slugifyHeading(text) {
461
+ return text.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
429
462
  }
430
463
  function flattenTableCellParagraphs(html) {
431
464
  return html.replace(
@@ -800,6 +833,8 @@ function classKeyForTag(tag, attrs, openStack) {
800
833
  if (tag === "p" && "data-callout-title" in attrs) return "calloutTitle";
801
834
  if (tag === "code" && openStack[openStack.length - 1] !== "pre")
802
835
  return "inlineCode";
836
+ if (tag === "details") return "collapsible";
837
+ if (tag === "summary") return "collapsibleTitle";
803
838
  const known = [
804
839
  "p",
805
840
  "br",
@@ -966,8 +1001,6 @@ function sanitizeAndStyle(html, options) {
966
1001
  }
967
1002
  return out.join("");
968
1003
  }
969
-
970
- // src/components/RichTextContent.tsx
971
1004
  var DEFAULT_CLASS_MAP = {
972
1005
  variants: {
973
1006
  "figure:pullquote": "relative my-8",
@@ -1002,6 +1035,46 @@ function findQuoteBody(bq) {
1002
1035
  if (lastIdx < 0) return { first: null, last: null };
1003
1036
  return { first: children[0], last: children[lastIdx] };
1004
1037
  }
1038
+ var CALLOUT_ICON_SVG = {
1039
+ info: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 16v-5"/><path d="M12 8h.01"/></svg>`,
1040
+ warning: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>`,
1041
+ success: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>`,
1042
+ danger: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`,
1043
+ default: `<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M12 2.5l1.6 5.4a4 4 0 0 0 2.5 2.5L21.5 12l-5.4 1.6a4 4 0 0 0-2.5 2.5L12 21.5l-1.6-5.4a4 4 0 0 0-2.5-2.5L2.5 12l5.4-1.6a4 4 0 0 0 2.5-2.5z"/></svg>`
1044
+ };
1045
+ function calloutVariantOf(el) {
1046
+ return el.getAttribute("data-variant") ?? "default";
1047
+ }
1048
+ function isIconEnabled(el) {
1049
+ const v = el.getAttribute("data-icon");
1050
+ if (v === null) return false;
1051
+ return v !== "false" && v !== "0";
1052
+ }
1053
+ function enhanceCallouts(root) {
1054
+ const callouts = root.querySelectorAll("aside[data-callout]");
1055
+ callouts.forEach((el) => {
1056
+ if (el.dataset.rtCalloutEnhanced === "1") return;
1057
+ el.dataset.rtCalloutEnhanced = "1";
1058
+ if (!isIconEnabled(el)) return;
1059
+ if (el.querySelector(":scope > .rt-callout-icon")) return;
1060
+ const variant = calloutVariantOf(el);
1061
+ const icon = document.createElement("span");
1062
+ icon.className = "rt-callout-icon";
1063
+ icon.dataset.variant = variant;
1064
+ icon.setAttribute("aria-hidden", "true");
1065
+ el.prepend(icon);
1066
+ });
1067
+ const chips = [];
1068
+ root.querySelectorAll(
1069
+ "aside[data-callout] > .rt-callout-icon"
1070
+ ).forEach((chip) => {
1071
+ chips.push({
1072
+ el: chip,
1073
+ variant: chip.dataset.variant || (chip.parentElement?.getAttribute("data-variant") ?? "default")
1074
+ });
1075
+ });
1076
+ return chips;
1077
+ }
1005
1078
  function enhanceBlockquotes(root) {
1006
1079
  const quotes = root.querySelectorAll("blockquote");
1007
1080
  quotes.forEach((bq) => {
@@ -1231,6 +1304,28 @@ function enhanceCodeBlocks(root) {
1231
1304
  highlightCodeBlock(pre);
1232
1305
  });
1233
1306
  }
1307
+ var HLJS_THEME = `
1308
+ pre code.hljs { display: block; overflow-x: auto; padding: 1em; }
1309
+ code.hljs { padding: 3px 5px; }
1310
+ .hljs-meta, .hljs-comment { color: #565f89; }
1311
+ .hljs-tag, .hljs-doctag, .hljs-selector-id, .hljs-selector-class,
1312
+ .hljs-regexp, .hljs-template-tag, .hljs-selector-pseudo,
1313
+ .hljs-selector-attr, .hljs-variable.language_, .hljs-deletion { color: #f7768e; }
1314
+ .hljs-variable, .hljs-template-variable, .hljs-number, .hljs-literal,
1315
+ .hljs-type, .hljs-params, .hljs-link { color: #ff9e64; }
1316
+ .hljs-built_in, .hljs-attribute { color: #e0af68; }
1317
+ .hljs-selector-tag { color: #73daca; }
1318
+ .hljs-keyword, .hljs-title.function_, .hljs-title, .hljs-title.class_,
1319
+ .hljs-title.class_.inherited__, .hljs-subst, .hljs-property { color: #7dcfff; }
1320
+ .hljs-quote, .hljs-string, .hljs-symbol, .hljs-bullet,
1321
+ .hljs-addition { color: #9ece6a; }
1322
+ .hljs-code, .hljs-formula, .hljs-section { color: #7aa2f7; }
1323
+ .hljs-name, .hljs-operator, .hljs-char.escape_, .hljs-attr { color: #bb9af7; }
1324
+ .hljs-punctuation { color: #c0caf5; }
1325
+ .hljs { background: #1a1b26; color: #9aa5ce; }
1326
+ .hljs-emphasis { font-style: italic; }
1327
+ .hljs-strong { font-weight: bold; }
1328
+ `;
1234
1329
  var CODEBLOCK_STYLE = `
1235
1330
  .rt-codeblock { position: relative; }
1236
1331
  .rt-codeblock[data-filename]:not([data-filename=""]) { padding-top: 2rem; }
@@ -1282,6 +1377,64 @@ var CODEBLOCK_STYLE = `
1282
1377
  }
1283
1378
  .rt-quote-open { margin-right: 0.15em; }
1284
1379
  .rt-quote-close { margin-left: 0.15em; }
1380
+
1381
+ /* Callout chip ----------------------------------------------------------- */
1382
+ /* Layout: when a callout opts into an icon, use a 2-column grid so the chip
1383
+ sits left of the title + body. Consumers can override via classMap. */
1384
+ aside[data-callout][data-icon]:not([data-icon="false"]):not([data-icon="0"]) {
1385
+ display: grid;
1386
+ grid-template-columns: auto 1fr;
1387
+ column-gap: 0.85rem;
1388
+ align-items: start;
1389
+ }
1390
+ aside[data-callout][data-icon]:not([data-icon="false"]):not([data-icon="0"]) > .rt-callout-icon {
1391
+ grid-row: 1 / span 99;
1392
+ margin-top: 0.05rem;
1393
+ }
1394
+ .rt-callout-icon {
1395
+ display: inline-flex;
1396
+ align-items: center;
1397
+ justify-content: center;
1398
+ width: 1.6rem;
1399
+ height: 1.6rem;
1400
+ border-radius: 0.4rem;
1401
+ color: #fff;
1402
+ background: #475569;
1403
+ flex-shrink: 0;
1404
+ }
1405
+ .rt-callout-icon[data-variant="info"] { background: #2563eb; }
1406
+ .rt-callout-icon[data-variant="warning"] { background: #d97706; }
1407
+ .rt-callout-icon[data-variant="success"] { background: #16a34a; }
1408
+ .rt-callout-icon[data-variant="danger"] { background: #dc2626; }
1409
+ .rt-callout-icon[data-variant="default"] { background: #475569; }
1410
+
1411
+ /* Collapsible (FAQ accordion) ------------------------------------------ */
1412
+ /* Native <details>/<summary> with a rotating chevron. Only structural
1413
+ styling lives here \u2014 colors, padding, and typography belong to
1414
+ consumers via the \`collapsible\` and \`collapsibleTitle\` classMap keys. */
1415
+ details[data-collapsible] > summary {
1416
+ cursor: pointer;
1417
+ list-style: none;
1418
+ display: flex;
1419
+ align-items: center;
1420
+ justify-content: space-between;
1421
+ gap: 0.75rem;
1422
+ }
1423
+ details[data-collapsible] > summary::-webkit-details-marker { display: none; }
1424
+ details[data-collapsible] > summary::after {
1425
+ content: "";
1426
+ width: 0.55rem;
1427
+ height: 0.55rem;
1428
+ border-right: 1.5px solid currentColor;
1429
+ border-bottom: 1.5px solid currentColor;
1430
+ transform: rotate(-45deg) translate(-0.1rem, 0.05rem);
1431
+ transition: transform 0.18s ease;
1432
+ opacity: 0.55;
1433
+ flex-shrink: 0;
1434
+ }
1435
+ details[data-collapsible][open] > summary::after {
1436
+ transform: rotate(45deg) translate(-0.05rem, -0.05rem);
1437
+ }
1285
1438
  /* highlight.js theme handles .hljs-* color classes; we only override the
1286
1439
  default .hljs background so the per-block chrome (dark bg, terminal,
1287
1440
  diff red/green rows) wins. */
@@ -1423,15 +1576,29 @@ function ensureCodeBlockStyles() {
1423
1576
  }
1424
1577
  const tag = document.createElement("style");
1425
1578
  tag.id = "rt-codeblock-style";
1426
- tag.textContent = CODEBLOCK_STYLE;
1579
+ tag.textContent = HLJS_THEME + "\n" + CODEBLOCK_STYLE;
1427
1580
  document.head.appendChild(tag);
1428
1581
  styleInjected = true;
1429
1582
  }
1583
+ function calloutChipsEqual(a, b) {
1584
+ if (a.length !== b.length) return false;
1585
+ for (let i = 0; i < a.length; i++) {
1586
+ if (a[i].el !== b[i].el || a[i].variant !== b[i].variant) return false;
1587
+ }
1588
+ return true;
1589
+ }
1590
+ function BuiltinCalloutIcon({ variant }) {
1591
+ const html = CALLOUT_ICON_SVG[variant] ?? CALLOUT_ICON_SVG.default;
1592
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { dangerouslySetInnerHTML: { __html: html } });
1593
+ }
1430
1594
  function RichTextContent({
1431
1595
  html,
1432
1596
  classMap,
1433
1597
  as = "div",
1434
- className
1598
+ className,
1599
+ onReady,
1600
+ contentRef,
1601
+ calloutIcons
1435
1602
  }) {
1436
1603
  const merged = react.useMemo(
1437
1604
  () => mergeClassMap(DEFAULT_CLASS_MAP, classMap),
@@ -1442,25 +1609,224 @@ function RichTextContent({
1442
1609
  [html, merged]
1443
1610
  );
1444
1611
  const ref = react.useRef(null);
1612
+ const [chips, setChips] = react.useState([]);
1445
1613
  react.useEffect(() => {
1446
1614
  ensureCodeBlockStyles();
1447
- if (ref.current) {
1448
- enhanceCodeBlocks(ref.current);
1449
- enhanceBlockquotes(ref.current);
1615
+ const root = ref.current;
1616
+ if (!root) return;
1617
+ if (contentRef) contentRef.current = root;
1618
+ const apply = () => {
1619
+ mo.disconnect();
1620
+ enhanceCodeBlocks(root);
1621
+ enhanceBlockquotes(root);
1622
+ const nextChips = enhanceCallouts(root);
1623
+ setChips((prev) => calloutChipsEqual(prev, nextChips) ? prev : nextChips);
1624
+ onReady?.(root);
1625
+ mo.observe(root, { childList: true, subtree: true });
1626
+ };
1627
+ let raf = 0;
1628
+ const mo = new MutationObserver(() => {
1629
+ if (raf) return;
1630
+ raf = requestAnimationFrame(() => {
1631
+ raf = 0;
1632
+ apply();
1633
+ });
1634
+ });
1635
+ apply();
1636
+ return () => {
1637
+ mo.disconnect();
1638
+ if (raf) cancelAnimationFrame(raf);
1639
+ };
1640
+ }, [safe, onReady, contentRef]);
1641
+ return /* @__PURE__ */ jsxRuntime.jsxs(react.Fragment, { children: [
1642
+ react.createElement(as, {
1643
+ ref,
1644
+ className,
1645
+ dangerouslySetInnerHTML: { __html: safe }
1646
+ }),
1647
+ chips.map(
1648
+ (chip, i) => reactDom.createPortal(
1649
+ calloutIcons && chip.variant in calloutIcons ? calloutIcons[chip.variant] : /* @__PURE__ */ jsxRuntime.jsx(BuiltinCalloutIcon, { variant: chip.variant }),
1650
+ chip.el,
1651
+ `${chip.variant}:${i}`
1652
+ )
1653
+ )
1654
+ ] });
1655
+ }
1656
+
1657
+ // src/utils/extractHeadings.ts
1658
+ var DEFAULT_LEVELS = [2, 3];
1659
+ function slugify(text) {
1660
+ return text.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1661
+ }
1662
+ function decodeBasicEntities(s) {
1663
+ return s.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
1664
+ }
1665
+ function stripTags(s) {
1666
+ return decodeBasicEntities(s.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
1667
+ }
1668
+ function uniqueId(base, used) {
1669
+ const seed = base || "section";
1670
+ const n = used.get(seed) ?? 0;
1671
+ used.set(seed, n + 1);
1672
+ return n === 0 ? seed : `${seed}-${n}`;
1673
+ }
1674
+ function extractHeadingsFromHtml(html, options = {}) {
1675
+ if (!html) return [];
1676
+ const levels = options.levels ?? DEFAULT_LEVELS;
1677
+ const slug = options.slugify ?? slugify;
1678
+ const used = /* @__PURE__ */ new Map();
1679
+ const out = [];
1680
+ const re = /<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi;
1681
+ let m;
1682
+ let i = 0;
1683
+ while ((m = re.exec(html)) !== null) {
1684
+ const level = Number(m[1]);
1685
+ if (!levels.includes(level)) continue;
1686
+ const attrs = m[2] ?? "";
1687
+ const inner = m[3] ?? "";
1688
+ const text = stripTags(inner);
1689
+ if (!text) continue;
1690
+ const explicitIdMatch = attrs.match(/\bid\s*=\s*("([^"]*)"|'([^']*)'|(\S+))/i);
1691
+ let id;
1692
+ if (explicitIdMatch) {
1693
+ id = explicitIdMatch[2] ?? explicitIdMatch[3] ?? explicitIdMatch[4] ?? "";
1694
+ if (id) used.set(id, (used.get(id) ?? 0) + 1);
1695
+ } else {
1696
+ id = uniqueId(slug(text, i), used);
1450
1697
  }
1451
- }, [safe]);
1452
- return react.createElement(as, {
1453
- ref,
1454
- className,
1455
- dangerouslySetInnerHTML: { __html: safe }
1698
+ out.push({ id, text, level });
1699
+ i++;
1700
+ }
1701
+ return out;
1702
+ }
1703
+ function extractHeadingsFromElement(root, options = {}) {
1704
+ const levels = options.levels ?? DEFAULT_LEVELS;
1705
+ const slug = options.slugify ?? slugify;
1706
+ const selector = levels.map((l) => `h${l}`).join(",");
1707
+ const nodes = root.querySelectorAll(selector);
1708
+ const used = /* @__PURE__ */ new Map();
1709
+ nodes.forEach((n) => {
1710
+ if (n.id) used.set(n.id, (used.get(n.id) ?? 0) + 1);
1456
1711
  });
1712
+ const out = [];
1713
+ let i = 0;
1714
+ nodes.forEach((node) => {
1715
+ const level = Number(node.tagName.slice(1));
1716
+ const text = (node.textContent ?? "").replace(/\s+/g, " ").trim();
1717
+ if (!text) return;
1718
+ if (!node.id) {
1719
+ node.id = uniqueId(slug(text, i), used);
1720
+ }
1721
+ if (options.scrollMarginTop != null) {
1722
+ node.style.scrollMarginTop = `${options.scrollMarginTop}px`;
1723
+ }
1724
+ out.push({ id: node.id, text, level });
1725
+ i++;
1726
+ });
1727
+ return out;
1728
+ }
1729
+
1730
+ // src/hooks/useTableOfContents.tsx
1731
+ function useTableOfContents(ref, options = {}) {
1732
+ const {
1733
+ levels,
1734
+ contentKey = null,
1735
+ scrollMarginTop = 24,
1736
+ activationOffset = 96
1737
+ } = options;
1738
+ const [items, setItems] = react.useState([]);
1739
+ const [activeId, setActiveId] = react.useState("");
1740
+ react.useEffect(() => {
1741
+ const root = ref.current;
1742
+ if (!root) {
1743
+ setItems([]);
1744
+ setActiveId("");
1745
+ return;
1746
+ }
1747
+ let raf = 0;
1748
+ const collect = () => {
1749
+ const next = extractHeadingsFromElement(root, {
1750
+ levels,
1751
+ scrollMarginTop
1752
+ });
1753
+ setItems(next);
1754
+ setActiveId(
1755
+ (prev) => next.some((h) => h.id === prev) ? prev : next[0]?.id ?? ""
1756
+ );
1757
+ };
1758
+ raf = requestAnimationFrame(collect);
1759
+ const mo = new MutationObserver(() => {
1760
+ if (raf) cancelAnimationFrame(raf);
1761
+ raf = requestAnimationFrame(collect);
1762
+ });
1763
+ mo.observe(root, { childList: true, subtree: true, characterData: true });
1764
+ return () => {
1765
+ mo.disconnect();
1766
+ if (raf) cancelAnimationFrame(raf);
1767
+ };
1768
+ }, [ref, contentKey, levels, scrollMarginTop]);
1769
+ react.useEffect(() => {
1770
+ if (items.length === 0) return;
1771
+ const targets = items.map((it) => document.getElementById(it.id)).filter((el) => el !== null);
1772
+ if (targets.length === 0) return;
1773
+ let raf = 0;
1774
+ const compute = () => {
1775
+ raf = 0;
1776
+ let activeIdx = 0;
1777
+ for (let i = 0; i < items.length; i++) {
1778
+ const el = document.getElementById(items[i].id);
1779
+ if (!el) continue;
1780
+ if (el.getBoundingClientRect().top - activationOffset <= 0) {
1781
+ activeIdx = i;
1782
+ } else {
1783
+ break;
1784
+ }
1785
+ }
1786
+ const scroller = document.scrollingElement || document.documentElement;
1787
+ const scrollY = window.scrollY;
1788
+ const viewportH = window.innerHeight;
1789
+ const atBottom = scrollY + viewportH >= scroller.scrollHeight - 2;
1790
+ if (atBottom) {
1791
+ for (let i = items.length - 1; i > activeIdx; i--) {
1792
+ const el = document.getElementById(items[i].id);
1793
+ if (el && el.getBoundingClientRect().top < viewportH) {
1794
+ activeIdx = i;
1795
+ break;
1796
+ }
1797
+ }
1798
+ }
1799
+ setActiveId(items[activeIdx].id);
1800
+ };
1801
+ const schedule = () => {
1802
+ if (raf) return;
1803
+ raf = requestAnimationFrame(compute);
1804
+ };
1805
+ const io = new IntersectionObserver(schedule, {
1806
+ rootMargin: `-${activationOffset}px 0px -${Math.max(0, window.innerHeight - activationOffset - 1)}px 0px`,
1807
+ threshold: 0
1808
+ });
1809
+ targets.forEach((t) => io.observe(t));
1810
+ window.addEventListener("resize", schedule, { passive: true });
1811
+ compute();
1812
+ return () => {
1813
+ io.disconnect();
1814
+ window.removeEventListener("resize", schedule);
1815
+ if (raf) cancelAnimationFrame(raf);
1816
+ };
1817
+ }, [items, activationOffset]);
1818
+ return { items, activeId };
1457
1819
  }
1458
1820
 
1459
1821
  exports.AsteroidCMSProvider = AsteroidCMSProvider;
1460
1822
  exports.RichTextContent = RichTextContent;
1823
+ exports.extractHeadingsFromElement = extractHeadingsFromElement;
1824
+ exports.extractHeadingsFromHtml = extractHeadingsFromHtml;
1825
+ exports.slugify = slugify;
1461
1826
  exports.useAsteroidCMSConfig = useAsteroidCMSConfig;
1462
1827
  exports.useCmsContent = useCmsContent;
1463
1828
  exports.useCmsImage = useCmsImage;
1464
1829
  exports.useCmsMutate = useCmsMutate;
1830
+ exports.useTableOfContents = useTableOfContents;
1465
1831
  //# sourceMappingURL=client.cjs.map
1466
1832
  //# sourceMappingURL=client.cjs.map