@myst-theme/site 0.0.17

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.
Files changed (37) hide show
  1. package/package.json +53 -0
  2. package/src/components/Bibliography.tsx +51 -0
  3. package/src/components/ContentBlocks.tsx +20 -0
  4. package/src/components/ContentReload.tsx +66 -0
  5. package/src/components/DocumentOutline.tsx +187 -0
  6. package/src/components/ExternalOrInternalLink.tsx +30 -0
  7. package/src/components/FooterLinksBlock.tsx +39 -0
  8. package/src/components/Navigation/Loading.tsx +59 -0
  9. package/src/components/Navigation/Navigation.tsx +31 -0
  10. package/src/components/Navigation/TableOfContents.tsx +155 -0
  11. package/src/components/Navigation/ThemeButton.tsx +25 -0
  12. package/src/components/Navigation/TopNav.tsx +249 -0
  13. package/src/components/Navigation/index.tsx +5 -0
  14. package/src/components/index.ts +8 -0
  15. package/src/components/renderers.ts +7 -0
  16. package/src/hooks/index.ts +23 -0
  17. package/src/index.ts +8 -0
  18. package/src/loaders/cdn.server.ts +145 -0
  19. package/src/loaders/errors.server.ts +20 -0
  20. package/src/loaders/index.ts +5 -0
  21. package/src/loaders/links.ts +8 -0
  22. package/src/loaders/theme.server.ts +47 -0
  23. package/src/loaders/utils.ts +145 -0
  24. package/src/pages/Article.tsx +31 -0
  25. package/src/pages/ErrorDocumentNotFound.tsx +10 -0
  26. package/src/pages/ErrorProjectNotFound.tsx +10 -0
  27. package/src/pages/ErrorSiteNotFound.tsx +30 -0
  28. package/src/pages/Root.tsx +91 -0
  29. package/src/pages/index.ts +5 -0
  30. package/src/seo/analytics.tsx +34 -0
  31. package/src/seo/index.ts +4 -0
  32. package/src/seo/meta.ts +57 -0
  33. package/src/seo/robots.ts +24 -0
  34. package/src/seo/sitemap.ts +216 -0
  35. package/src/store.ts +21 -0
  36. package/src/types.ts +49 -0
  37. package/src/utils.ts +5 -0
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@myst-theme/site",
3
+ "version": "0.0.17",
4
+ "main": "./src/index.ts",
5
+ "types": "./src/index.ts",
6
+ "files": [
7
+ "src"
8
+ ],
9
+ "license": "MIT",
10
+ "sideEffects": false,
11
+ "scripts": {
12
+ "compile": "tsc",
13
+ "lint": "eslint src/**/*.ts*",
14
+ "lint:format": "prettier --check \"src/**/*.{ts,tsx,md}\""
15
+ },
16
+ "dependencies": {
17
+ "@curvenote/blocks": "^1.5.16",
18
+ "@curvenote/connect": "0.0.6",
19
+ "@curvenote/icons": "^0.0.3",
20
+ "@curvenote/nbtx": "^0.1.11",
21
+ "@curvenote/runtime": "^0.2.9",
22
+ "@curvenote/ui-providers": "^0.0.16",
23
+ "@headlessui/react": "^1.6.6",
24
+ "@heroicons/react": "^2.0.12",
25
+ "@myst-theme/frontmatter": "^0.1.19",
26
+ "classnames": "^2.3.2",
27
+ "lodash.throttle": "^4.1.1",
28
+ "myst-common": "^0.0.11",
29
+ "myst-config": "^0.0.6",
30
+ "myst-demo": "^0.1.19",
31
+ "myst-to-react": "^0.1.19",
32
+ "mystjs": "^0.0.15",
33
+ "node-cache": "^5.1.2",
34
+ "node-fetch": "^2.6.7",
35
+ "unist-util-select": "^4.0.1"
36
+ },
37
+ "peerDependencies": {
38
+ "@remix-run/node": "^1.7.5",
39
+ "@remix-run/react": "^1.7.5",
40
+ "@types/react": "^16.8 || ^17.0 || ^18.0",
41
+ "@types/react-dom": "^16.8 || ^17.0 || ^18.0",
42
+ "react": "^16.8 || ^17.0 || ^18.0",
43
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
44
+ },
45
+ "devDependencies": {
46
+ "@types/lodash.throttle": "^4.1.7",
47
+ "@types/node-fetch": "^2.6.2",
48
+ "eslint": "^8.21.0",
49
+ "eslint-config-curvenote": "latest",
50
+ "tsconfig": "latest",
51
+ "typescript": "latest"
52
+ }
53
+ }
@@ -0,0 +1,51 @@
1
+ import { useReferences } from '@myst-theme/providers';
2
+ import { useState } from 'react';
3
+
4
+ const HIDE_OVER_N_REFERENCES = 5;
5
+
6
+ export function Bibliography() {
7
+ const references = useReferences();
8
+ const { order, data } = references?.cite ?? {};
9
+ const filtered = order?.filter((l) => l);
10
+ const [hidden, setHidden] = useState(true);
11
+ if (!filtered || !data || filtered.length === 0) return null;
12
+ const refs = hidden ? filtered.slice(0, HIDE_OVER_N_REFERENCES) : filtered;
13
+ return (
14
+ <section>
15
+ {filtered.length > HIDE_OVER_N_REFERENCES && (
16
+ <button
17
+ onClick={() => setHidden(!hidden)}
18
+ className="float-right text-xs p-1 px-2 border rounded hover:border-blue-500 dark:hover:border-blue-400"
19
+ >
20
+ {hidden ? 'Show All' : 'Collapse'}
21
+ </button>
22
+ )}
23
+ <header className="text-lg font-semibold text-stone-900 dark:text-white">References</header>
24
+ <div className="text-xs mb-8 pl-3 text-stone-500 dark:text-stone-300">
25
+ <ol>
26
+ {refs.map((label) => {
27
+ const { html } = data[label];
28
+ return (
29
+ <li
30
+ key={label}
31
+ className="break-words"
32
+ id={`cite-${label}`}
33
+ dangerouslySetInnerHTML={{ __html: html || '' }}
34
+ />
35
+ );
36
+ })}
37
+ {filtered.length > HIDE_OVER_N_REFERENCES && (
38
+ <li className="list-none text-center">
39
+ <button
40
+ onClick={() => setHidden(!hidden)}
41
+ className="p-2 border rounded hover:border-blue-500 dark:hover:border-blue-400"
42
+ >
43
+ {hidden ? `Show all ${filtered.length} references` : 'Collapse references'}
44
+ </button>
45
+ </li>
46
+ )}
47
+ </ol>
48
+ </div>
49
+ </section>
50
+ );
51
+ }
@@ -0,0 +1,20 @@
1
+ import { useParse, DEFAULT_RENDERERS } from 'myst-to-react';
2
+ import type { Parent } from 'myst-spec';
3
+ import { useNodeRenderers } from '@myst-theme/providers';
4
+
5
+ function Block({ id, node }: { id: string; node: Parent }) {
6
+ const renderers = useNodeRenderers() ?? DEFAULT_RENDERERS;
7
+ const children = useParse(node, renderers);
8
+ return <div id={id}>{children}</div>;
9
+ }
10
+
11
+ export function ContentBlocks({ mdast }: { mdast: Parent }) {
12
+ const blocks = mdast.children as Parent[];
13
+ return (
14
+ <>
15
+ {blocks.map((node, index) => {
16
+ return <Block key={(node as any).key} id={`${index}`} node={node} />;
17
+ })}
18
+ </>
19
+ );
20
+ }
@@ -0,0 +1,66 @@
1
+ import { useEffect } from 'react';
2
+
3
+ const STORAGE_KEY = 'myst';
4
+
5
+ async function mystLiveReloadConnect(config: { onOpen?: () => void; port?: string | number }) {
6
+ if (!config.port || (window as any).mystLiveReloadConnected) return;
7
+ (window as any).mystLiveReloadConnected = true;
8
+ setTimeout(() => {
9
+ const myst = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}');
10
+ if (myst.scroll) {
11
+ window.scrollTo(0, myst.scroll);
12
+ sessionStorage.removeItem(STORAGE_KEY);
13
+ }
14
+ }, 30);
15
+ console.log(`🔊 Listening to live content changes on port ${config.port}`);
16
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
17
+ const host = location.hostname;
18
+ const socketPath = `${protocol}//${host}:${config.port}/socket`;
19
+ const ws = new WebSocket(socketPath);
20
+
21
+ ws.onmessage = (message) => {
22
+ const event = JSON.parse(message.data);
23
+ if (event.type === 'LOG') {
24
+ console.log(event.message);
25
+ }
26
+ if (event.type === 'RELOAD') {
27
+ console.log('🚀 Reloading window ...');
28
+ console.log(`📌 Keeping scroll for page at ${window.scrollY}`);
29
+ const myst = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}');
30
+ myst.scroll = window.scrollY;
31
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(myst));
32
+ window.location.reload();
33
+ }
34
+ };
35
+ ws.onopen = () => {
36
+ if (config && typeof config.onOpen === 'function') {
37
+ config.onOpen();
38
+ }
39
+ };
40
+ ws.onclose = () => {
41
+ console.log('MyST content server web socket closed. Reconnecting...');
42
+ setTimeout(
43
+ () =>
44
+ mystLiveReloadConnect({
45
+ ...config,
46
+ onOpen: () => window.location.reload(),
47
+ }),
48
+ 1000,
49
+ );
50
+ };
51
+ ws.onerror = (error: any) => {
52
+ console.log('MyST content server web socket error:');
53
+ console.error(error);
54
+ };
55
+ }
56
+
57
+ // Inspired by the LiveReload component in Remix
58
+ export const ContentReload =
59
+ process.env.NODE_ENV !== 'development'
60
+ ? () => null
61
+ : ({ port }: { port?: string | number }) => {
62
+ useEffect(() => {
63
+ mystLiveReloadConnect({ port });
64
+ }, []);
65
+ return null;
66
+ };
@@ -0,0 +1,187 @@
1
+ import classNames from 'classnames';
2
+ import throttle from 'lodash.throttle';
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+
5
+ const SELECTOR = [1, 2, 3, 4, 5, 6].map((n) => `main h${n}`).join(', ');
6
+ const HIGHLIGHT_CLASS = 'highlight';
7
+
8
+ const onClient = typeof document !== 'undefined';
9
+
10
+ type Heading = {
11
+ id: string;
12
+ title: string;
13
+ titleHTML: string;
14
+ level: number;
15
+ };
16
+ type Props = {
17
+ headings: Heading[];
18
+ activeId?: string;
19
+ highlight?: () => void;
20
+ };
21
+ /**
22
+ * This renders an item in the table of contents list.
23
+ * scrollIntoView is used to ensure that when a user clicks on an item, it will smoothly scroll.
24
+ */
25
+ const Headings = ({ headings, activeId, highlight }: Props) => (
26
+ <ul className="text-slate-400 text-sm leading-6">
27
+ {headings.map((heading) => (
28
+ <li
29
+ key={heading.id}
30
+ className={classNames('border-l-2', {
31
+ 'text-blue-600': heading.id === activeId,
32
+ 'border-l-gray-300 dark:border-l-gray-50 hover:border-l-blue-500':
33
+ heading.id !== activeId,
34
+ 'border-l-blue-500': heading.id === activeId,
35
+ 'bg-blue-50 dark:bg-slate-800': heading.id === activeId,
36
+ })}
37
+ >
38
+ <a
39
+ className={classNames('block p-1 pl-2 text-slate-800 dark:text-slate-100', {
40
+ 'text-blue-600 dark:text-white font-semibold': heading.id === activeId,
41
+ 'pr-2': heading.id !== activeId,
42
+ 'pl-3': heading.level === 2,
43
+ 'pl-4': heading.level === 3,
44
+ 'pl-5': heading.level === 4,
45
+ 'pl-6': heading.level === 5,
46
+ 'pl-7': heading.level === 6,
47
+ })}
48
+ href={`#${heading.id}`}
49
+ onClick={(e) => {
50
+ e.preventDefault();
51
+ const el = document.querySelector(`#${heading.id}`);
52
+ if (!el) return;
53
+ getHeaders().forEach((h) => {
54
+ h.classList.remove(HIGHLIGHT_CLASS);
55
+ });
56
+ el.classList.add(HIGHLIGHT_CLASS);
57
+ highlight?.();
58
+ el.scrollIntoView({ behavior: 'smooth' });
59
+ }}
60
+ // Note that the title can have math in it!
61
+ dangerouslySetInnerHTML={{ __html: heading.titleHTML }}
62
+ />
63
+ </li>
64
+ ))}
65
+ </ul>
66
+ );
67
+
68
+ function getHeaders(): HTMLHeadingElement[] {
69
+ const headers = Array.from(document.querySelectorAll(SELECTOR)).filter((e) => {
70
+ const parent = e.closest('.exclude-from-outline');
71
+ return !(e.classList.contains('title') || parent);
72
+ });
73
+ return headers as HTMLHeadingElement[];
74
+ }
75
+
76
+ function useHeaders() {
77
+ if (!onClient) return { activeId: '', headings: [] };
78
+ const onScreen = useRef<Set<HTMLHeadingElement>>(new Set());
79
+ const [activeId, setActiveId] = useState<string>();
80
+ const headingsSet = useRef<Set<HTMLHeadingElement>>(new Set());
81
+
82
+ const highlight = useCallback(() => {
83
+ const current = [...onScreen.current];
84
+ const highlighted = current.reduce((a, b) => {
85
+ if (a) return a;
86
+ if (b.classList.contains('highlight')) return b.id;
87
+ return null;
88
+ }, null as string | null);
89
+ const active = [...onScreen.current].sort((a, b) => a.offsetTop - b.offsetTop)[0];
90
+ if (highlighted || active) setActiveId(highlighted || active.id);
91
+ }, []);
92
+
93
+ const { observer } = useIntersectionObserver(highlight, onScreen.current);
94
+ const [elements, setElements] = useState<HTMLHeadingElement[]>([]);
95
+
96
+ const render = throttle(() => setElements(getHeaders()), 500);
97
+ useEffect(() => {
98
+ // We have to look at the document changes for reloads/mutations
99
+ const main = document.querySelector('main');
100
+ const mutations = new MutationObserver(render);
101
+ // Fire when added to the dom
102
+ render();
103
+ if (main) {
104
+ mutations.observe(main, { attributes: true, childList: true, subtree: true });
105
+ }
106
+ return () => mutations.disconnect();
107
+ }, []);
108
+
109
+ useEffect(() => {
110
+ // Re-observe all elements when the observer changes
111
+ Array.from(elements).map((e) => observer.current?.observe(e));
112
+ }, [observer]);
113
+
114
+ elements.forEach((e) => {
115
+ if (headingsSet.current.has(e)) return;
116
+ observer.current?.observe(e);
117
+ headingsSet.current.add(e);
118
+ });
119
+
120
+ const headings: Heading[] = elements
121
+ .map((heading) => {
122
+ return {
123
+ level: Number(heading.tagName.slice(1)),
124
+ id: heading.id,
125
+ text: heading.querySelector('.heading-text'),
126
+ };
127
+ })
128
+ .filter((h) => !!h.text)
129
+ .map(({ level, text, id }) => {
130
+ const { innerText: title, innerHTML: titleHTML } = text as HTMLSpanElement;
131
+ return { title, titleHTML, id, level };
132
+ });
133
+
134
+ return { activeId, highlight, headings };
135
+ }
136
+
137
+ const useIntersectionObserver = (highlight: () => void, onScreen: Set<HTMLHeadingElement>) => {
138
+ const observer = useRef<IntersectionObserver | null>(null);
139
+ if (!onClient) return { observer };
140
+ useEffect(() => {
141
+ const callback: IntersectionObserverCallback = (entries) => {
142
+ entries.forEach((entry) => {
143
+ onScreen[entry.isIntersecting ? 'add' : 'delete'](entry.target as HTMLHeadingElement);
144
+ });
145
+ highlight();
146
+ };
147
+ const o = new IntersectionObserver(callback);
148
+ observer.current = o;
149
+ return () => o.disconnect();
150
+ }, [highlight, onScreen]);
151
+ return { observer };
152
+ };
153
+
154
+ export const DocumentOutline = ({
155
+ top,
156
+ height,
157
+ className = 'document-outline',
158
+ }: {
159
+ top?: number;
160
+ height?: number;
161
+ className?: string;
162
+ }) => {
163
+ const { activeId, headings, highlight } = useHeaders();
164
+ if (height && height < 50) return null;
165
+ if (headings.length <= 1) return <nav suppressHydrationWarning />;
166
+ return (
167
+ <nav
168
+ aria-label="Document Outline"
169
+ suppressHydrationWarning
170
+ className={classNames('not-prose transition-opacity', className)}
171
+ style={{
172
+ top: top ?? 0,
173
+ height:
174
+ typeof document === 'undefined' || (height && height > window.innerHeight)
175
+ ? undefined
176
+ : height,
177
+ opacity: height && height > 300 ? undefined : 0,
178
+ pointerEvents: height && height > 300 ? undefined : 'none',
179
+ }}
180
+ >
181
+ <div className="text-slate-900 mb-4 text-sm leading-6 dark:text-slate-100 uppercase">
182
+ In this article
183
+ </div>
184
+ {onClient && <Headings headings={headings} activeId={activeId} highlight={highlight} />}
185
+ </nav>
186
+ );
187
+ };
@@ -0,0 +1,30 @@
1
+ import { Link } from '@remix-run/react';
2
+
3
+ export function ExternalOrInternalLink({
4
+ to,
5
+ className,
6
+ isStatic,
7
+ prefetch = 'intent',
8
+ title,
9
+ children,
10
+ }: {
11
+ to: string;
12
+ className?: string;
13
+ isStatic?: boolean;
14
+ prefetch?: 'intent' | 'render' | 'none';
15
+ title?: string;
16
+ children: React.ReactNode;
17
+ }) {
18
+ if (to.startsWith('http') || isStatic) {
19
+ return (
20
+ <a href={to} className={className} target="_blank" rel="noopener noreferrer" title={title}>
21
+ {children}
22
+ </a>
23
+ );
24
+ }
25
+ return (
26
+ <Link to={to} className={className} prefetch={prefetch} title={title}>
27
+ {children}
28
+ </Link>
29
+ );
30
+ }
@@ -0,0 +1,39 @@
1
+ import classNames from 'classnames';
2
+ import { Link } from '@remix-run/react';
3
+ import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
4
+ import type { FooterLinks, NavigationLink } from '../types';
5
+ import { useUrlbase, withUrlbase } from '@myst-theme/providers';
6
+
7
+ const FooterLink = ({ title, url, group, right }: NavigationLink & { right?: boolean }) => {
8
+ const urlbase = useUrlbase();
9
+ return (
10
+ <Link
11
+ prefetch="intent"
12
+ className="group flex-1 p-4 block border font-normal hover:border-blue-600 dark:hover:border-blue-400 no-underline hover:text-blue-600 dark:hover:text-blue-400 text-gray-600 dark:text-gray-100 border-gray-200 dark:border-gray-500 rounded shadow-sm hover:shadow-lg dark:shadow-neutral-700"
13
+ to={withUrlbase(url, urlbase)}
14
+ >
15
+ <div className="flex align-middle h-full">
16
+ {right && (
17
+ <ArrowLeftIcon className="w-6 h-6 self-center transition-transform group-hover:-translate-x-1" />
18
+ )}
19
+ <div className={classNames('flex-grow', { 'text-right': right })}>
20
+ <div className="text-xs text-gray-500 dark:text-gray-400">{group || ' '}</div>
21
+ {title}
22
+ </div>
23
+ {!right && (
24
+ <ArrowRightIcon className="w-6 h-6 self-center transition-transform group-hover:translate-x-1" />
25
+ )}
26
+ </div>
27
+ </Link>
28
+ );
29
+ };
30
+
31
+ export function FooterLinksBlock({ links }: { links?: FooterLinks }) {
32
+ if (!links) return null;
33
+ return (
34
+ <div className="flex space-x-4 my-10">
35
+ {links.navigation?.prev && <FooterLink {...links.navigation?.prev} right />}
36
+ {links.navigation?.next && <FooterLink {...links.navigation?.next} />}
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,59 @@
1
+ import { useTransition } from '@remix-run/react';
2
+ import { useEffect, useMemo, useState } from 'react';
3
+ import classNames from 'classnames';
4
+
5
+ /**
6
+ * Show a loading progess bad if the load takes more than 150ms
7
+ */
8
+ function useLoading() {
9
+ const transitionState = useTransition().state;
10
+ const ref = useMemo<{ start?: NodeJS.Timeout; finish?: NodeJS.Timeout }>(() => ({}), []);
11
+ const [showLoading, setShowLoading] = useState(false);
12
+
13
+ useEffect(() => {
14
+ if (transitionState === 'loading') {
15
+ ref.start = setTimeout(() => {
16
+ setShowLoading(true);
17
+ }, 150);
18
+ } else {
19
+ if (ref.start) {
20
+ // We have stoped loading in <150ms
21
+ clearTimeout(ref.start);
22
+ delete ref.start;
23
+ setShowLoading(false);
24
+ return;
25
+ }
26
+ ref.finish = setTimeout(() => {
27
+ setShowLoading(false);
28
+ }, 150);
29
+ }
30
+ return () => {
31
+ if (ref.start) {
32
+ clearTimeout(ref.start);
33
+ delete ref.start;
34
+ }
35
+ if (ref.finish) {
36
+ clearTimeout(ref.finish);
37
+ delete ref.finish;
38
+ }
39
+ };
40
+ }, [transitionState]);
41
+
42
+ return { showLoading, isLoading: transitionState === 'loading' };
43
+ }
44
+
45
+ export function LoadingBar() {
46
+ const { isLoading, showLoading } = useLoading();
47
+ if (!showLoading) return null;
48
+ return (
49
+ <div
50
+ className={classNames(
51
+ 'w-screen h-[2px] bg-blue-500 absolute left-0 bottom-0 transition-transform',
52
+ {
53
+ 'animate-load scale-x-40': isLoading,
54
+ 'scale-x-100': !isLoading,
55
+ },
56
+ )}
57
+ />
58
+ );
59
+ }
@@ -0,0 +1,31 @@
1
+ import { useNavOpen } from '@myst-theme/providers';
2
+ import { TableOfContents } from './TableOfContents';
3
+
4
+ export function Navigation({
5
+ children,
6
+ projectSlug,
7
+ top,
8
+ height,
9
+ hide_toc,
10
+ }: {
11
+ children?: React.ReactNode;
12
+ projectSlug?: string;
13
+ top?: number;
14
+ height?: number;
15
+ hide_toc?: boolean;
16
+ }) {
17
+ const [open, setOpen] = useNavOpen();
18
+ if (hide_toc) return <>{children}</>;
19
+ return (
20
+ <>
21
+ {open && (
22
+ <div
23
+ className="fixed inset-0 bg-black opacity-50 z-30"
24
+ onClick={() => setOpen(false)}
25
+ ></div>
26
+ )}
27
+ {children}
28
+ <TableOfContents projectSlug={projectSlug} top={top} height={height} />
29
+ </>
30
+ );
31
+ }