@myst-theme/site 0.12.0 → 0.13.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myst-theme/site",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -21,22 +21,30 @@
21
21
  "dependencies": {
22
22
  "@headlessui/react": "^1.7.15",
23
23
  "@heroicons/react": "^2.0.18",
24
- "@myst-theme/common": "^0.12.0",
25
- "@myst-theme/diagrams": "^0.12.0",
26
- "@myst-theme/frontmatter": "^0.12.0",
27
- "@myst-theme/jupyter": "^0.12.0",
28
- "@myst-theme/providers": "^0.12.0",
24
+ "@myst-theme/common": "^0.13.1",
25
+ "@myst-theme/diagrams": "^0.13.1",
26
+ "@myst-theme/frontmatter": "^0.13.1",
27
+ "@myst-theme/jupyter": "^0.13.1",
28
+ "@myst-theme/providers": "^0.13.1",
29
+ "@myst-theme/search": "^0.13.1",
29
30
  "@radix-ui/react-collapsible": "^1.0.3",
31
+ "@radix-ui/react-dialog": "^1.0.3",
32
+ "@radix-ui/react-radio-group": "^1.2.0",
33
+ "@radix-ui/react-roving-focus": "^1.1.0",
34
+ "@radix-ui/react-slot": "^1.1.0",
35
+ "@radix-ui/react-visually-hidden": "^1.1.0",
30
36
  "classnames": "^2.3.2",
31
37
  "lodash.throttle": "^4.1.1",
32
- "myst-common": "^1.6.0",
33
- "myst-config": "^1.6.0",
34
- "myst-demo": "^0.12.0",
35
- "myst-spec-ext": "^1.6.0",
36
- "myst-to-react": "^0.12.0",
38
+ "myst-common": "^1.7.0",
39
+ "myst-config": "^1.7.0",
40
+ "myst-demo": "^0.13.1",
41
+ "myst-spec-ext": "^1.7.0",
42
+ "myst-to-react": "^0.13.1",
37
43
  "nbtx": "^0.2.3",
38
44
  "node-cache": "^5.1.2",
39
45
  "node-fetch": "^2.6.11",
46
+ "react-merge-refs": "^2.1.1",
47
+ "string.prototype.matchall": "^4.0.11",
40
48
  "thebe-react": "0.4.10",
41
49
  "unist-util-select": "^4.0.1"
42
50
  },
@@ -8,10 +8,12 @@ import { useNavigation } from '@remix-run/react';
8
8
  import classNames from 'classnames';
9
9
  import throttle from 'lodash.throttle';
10
10
  import React, { useCallback, useEffect, useRef, useState } from 'react';
11
+ import type { RefObject } from 'react';
11
12
  import { DocumentChartBarIcon } from '@heroicons/react/24/outline';
13
+ import { ChevronRightIcon } from '@heroicons/react/24/solid';
14
+ import * as Collapsible from '@radix-ui/react-collapsible';
12
15
 
13
16
  const SELECTOR = [1, 2, 3, 4].map((n) => `main h${n}`).join(', ');
14
- const HIGHLIGHT_CLASS = 'highlight';
15
17
 
16
18
  const onClient = typeof document !== 'undefined';
17
19
 
@@ -25,15 +27,13 @@ export type Heading = {
25
27
 
26
28
  type Props = {
27
29
  headings: Heading[];
28
- selector: string;
29
30
  activeId?: string;
30
- highlight?: () => void;
31
31
  };
32
32
  /**
33
33
  * This renders an item in the table of contents list.
34
34
  * scrollIntoView is used to ensure that when a user clicks on an item, it will smoothly scroll.
35
35
  */
36
- const Headings = ({ headings, activeId, highlight, selector }: Props) => (
36
+ const Headings = ({ headings, activeId }: Props) => (
37
37
  <ul className="text-sm leading-6 text-slate-400">
38
38
  {headings.map((heading) => (
39
39
  <li
@@ -60,15 +60,14 @@ const Headings = ({ headings, activeId, highlight, selector }: Props) => (
60
60
  href={`#${heading.id}`}
61
61
  onClick={(e) => {
62
62
  e.preventDefault();
63
- const el = document.querySelector(`#${heading.id}`);
63
+ const el = document.querySelector(`#${heading.id}`) as HTMLElement | undefined;
64
64
  if (!el) return;
65
- getHeaders(selector).forEach((h) => {
66
- h.classList.remove(HIGHLIGHT_CLASS);
67
- });
68
- el.classList.add(HIGHLIGHT_CLASS);
69
- highlight?.();
65
+
70
66
  el.scrollIntoView({ behavior: 'smooth' });
71
67
  history.replaceState(undefined, '', `#${heading.id}`);
68
+ // Changes keyboard tab-index location
69
+ if (el.tabIndex === -1) el.tabIndex = -1;
70
+ el.focus({ preventScroll: true });
72
71
  }}
73
72
  // Note that the title can have math in it!
74
73
  dangerouslySetInnerHTML={{ __html: heading.titleHTML }}
@@ -105,15 +104,111 @@ function getHeaders(selector: string): HTMLHeadingElement[] {
105
104
  return headers as HTMLHeadingElement[];
106
105
  }
107
106
 
107
+ type MutationCallback = (mutations: MutationRecord[], observer: MutationObserver) => void;
108
+
109
+ function useMutationObserver(
110
+ targetRef: RefObject<Element>,
111
+ callback: MutationCallback,
112
+ options: Record<string, any>,
113
+ ) {
114
+ const [observer, setObserver] = useState<MutationObserver | null>(null);
115
+
116
+ if (!onClient) return { observer };
117
+
118
+ // Create observer
119
+ useEffect(() => {
120
+ const obs = new MutationObserver(callback);
121
+ setObserver(obs);
122
+ }, [callback, setObserver]);
123
+
124
+ // Setup observer
125
+ useEffect(() => {
126
+ if (!observer || !targetRef.current) {
127
+ return;
128
+ }
129
+
130
+ try {
131
+ observer.observe(targetRef.current, options);
132
+ } catch (e) {
133
+ console.error(e);
134
+ }
135
+ return () => {
136
+ if (observer) {
137
+ observer.disconnect();
138
+ }
139
+ };
140
+ }, [observer]);
141
+ }
142
+
143
+ const useIntersectionObserver = (elements: Element[], options?: Record<string, any>) => {
144
+ const [observer, setObserver] = useState<IntersectionObserver | null>(null);
145
+ const [intersecting, setIntersecting] = useState<Element[]>([]);
146
+
147
+ if (!onClient) return { observer };
148
+ useEffect(() => {
149
+ const cb: IntersectionObserverCallback = (entries) => {
150
+ setIntersecting(entries.filter((e) => e.isIntersecting).map((e) => e.target));
151
+ };
152
+ const o = new IntersectionObserver(cb, options ?? {});
153
+ setObserver(o);
154
+ return () => o.disconnect();
155
+ }, []);
156
+
157
+ // Changes to the DOM mean we need to update our intersection observer
158
+ useEffect(() => {
159
+ if (!observer) {
160
+ return;
161
+ }
162
+ // Observe all heading elements
163
+ const toWatch = elements;
164
+ toWatch.map((e) => observer.observe(e));
165
+ // Cleanup afterwards
166
+ return () => {
167
+ toWatch.map((e) => observer.unobserve(e));
168
+ };
169
+ }, [elements]);
170
+
171
+ return { observer, intersecting };
172
+ };
173
+
174
+ /**
175
+ * Keep track of which headers are visible, and which header is active
176
+ */
108
177
  export function useHeaders(selector: string, maxdepth: number) {
109
178
  if (!onClient) return { activeId: '', headings: [] };
110
- const onScreen = useRef<Set<HTMLHeadingElement>>(new Set());
179
+ // Keep track of main manually for now
180
+ const mainElementRef = useRef<HTMLElement | null>(null);
181
+ useEffect(() => {
182
+ mainElementRef.current = document.querySelector('main');
183
+ }, []);
184
+
185
+ // Track changes to the DOM
186
+ const [elements, setElements] = useState<HTMLHeadingElement[]>([]);
187
+ const onMutation = useCallback(
188
+ throttle(
189
+ () => {
190
+ setElements(getHeaders(selector));
191
+ },
192
+ 500,
193
+ { trailing: false },
194
+ ),
195
+ [selector],
196
+ );
197
+ useMutationObserver(mainElementRef, onMutation, {
198
+ attributes: true,
199
+ childList: true,
200
+ subtree: true,
201
+ });
202
+
203
+ // Trigger initial update
204
+ useEffect(onMutation, []);
205
+
206
+ // Watch intersections with headings
207
+ const { intersecting } = useIntersectionObserver(elements);
111
208
  const [activeId, setActiveId] = useState<string>();
112
- const headingsSet = useRef<Set<HTMLHeadingElement>>(new Set());
113
209
 
114
- const highlight = useCallback(() => {
115
- const current = [...onScreen.current];
116
- const highlighted = current.reduce(
210
+ useEffect(() => {
211
+ const highlighted = intersecting!.reduce(
117
212
  (a, b) => {
118
213
  if (a) return a;
119
214
  if (b.classList.contains('highlight')) return b.id;
@@ -121,80 +216,43 @@ export function useHeaders(selector: string, maxdepth: number) {
121
216
  },
122
217
  null as string | null,
123
218
  );
124
- const active = [...onScreen.current].sort((a, b) => a.offsetTop - b.offsetTop)[0];
219
+ const active = [...(intersecting as HTMLElement[])].sort(
220
+ (a, b) => a.offsetTop - b.offsetTop,
221
+ )[0];
125
222
  if (highlighted || active) setActiveId(highlighted || active.id);
126
- }, []);
127
-
128
- const { observer } = useIntersectionObserver(highlight, onScreen.current);
129
- const [elements, setElements] = useState<HTMLHeadingElement[]>([]);
223
+ }, [intersecting]);
130
224
 
131
- const render = throttle(() => setElements(getHeaders(selector)), 500);
225
+ const [headings, setHeadings] = useState<Heading[]>([]);
132
226
  useEffect(() => {
133
- // We have to look at the document changes for reloads/mutations
134
- const main = document.querySelector('main');
135
- const mutations = new MutationObserver(render);
136
- // Fire when added to the dom
137
- render();
138
- if (main) {
139
- mutations.observe(main, { attributes: true, childList: true, subtree: true });
140
- }
141
- return () => mutations.disconnect();
142
- }, []);
143
-
144
- useEffect(() => {
145
- // Re-observe all elements when the observer changes
146
- Array.from(elements).map((e) => observer.current?.observe(e));
147
- }, [observer]);
227
+ let minLevel = 10;
228
+ const thisHeadings: Heading[] = elements
229
+ .map((element) => {
230
+ return {
231
+ element,
232
+ level: Number(element.tagName.slice(1)),
233
+ id: element.id,
234
+ text: element.querySelector('.heading-text'),
235
+ };
236
+ })
237
+ .filter((h) => !!h.text)
238
+ .map(({ element, level, text, id }) => {
239
+ const { innerText: title, innerHTML: titleHTML } = cloneHeadingElement(
240
+ text as HTMLSpanElement,
241
+ );
242
+ minLevel = Math.min(minLevel, level);
243
+ return { element, title, titleHTML, id, level };
244
+ })
245
+ .filter((heading) => {
246
+ heading.level = heading.level - minLevel + 1;
247
+ return heading.level < maxdepth + 1;
248
+ });
148
249
 
149
- let minLevel = 10;
150
- const headings: Heading[] = elements
151
- .map((element) => {
152
- return {
153
- element,
154
- level: Number(element.tagName.slice(1)),
155
- id: element.id,
156
- text: element.querySelector('.heading-text'),
157
- };
158
- })
159
- .filter((h) => !!h.text)
160
- .map(({ element, level, text, id }) => {
161
- const { innerText: title, innerHTML: titleHTML } = cloneHeadingElement(
162
- text as HTMLSpanElement,
163
- );
164
- minLevel = Math.min(minLevel, level);
165
- return { element, title, titleHTML, id, level };
166
- })
167
- .filter((heading) => {
168
- heading.level = heading.level - minLevel + 1;
169
- return heading.level < maxdepth + 1;
170
- });
171
-
172
- headings.forEach(({ element: e }) => {
173
- if (headingsSet.current.has(e)) return;
174
- observer.current?.observe(e);
175
- headingsSet.current.add(e);
176
- });
250
+ setHeadings(thisHeadings);
251
+ }, [elements]);
177
252
 
178
- return { activeId, highlight, headings };
253
+ return { activeId, headings };
179
254
  }
180
255
 
181
- const useIntersectionObserver = (highlight: () => void, onScreen: Set<HTMLHeadingElement>) => {
182
- const observer = useRef<IntersectionObserver | null>(null);
183
- if (!onClient) return { observer };
184
- useEffect(() => {
185
- const callback: IntersectionObserverCallback = (entries) => {
186
- entries.forEach((entry) => {
187
- onScreen[entry.isIntersecting ? 'add' : 'delete'](entry.target as HTMLHeadingElement);
188
- });
189
- highlight();
190
- };
191
- const o = new IntersectionObserver(callback);
192
- observer.current = o;
193
- return () => o.disconnect();
194
- }, [highlight, onScreen]);
195
- return { observer };
196
- };
197
-
198
256
  export function useOutlineHeight<T extends HTMLElement = HTMLElement>(
199
257
  existingContainer?: React.RefObject<T>,
200
258
  ) {
@@ -226,6 +284,71 @@ export function useOutlineHeight<T extends HTMLElement = HTMLElement>(
226
284
  return { container, outline };
227
285
  }
228
286
 
287
+ /**
288
+ * Determine whether the margin outline should be occluded by margin elements
289
+ */
290
+ function useMarginOccluder() {
291
+ const [occluded, setOccluded] = useState(false);
292
+ const [elements, setElements] = useState<Element[]>([]);
293
+
294
+ // Keep track of main manually for now
295
+ const mainElementRef = useRef<HTMLElement | null>(null);
296
+ useEffect(() => {
297
+ mainElementRef.current = document.querySelector('main');
298
+ }, []);
299
+
300
+ // Update list of margin elements
301
+ const onMutation = useCallback(
302
+ throttle(
303
+ () => {
304
+ if (!mainElementRef.current) {
305
+ return;
306
+ }
307
+ // Watch margin elements, or their direct descendents (as some margin elements have height set to zero)
308
+ const classes = [
309
+ 'col-margin-right',
310
+ 'col-margin-right-inset',
311
+ 'col-gutter-outset-right',
312
+ 'col-screen-right',
313
+ 'col-screen-inset-right',
314
+ 'col-page-right',
315
+ 'col-page-inset-right',
316
+ 'col-body-outset-right',
317
+ 'col-gutter-page-right',
318
+ // 'col-screen', // This is on everything!
319
+ 'col-page',
320
+ 'col-page-inset',
321
+ 'col-body-outset',
322
+ ];
323
+ const selector = classes
324
+ .map((cls) => [`.${cls}`, `.${cls} > *`])
325
+ .flat()
326
+ .join(', ');
327
+ const marginElements = mainElementRef.current.querySelectorAll(selector);
328
+ setElements(Array.from(marginElements));
329
+ },
330
+ 500,
331
+ { trailing: false },
332
+ ),
333
+ [],
334
+ );
335
+ useMutationObserver(mainElementRef, onMutation, {
336
+ attributes: true,
337
+ childList: true,
338
+ subtree: true,
339
+ });
340
+
341
+ // Trigger initial update
342
+ useEffect(onMutation, []);
343
+ // Keep tabs of margin elements on screen
344
+ const { intersecting } = useIntersectionObserver(elements, { rootMargin: '0px 0px -33% 0px' });
345
+ useEffect(() => {
346
+ setOccluded(intersecting!.length > 0);
347
+ }, [intersecting]);
348
+
349
+ return { occluded };
350
+ }
351
+
229
352
  export const DocumentOutline = ({
230
353
  outlineRef,
231
354
  top = 0,
@@ -233,6 +356,7 @@ export const DocumentOutline = ({
233
356
  selector = SELECTOR,
234
357
  children,
235
358
  maxdepth = 4,
359
+ isMargin,
236
360
  }: {
237
361
  outlineRef?: React.RefObject<HTMLElement>;
238
362
  top?: number;
@@ -241,31 +365,63 @@ export const DocumentOutline = ({
241
365
  selector?: string;
242
366
  children?: React.ReactNode;
243
367
  maxdepth?: number;
368
+ isMargin: boolean;
244
369
  }) => {
245
- const { activeId, headings, highlight } = useHeaders(selector, maxdepth);
370
+ const { activeId, headings } = useHeaders(selector, maxdepth);
371
+ const [open, setOpen] = useState(false);
372
+
373
+ // Keep track of changing occlusion
374
+ const { occluded } = useMarginOccluder();
375
+
376
+ // Handle transition between margin and non-margin
377
+ useEffect(() => {
378
+ setOpen(true);
379
+ }, [isMargin]);
380
+
381
+ // Handle occlusion when outline is in margin
382
+ useEffect(() => {
383
+ if (isMargin) {
384
+ setOpen(!occluded);
385
+ }
386
+ }, [occluded, isMargin]);
387
+
246
388
  if (headings.length <= 1 || !onClient) {
247
389
  return <nav suppressHydrationWarning>{children}</nav>;
248
390
  }
391
+
249
392
  return (
250
- <nav
251
- ref={outlineRef}
252
- aria-label="Document Outline"
253
- className={classNames(
254
- 'not-prose overflow-y-auto',
255
- 'transition-opacity duration-700', // Animation on load
256
- className,
257
- )}
258
- style={{
259
- top: top,
260
- maxHeight: `calc(100vh - ${top + 20}px)`,
261
- }}
262
- >
263
- <div className="mb-4 text-sm leading-6 uppercase text-slate-900 dark:text-slate-100">
264
- In this article
265
- </div>
266
- <Headings headings={headings} activeId={activeId} highlight={highlight} selector={selector} />
267
- {children}
268
- </nav>
393
+ <Collapsible.Root open={open} onOpenChange={setOpen}>
394
+ <nav
395
+ ref={outlineRef}
396
+ aria-label="Document Outline"
397
+ className={classNames(
398
+ 'not-prose overflow-y-auto',
399
+ 'transition-opacity duration-700', // Animation on load
400
+ className,
401
+ )}
402
+ style={{
403
+ top: top,
404
+ maxHeight: `calc(100vh - ${top + 20}px)`,
405
+ }}
406
+ >
407
+ <div className="flex flex-row gap-2 mb-4 text-sm leading-6 uppercase rounded-lg text-slate-900 dark:text-slate-100">
408
+ In this article
409
+ <Collapsible.Trigger asChild>
410
+ <button className="self-center flex-none rounded-md group hover:bg-slate-300/30 focus:outline outline-blue-200 outline-2">
411
+ <ChevronRightIcon
412
+ className="transition-transform duration-300 group-data-[state=open]:rotate-90 text-text-slate-700 dark:text-slate-100"
413
+ height="1.5rem"
414
+ width="1.5rem"
415
+ />
416
+ </button>
417
+ </Collapsible.Trigger>
418
+ </div>
419
+ <Collapsible.Content className="CollapsibleContent">
420
+ <Headings headings={headings} activeId={activeId} />
421
+ {children}
422
+ </Collapsible.Content>
423
+ </nav>
424
+ </Collapsible.Root>
269
425
  );
270
426
  };
271
427
 
@@ -0,0 +1,633 @@
1
+ import { useEffect, useState, useMemo, useCallback, useRef, forwardRef } from 'react';
2
+ import type { KeyboardEventHandler, Dispatch, SetStateAction, FormEvent, MouseEvent } from 'react';
3
+ import { useFetcher } from '@remix-run/react';
4
+ import {
5
+ ArrowTurnDownLeftIcon,
6
+ MagnifyingGlassIcon,
7
+ HashtagIcon,
8
+ Bars3BottomLeftIcon,
9
+ XCircleIcon,
10
+ } from '@heroicons/react/24/solid';
11
+ import { DocumentIcon } from '@heroicons/react/24/outline';
12
+ import classNames from 'classnames';
13
+ import * as Dialog from '@radix-ui/react-dialog';
14
+ import * as VisuallyHidden from '@radix-ui/react-visually-hidden';
15
+ import type { RankedSearchResult, HeadingLevel, MystSearchIndex } from '@myst-theme/search';
16
+ import { SPACE_OR_PUNCTUATION, rankResults } from '@myst-theme/search';
17
+ import {
18
+ useThemeTop,
19
+ useSearchFactory,
20
+ useLinkProvider,
21
+ withBaseurl,
22
+ useBaseurl,
23
+ useNavigateProvider,
24
+ } from '@myst-theme/providers';
25
+
26
+ /**
27
+ * Shim for string.matchAll
28
+ *
29
+ * @param text - text to repeatedly match with pattern
30
+ * @param pattern - global pattern
31
+ */
32
+ function matchAll(text: string, pattern: RegExp) {
33
+ const matches = [];
34
+ let match;
35
+ while ((match = pattern.exec(text))) {
36
+ matches.push(match);
37
+ }
38
+ return matches;
39
+ }
40
+
41
+ /**
42
+ * Highlight a text string with an array of match words
43
+ *
44
+ * @param text - text to highlight
45
+ * @param result - search result to use for highlighting
46
+ * @param limit - limit to the number of tokens after first match
47
+ */
48
+ function MarkedText({ text, matches, limit }: { text: string; matches: string[]; limit?: number }) {
49
+ // Split by delimeter, but _keep_ delimeter!
50
+ const splits = matchAll(text, SPACE_OR_PUNCTUATION);
51
+ const tokens: string[] = [];
52
+ let start = 0;
53
+ for (const splitMatch of splits) {
54
+ tokens.push(text.slice(start, splitMatch.index));
55
+ tokens.push(splitMatch[0]);
56
+ start = splitMatch.index + splitMatch[0].length;
57
+ }
58
+ tokens.push(text.slice(start));
59
+
60
+ // Build RegExp matching all highlight matches
61
+ const allTerms = matches.join('|');
62
+ const pattern = new RegExp(`^(${allTerms})`, 'i'); // Match prefix and total pattern, case-insensitively
63
+ const renderToken = (token: string) =>
64
+ pattern.test(token) ? (
65
+ <>
66
+ <mark className="text-blue-600 bg-inherit dark:text-blue-400 group-aria-selected:text-white group-aria-selected:underline">
67
+ {token}
68
+ </mark>
69
+ </>
70
+ ) : (
71
+ token
72
+ );
73
+ let firstIndex: number;
74
+ let lastIndex: number;
75
+ const hasLimit = limit !== undefined;
76
+
77
+ if (!hasLimit) {
78
+ firstIndex = 0;
79
+ lastIndex = tokens.length;
80
+ } else {
81
+ firstIndex = tokens.findIndex((token) => pattern.test(token));
82
+ lastIndex = firstIndex + limit;
83
+ }
84
+
85
+ if (tokens.length === 0) {
86
+ return <>{...tokens}</>;
87
+ } else {
88
+ const firstRenderer = renderToken(tokens[firstIndex]);
89
+ const remainingTokens = tokens.slice(firstIndex + 1, lastIndex);
90
+ const remainingRenderers = remainingTokens.map((token) => renderToken(token));
91
+
92
+ return (
93
+ <>
94
+ {hasLimit && '... '}
95
+ {firstRenderer}
96
+ {...remainingRenderers}
97
+ {hasLimit && ' ...'}
98
+ </>
99
+ );
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Return true if the client is a Mac, false if not, or undefined if running on the server
105
+ */
106
+ function isMac(): boolean | undefined {
107
+ if (typeof window === 'undefined') {
108
+ return undefined;
109
+ } else {
110
+ const hostIsMac = /mac/i.test(
111
+ (window.navigator as any).userAgentData?.platform ?? window.navigator.userAgent,
112
+ );
113
+ return hostIsMac;
114
+ }
115
+ }
116
+
117
+ // Blocking code to ensure that the pre-hydration state on the client matches the post-hydration state
118
+ // The server with SSR cannot determine the client platform
119
+ const clientThemeCode = `
120
+ ;(() => {
121
+ const script = document.currentScript;
122
+ const root = script.parentElement;
123
+
124
+ const isMac = /mac/i.test(
125
+ window.navigator.userAgentData?.platform ?? window.navigator.userAgent,
126
+ );
127
+ root.querySelectorAll(".hide-mac").forEach(node => {node.classList.add(isMac ? "hidden" : "block")});
128
+ root.querySelectorAll(".show-mac").forEach(node => {node.classList.add(!isMac ? "hidden" : "block")});
129
+ })()`;
130
+
131
+ function BlockingPlatformLoader() {
132
+ return <script dangerouslySetInnerHTML={{ __html: clientThemeCode }} />;
133
+ }
134
+
135
+ /**
136
+ * Component that represents the keyboard shortcut for launching search
137
+ */
138
+ function SearchShortcut() {
139
+ const hostIsMac = isMac();
140
+ return (
141
+ <div
142
+ aria-hidden
143
+ className="items-center hidden mx-1 font-mono text-sm text-gray-400 sm:flex gap-x-1"
144
+ >
145
+ <kbd
146
+ className={classNames(
147
+ 'px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-md',
148
+ 'shadow-[0px_2px_0px_0px_rgba(0,0,0,0.08)] dark:shadow-none',
149
+ 'hide-mac',
150
+ { hidden: hostIsMac === true },
151
+ { block: hostIsMac === false },
152
+ )}
153
+ >
154
+ CTRL
155
+ </kbd>
156
+ <kbd
157
+ className={classNames(
158
+ 'px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-md',
159
+ 'shadow-[0px_2px_0px_0px_rgba(0,0,0,0.08)] dark:shadow-none',
160
+ 'show-mac',
161
+ { hidden: hostIsMac === false },
162
+ { block: hostIsMac === true },
163
+ )}
164
+ >
165
+
166
+ </kbd>
167
+ <kbd className="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-md shadow-[0px_2px_0px_0px_rgba(0,0,0,0.08)] dark:shadow-none ">
168
+ K
169
+ </kbd>
170
+ <BlockingPlatformLoader />
171
+ </div>
172
+ );
173
+ }
174
+
175
+ /**
176
+ * Renderer for a single search result
177
+ */
178
+ function SearchResultItem({
179
+ result,
180
+ closeSearch,
181
+ }: {
182
+ result: RankedSearchResult;
183
+ closeSearch?: () => void;
184
+ }) {
185
+ const { hierarchy, type, url, queries } = result;
186
+ const baseurl = useBaseurl();
187
+ const Link = useLinkProvider();
188
+
189
+ // Render the icon
190
+ const iconRenderer =
191
+ type === 'lvl1' ? (
192
+ <DocumentIcon className="inline-block w-6 mx-2" />
193
+ ) : type === 'content' ? (
194
+ <Bars3BottomLeftIcon className="inline-block w-6 mx-2" />
195
+ ) : (
196
+ <HashtagIcon className="inline-block w-6 mx-2" />
197
+ );
198
+
199
+ // Generic "this document matched"
200
+ const title = result.type === 'content' ? result['content'] : hierarchy[type as HeadingLevel]!;
201
+ const matches = useMemo(() => queries.flatMap((query) => Object.keys(query.matches)), [queries]);
202
+
203
+ // Render the title, i.e. content or heading
204
+ const titleRenderer = (
205
+ <MarkedText text={title} matches={matches} limit={type === 'content' ? 16 : undefined} />
206
+ );
207
+
208
+ // Render the subtitle i.e. file name
209
+ let subtitleRenderer;
210
+ if (result.type === 'lvl1') {
211
+ subtitleRenderer = undefined;
212
+ } else {
213
+ const subtitle = result.hierarchy.lvl1!;
214
+ subtitleRenderer = <MarkedText text={subtitle} matches={matches} />;
215
+ }
216
+
217
+ const enterIconRenderer = (
218
+ <ArrowTurnDownLeftIcon className="invisible w-6 mx-2 group-aria-selected:visible" />
219
+ );
220
+
221
+ return (
222
+ <Link
223
+ className="block px-1 py-2 text-gray-700 rounded shadow-md dark:text-white group-aria-selected:bg-blue-600 group-aria-selected:text-white dark:shadow-none dark:bg-stone-800"
224
+ to={withBaseurl(url, baseurl)}
225
+ // Close the main search on click
226
+ onClick={closeSearch}
227
+ >
228
+ <div className="flex flex-row h-11">
229
+ {iconRenderer}
230
+ <div className="flex flex-col justify-center grow">
231
+ <span className="text-sm">{titleRenderer}</span>
232
+ {subtitleRenderer && <span className="text-xs">{subtitleRenderer}</span>}
233
+ </div>
234
+ {enterIconRenderer}
235
+ </div>
236
+ </Link>
237
+ );
238
+ }
239
+
240
+ interface SearchResultsProps {
241
+ searchResults: RankedSearchResult[];
242
+ searchListID: string;
243
+ searchLabelID: string;
244
+ selectedIndex: number;
245
+ onHoverSelect: (index: number) => void;
246
+ className?: string;
247
+ closeSearch?: () => void;
248
+ }
249
+
250
+ function SearchResults({
251
+ searchResults,
252
+ searchListID,
253
+ searchLabelID,
254
+ className,
255
+ selectedIndex,
256
+ onHoverSelect,
257
+ closeSearch,
258
+ }: SearchResultsProps) {
259
+ // Array of search item refs
260
+ const itemsRef = useRef<(HTMLLIElement | null)[]>([]);
261
+
262
+ // Ref to assign items
263
+ const setItemRef = useCallback(
264
+ (elem: HTMLLIElement) => {
265
+ if (!elem) {
266
+ return;
267
+ }
268
+ const index = parseInt(elem.dataset.index!);
269
+ itemsRef.current[index] = elem;
270
+ },
271
+ [itemsRef],
272
+ );
273
+
274
+ // Keep activeDescendent in sync wth selected index
275
+ const activeDescendent = useMemo(() => {
276
+ const item = itemsRef.current[selectedIndex];
277
+ if (!item) {
278
+ return '';
279
+ } else {
280
+ return item.id;
281
+ }
282
+ }, [selectedIndex, itemsRef]);
283
+
284
+ // If the select item changes, bring it into view
285
+ useEffect(() => {
286
+ const item = itemsRef.current[selectedIndex];
287
+ item?.scrollIntoView({ block: 'nearest' });
288
+ }, [selectedIndex]);
289
+
290
+ // Handle mouse movement events that set the current hovered element
291
+ const handleMouseMove = useCallback(
292
+ (event: MouseEvent<HTMLLIElement>) => {
293
+ const index = parseInt((event.currentTarget as HTMLLIElement).dataset.index!);
294
+ onHoverSelect(index);
295
+ },
296
+ [onHoverSelect],
297
+ );
298
+ return (
299
+ <div className="mt-4 overflow-y-scroll">
300
+ {searchResults.length ? (
301
+ <ul
302
+ // Accessiblity:
303
+ // indicate that this is a selectbox
304
+ role="listbox"
305
+ id={searchListID}
306
+ aria-label="Search results"
307
+ aria-labelledby={searchLabelID}
308
+ aria-orientation="vertical"
309
+ // Track focused item
310
+ aria-activedescendant={activeDescendent}
311
+ className={classNames('flex flex-col gap-y-2 px-1', className)}
312
+ >
313
+ {searchResults.map((result, index) => (
314
+ <li
315
+ key={result.id}
316
+ ref={setItemRef}
317
+ data-index={index}
318
+ // Accessiblity:
319
+ // Indicate that this is an option
320
+ role="option"
321
+ // Indicate whether this is selected
322
+ aria-selected={selectedIndex === index}
323
+ // Allow for nested-highlighting
324
+ className="group"
325
+ // Trigger selection on movement, so that scrolling doesn't trigger handler
326
+ onMouseMove={handleMouseMove}
327
+ >
328
+ <SearchResultItem result={result} closeSearch={closeSearch} />
329
+ </li>
330
+ ))}
331
+ </ul>
332
+ ) : (
333
+ <span>No results found.</span>
334
+ )}
335
+ </div>
336
+ );
337
+ }
338
+
339
+ /**
340
+ * Build search implementation by requesting search index from server
341
+ */
342
+ function useSearch() {
343
+ const baseURL = useBaseurl();
344
+ const fetcher = useFetcher();
345
+ const [enabled, setEnabled] = useState(true);
346
+ // Load index when this component is required
347
+ // TODO: this reloads every time the search box is opened.
348
+ // we should lift the state up
349
+ useEffect(() => {
350
+ if (fetcher.state === 'idle' && fetcher.data == null) {
351
+ const searchURL = withBaseurl('/myst.search.json', baseURL);
352
+ fetcher.load(searchURL);
353
+ }
354
+ }, [fetcher, baseURL]);
355
+
356
+ const searchFactory = useSearchFactory();
357
+ const search = useMemo(() => {
358
+ if (!fetcher.data || !searchFactory) {
359
+ return undefined;
360
+ } else {
361
+ if (fetcher.data?.version && fetcher.data?.records) {
362
+ return searchFactory(fetcher.data as MystSearchIndex);
363
+ }
364
+ setEnabled(false);
365
+ return undefined;
366
+ }
367
+ }, [searchFactory, fetcher.data, setEnabled]);
368
+
369
+ // Implement pass-through
370
+ return { search, enabled };
371
+ }
372
+
373
+ interface SearchFormProps {
374
+ debounceTime: number;
375
+ searchResults: RankedSearchResult[] | undefined;
376
+ setSearchResults: Dispatch<SetStateAction<RankedSearchResult[] | undefined>>;
377
+ searchInputID: string;
378
+ searchListID: string;
379
+ searchLabelID: string;
380
+ selectedIndex: number;
381
+ setSelectedIndex: Dispatch<SetStateAction<number>>;
382
+ closeSearch?: () => void;
383
+ }
384
+
385
+ function SearchForm({
386
+ debounceTime,
387
+ searchResults,
388
+ setSearchResults,
389
+ searchInputID,
390
+ searchListID,
391
+ searchLabelID,
392
+ selectedIndex,
393
+ setSelectedIndex,
394
+ closeSearch,
395
+ }: SearchFormProps) {
396
+ const [query, setQuery] = useState<string>('');
397
+ const { search: doSearch, enabled } = useSearch();
398
+
399
+ // Debounce user input
400
+ useEffect(() => {
401
+ const timeoutId = setTimeout(() => {
402
+ if (query != undefined && !!doSearch) {
403
+ doSearch(query).then((rawResults) => {
404
+ setSearchResults(
405
+ rawResults &&
406
+ rankResults(rawResults)
407
+ // Filter duplicates by URL
408
+ .filter((result, index, array) => result.url !== array[index - 1]?.url),
409
+ );
410
+ });
411
+ }
412
+ }, debounceTime);
413
+ return () => clearTimeout(timeoutId);
414
+ }, [doSearch, query, debounceTime]);
415
+ // Handle user input
416
+ const handleSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
417
+ setQuery(event.target.value);
418
+ }, []);
419
+ // Handle item selection
420
+ const navigate = useNavigateProvider();
421
+ const baseurl = useBaseurl();
422
+
423
+ // Handle item selection and navigation
424
+ const handleSearchKeyPress = useCallback<KeyboardEventHandler<HTMLInputElement>>(
425
+ (event) => {
426
+ // Ignore modifiers
427
+ if (event.ctrlKey || event.altKey || event.shiftKey) {
428
+ return;
429
+ }
430
+ if (!searchResults) {
431
+ return;
432
+ }
433
+
434
+ // Item selection
435
+ if (event.key === 'Enter') {
436
+ event.preventDefault();
437
+
438
+ const url = searchResults[selectedIndex]?.url;
439
+ if (url) {
440
+ navigate(withBaseurl(url, baseurl));
441
+ closeSearch?.();
442
+ }
443
+ }
444
+ // Item navigation
445
+ else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
446
+ event.preventDefault();
447
+
448
+ if (event.key === 'ArrowUp') {
449
+ setSelectedIndex(selectedIndex > 0 ? selectedIndex - 1 : 0);
450
+ } else {
451
+ setSelectedIndex(
452
+ selectedIndex < searchResults.length - 1 ? selectedIndex + 1 : searchResults.length - 1,
453
+ );
454
+ }
455
+ }
456
+ },
457
+ [searchResults, selectedIndex],
458
+ ); // Our form doesn't use the submit function
459
+ const onSubmit = useCallback((event: FormEvent<HTMLFormElement>) => {
460
+ event.preventDefault();
461
+ }, []);
462
+
463
+ return (
464
+ <>
465
+ <form onSubmit={onSubmit}>
466
+ <div className="relative flex w-full h-10 flow-row gap-x-1 ">
467
+ <label id={searchListID} htmlFor={searchInputID}>
468
+ <MagnifyingGlassIcon className="absolute text-gray-400 inset-y-0 start-0 h-10 w-10 p-2.5 aspect-square flex items-center pointer-events-none" />
469
+ </label>
470
+ <input
471
+ autoComplete="off"
472
+ spellCheck="false"
473
+ disabled={!enabled}
474
+ autoCapitalize="false"
475
+ className={classNames(
476
+ 'block flex-grow p-2 ps-10 placeholder-gray-400',
477
+ 'border border-gray-300 dark:border-gray-600',
478
+ 'rounded-lg bg-gray-50 dark:bg-gray-700',
479
+ 'focus:ring-blue-500 dark:focus:ring-blue-500',
480
+ 'focus:border-blue-500 dark:focus:border-blue-500',
481
+ 'dark:placeholder-gray-400',
482
+ { 'border-red-500': !enabled },
483
+ )}
484
+ id={searchInputID}
485
+ aria-labelledby={searchLabelID}
486
+ aria-controls={searchListID}
487
+ placeholder="Search"
488
+ type="search"
489
+ required
490
+ onChange={handleSearchChange}
491
+ onKeyDown={handleSearchKeyPress}
492
+ />
493
+ <Dialog.Close asChild className="block grow-0 sm:hidden">
494
+ <button aria-label="Close">
495
+ <XCircleIcon className="flex items-center w-10 h-10 aspect-square" />
496
+ </button>
497
+ </Dialog.Close>
498
+ </div>
499
+ </form>
500
+ {!enabled && (
501
+ <div className="mx-2 mt-4 text-sm text-gray-500">
502
+ Search is not enabled for this site. :(
503
+ </div>
504
+ )}
505
+ </>
506
+ );
507
+ }
508
+
509
+ interface SearchPlaceholderButtonProps {
510
+ className?: string;
511
+ disabled?: boolean;
512
+ }
513
+ const SearchPlaceholderButton = forwardRef<
514
+ HTMLButtonElement,
515
+ SearchPlaceholderButtonProps & Dialog.DialogTriggerProps
516
+ >(({ className, disabled, ...props }, ref) => {
517
+ return (
518
+ <button
519
+ {...props}
520
+ className={classNames(
521
+ className,
522
+ 'flex items-center h-10 aspect-square sm:w-64 text-left text-gray-400',
523
+ 'border border-gray-300 dark:border-gray-600',
524
+ 'rounded-lg bg-gray-50 dark:bg-gray-700',
525
+ {
526
+ 'hover:ring-blue-500': !disabled,
527
+ 'dark:hover:ring-blue-500': !disabled,
528
+ 'hover:border-blue-500': !disabled,
529
+ 'dark:hover:border-blue-500': !disabled,
530
+ },
531
+ )}
532
+ disabled={!!disabled}
533
+ ref={ref}
534
+ >
535
+ <MagnifyingGlassIcon className="p-2.5 h-10 w-10 aspect-square" />
536
+ <span className="hidden sm:block grow">Search</span>
537
+ <SearchShortcut />
538
+ </button>
539
+ );
540
+ });
541
+
542
+ export interface SearchProps {
543
+ debounceTime?: number;
544
+ }
545
+ /**
546
+ * Component that implements a basic search interface
547
+ */
548
+ export function Search({ debounceTime = 500 }: SearchProps) {
549
+ const [open, setOpen] = useState(false);
550
+ const [searchResults, setSearchResults] = useState<RankedSearchResult[] | undefined>();
551
+ const [selectedIndex, setSelectedIndex] = useState(0);
552
+ const top = useThemeTop();
553
+
554
+ // Clear search state on close
555
+ useEffect(() => {
556
+ if (!open) {
557
+ setSearchResults(undefined);
558
+ setSelectedIndex(0);
559
+ }
560
+ }, [open]);
561
+
562
+ // Trigger modal on keypress
563
+ const handleDocumentKeyPress = useCallback((event: KeyboardEvent) => {
564
+ if (event.key === 'k' && (isMac() ? event.metaKey : event.ctrlKey)) {
565
+ setOpen(true);
566
+ event.preventDefault();
567
+ }
568
+ }, []);
569
+
570
+ // Mount the document event handlers
571
+ useEffect(() => {
572
+ // attach the event listener
573
+ document.addEventListener('keydown', handleDocumentKeyPress);
574
+
575
+ // remove the event listener
576
+ return () => {
577
+ document.removeEventListener('keydown', handleDocumentKeyPress);
578
+ };
579
+ }, [handleDocumentKeyPress]);
580
+
581
+ const triggerClose = useCallback(() => setOpen(false), [setOpen]);
582
+ return (
583
+ <Dialog.Root open={open} onOpenChange={setOpen}>
584
+ <Dialog.Trigger asChild>
585
+ <SearchPlaceholderButton />
586
+ </Dialog.Trigger>
587
+ <Dialog.Portal>
588
+ <Dialog.Overlay className="fixed inset-0 bg-[#656c85cc] z-[1000]" />
589
+ <Dialog.Content
590
+ className="fixed flex flex-col top-0 bg-white dark:bg-stone-900 z-[1001] h-screen w-screen sm:left-1/2 sm:-translate-x-1/2 sm:w-[90vw] sm:max-w-screen-sm sm:h-auto sm:max-h-[var(--content-max-height)] sm:top-[var(--content-top)] sm:rounded-md p-4 text-gray-900 dark:text-white"
591
+ // Store state as CSS variables so that we can set the style with tailwind variants
592
+ style={
593
+ {
594
+ '--content-top': `${top}px`,
595
+ '--content-max-height': `calc(90vh - var(--content-top))`,
596
+ } as React.CSSProperties
597
+ }
598
+ >
599
+ <VisuallyHidden.Root asChild>
600
+ <Dialog.Title>Search Website</Dialog.Title>
601
+ </VisuallyHidden.Root>
602
+ <VisuallyHidden.Root asChild>
603
+ <Dialog.Description>
604
+ Search articles and their contents using fuzzy-search and prefix-matching
605
+ </Dialog.Description>
606
+ </VisuallyHidden.Root>
607
+ <SearchForm
608
+ searchListID="search-list"
609
+ searchLabelID="search-label"
610
+ searchInputID="search-input"
611
+ debounceTime={debounceTime}
612
+ searchResults={searchResults}
613
+ setSearchResults={setSearchResults}
614
+ selectedIndex={selectedIndex}
615
+ setSelectedIndex={setSelectedIndex}
616
+ closeSearch={triggerClose}
617
+ />
618
+ {searchResults && (
619
+ <SearchResults
620
+ searchListID="search-list"
621
+ searchLabelID="search-label"
622
+ className="mt-4"
623
+ searchResults={searchResults}
624
+ selectedIndex={selectedIndex}
625
+ onHoverSelect={setSelectedIndex}
626
+ closeSearch={triggerClose}
627
+ />
628
+ )}
629
+ </Dialog.Content>
630
+ </Dialog.Portal>
631
+ </Dialog.Root>
632
+ );
633
+ }
@@ -8,7 +8,7 @@ export function ThemeButton({ className = 'w-8 h-8 mx-3' }: { className?: string
8
8
  return (
9
9
  <button
10
10
  className={classNames(
11
- 'theme rounded-full border border-stone-700 dark:border-white hover:bg-neutral-100 border-solid overflow-hidden text-stone-700 dark:text-white hover:text-stone-500 dark:hover:text-neutral-800',
11
+ 'theme rounded-full aspect-square border border-stone-700 dark:border-white hover:bg-neutral-100 border-solid overflow-hidden text-stone-700 dark:text-white hover:text-stone-500 dark:hover:text-neutral-800',
12
12
  className,
13
13
  )}
14
14
  title={`Toggle theme between light and dark mode.`}
@@ -4,12 +4,8 @@ import { Menu, Transition } from '@headlessui/react';
4
4
  import { ChevronDownIcon, Bars3Icon as MenuIcon } from '@heroicons/react/24/solid';
5
5
  import type { SiteManifest, SiteNavItem } from 'myst-config';
6
6
  import { ThemeButton } from './ThemeButton.js';
7
- import {
8
- useLinkProvider,
9
- useNavLinkProvider,
10
- useNavOpen,
11
- useSiteManifest,
12
- } from '@myst-theme/providers';
7
+ import { Search } from './Search.js';
8
+ import { useNavLinkProvider, useNavOpen, useSiteManifest } from '@myst-theme/providers';
13
9
  import { LoadingBar } from './Loading.js';
14
10
  import { HomeLink } from './HomeLink.js';
15
11
  import { ActionMenu } from './ActionMenu.js';
@@ -107,7 +103,7 @@ export function NavItems({ nav }: { nav?: SiteManifest['nav'] }) {
107
103
  );
108
104
  }
109
105
 
110
- export function TopNav({ hideToc }: { hideToc?: boolean }) {
106
+ export function TopNav({ hideToc, hideSearch }: { hideToc?: boolean; hideSearch?: boolean }) {
111
107
  const [open, setOpen] = useNavOpen();
112
108
  const config = useSiteManifest();
113
109
  const { title, nav, actions } = config ?? {};
@@ -134,6 +130,7 @@ export function TopNav({ hideToc }: { hideToc?: boolean }) {
134
130
  <div className="flex items-center flex-grow w-auto">
135
131
  <NavItems nav={nav} />
136
132
  <div className="flex-grow block"></div>
133
+ {!hideSearch && <Search />}
137
134
  <ThemeButton />
138
135
  <div className="block sm:hidden">
139
136
  <ActionMenu actions={actions} />
@@ -3,10 +3,13 @@ import React, { useCallback } from 'react';
3
3
  function makeSkipClickHandler(hash: string) {
4
4
  return (e: React.UIEvent<HTMLElement, Event>) => {
5
5
  e.preventDefault();
6
- const el = document.querySelector(`#${hash}`);
6
+ const el = document.querySelector(`#${hash}`) as HTMLElement;
7
7
  if (!el) return;
8
8
  (el.nextSibling as HTMLElement).focus();
9
9
  history.replaceState(undefined, '', `#${hash}`);
10
+ // Changes keyboard tab-index location
11
+ if (el.tabIndex === -1) el.tabIndex = -1;
12
+ el.focus({ preventScroll: true });
10
13
  };
11
14
  }
12
15
 
@@ -42,7 +42,7 @@ export const ArticlePage = React.memo(function ({
42
42
  const downloads = combineDownloads(manifest?.downloads, article.frontmatter);
43
43
  const tree = copyNode(article.mdast);
44
44
  const keywords = article.frontmatter?.keywords ?? [];
45
- const parts = extractKnownParts(tree);
45
+ const parts = extractKnownParts(tree, article.frontmatter?.parts);
46
46
 
47
47
  return (
48
48
  <ReferencesProvider
@@ -8,7 +8,7 @@ export function ErrorUnhandled({ error }: { error: ErrorResponse }) {
8
8
  <>
9
9
  <h1>Unexpected Error Occurred</h1>
10
10
  <p>Status: {error.status}</p>
11
- <p>{error.data.message}</p>
11
+ <p>{error.data?.message ?? ''}</p>
12
12
  </>
13
13
  );
14
14
  }
@@ -20,6 +20,7 @@ import {
20
20
  NavLink,
21
21
  useRouteError,
22
22
  isRouteErrorResponse,
23
+ useNavigate,
23
24
  } from '@remix-run/react';
24
25
  import {
25
26
  DEFAULT_NAV_HEIGHT,
@@ -53,6 +54,7 @@ export function Document({
53
54
  top?: number;
54
55
  renderers?: NodeRenderers;
55
56
  }) {
57
+ const navigate = useNavigate();
56
58
  const links = staticBuild
57
59
  ? {
58
60
  Link: (props: any) => <Link {...{ ...props, reloadDocument: true }} />,
@@ -61,6 +63,7 @@ export function Document({
61
63
  : {
62
64
  Link: Link as any,
63
65
  NavLink: NavLink as any,
66
+ navigate,
64
67
  };
65
68
 
66
69
  // (Local) theme state driven by SSR and cookie/localStorage
package/src/utils.ts CHANGED
@@ -18,13 +18,21 @@ export type KnownParts = {
18
18
  acknowledgments?: GenericParent;
19
19
  };
20
20
 
21
- export function extractKnownParts(tree: GenericParent): KnownParts {
21
+ export function extractKnownParts(
22
+ tree: GenericParent,
23
+ parts?: Record<string, { mdast?: GenericParent }>,
24
+ ): KnownParts {
22
25
  const abstract = extractPart(tree, 'abstract');
23
26
  const summary = extractPart(tree, 'summary', { requireExplicitPart: true });
24
27
  const keypoints = extractPart(tree, ['keypoints'], { requireExplicitPart: true });
25
28
  const data_availability = extractPart(tree, ['data_availability', 'data availability']);
26
29
  const acknowledgments = extractPart(tree, ['acknowledgments', 'acknowledgements']);
27
- return { abstract, summary, keypoints, data_availability, acknowledgments };
30
+ const otherParts = Object.fromEntries(
31
+ Object.entries(parts ?? {}).map(([k, v]) => {
32
+ return [k, v.mdast];
33
+ }),
34
+ );
35
+ return { abstract, summary, keypoints, data_availability, acknowledgments, ...otherParts };
28
36
  }
29
37
 
30
38
  /**