@asteroidcms/core-utils 0.1.3 → 0.1.4

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,20 @@ 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), 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
363
 
364
364
  <RichTextContent
365
- content={article.body}
365
+ html={article.body}
366
+ as="article"
367
+ className="prose"
366
368
  classMap={{ p: "my-2 leading-relaxed", h2: "text-2xl font-bold" }}
367
369
  />;
368
370
  ```
369
371
 
370
- Or use the parser directly:
372
+ Or use the parser directly (server-safe, no `highlight.js`):
371
373
 
372
374
  ```ts
373
375
  import { parseRichText } from "@asteroidcms/core-utils";
@@ -379,6 +381,59 @@ const html = parseRichText(article.body, {
379
381
  });
380
382
  ```
381
383
 
384
+ ### Table of contents
385
+
386
+ Build a live, scroll-tracked ToC from rendered content with `useTableOfContents`. Pair it with `RichTextContent`'s `contentRef` prop:
387
+
388
+ ```tsx
389
+ import { useRef } from "react";
390
+ import {
391
+ RichTextContent,
392
+ useTableOfContents,
393
+ } from "@asteroidcms/core-utils/client";
394
+
395
+ function Article({ slug, html }: { slug: string; html: string }) {
396
+ const contentRef = useRef<HTMLElement | null>(null);
397
+ const { items, activeId } = useTableOfContents(contentRef, {
398
+ levels: [2, 3],
399
+ contentKey: slug, // re-collect when content swaps
400
+ activationOffset: 96, // px from viewport top
401
+ });
402
+
403
+ return (
404
+ <div className="flex gap-8">
405
+ <RichTextContent
406
+ html={html}
407
+ as="article"
408
+ className="prose"
409
+ contentRef={contentRef}
410
+ />
411
+ <nav>
412
+ {items.map((it) => (
413
+ <a
414
+ key={it.id}
415
+ href={`#${it.id}`}
416
+ className={it.id === activeId ? "font-semibold" : ""}
417
+ >
418
+ {it.text}
419
+ </a>
420
+ ))}
421
+ </nav>
422
+ </div>
423
+ );
424
+ }
425
+ ```
426
+
427
+ For a static, server-side outline (RSC layouts, sitemaps, RSS), use `extractHeadingsFromHtml`:
428
+
429
+ ```ts
430
+ import { extractHeadingsFromHtml } from "@asteroidcms/core-utils";
431
+
432
+ const toc = extractHeadingsFromHtml(article.body, { levels: [2, 3] });
433
+ ```
434
+
435
+ See the [full rich-text docs](./docs/web-sdk-react/10-rich-text.md) for `classMap` variants, parser options, and `useTableOfContents` tuning.
436
+
382
437
  ---
383
438
 
384
439
  ## Advanced - bring your own Apollo client
package/dist/client.cjs CHANGED
@@ -425,7 +425,36 @@ function parseRichText(html, options = {}) {
425
425
  working = upgradeStandaloneImages(working);
426
426
  working = upgradeAuthoredBlockquotes(working);
427
427
  working = flattenTableCellParagraphs(working);
428
- return sanitizeAndStyle(working, options);
428
+ working = sanitizeAndStyle(working, options);
429
+ if (options.autoHeadingIds !== false) {
430
+ working = injectHeadingIds(working);
431
+ }
432
+ return working;
433
+ }
434
+ function injectHeadingIds(html) {
435
+ const used = /* @__PURE__ */ new Map();
436
+ const existingRe = /<h[1-6]\b[^>]*\bid\s*=\s*("([^"]*)"|'([^']*)'|(\S+))/gi;
437
+ let em;
438
+ while ((em = existingRe.exec(html)) !== null) {
439
+ const id = em[2] ?? em[3] ?? em[4] ?? "";
440
+ if (id) used.set(id, (used.get(id) ?? 0) + 1);
441
+ }
442
+ return html.replace(
443
+ /<(h[1-6])\b([^>]*)>([\s\S]*?)<\/\1>/gi,
444
+ (full, tag, attrs, inner) => {
445
+ if (/\bid\s*=/i.test(attrs)) return full;
446
+ 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();
447
+ if (!text) return full;
448
+ const base = slugifyHeading(text) || tag;
449
+ const n = used.get(base) ?? 0;
450
+ used.set(base, n + 1);
451
+ const id = n === 0 ? base : `${base}-${n}`;
452
+ return `<${tag}${attrs} id="${id}">${inner}</${tag}>`;
453
+ }
454
+ );
455
+ }
456
+ function slugifyHeading(text) {
457
+ return text.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
429
458
  }
430
459
  function flattenTableCellParagraphs(html) {
431
460
  return html.replace(
@@ -1431,7 +1460,9 @@ function RichTextContent({
1431
1460
  html,
1432
1461
  classMap,
1433
1462
  as = "div",
1434
- className
1463
+ className,
1464
+ onReady,
1465
+ contentRef
1435
1466
  }) {
1436
1467
  const merged = react.useMemo(
1437
1468
  () => mergeClassMap(DEFAULT_CLASS_MAP, classMap),
@@ -1444,11 +1475,30 @@ function RichTextContent({
1444
1475
  const ref = react.useRef(null);
1445
1476
  react.useEffect(() => {
1446
1477
  ensureCodeBlockStyles();
1447
- if (ref.current) {
1448
- enhanceCodeBlocks(ref.current);
1449
- enhanceBlockquotes(ref.current);
1450
- }
1451
- }, [safe]);
1478
+ const root = ref.current;
1479
+ if (!root) return;
1480
+ if (contentRef) contentRef.current = root;
1481
+ const apply = () => {
1482
+ mo.disconnect();
1483
+ enhanceCodeBlocks(root);
1484
+ enhanceBlockquotes(root);
1485
+ onReady?.(root);
1486
+ mo.observe(root, { childList: true, subtree: true });
1487
+ };
1488
+ let raf = 0;
1489
+ const mo = new MutationObserver(() => {
1490
+ if (raf) return;
1491
+ raf = requestAnimationFrame(() => {
1492
+ raf = 0;
1493
+ apply();
1494
+ });
1495
+ });
1496
+ apply();
1497
+ return () => {
1498
+ mo.disconnect();
1499
+ if (raf) cancelAnimationFrame(raf);
1500
+ };
1501
+ }, [safe, onReady, contentRef]);
1452
1502
  return react.createElement(as, {
1453
1503
  ref,
1454
1504
  className,
@@ -1456,11 +1506,179 @@ function RichTextContent({
1456
1506
  });
1457
1507
  }
1458
1508
 
1509
+ // src/utils/extractHeadings.ts
1510
+ var DEFAULT_LEVELS = [2, 3];
1511
+ function slugify(text) {
1512
+ return text.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1513
+ }
1514
+ function decodeBasicEntities(s) {
1515
+ return s.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
1516
+ }
1517
+ function stripTags(s) {
1518
+ return decodeBasicEntities(s.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
1519
+ }
1520
+ function uniqueId(base, used) {
1521
+ const seed = base || "section";
1522
+ const n = used.get(seed) ?? 0;
1523
+ used.set(seed, n + 1);
1524
+ return n === 0 ? seed : `${seed}-${n}`;
1525
+ }
1526
+ function extractHeadingsFromHtml(html, options = {}) {
1527
+ if (!html) return [];
1528
+ const levels = options.levels ?? DEFAULT_LEVELS;
1529
+ const slug = options.slugify ?? slugify;
1530
+ const used = /* @__PURE__ */ new Map();
1531
+ const out = [];
1532
+ const re = /<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi;
1533
+ let m;
1534
+ let i = 0;
1535
+ while ((m = re.exec(html)) !== null) {
1536
+ const level = Number(m[1]);
1537
+ if (!levels.includes(level)) continue;
1538
+ const attrs = m[2] ?? "";
1539
+ const inner = m[3] ?? "";
1540
+ const text = stripTags(inner);
1541
+ if (!text) continue;
1542
+ const explicitIdMatch = attrs.match(/\bid\s*=\s*("([^"]*)"|'([^']*)'|(\S+))/i);
1543
+ let id;
1544
+ if (explicitIdMatch) {
1545
+ id = explicitIdMatch[2] ?? explicitIdMatch[3] ?? explicitIdMatch[4] ?? "";
1546
+ if (id) used.set(id, (used.get(id) ?? 0) + 1);
1547
+ } else {
1548
+ id = uniqueId(slug(text, i), used);
1549
+ }
1550
+ out.push({ id, text, level });
1551
+ i++;
1552
+ }
1553
+ return out;
1554
+ }
1555
+ function extractHeadingsFromElement(root, options = {}) {
1556
+ const levels = options.levels ?? DEFAULT_LEVELS;
1557
+ const slug = options.slugify ?? slugify;
1558
+ const selector = levels.map((l) => `h${l}`).join(",");
1559
+ const nodes = root.querySelectorAll(selector);
1560
+ const used = /* @__PURE__ */ new Map();
1561
+ nodes.forEach((n) => {
1562
+ if (n.id) used.set(n.id, (used.get(n.id) ?? 0) + 1);
1563
+ });
1564
+ const out = [];
1565
+ let i = 0;
1566
+ nodes.forEach((node) => {
1567
+ const level = Number(node.tagName.slice(1));
1568
+ const text = (node.textContent ?? "").replace(/\s+/g, " ").trim();
1569
+ if (!text) return;
1570
+ if (!node.id) {
1571
+ node.id = uniqueId(slug(text, i), used);
1572
+ }
1573
+ if (options.scrollMarginTop != null) {
1574
+ node.style.scrollMarginTop = `${options.scrollMarginTop}px`;
1575
+ }
1576
+ out.push({ id: node.id, text, level });
1577
+ i++;
1578
+ });
1579
+ return out;
1580
+ }
1581
+
1582
+ // src/hooks/useTableOfContents.tsx
1583
+ function useTableOfContents(ref, options = {}) {
1584
+ const {
1585
+ levels,
1586
+ contentKey = null,
1587
+ scrollMarginTop = 24,
1588
+ activationOffset = 96
1589
+ } = options;
1590
+ const [items, setItems] = react.useState([]);
1591
+ const [activeId, setActiveId] = react.useState("");
1592
+ react.useEffect(() => {
1593
+ const root = ref.current;
1594
+ if (!root) {
1595
+ setItems([]);
1596
+ setActiveId("");
1597
+ return;
1598
+ }
1599
+ let raf = 0;
1600
+ const collect = () => {
1601
+ const next = extractHeadingsFromElement(root, {
1602
+ levels,
1603
+ scrollMarginTop
1604
+ });
1605
+ setItems(next);
1606
+ setActiveId(
1607
+ (prev) => next.some((h) => h.id === prev) ? prev : next[0]?.id ?? ""
1608
+ );
1609
+ };
1610
+ raf = requestAnimationFrame(collect);
1611
+ const mo = new MutationObserver(() => {
1612
+ if (raf) cancelAnimationFrame(raf);
1613
+ raf = requestAnimationFrame(collect);
1614
+ });
1615
+ mo.observe(root, { childList: true, subtree: true, characterData: true });
1616
+ return () => {
1617
+ mo.disconnect();
1618
+ if (raf) cancelAnimationFrame(raf);
1619
+ };
1620
+ }, [ref, contentKey, levels, scrollMarginTop]);
1621
+ react.useEffect(() => {
1622
+ if (items.length === 0) return;
1623
+ const targets = items.map((it) => document.getElementById(it.id)).filter((el) => el !== null);
1624
+ if (targets.length === 0) return;
1625
+ let raf = 0;
1626
+ const compute = () => {
1627
+ raf = 0;
1628
+ let activeIdx = 0;
1629
+ for (let i = 0; i < items.length; i++) {
1630
+ const el = document.getElementById(items[i].id);
1631
+ if (!el) continue;
1632
+ if (el.getBoundingClientRect().top - activationOffset <= 0) {
1633
+ activeIdx = i;
1634
+ } else {
1635
+ break;
1636
+ }
1637
+ }
1638
+ const scroller = document.scrollingElement || document.documentElement;
1639
+ const scrollY = window.scrollY;
1640
+ const viewportH = window.innerHeight;
1641
+ const atBottom = scrollY + viewportH >= scroller.scrollHeight - 2;
1642
+ if (atBottom) {
1643
+ for (let i = items.length - 1; i > activeIdx; i--) {
1644
+ const el = document.getElementById(items[i].id);
1645
+ if (el && el.getBoundingClientRect().top < viewportH) {
1646
+ activeIdx = i;
1647
+ break;
1648
+ }
1649
+ }
1650
+ }
1651
+ setActiveId(items[activeIdx].id);
1652
+ };
1653
+ const schedule = () => {
1654
+ if (raf) return;
1655
+ raf = requestAnimationFrame(compute);
1656
+ };
1657
+ const io = new IntersectionObserver(schedule, {
1658
+ rootMargin: `-${activationOffset}px 0px -${Math.max(0, window.innerHeight - activationOffset - 1)}px 0px`,
1659
+ threshold: 0
1660
+ });
1661
+ targets.forEach((t) => io.observe(t));
1662
+ window.addEventListener("resize", schedule, { passive: true });
1663
+ compute();
1664
+ return () => {
1665
+ io.disconnect();
1666
+ window.removeEventListener("resize", schedule);
1667
+ if (raf) cancelAnimationFrame(raf);
1668
+ };
1669
+ }, [items, activationOffset]);
1670
+ return { items, activeId };
1671
+ }
1672
+
1459
1673
  exports.AsteroidCMSProvider = AsteroidCMSProvider;
1460
1674
  exports.RichTextContent = RichTextContent;
1675
+ exports.extractHeadingsFromElement = extractHeadingsFromElement;
1676
+ exports.extractHeadingsFromHtml = extractHeadingsFromHtml;
1677
+ exports.slugify = slugify;
1461
1678
  exports.useAsteroidCMSConfig = useAsteroidCMSConfig;
1462
1679
  exports.useCmsContent = useCmsContent;
1463
1680
  exports.useCmsImage = useCmsImage;
1464
1681
  exports.useCmsMutate = useCmsMutate;
1682
+ exports.useTableOfContents = useTableOfContents;
1465
1683
  //# sourceMappingURL=client.cjs.map
1466
1684
  //# sourceMappingURL=client.cjs.map