@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/dist/client.d.cts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
2
|
import * as react from 'react';
|
|
3
|
-
import { PropsWithChildren } from 'react';
|
|
3
|
+
import { PropsWithChildren, RefObject } from 'react';
|
|
4
4
|
import * as _apollo_client from '@apollo/client';
|
|
5
5
|
import { InMemoryCacheConfig, ApolloClientOptions, ApolloClient } from '@apollo/client';
|
|
6
6
|
import { useMutation } from '@apollo/client/react';
|
|
@@ -217,8 +217,20 @@ interface RichTextContentProps {
|
|
|
217
217
|
/** Wrapper element. Defaults to `div`. Use `article` for blog content. */
|
|
218
218
|
as?: keyof React.JSX.IntrinsicElements;
|
|
219
219
|
className?: string;
|
|
220
|
+
/**
|
|
221
|
+
* Fires after the parsed HTML is in the DOM and post-render enhancements
|
|
222
|
+
* (syntax highlighting, copy buttons, blockquote decorations) have run.
|
|
223
|
+
* The wrapper element is passed back so callers can read headings, attach
|
|
224
|
+
* a ToC observer, or do other DOM work without re-querying.
|
|
225
|
+
*/
|
|
226
|
+
onReady?: (root: HTMLElement) => void;
|
|
227
|
+
/**
|
|
228
|
+
* Optional ref to the wrapper element. Useful for hooks like
|
|
229
|
+
* `useTableOfContents` that need a stable reference to the rendered tree.
|
|
230
|
+
*/
|
|
231
|
+
contentRef?: React.MutableRefObject<HTMLElement | null>;
|
|
220
232
|
}
|
|
221
|
-
declare function RichTextContent({ html, classMap, as, className, }: RichTextContentProps): react.DOMElement<{
|
|
233
|
+
declare function RichTextContent({ html, classMap, as, className, onReady, contentRef, }: RichTextContentProps): react.DOMElement<{
|
|
222
234
|
ref: react.MutableRefObject<HTMLElement | null>;
|
|
223
235
|
className: string | undefined;
|
|
224
236
|
dangerouslySetInnerHTML: {
|
|
@@ -226,4 +238,79 @@ declare function RichTextContent({ html, classMap, as, className, }: RichTextCon
|
|
|
226
238
|
};
|
|
227
239
|
}, HTMLElement>;
|
|
228
240
|
|
|
229
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Heading extraction helpers used to build tables of contents (ToC) from
|
|
243
|
+
* rich-text HTML. Server-safe — no React, no DOM dependency in the HTML
|
|
244
|
+
* variant. The DOM variant assigns missing `id`s in-place so anchor links
|
|
245
|
+
* resolve immediately.
|
|
246
|
+
*/
|
|
247
|
+
type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
|
248
|
+
interface ExtractedHeading {
|
|
249
|
+
id: string;
|
|
250
|
+
text: string;
|
|
251
|
+
level: HeadingLevel;
|
|
252
|
+
}
|
|
253
|
+
interface ExtractHeadingsOptions {
|
|
254
|
+
/** Levels to include. Defaults to `[2, 3]` — typical doc page outline. */
|
|
255
|
+
levels?: ReadonlyArray<HeadingLevel>;
|
|
256
|
+
/** Custom slug function. Defaults to a lowercase/kebab/diacritic-safe slug. */
|
|
257
|
+
slugify?: (text: string, index: number) => string;
|
|
258
|
+
}
|
|
259
|
+
declare function slugify(text: string): string;
|
|
260
|
+
/**
|
|
261
|
+
* Parse headings out of a raw HTML string. Returns headings in document
|
|
262
|
+
* order with stable, de-duplicated IDs.
|
|
263
|
+
*
|
|
264
|
+
* If a heading already has an `id` attribute, it's preserved verbatim
|
|
265
|
+
* (and reserved so later slugs don't collide with it).
|
|
266
|
+
*/
|
|
267
|
+
declare function extractHeadingsFromHtml(html: string, options?: ExtractHeadingsOptions): ExtractedHeading[];
|
|
268
|
+
/**
|
|
269
|
+
* Walk a rendered DOM subtree, collect headings, and assign missing `id`s
|
|
270
|
+
* in-place so anchor links resolve immediately. Also sets `scrollMarginTop`
|
|
271
|
+
* on each heading when `scrollMarginTop` is provided so navigation lands
|
|
272
|
+
* cleanly below a sticky header.
|
|
273
|
+
*/
|
|
274
|
+
declare function extractHeadingsFromElement(root: HTMLElement, options?: ExtractHeadingsOptions & {
|
|
275
|
+
scrollMarginTop?: number;
|
|
276
|
+
}): ExtractedHeading[];
|
|
277
|
+
|
|
278
|
+
interface UseTableOfContentsOptions {
|
|
279
|
+
/** Heading levels to include. Defaults to `[2, 3]`. */
|
|
280
|
+
levels?: ReadonlyArray<HeadingLevel>;
|
|
281
|
+
/**
|
|
282
|
+
* Re-collect headings whenever this value changes. Pass the article slug
|
|
283
|
+
* (or any stable identifier) so swapping content rebuilds the ToC.
|
|
284
|
+
*/
|
|
285
|
+
contentKey?: string | number | null;
|
|
286
|
+
/** Pixels to subtract from heading scroll-into-view target. Default 24. */
|
|
287
|
+
scrollMarginTop?: number;
|
|
288
|
+
/**
|
|
289
|
+
* Distance from the top of the viewport (in px) at which a heading becomes
|
|
290
|
+
* "active". A heading is considered active once its top edge has scrolled
|
|
291
|
+
* past this line. Default `96` — works well with a sticky header that
|
|
292
|
+
* stands ~60–80px tall.
|
|
293
|
+
*/
|
|
294
|
+
activationOffset?: number;
|
|
295
|
+
}
|
|
296
|
+
interface UseTableOfContentsResult {
|
|
297
|
+
items: ExtractedHeading[];
|
|
298
|
+
activeId: string;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Build a table of contents from a rendered element and track which
|
|
302
|
+
* heading is currently in view via IntersectionObserver. Resilient to
|
|
303
|
+
* content swaps when `contentKey` is provided.
|
|
304
|
+
*
|
|
305
|
+
* Usage:
|
|
306
|
+
* ```tsx
|
|
307
|
+
* const ref = useRef<HTMLDivElement>(null);
|
|
308
|
+
* const { items, activeId } = useTableOfContents(ref, {
|
|
309
|
+
* contentKey: article.slug,
|
|
310
|
+
* levels: [2, 3],
|
|
311
|
+
* });
|
|
312
|
+
* ```
|
|
313
|
+
*/
|
|
314
|
+
declare function useTableOfContents(ref: RefObject<HTMLElement | null>, options?: UseTableOfContentsOptions): UseTableOfContentsResult;
|
|
315
|
+
|
|
316
|
+
export { AsteroidCMSProvider, type AsteroidCMSProviderProps, type ExtractHeadingsOptions, type ExtractedHeading, type HeadingLevel, RichTextContent, type UseCmsContentOptions, type UseCmsMutateOptions, type UseTableOfContentsOptions, type UseTableOfContentsResult, extractHeadingsFromElement, extractHeadingsFromHtml, slugify, useAsteroidCMSConfig, useCmsContent, useCmsImage, useCmsMutate, useTableOfContents };
|
package/dist/client.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
2
|
import * as react from 'react';
|
|
3
|
-
import { PropsWithChildren } from 'react';
|
|
3
|
+
import { PropsWithChildren, RefObject } from 'react';
|
|
4
4
|
import * as _apollo_client from '@apollo/client';
|
|
5
5
|
import { InMemoryCacheConfig, ApolloClientOptions, ApolloClient } from '@apollo/client';
|
|
6
6
|
import { useMutation } from '@apollo/client/react';
|
|
@@ -217,8 +217,20 @@ interface RichTextContentProps {
|
|
|
217
217
|
/** Wrapper element. Defaults to `div`. Use `article` for blog content. */
|
|
218
218
|
as?: keyof React.JSX.IntrinsicElements;
|
|
219
219
|
className?: string;
|
|
220
|
+
/**
|
|
221
|
+
* Fires after the parsed HTML is in the DOM and post-render enhancements
|
|
222
|
+
* (syntax highlighting, copy buttons, blockquote decorations) have run.
|
|
223
|
+
* The wrapper element is passed back so callers can read headings, attach
|
|
224
|
+
* a ToC observer, or do other DOM work without re-querying.
|
|
225
|
+
*/
|
|
226
|
+
onReady?: (root: HTMLElement) => void;
|
|
227
|
+
/**
|
|
228
|
+
* Optional ref to the wrapper element. Useful for hooks like
|
|
229
|
+
* `useTableOfContents` that need a stable reference to the rendered tree.
|
|
230
|
+
*/
|
|
231
|
+
contentRef?: React.MutableRefObject<HTMLElement | null>;
|
|
220
232
|
}
|
|
221
|
-
declare function RichTextContent({ html, classMap, as, className, }: RichTextContentProps): react.DOMElement<{
|
|
233
|
+
declare function RichTextContent({ html, classMap, as, className, onReady, contentRef, }: RichTextContentProps): react.DOMElement<{
|
|
222
234
|
ref: react.MutableRefObject<HTMLElement | null>;
|
|
223
235
|
className: string | undefined;
|
|
224
236
|
dangerouslySetInnerHTML: {
|
|
@@ -226,4 +238,79 @@ declare function RichTextContent({ html, classMap, as, className, }: RichTextCon
|
|
|
226
238
|
};
|
|
227
239
|
}, HTMLElement>;
|
|
228
240
|
|
|
229
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Heading extraction helpers used to build tables of contents (ToC) from
|
|
243
|
+
* rich-text HTML. Server-safe — no React, no DOM dependency in the HTML
|
|
244
|
+
* variant. The DOM variant assigns missing `id`s in-place so anchor links
|
|
245
|
+
* resolve immediately.
|
|
246
|
+
*/
|
|
247
|
+
type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
|
248
|
+
interface ExtractedHeading {
|
|
249
|
+
id: string;
|
|
250
|
+
text: string;
|
|
251
|
+
level: HeadingLevel;
|
|
252
|
+
}
|
|
253
|
+
interface ExtractHeadingsOptions {
|
|
254
|
+
/** Levels to include. Defaults to `[2, 3]` — typical doc page outline. */
|
|
255
|
+
levels?: ReadonlyArray<HeadingLevel>;
|
|
256
|
+
/** Custom slug function. Defaults to a lowercase/kebab/diacritic-safe slug. */
|
|
257
|
+
slugify?: (text: string, index: number) => string;
|
|
258
|
+
}
|
|
259
|
+
declare function slugify(text: string): string;
|
|
260
|
+
/**
|
|
261
|
+
* Parse headings out of a raw HTML string. Returns headings in document
|
|
262
|
+
* order with stable, de-duplicated IDs.
|
|
263
|
+
*
|
|
264
|
+
* If a heading already has an `id` attribute, it's preserved verbatim
|
|
265
|
+
* (and reserved so later slugs don't collide with it).
|
|
266
|
+
*/
|
|
267
|
+
declare function extractHeadingsFromHtml(html: string, options?: ExtractHeadingsOptions): ExtractedHeading[];
|
|
268
|
+
/**
|
|
269
|
+
* Walk a rendered DOM subtree, collect headings, and assign missing `id`s
|
|
270
|
+
* in-place so anchor links resolve immediately. Also sets `scrollMarginTop`
|
|
271
|
+
* on each heading when `scrollMarginTop` is provided so navigation lands
|
|
272
|
+
* cleanly below a sticky header.
|
|
273
|
+
*/
|
|
274
|
+
declare function extractHeadingsFromElement(root: HTMLElement, options?: ExtractHeadingsOptions & {
|
|
275
|
+
scrollMarginTop?: number;
|
|
276
|
+
}): ExtractedHeading[];
|
|
277
|
+
|
|
278
|
+
interface UseTableOfContentsOptions {
|
|
279
|
+
/** Heading levels to include. Defaults to `[2, 3]`. */
|
|
280
|
+
levels?: ReadonlyArray<HeadingLevel>;
|
|
281
|
+
/**
|
|
282
|
+
* Re-collect headings whenever this value changes. Pass the article slug
|
|
283
|
+
* (or any stable identifier) so swapping content rebuilds the ToC.
|
|
284
|
+
*/
|
|
285
|
+
contentKey?: string | number | null;
|
|
286
|
+
/** Pixels to subtract from heading scroll-into-view target. Default 24. */
|
|
287
|
+
scrollMarginTop?: number;
|
|
288
|
+
/**
|
|
289
|
+
* Distance from the top of the viewport (in px) at which a heading becomes
|
|
290
|
+
* "active". A heading is considered active once its top edge has scrolled
|
|
291
|
+
* past this line. Default `96` — works well with a sticky header that
|
|
292
|
+
* stands ~60–80px tall.
|
|
293
|
+
*/
|
|
294
|
+
activationOffset?: number;
|
|
295
|
+
}
|
|
296
|
+
interface UseTableOfContentsResult {
|
|
297
|
+
items: ExtractedHeading[];
|
|
298
|
+
activeId: string;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Build a table of contents from a rendered element and track which
|
|
302
|
+
* heading is currently in view via IntersectionObserver. Resilient to
|
|
303
|
+
* content swaps when `contentKey` is provided.
|
|
304
|
+
*
|
|
305
|
+
* Usage:
|
|
306
|
+
* ```tsx
|
|
307
|
+
* const ref = useRef<HTMLDivElement>(null);
|
|
308
|
+
* const { items, activeId } = useTableOfContents(ref, {
|
|
309
|
+
* contentKey: article.slug,
|
|
310
|
+
* levels: [2, 3],
|
|
311
|
+
* });
|
|
312
|
+
* ```
|
|
313
|
+
*/
|
|
314
|
+
declare function useTableOfContents(ref: RefObject<HTMLElement | null>, options?: UseTableOfContentsOptions): UseTableOfContentsResult;
|
|
315
|
+
|
|
316
|
+
export { AsteroidCMSProvider, type AsteroidCMSProviderProps, type ExtractHeadingsOptions, type ExtractedHeading, type HeadingLevel, RichTextContent, type UseCmsContentOptions, type UseCmsMutateOptions, type UseTableOfContentsOptions, type UseTableOfContentsResult, extractHeadingsFromElement, extractHeadingsFromHtml, slugify, useAsteroidCMSConfig, useCmsContent, useCmsImage, useCmsMutate, useTableOfContents };
|
package/dist/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { createContext, useContext, useMemo, useRef, useEffect, createElement } from 'react';
|
|
2
|
+
import { createContext, useContext, useMemo, useRef, useEffect, createElement, useState } from 'react';
|
|
3
3
|
import { ApolloProvider, useQuery, useMutation } from '@apollo/client/react';
|
|
4
4
|
import { gql, HttpLink, ApolloClient, InMemoryCache, ApolloLink, CombinedGraphQLErrors, CombinedProtocolErrors } from '@apollo/client';
|
|
5
5
|
import { SetContextLink } from '@apollo/client/link/context';
|
|
@@ -419,7 +419,36 @@ function parseRichText(html, options = {}) {
|
|
|
419
419
|
working = upgradeStandaloneImages(working);
|
|
420
420
|
working = upgradeAuthoredBlockquotes(working);
|
|
421
421
|
working = flattenTableCellParagraphs(working);
|
|
422
|
-
|
|
422
|
+
working = sanitizeAndStyle(working, options);
|
|
423
|
+
if (options.autoHeadingIds !== false) {
|
|
424
|
+
working = injectHeadingIds(working);
|
|
425
|
+
}
|
|
426
|
+
return working;
|
|
427
|
+
}
|
|
428
|
+
function injectHeadingIds(html) {
|
|
429
|
+
const used = /* @__PURE__ */ new Map();
|
|
430
|
+
const existingRe = /<h[1-6]\b[^>]*\bid\s*=\s*("([^"]*)"|'([^']*)'|(\S+))/gi;
|
|
431
|
+
let em;
|
|
432
|
+
while ((em = existingRe.exec(html)) !== null) {
|
|
433
|
+
const id = em[2] ?? em[3] ?? em[4] ?? "";
|
|
434
|
+
if (id) used.set(id, (used.get(id) ?? 0) + 1);
|
|
435
|
+
}
|
|
436
|
+
return html.replace(
|
|
437
|
+
/<(h[1-6])\b([^>]*)>([\s\S]*?)<\/\1>/gi,
|
|
438
|
+
(full, tag, attrs, inner) => {
|
|
439
|
+
if (/\bid\s*=/i.test(attrs)) return full;
|
|
440
|
+
const text = inner.replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/\s+/g, " ").trim();
|
|
441
|
+
if (!text) return full;
|
|
442
|
+
const base = slugifyHeading(text) || tag;
|
|
443
|
+
const n = used.get(base) ?? 0;
|
|
444
|
+
used.set(base, n + 1);
|
|
445
|
+
const id = n === 0 ? base : `${base}-${n}`;
|
|
446
|
+
return `<${tag}${attrs} id="${id}">${inner}</${tag}>`;
|
|
447
|
+
}
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
function slugifyHeading(text) {
|
|
451
|
+
return text.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
423
452
|
}
|
|
424
453
|
function flattenTableCellParagraphs(html) {
|
|
425
454
|
return html.replace(
|
|
@@ -1425,7 +1454,9 @@ function RichTextContent({
|
|
|
1425
1454
|
html,
|
|
1426
1455
|
classMap,
|
|
1427
1456
|
as = "div",
|
|
1428
|
-
className
|
|
1457
|
+
className,
|
|
1458
|
+
onReady,
|
|
1459
|
+
contentRef
|
|
1429
1460
|
}) {
|
|
1430
1461
|
const merged = useMemo(
|
|
1431
1462
|
() => mergeClassMap(DEFAULT_CLASS_MAP, classMap),
|
|
@@ -1438,11 +1469,30 @@ function RichTextContent({
|
|
|
1438
1469
|
const ref = useRef(null);
|
|
1439
1470
|
useEffect(() => {
|
|
1440
1471
|
ensureCodeBlockStyles();
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1472
|
+
const root = ref.current;
|
|
1473
|
+
if (!root) return;
|
|
1474
|
+
if (contentRef) contentRef.current = root;
|
|
1475
|
+
const apply = () => {
|
|
1476
|
+
mo.disconnect();
|
|
1477
|
+
enhanceCodeBlocks(root);
|
|
1478
|
+
enhanceBlockquotes(root);
|
|
1479
|
+
onReady?.(root);
|
|
1480
|
+
mo.observe(root, { childList: true, subtree: true });
|
|
1481
|
+
};
|
|
1482
|
+
let raf = 0;
|
|
1483
|
+
const mo = new MutationObserver(() => {
|
|
1484
|
+
if (raf) return;
|
|
1485
|
+
raf = requestAnimationFrame(() => {
|
|
1486
|
+
raf = 0;
|
|
1487
|
+
apply();
|
|
1488
|
+
});
|
|
1489
|
+
});
|
|
1490
|
+
apply();
|
|
1491
|
+
return () => {
|
|
1492
|
+
mo.disconnect();
|
|
1493
|
+
if (raf) cancelAnimationFrame(raf);
|
|
1494
|
+
};
|
|
1495
|
+
}, [safe, onReady, contentRef]);
|
|
1446
1496
|
return createElement(as, {
|
|
1447
1497
|
ref,
|
|
1448
1498
|
className,
|
|
@@ -1450,6 +1500,170 @@ function RichTextContent({
|
|
|
1450
1500
|
});
|
|
1451
1501
|
}
|
|
1452
1502
|
|
|
1453
|
-
|
|
1503
|
+
// src/utils/extractHeadings.ts
|
|
1504
|
+
var DEFAULT_LEVELS = [2, 3];
|
|
1505
|
+
function slugify(text) {
|
|
1506
|
+
return text.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
1507
|
+
}
|
|
1508
|
+
function decodeBasicEntities(s) {
|
|
1509
|
+
return s.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'");
|
|
1510
|
+
}
|
|
1511
|
+
function stripTags(s) {
|
|
1512
|
+
return decodeBasicEntities(s.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
|
|
1513
|
+
}
|
|
1514
|
+
function uniqueId(base, used) {
|
|
1515
|
+
const seed = base || "section";
|
|
1516
|
+
const n = used.get(seed) ?? 0;
|
|
1517
|
+
used.set(seed, n + 1);
|
|
1518
|
+
return n === 0 ? seed : `${seed}-${n}`;
|
|
1519
|
+
}
|
|
1520
|
+
function extractHeadingsFromHtml(html, options = {}) {
|
|
1521
|
+
if (!html) return [];
|
|
1522
|
+
const levels = options.levels ?? DEFAULT_LEVELS;
|
|
1523
|
+
const slug = options.slugify ?? slugify;
|
|
1524
|
+
const used = /* @__PURE__ */ new Map();
|
|
1525
|
+
const out = [];
|
|
1526
|
+
const re = /<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi;
|
|
1527
|
+
let m;
|
|
1528
|
+
let i = 0;
|
|
1529
|
+
while ((m = re.exec(html)) !== null) {
|
|
1530
|
+
const level = Number(m[1]);
|
|
1531
|
+
if (!levels.includes(level)) continue;
|
|
1532
|
+
const attrs = m[2] ?? "";
|
|
1533
|
+
const inner = m[3] ?? "";
|
|
1534
|
+
const text = stripTags(inner);
|
|
1535
|
+
if (!text) continue;
|
|
1536
|
+
const explicitIdMatch = attrs.match(/\bid\s*=\s*("([^"]*)"|'([^']*)'|(\S+))/i);
|
|
1537
|
+
let id;
|
|
1538
|
+
if (explicitIdMatch) {
|
|
1539
|
+
id = explicitIdMatch[2] ?? explicitIdMatch[3] ?? explicitIdMatch[4] ?? "";
|
|
1540
|
+
if (id) used.set(id, (used.get(id) ?? 0) + 1);
|
|
1541
|
+
} else {
|
|
1542
|
+
id = uniqueId(slug(text, i), used);
|
|
1543
|
+
}
|
|
1544
|
+
out.push({ id, text, level });
|
|
1545
|
+
i++;
|
|
1546
|
+
}
|
|
1547
|
+
return out;
|
|
1548
|
+
}
|
|
1549
|
+
function extractHeadingsFromElement(root, options = {}) {
|
|
1550
|
+
const levels = options.levels ?? DEFAULT_LEVELS;
|
|
1551
|
+
const slug = options.slugify ?? slugify;
|
|
1552
|
+
const selector = levels.map((l) => `h${l}`).join(",");
|
|
1553
|
+
const nodes = root.querySelectorAll(selector);
|
|
1554
|
+
const used = /* @__PURE__ */ new Map();
|
|
1555
|
+
nodes.forEach((n) => {
|
|
1556
|
+
if (n.id) used.set(n.id, (used.get(n.id) ?? 0) + 1);
|
|
1557
|
+
});
|
|
1558
|
+
const out = [];
|
|
1559
|
+
let i = 0;
|
|
1560
|
+
nodes.forEach((node) => {
|
|
1561
|
+
const level = Number(node.tagName.slice(1));
|
|
1562
|
+
const text = (node.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
1563
|
+
if (!text) return;
|
|
1564
|
+
if (!node.id) {
|
|
1565
|
+
node.id = uniqueId(slug(text, i), used);
|
|
1566
|
+
}
|
|
1567
|
+
if (options.scrollMarginTop != null) {
|
|
1568
|
+
node.style.scrollMarginTop = `${options.scrollMarginTop}px`;
|
|
1569
|
+
}
|
|
1570
|
+
out.push({ id: node.id, text, level });
|
|
1571
|
+
i++;
|
|
1572
|
+
});
|
|
1573
|
+
return out;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// src/hooks/useTableOfContents.tsx
|
|
1577
|
+
function useTableOfContents(ref, options = {}) {
|
|
1578
|
+
const {
|
|
1579
|
+
levels,
|
|
1580
|
+
contentKey = null,
|
|
1581
|
+
scrollMarginTop = 24,
|
|
1582
|
+
activationOffset = 96
|
|
1583
|
+
} = options;
|
|
1584
|
+
const [items, setItems] = useState([]);
|
|
1585
|
+
const [activeId, setActiveId] = useState("");
|
|
1586
|
+
useEffect(() => {
|
|
1587
|
+
const root = ref.current;
|
|
1588
|
+
if (!root) {
|
|
1589
|
+
setItems([]);
|
|
1590
|
+
setActiveId("");
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
let raf = 0;
|
|
1594
|
+
const collect = () => {
|
|
1595
|
+
const next = extractHeadingsFromElement(root, {
|
|
1596
|
+
levels,
|
|
1597
|
+
scrollMarginTop
|
|
1598
|
+
});
|
|
1599
|
+
setItems(next);
|
|
1600
|
+
setActiveId(
|
|
1601
|
+
(prev) => next.some((h) => h.id === prev) ? prev : next[0]?.id ?? ""
|
|
1602
|
+
);
|
|
1603
|
+
};
|
|
1604
|
+
raf = requestAnimationFrame(collect);
|
|
1605
|
+
const mo = new MutationObserver(() => {
|
|
1606
|
+
if (raf) cancelAnimationFrame(raf);
|
|
1607
|
+
raf = requestAnimationFrame(collect);
|
|
1608
|
+
});
|
|
1609
|
+
mo.observe(root, { childList: true, subtree: true, characterData: true });
|
|
1610
|
+
return () => {
|
|
1611
|
+
mo.disconnect();
|
|
1612
|
+
if (raf) cancelAnimationFrame(raf);
|
|
1613
|
+
};
|
|
1614
|
+
}, [ref, contentKey, levels, scrollMarginTop]);
|
|
1615
|
+
useEffect(() => {
|
|
1616
|
+
if (items.length === 0) return;
|
|
1617
|
+
const targets = items.map((it) => document.getElementById(it.id)).filter((el) => el !== null);
|
|
1618
|
+
if (targets.length === 0) return;
|
|
1619
|
+
let raf = 0;
|
|
1620
|
+
const compute = () => {
|
|
1621
|
+
raf = 0;
|
|
1622
|
+
let activeIdx = 0;
|
|
1623
|
+
for (let i = 0; i < items.length; i++) {
|
|
1624
|
+
const el = document.getElementById(items[i].id);
|
|
1625
|
+
if (!el) continue;
|
|
1626
|
+
if (el.getBoundingClientRect().top - activationOffset <= 0) {
|
|
1627
|
+
activeIdx = i;
|
|
1628
|
+
} else {
|
|
1629
|
+
break;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
const scroller = document.scrollingElement || document.documentElement;
|
|
1633
|
+
const scrollY = window.scrollY;
|
|
1634
|
+
const viewportH = window.innerHeight;
|
|
1635
|
+
const atBottom = scrollY + viewportH >= scroller.scrollHeight - 2;
|
|
1636
|
+
if (atBottom) {
|
|
1637
|
+
for (let i = items.length - 1; i > activeIdx; i--) {
|
|
1638
|
+
const el = document.getElementById(items[i].id);
|
|
1639
|
+
if (el && el.getBoundingClientRect().top < viewportH) {
|
|
1640
|
+
activeIdx = i;
|
|
1641
|
+
break;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
setActiveId(items[activeIdx].id);
|
|
1646
|
+
};
|
|
1647
|
+
const schedule = () => {
|
|
1648
|
+
if (raf) return;
|
|
1649
|
+
raf = requestAnimationFrame(compute);
|
|
1650
|
+
};
|
|
1651
|
+
const io = new IntersectionObserver(schedule, {
|
|
1652
|
+
rootMargin: `-${activationOffset}px 0px -${Math.max(0, window.innerHeight - activationOffset - 1)}px 0px`,
|
|
1653
|
+
threshold: 0
|
|
1654
|
+
});
|
|
1655
|
+
targets.forEach((t) => io.observe(t));
|
|
1656
|
+
window.addEventListener("resize", schedule, { passive: true });
|
|
1657
|
+
compute();
|
|
1658
|
+
return () => {
|
|
1659
|
+
io.disconnect();
|
|
1660
|
+
window.removeEventListener("resize", schedule);
|
|
1661
|
+
if (raf) cancelAnimationFrame(raf);
|
|
1662
|
+
};
|
|
1663
|
+
}, [items, activationOffset]);
|
|
1664
|
+
return { items, activeId };
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
export { AsteroidCMSProvider, RichTextContent, extractHeadingsFromElement, extractHeadingsFromHtml, slugify, useAsteroidCMSConfig, useCmsContent, useCmsImage, useCmsMutate, useTableOfContents };
|
|
1454
1668
|
//# sourceMappingURL=client.js.map
|
|
1455
1669
|
//# sourceMappingURL=client.js.map
|