@asteroidcms/core-utils 0.1.5 → 0.1.7

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
@@ -11,6 +11,7 @@
11
11
  - **Provider-driven** - configure `cmsUrl`, `apiKey`, and Apollo behavior in one place
12
12
  - **API-key auth only** - sends `x-api-key` on every request, nothing else
13
13
  - **Typed hooks** - `useCmsContent` / `useCmsMutate` build GraphQL on the fly from a declarative selection
14
+ - **Server helpers** - `fetchCmsContent` / `cmsMutate` for Next.js Server Components, Route Handlers, and scripts
14
15
  - **Tree-shakeable** - ESM + CJS + types, `@apollo/client`/`react` as peer deps
15
16
 
16
17
  ---
@@ -34,7 +35,7 @@ npm install @apollo/client-integration-nextjs # for nextjs (optional)
34
35
  Wrap your app once:
35
36
 
36
37
  ```tsx
37
- import { AsteroidCMSProvider } from "@asteroidcms/core-utils";
38
+ import { AsteroidCMSProvider } from "@asteroidcms/core-utils/client";
38
39
 
39
40
  export function Root() {
40
41
  return (
@@ -51,7 +52,7 @@ export function Root() {
51
52
  Then use the hooks anywhere:
52
53
 
53
54
  ```tsx
54
- import { useCmsContent, useCmsImage } from "@asteroidcms/core-utils";
55
+ import { useCmsContent, useCmsImage } from "@asteroidcms/core-utils/client";
55
56
 
56
57
  function NewsList() {
57
58
  const cmsImage = useCmsImage();
@@ -197,9 +198,7 @@ const { data: topStories } = useCmsContent({
197
198
 
198
199
  ## `fetchCmsContent` (Next.js / RSC)
199
200
 
200
- Server-side counterpart to `useCmsContent`. Use it in Next.js Server Components, route handlers, or any other server context. Accepts a server-side Apollo client plus the same options object as `useCmsContent`, and returns the resolved data directly.
201
-
202
- Pass a `getClient` function that returns a server-side Apollo client. The shape matches what `registerApolloClient` from `@apollo/client-integration-nextjs` already returns, so you can hand it through directly.
201
+ Server-side counterpart to `useCmsContent`. Use it in Next.js Server Components, Route Handlers, or any other server context. Accepts a `getClient` function plus the same options object as `useCmsContent`, and returns the resolved data directly.
203
202
 
204
203
  ```ts
205
204
  // app/lib/cms-server.ts
@@ -242,28 +241,6 @@ const articles = await fetchCmsContent<Article[]>(getClient, {
242
241
 
243
242
  Outside Next.js you can pass any `() => ApolloClient` - e.g. `() => createApolloClient({ cmsUrl, apiKey })`.
244
243
 
245
- Add `import "server-only"` in the file that calls it if you want Next.js to fail the build when it leaks into a client component.
246
-
247
- ---
248
-
249
- ## `buildCmsQuery`
250
-
251
- Lower-level helper that turns a declarative selection into a GraphQL `DocumentNode` plus variables. Used internally by `useCmsContent` and `fetchCmsContent`; exported so you can drive your own Apollo calls (cache reads, prefetching, batching, etc.).
252
-
253
- ```ts
254
- import { buildCmsQuery } from "@asteroidcms/core-utils";
255
-
256
- const { query, variables, isSingle } = buildCmsQuery({
257
- schema_slug: "news",
258
- limit: 10,
259
- status: "PUBLISHED",
260
- select: ["title", "slug"],
261
- });
262
-
263
- const { data } = await apolloClient.query({ query, variables });
264
- const entries = isSingle ? data.entry : data.entries;
265
- ```
266
-
267
244
  ---
268
245
 
269
246
  ## `useCmsMutate`
@@ -310,6 +287,69 @@ removeComment();
310
287
 
311
288
  ---
312
289
 
290
+ ## `cmsMutate` (Next.js / RSC)
291
+
292
+ Server-side counterpart to `useCmsMutate`. Use it in Route Handlers, webhooks, cron jobs, or build scripts.
293
+
294
+ ```ts
295
+ import { cmsMutate } from "@asteroidcms/core-utils";
296
+ import { getClient } from "@/app/lib/cms-server";
297
+
298
+ // Create
299
+ const entry = await cmsMutate<{ id: string }>(getClient, {
300
+ schema_slug: "newsletter_subscribers",
301
+ mutationType: "create",
302
+ variables: { data: { email: "user@example.com", name: "Alice" } },
303
+ });
304
+
305
+ // Update
306
+ await cmsMutate(getClient, {
307
+ schema_slug: "news",
308
+ mutationType: "update",
309
+ entryId: "abc123",
310
+ variables: { data: { title: "Updated title" } },
311
+ });
312
+
313
+ // Delete
314
+ await cmsMutate(getClient, {
315
+ schema_slug: "comments",
316
+ mutationType: "delete",
317
+ entryId: "xyz789",
318
+ });
319
+ ```
320
+
321
+ ---
322
+
323
+ ## `buildCmsQuery` / `buildCmsMutation`
324
+
325
+ Lower-level helpers that turn a declarative selection into GraphQL `DocumentNode` plus variables. Used internally by the hooks and server helpers; exported so you can drive your own Apollo calls.
326
+
327
+ ```ts
328
+ import { buildCmsQuery, buildCmsMutation } from "@asteroidcms/core-utils";
329
+
330
+ // Query
331
+ const { query, variables, isSingle } = buildCmsQuery({
332
+ schema_slug: "news",
333
+ limit: 10,
334
+ status: "PUBLISHED",
335
+ select: ["title", "slug"],
336
+ });
337
+
338
+ const { data } = await apolloClient.query({ query, variables });
339
+ const entries = isSingle ? data.entry : data.entries;
340
+
341
+ // Mutation
342
+ const { mutation, variables: mutVars } = buildCmsMutation({
343
+ schema_slug: "news",
344
+ mutationType: "create",
345
+ variables: { data: { title: "Hello" } },
346
+ });
347
+
348
+ const { data: mutData } = await apolloClient.mutate({ mutation, variables: mutVars });
349
+ ```
350
+
351
+ ---
352
+
313
353
  ## `cmsImage` / `useCmsImage`
314
354
 
315
355
  Build a canonical media URL for an asset id.
@@ -372,7 +412,7 @@ import { Info } from "lucide-react";
372
412
  />;
373
413
  ```
374
414
 
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).
415
+ Additional props: `contentRef` (forwards the wrapper element, useful for scroll observers or ToC hooks), `onReady` (fires once enhancements have run), and `calloutIcons` (per-variant icon override for `<aside data-callout data-icon>` blocks).
376
416
 
377
417
  Or use the parser directly (server-safe, no `highlight.js`):
378
418
 
@@ -388,56 +428,24 @@ const html = parseRichText(article.body, {
388
428
 
389
429
  ### Table of contents
390
430
 
391
- Build a live, scroll-tracked ToC from rendered content with `useTableOfContents`. Pair it with `RichTextContent`'s `contentRef` prop:
431
+ Build a static ToC from HTML with `extractHeadingsFromHtml`, or extract headings from live DOM with `extractHeadingsFromElement`:
392
432
 
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
- });
433
+ ```ts
434
+ import { extractHeadingsFromHtml } from "@asteroidcms/core-utils";
407
435
 
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
- }
436
+ const toc = extractHeadingsFromHtml(article.body, { levels: [2, 3] });
437
+ // → [{ id: "intro", text: "Intro", level: 2 }, ...]
430
438
  ```
431
439
 
432
- For a static, server-side outline (RSC layouts, sitemaps, RSS), use `extractHeadingsFromHtml`:
433
-
434
440
  ```ts
435
- import { extractHeadingsFromHtml } from "@asteroidcms/core-utils";
441
+ import { extractHeadingsFromElement } from "@asteroidcms/core-utils/client";
436
442
 
437
- const toc = extractHeadingsFromHtml(article.body, { levels: [2, 3] });
443
+ const toc = extractHeadingsFromElement(contentRef.current, { levels: [2, 3] });
438
444
  ```
439
445
 
440
- See the [full rich-text docs](./docs/web-sdk-react/10-rich-text.md) for `classMap` variants, parser options, and `useTableOfContents` tuning.
446
+ Pair with `RichTextContent`'s `contentRef` prop and a scroll listener to build live active-heading tracking.
447
+
448
+ See the [full rich-text docs](./docs/web-sdk-react/10-rich-text.md) for `classMap` variants, parser options, and code block features.
441
449
 
442
450
  ---
443
451
 
package/dist/client.cjs CHANGED
@@ -1050,37 +1050,38 @@ function isIconEnabled(el) {
1050
1050
  if (v === null) return false;
1051
1051
  return v !== "false" && v !== "0";
1052
1052
  }
1053
- function enhanceCallouts(root) {
1053
+ function enhanceCallouts(root, calloutIcons) {
1054
1054
  const callouts = root.querySelectorAll("aside[data-callout]");
1055
1055
  callouts.forEach((el) => {
1056
- if (el.dataset.rtCalloutEnhanced === "1") return;
1057
- el.dataset.rtCalloutEnhanced = "1";
1058
- if (!isIconEnabled(el)) return;
1059
1056
  if (el.querySelector(":scope > .rt-callout-icon")) return;
1057
+ if (!isIconEnabled(el)) return;
1060
1058
  const variant = calloutVariantOf(el);
1061
1059
  const icon = document.createElement("span");
1062
1060
  icon.className = "rt-callout-icon";
1063
1061
  icon.dataset.variant = variant;
1064
1062
  icon.setAttribute("aria-hidden", "true");
1063
+ if (!calloutIcons || !(variant in calloutIcons)) {
1064
+ icon.innerHTML = CALLOUT_ICON_SVG[variant] ?? CALLOUT_ICON_SVG.default;
1065
+ }
1065
1066
  el.prepend(icon);
1066
1067
  });
1068
+ if (!calloutIcons) return [];
1067
1069
  const chips = [];
1068
1070
  root.querySelectorAll(
1069
1071
  "aside[data-callout] > .rt-callout-icon"
1070
1072
  ).forEach((chip) => {
1071
- chips.push({
1072
- el: chip,
1073
- variant: chip.dataset.variant || (chip.parentElement?.getAttribute("data-variant") ?? "default")
1074
- });
1073
+ const variant = chip.dataset.variant || (chip.parentElement?.getAttribute("data-variant") ?? "default");
1074
+ if (variant in calloutIcons) {
1075
+ chips.push({ el: chip, variant });
1076
+ }
1075
1077
  });
1076
1078
  return chips;
1077
1079
  }
1078
1080
  function enhanceBlockquotes(root) {
1079
1081
  const quotes = root.querySelectorAll("blockquote");
1080
1082
  quotes.forEach((bq) => {
1081
- if (bq.dataset.rtQuoted === "1") return;
1083
+ if (bq.querySelector(":scope > .rt-quote-open")) return;
1082
1084
  if (bq.closest('figure[data-variant="pullquote"]')) return;
1083
- bq.dataset.rtQuoted = "1";
1084
1085
  const { first, last } = findQuoteBody(bq);
1085
1086
  if (!first || !last) return;
1086
1087
  const open = document.createElement("span");
@@ -1119,11 +1120,10 @@ function highlightCodeBlock(pre) {
1119
1120
  if (!lang) return;
1120
1121
  const code = pre.querySelector("code");
1121
1122
  if (!code) return;
1122
- if (code.dataset.rtHighlighted === "1") return;
1123
+ if (code.classList.contains("hljs")) return;
1123
1124
  const source = code.textContent ?? "";
1124
1125
  code.innerHTML = highlightSource(source, lang);
1125
1126
  code.classList.add("hljs");
1126
- code.dataset.rtHighlighted = "1";
1127
1127
  }
1128
1128
  var DIFF_SEPARATOR_RE = /\n?@@---@@\n?/;
1129
1129
  function diffLines(a, b) {
@@ -1246,8 +1246,7 @@ function buildCodeBlockLabel(pre) {
1246
1246
  function enhanceCodeBlocks(root) {
1247
1247
  const blocks = root.querySelectorAll("pre");
1248
1248
  blocks.forEach((pre) => {
1249
- if (pre.dataset.rtEnhanced === "1") return;
1250
- pre.dataset.rtEnhanced = "1";
1249
+ if (pre.classList.contains("rt-codeblock")) return;
1251
1250
  pre.classList.add("rt-codeblock");
1252
1251
  const variant = pre.dataset.variant;
1253
1252
  if (variant === "diff") {
@@ -1591,7 +1590,7 @@ function BuiltinCalloutIcon({ variant }) {
1591
1590
  const html = CALLOUT_ICON_SVG[variant] ?? CALLOUT_ICON_SVG.default;
1592
1591
  return /* @__PURE__ */ jsxRuntime.jsx("span", { dangerouslySetInnerHTML: { __html: html } });
1593
1592
  }
1594
- function RichTextContent({
1593
+ var RichTextContent = react.memo(function RichTextContent2({
1595
1594
  html,
1596
1595
  classMap,
1597
1596
  as = "div",
@@ -1609,49 +1608,58 @@ function RichTextContent({
1609
1608
  [html, merged]
1610
1609
  );
1611
1610
  const ref = react.useRef(null);
1612
- const [chips, setChips] = react.useState([]);
1613
- react.useEffect(() => {
1611
+ const prevSafe = react.useRef("");
1612
+ const chipsRef = react.useRef([]);
1613
+ const onReadyRef = react.useRef(onReady);
1614
+ onReadyRef.current = onReady;
1615
+ const contentRefStable = react.useRef(contentRef);
1616
+ contentRefStable.current = contentRef;
1617
+ const calloutIconsRef = react.useRef(calloutIcons);
1618
+ calloutIconsRef.current = calloutIcons;
1619
+ const [renderKey, setRenderKey] = react.useState(0);
1620
+ react.useLayoutEffect(() => {
1614
1621
  ensureCodeBlockStyles();
1615
1622
  const root = ref.current;
1616
1623
  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]);
1624
+ if (contentRefStable.current) contentRefStable.current.current = root;
1625
+ if (prevSafe.current === safe) return;
1626
+ prevSafe.current = safe;
1627
+ root.innerHTML = safe;
1628
+ enhanceCodeBlocks(root);
1629
+ enhanceBlockquotes(root);
1630
+ const nextChips = enhanceCallouts(root, calloutIconsRef.current);
1631
+ if (!calloutChipsEqual(chipsRef.current, nextChips)) {
1632
+ chipsRef.current = nextChips;
1633
+ if (nextChips.length > 0) setRenderKey((n) => n + 1);
1634
+ }
1635
+ onReadyRef.current?.(root);
1636
+ }, [safe]);
1641
1637
  return /* @__PURE__ */ jsxRuntime.jsxs(react.Fragment, { children: [
1642
1638
  react.createElement(as, {
1643
1639
  ref,
1644
- className,
1645
- dangerouslySetInnerHTML: { __html: safe }
1640
+ className
1646
1641
  }),
1647
- chips.map(
1642
+ chipsRef.current.map(
1648
1643
  (chip, i) => reactDom.createPortal(
1649
- calloutIcons && chip.variant in calloutIcons ? calloutIcons[chip.variant] : /* @__PURE__ */ jsxRuntime.jsx(BuiltinCalloutIcon, { variant: chip.variant }),
1644
+ calloutIconsRef.current && chip.variant in calloutIconsRef.current ? calloutIconsRef.current[chip.variant] : /* @__PURE__ */ jsxRuntime.jsx(BuiltinCalloutIcon, { variant: chip.variant }),
1650
1645
  chip.el,
1651
1646
  `${chip.variant}:${i}`
1652
1647
  )
1653
1648
  )
1654
1649
  ] });
1650
+ }, richTextPropsEqual);
1651
+ function richTextPropsEqual(prev, next) {
1652
+ if (prev.html !== next.html) return false;
1653
+ if (prev.classMap !== next.classMap) return false;
1654
+ if (prev.as !== next.as) return false;
1655
+ if (prev.className !== next.className) return false;
1656
+ const prevKeys = prev.calloutIcons ? Object.keys(prev.calloutIcons) : [];
1657
+ const nextKeys = next.calloutIcons ? Object.keys(next.calloutIcons) : [];
1658
+ if (prevKeys.length !== nextKeys.length) return false;
1659
+ for (const k of nextKeys) {
1660
+ if (!prevKeys.includes(k)) return false;
1661
+ }
1662
+ return true;
1655
1663
  }
1656
1664
 
1657
1665
  // src/utils/extractHeadings.ts
@@ -1727,97 +1735,6 @@ function extractHeadingsFromElement(root, options = {}) {
1727
1735
  return out;
1728
1736
  }
1729
1737
 
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 };
1819
- }
1820
-
1821
1738
  exports.AsteroidCMSProvider = AsteroidCMSProvider;
1822
1739
  exports.RichTextContent = RichTextContent;
1823
1740
  exports.extractHeadingsFromElement = extractHeadingsFromElement;
@@ -1827,6 +1744,5 @@ exports.useAsteroidCMSConfig = useAsteroidCMSConfig;
1827
1744
  exports.useCmsContent = useCmsContent;
1828
1745
  exports.useCmsImage = useCmsImage;
1829
1746
  exports.useCmsMutate = useCmsMutate;
1830
- exports.useTableOfContents = useTableOfContents;
1831
1747
  //# sourceMappingURL=client.cjs.map
1832
1748
  //# sourceMappingURL=client.cjs.map