@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 +59 -4
- package/dist/client.cjs +225 -7
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +90 -3
- package/dist/client.d.ts +90 -3
- package/dist/client.js +223 -9
- package/dist/client.js.map +1 -1
- package/dist/index.cjs +106 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +44 -1
- package/dist/index.d.ts +44 -1
- package/dist/index.js +104 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -356,18 +356,20 @@ getContentReadTime(article.body, {
|
|
|
356
356
|
|
|
357
357
|
## `<RichTextContent>`
|
|
358
358
|
|
|
359
|
-
Render Asteroid CMS rich-text
|
|
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
|
-
|
|
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
|
-
|
|
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(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/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
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
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(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/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
|