@myst-theme/site 0.10.0 → 0.12.0

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.10.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -21,23 +21,23 @@
21
21
  "dependencies": {
22
22
  "@headlessui/react": "^1.7.15",
23
23
  "@heroicons/react": "^2.0.18",
24
- "@myst-theme/common": "^0.10.0",
25
- "@myst-theme/diagrams": "^0.10.0",
26
- "@myst-theme/frontmatter": "^0.10.0",
27
- "@myst-theme/jupyter": "^0.10.0",
28
- "@myst-theme/providers": "^0.10.0",
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",
29
29
  "@radix-ui/react-collapsible": "^1.0.3",
30
30
  "classnames": "^2.3.2",
31
31
  "lodash.throttle": "^4.1.1",
32
- "myst-common": "^1.5.0",
33
- "myst-config": "^1.5.0",
34
- "myst-demo": "^0.10.0",
35
- "myst-spec-ext": "^1.5.0",
36
- "myst-to-react": "^0.10.0",
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",
37
37
  "nbtx": "^0.2.3",
38
38
  "node-cache": "^5.1.2",
39
39
  "node-fetch": "^2.6.11",
40
- "thebe-react": "0.4.7",
40
+ "thebe-react": "0.4.10",
41
41
  "unist-util-select": "^4.0.1"
42
42
  },
43
43
  "peerDependencies": {
@@ -0,0 +1 @@
1
+ export * from './theme.js';
@@ -0,0 +1,8 @@
1
+ import type { Theme } from '@myst-theme/common';
2
+
3
+ export function postThemeToAPI(theme: Theme) {
4
+ const xmlhttp = new XMLHttpRequest();
5
+ xmlhttp.open('POST', '/api/theme');
6
+ xmlhttp.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
7
+ xmlhttp.send(JSON.stringify({ theme }));
8
+ }
@@ -6,6 +6,7 @@ import {
6
6
  useSiteManifest,
7
7
  useGridSystemProvider,
8
8
  useThemeTop,
9
+ useIsWide,
9
10
  } from '@myst-theme/providers';
10
11
  import type { Heading } from '@myst-theme/common';
11
12
  import { Toc } from './TableOfContentsItems.js';
@@ -95,10 +96,15 @@ export function useSidebarHeight<T extends HTMLElement = HTMLElement>(top = 0, i
95
96
  const container = useRef<T>(null);
96
97
  const toc = useRef<HTMLDivElement>(null);
97
98
  const transitionState = useNavigation().state;
99
+ const wide = useIsWide();
98
100
  const setHeight = () => {
99
101
  if (!container.current || !toc.current) return;
100
102
  const height = container.current.offsetHeight - window.scrollY;
101
103
  const div = toc.current.firstChild as HTMLDivElement;
104
+ if (div)
105
+ div.style.height = wide
106
+ ? `min(calc(100vh - ${top}px), ${height + inset}px)`
107
+ : `calc(100vh - ${top}px)`;
102
108
  if (div) div.style.height = `min(calc(100vh - ${top}px), ${height + inset}px)`;
103
109
  const nav = toc.current.querySelector('nav');
104
110
  if (nav) nav.style.opacity = height > 150 ? '1' : '0';
@@ -111,7 +117,7 @@ export function useSidebarHeight<T extends HTMLElement = HTMLElement>(top = 0, i
111
117
  return () => {
112
118
  window.removeEventListener('scroll', handleScroll);
113
119
  };
114
- }, [container, toc, transitionState]);
120
+ }, [container, toc, transitionState, wide]);
115
121
  return { container, toc };
116
122
  }
117
123
 
@@ -1,25 +1,22 @@
1
- import { useTheme } from '@myst-theme/providers';
1
+ import { useThemeSwitcher } from '@myst-theme/providers';
2
2
  import { MoonIcon } from '@heroicons/react/24/solid';
3
3
  import { SunIcon } from '@heroicons/react/24/outline';
4
4
  import classNames from 'classnames';
5
5
 
6
6
  export function ThemeButton({ className = 'w-8 h-8 mx-3' }: { className?: string }) {
7
- const { isDark, nextTheme } = useTheme();
7
+ const { nextTheme } = useThemeSwitcher();
8
8
  return (
9
9
  <button
10
10
  className={classNames(
11
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',
12
12
  className,
13
13
  )}
14
- title={`Change theme to ${isDark ? 'light' : 'dark'} mode.`}
15
- aria-label={`Change theme to ${isDark ? 'light' : 'dark'} mode.`}
14
+ title={`Toggle theme between light and dark mode.`}
15
+ aria-label={`Toggle theme between light and dark mode.`}
16
16
  onClick={nextTheme}
17
17
  >
18
- {isDark ? (
19
- <MoonIcon className="h-full w-full p-0.5" />
20
- ) : (
21
- <SunIcon className="h-full w-full p-0.5" />
22
- )}
18
+ <MoonIcon className="h-full w-full p-0.5 hidden dark:block" />
19
+ <SunIcon className="h-full w-full p-0.5 dark:hidden" />
23
20
  </button>
24
21
  );
25
22
  }
@@ -18,3 +18,4 @@ export { ExternalOrInternalLink } from './ExternalOrInternalLink.js';
18
18
  export * from './Navigation/index.js';
19
19
  export { renderers } from './renderers.js';
20
20
  export { SkipToArticle, SkipTo } from './SkipToArticle.js';
21
+ export { BlockingThemeLoader } from './theme.js';
@@ -1,10 +1,10 @@
1
- import type { NodeRenderer } from '@myst-theme/providers';
1
+ import type { NodeRenderers } from '@myst-theme/providers';
2
2
  import { DEFAULT_RENDERERS } from 'myst-to-react';
3
3
  import { MystDemoRenderer } from 'myst-demo';
4
4
  import { MermaidNodeRenderer } from '@myst-theme/diagrams';
5
5
  import OUTPUT_RENDERERS from '@myst-theme/jupyter';
6
6
 
7
- export const renderers: Record<string, NodeRenderer> = {
7
+ export const renderers: NodeRenderers = {
8
8
  ...DEFAULT_RENDERERS,
9
9
  myst: MystDemoRenderer,
10
10
  mermaid: MermaidNodeRenderer,
@@ -0,0 +1,19 @@
1
+ import { THEME_LOCALSTORAGE_KEY, PREFERS_LIGHT_MQ } from '../hooks/theme.js';
2
+
3
+ /**
4
+ * A blocking element that runs on the client before hydration to update the <html> preferred class
5
+ * This ensures that the hydrated state matches the non-hydrated state (by updating the DOM on the
6
+ * client between SSR on the server and hydration on the client)
7
+ */
8
+ export function BlockingThemeLoader({ useLocalStorage }: { useLocalStorage: boolean }) {
9
+ const LOCAL_STORAGE_SOURCE = `localStorage.getItem(${JSON.stringify(THEME_LOCALSTORAGE_KEY)})`;
10
+ const CLIENT_THEME_SOURCE = `
11
+ const savedTheme = ${useLocalStorage ? LOCAL_STORAGE_SOURCE : 'null'};
12
+ const theme = window.matchMedia(${JSON.stringify(PREFERS_LIGHT_MQ)}).matches ? 'light' : 'dark';
13
+ const classes = document.documentElement.classList;
14
+ const hasAnyTheme = classes.contains('light') || classes.contains('dark');
15
+ if (!hasAnyTheme) classes.add(savedTheme ?? theme);
16
+ `;
17
+
18
+ return <script dangerouslySetInnerHTML={{ __html: CLIENT_THEME_SOURCE }} />;
19
+ }
@@ -0,0 +1 @@
1
+ export * from './theme.js';
@@ -0,0 +1,84 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { Theme } from '@myst-theme/common';
3
+ import { isTheme } from '@myst-theme/providers';
4
+ import { postThemeToAPI } from '../actions/theme.js';
5
+
6
+ export const PREFERS_LIGHT_MQ = '(prefers-color-scheme: light)';
7
+ export const THEME_LOCALSTORAGE_KEY = 'myst:theme';
8
+
9
+ export function getPreferredTheme() {
10
+ if (typeof window !== 'object') {
11
+ return null;
12
+ }
13
+ const mediaQuery = window.matchMedia(PREFERS_LIGHT_MQ);
14
+ return mediaQuery.matches ? Theme.light : Theme.dark;
15
+ }
16
+
17
+ /**
18
+ * Hook that changes theme to follow changes to system preference.
19
+ */
20
+ export function usePreferredTheme({ setTheme }: { setTheme: (theme: Theme | null) => void }) {
21
+ // Listen for system-updates that change the preferred theme
22
+ // This will modify the saved theme
23
+ useEffect(() => {
24
+ const mediaQuery = window.matchMedia(PREFERS_LIGHT_MQ);
25
+ const handleChange = () => {
26
+ setTheme(mediaQuery.matches ? Theme.light : Theme.dark);
27
+ };
28
+ mediaQuery.addEventListener('change', handleChange);
29
+ return () => mediaQuery.removeEventListener('change', handleChange);
30
+ }, []);
31
+ }
32
+
33
+ export function useTheme({
34
+ ssrTheme,
35
+ useLocalStorage,
36
+ }: {
37
+ ssrTheme?: Theme;
38
+ useLocalStorage?: boolean;
39
+ }): [Theme | null, (theme: Theme) => void] {
40
+ // Here, the initial state on the server without any set cookies will be null.
41
+ // The client will then load the initial state as non-null.
42
+ // Thus, we must mutate the DOM *pre-hydration* to ensure that the initial state is
43
+ // identical to that of the hydrated state, i.e. perform out-of-react DOM updates
44
+ // This is handled by the BlockingThemeLoader component.
45
+ const [theme, setTheme] = React.useState<Theme | null>(() => {
46
+ if (isTheme(ssrTheme)) {
47
+ return ssrTheme;
48
+ }
49
+ // On the server we can't know what the preferred theme is, so leave it up to client
50
+ if (typeof window !== 'object') {
51
+ return null;
52
+ }
53
+ // System preferred theme
54
+ const preferredTheme = getPreferredTheme();
55
+
56
+ // Local storage preferred theme
57
+ const savedTheme = localStorage.getItem(THEME_LOCALSTORAGE_KEY);
58
+ return useLocalStorage && isTheme(savedTheme) ? savedTheme : preferredTheme;
59
+ });
60
+
61
+ // Listen for system-updates that change the preferred theme
62
+ usePreferredTheme({ setTheme });
63
+
64
+ // Listen for changes to theme, and propagate to server
65
+ // This should be unidirectional; updates to the cookie do not trigger document rerenders
66
+ const mountRun = useRef(false);
67
+ useEffect(() => {
68
+ // Only update after the component is mounted (i.e. don't send initial state)
69
+ if (!mountRun.current) {
70
+ mountRun.current = true;
71
+ return;
72
+ }
73
+ if (!isTheme(theme)) {
74
+ return;
75
+ }
76
+ if (useLocalStorage) {
77
+ localStorage.setItem(THEME_LOCALSTORAGE_KEY, theme);
78
+ } else {
79
+ postThemeToAPI(theme);
80
+ }
81
+ }, [theme]);
82
+
83
+ return [theme, setTheme];
84
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from './utils.js';
2
2
  export * from './loaders/index.js';
3
3
  export * from './components/index.js';
4
+ export * from './hooks/index.js';
4
5
  export * from './pages/index.js';
5
6
  export * from './seo/index.js';
6
7
  export * from './themeCSS.js';
8
+ export * from './actions/index.js';
@@ -1,5 +1,6 @@
1
1
  import { createCookieSessionStorage, json } from '@remix-run/node';
2
- import { isTheme, Theme } from '@myst-theme/providers';
2
+ import { isTheme } from '@myst-theme/providers';
3
+ import type { Theme } from '@myst-theme/providers';
3
4
  import type { ActionFunction } from '@remix-run/node';
4
5
 
5
6
  export const themeStorage = createCookieSessionStorage({
@@ -18,7 +19,7 @@ async function getThemeSession(request: Request) {
18
19
  return {
19
20
  getTheme: () => {
20
21
  const themeValue = session.get('theme');
21
- return isTheme(themeValue) ? themeValue : Theme.light;
22
+ return isTheme(themeValue) ? themeValue : undefined;
22
23
  },
23
24
  setTheme: (theme: Theme) => session.set('theme', theme),
24
25
  commit: () => themeStorage.commitSession(session, { expires: new Date('2100-01-01') }),
@@ -7,8 +7,6 @@ import {
7
7
  FrontmatterParts,
8
8
  BackmatterParts,
9
9
  } from '../components/index.js';
10
- import { ErrorDocumentNotFound } from './ErrorDocumentNotFound.js';
11
- import { ErrorProjectNotFound } from './ErrorProjectNotFound.js';
12
10
  import type { PageLoader } from '@myst-theme/common';
13
11
  import { copyNode, type GenericParent } from 'myst-common';
14
12
  import { SourceFileKind } from 'myst-spec-ext';
@@ -80,11 +78,3 @@ export const ArticlePage = React.memo(function ({
80
78
  </ReferencesProvider>
81
79
  );
82
80
  });
83
-
84
- export function ProjectPageCatchBoundary() {
85
- return <ErrorProjectNotFound />;
86
- }
87
-
88
- export function ArticlePageCatchBoundary() {
89
- return <ErrorDocumentNotFound />;
90
- }
@@ -0,0 +1,14 @@
1
+ export type ErrorResponse = {
2
+ status: number;
3
+ statusText: string;
4
+ data: any;
5
+ };
6
+ export function ErrorUnhandled({ error }: { error: ErrorResponse }) {
7
+ return (
8
+ <>
9
+ <h1>Unexpected Error Occurred</h1>
10
+ <p>Status: {error.status}</p>
11
+ <p>{error.data.message}</p>
12
+ </>
13
+ );
14
+ }
@@ -1,7 +1,13 @@
1
1
  import type { SiteManifest } from 'myst-config';
2
2
  import type { SiteLoader } from '@myst-theme/common';
3
- import type { NodeRenderer } from '@myst-theme/providers';
4
- import { BaseUrlProvider, SiteProvider, Theme, ThemeProvider } from '@myst-theme/providers';
3
+ import type { NodeRenderers } from '@myst-theme/providers';
4
+ import {
5
+ BaseUrlProvider,
6
+ SiteProvider,
7
+ Theme,
8
+ ThemeProvider,
9
+ useThemeSwitcher,
10
+ } from '@myst-theme/providers';
5
11
  import {
6
12
  Links,
7
13
  LiveReload,
@@ -12,16 +18,24 @@ import {
12
18
  useLoaderData,
13
19
  Link,
14
20
  NavLink,
21
+ useRouteError,
22
+ isRouteErrorResponse,
15
23
  } from '@remix-run/react';
16
- import { DEFAULT_NAV_HEIGHT, renderers as defaultRenderers } from '../components/index.js';
24
+ import {
25
+ DEFAULT_NAV_HEIGHT,
26
+ renderers as defaultRenderers,
27
+ BlockingThemeLoader,
28
+ } from '../components/index.js';
29
+ import { useTheme } from '../hooks/index.js';
17
30
  import { Analytics } from '../seo/index.js';
18
31
  import { Error404 } from './Error404.js';
32
+ import { ErrorUnhandled } from './ErrorUnhandled.js';
19
33
  import classNames from 'classnames';
20
34
 
21
35
  export function Document({
22
36
  children,
23
37
  scripts,
24
- theme,
38
+ theme: ssrTheme,
25
39
  config,
26
40
  title,
27
41
  staticBuild,
@@ -31,13 +45,13 @@ export function Document({
31
45
  }: {
32
46
  children: React.ReactNode;
33
47
  scripts?: React.ReactNode;
34
- theme: Theme;
48
+ theme?: Theme;
35
49
  config?: SiteManifest;
36
50
  title?: string;
37
51
  staticBuild?: boolean;
38
52
  baseurl?: string;
39
53
  top?: number;
40
- renderers?: Record<string, NodeRenderer>;
54
+ renderers?: NodeRenderers;
41
55
  }) {
42
56
  const links = staticBuild
43
57
  ? {
@@ -49,7 +63,62 @@ export function Document({
49
63
  NavLink: NavLink as any,
50
64
  };
51
65
 
66
+ // (Local) theme state driven by SSR and cookie/localStorage
67
+ const [theme, setTheme] = useTheme({ ssrTheme: ssrTheme, useLocalStorage: staticBuild });
68
+
69
+ // Inject blocking element to set proper pre-hydration state
70
+ const head = ssrTheme ? undefined : <BlockingThemeLoader useLocalStorage={!!staticBuild} />;
71
+
72
+ return (
73
+ <ThemeProvider theme={theme} setTheme={setTheme} renderers={renderers} {...links} top={top}>
74
+ <DocumentWithoutProviders
75
+ children={children}
76
+ scripts={scripts}
77
+ head={head}
78
+ config={config}
79
+ title={title}
80
+ liveReloadListener={!staticBuild}
81
+ baseurl={baseurl}
82
+ top={top}
83
+ />
84
+ </ThemeProvider>
85
+ );
86
+ }
87
+
88
+ export function DocumentWithoutProviders({
89
+ children,
90
+ scripts,
91
+ head,
92
+ config,
93
+ title,
94
+ baseurl,
95
+ top = DEFAULT_NAV_HEIGHT,
96
+ liveReloadListener,
97
+ }: {
98
+ children: React.ReactNode;
99
+ scripts?: React.ReactNode;
100
+ head?: React.ReactNode;
101
+ config?: SiteManifest;
102
+ title?: string;
103
+ baseurl?: string;
104
+ useLocalStorageForDarkMode?: boolean;
105
+ top?: number;
106
+ theme?: Theme;
107
+ liveReloadListener?: boolean;
108
+ }) {
109
+ // Theme value from theme context. For a clean page load (no cookies), both ssrTheme and theme are null
110
+ // And thus the BlockingThemeLoader is used to inject the client-preferred theme (localStorage or media query)
111
+ // without a FOUC.
112
+ //
113
+ // In live-server contexts, setting the theme or changing the system preferred theme will modify the ssrTheme upon next request _and_ update the useThemeSwitcher context state, leading to a re-render
114
+ // Upon re-render, the state-theme value is set on `html` and the client-side BlockingThemeLoader discovers that it has no additional work to do, exiting the script tag early
115
+ // Upon a new request to the server, the theme preference is received from the set cookie, and therefore we don't inject a BlockingThemeLoader AND we have the theme value in useThemeSwitcher.
116
+ //
117
+ // In static sites, ssrTheme is forever null.
118
+ // if (ssrTheme) { assert(theme === ssrTheme) }
119
+ const { theme } = useThemeSwitcher();
52
120
  return (
121
+ // Set the theme during SSR if possible, otherwise leave it up to the BlockingThemeLoader
53
122
  <html lang="en" className={classNames(theme)} style={{ scrollPadding: top }}>
54
123
  <head>
55
124
  <meta charSet="utf-8" />
@@ -61,16 +130,15 @@ export function Document({
61
130
  analytics_google={config?.options?.analytics_google}
62
131
  analytics_plausible={config?.options?.analytics_plausible}
63
132
  />
133
+ {head}
64
134
  </head>
65
135
  <body className="m-0 transition-colors duration-500 bg-white dark:bg-stone-900">
66
- <ThemeProvider theme={theme} renderers={renderers} {...links} top={top}>
67
- <BaseUrlProvider baseurl={baseurl}>
68
- <SiteProvider config={config}>{children}</SiteProvider>
69
- </BaseUrlProvider>
70
- </ThemeProvider>
136
+ <BaseUrlProvider baseurl={baseurl}>
137
+ <SiteProvider config={config}>{children}</SiteProvider>
138
+ </BaseUrlProvider>
71
139
  <ScrollRestoration />
72
140
  <Scripts />
73
- {!staticBuild && <LiveReload />}
141
+ {liveReloadListener && <LiveReload />}
74
142
  {scripts}
75
143
  </body>
76
144
  </html>
@@ -86,12 +154,13 @@ export function App() {
86
154
  );
87
155
  }
88
156
 
89
- export function AppCatchBoundary() {
157
+ export function AppErrorBoundary() {
158
+ const error = useRouteError();
90
159
  return (
91
160
  <Document theme={Theme.light}>
92
161
  <article className="article">
93
162
  <main className="article-grid subgrid-gap col-screen">
94
- <Error404 />
163
+ {isRouteErrorResponse(error) ? <Error404 /> : <ErrorUnhandled error={error as any} />}
95
164
  </main>
96
165
  </article>
97
166
  </Document>
@@ -1,5 +1,6 @@
1
1
  export { ErrorProjectNotFound } from './ErrorProjectNotFound.js';
2
2
  export { ErrorDocumentNotFound } from './ErrorDocumentNotFound.js';
3
3
  export { Error404 } from './Error404.js';
4
- export { ArticlePage, ArticlePageCatchBoundary, ProjectPageCatchBoundary } from './Article.js';
5
- export { App, Document, AppCatchBoundary } from './Root.js';
4
+ export { ErrorUnhandled } from './ErrorUnhandled.js';
5
+ export { ArticlePage } from './Article.js';
6
+ export { App, Document, AppErrorBoundary } from './Root.js';
package/src/seo/meta.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { HtmlMetaDescriptor, V2_MetaDescriptor } from '@remix-run/react';
1
+ import type { V2_MetaDescriptor } from '@remix-run/react';
2
2
 
3
3
  export type SocialSite = {
4
4
  title: string;
@@ -17,15 +17,15 @@ export type SocialArticle = {
17
17
  keywords?: string[];
18
18
  };
19
19
 
20
- function allDefined(meta: Record<string, string | null | undefined>): HtmlMetaDescriptor {
21
- return Object.fromEntries(Object.entries(meta).filter(([, v]) => v)) as HtmlMetaDescriptor;
20
+ function allDefined(meta: Record<string, string | null | undefined>): V2_MetaDescriptor {
21
+ return Object.fromEntries(Object.entries(meta).filter(([, v]) => v)) as V2_MetaDescriptor;
22
22
  }
23
23
 
24
24
  export function getMetaTagsForSite_V1({
25
25
  title,
26
26
  description,
27
27
  twitter,
28
- }: SocialSite): HtmlMetaDescriptor {
28
+ }: SocialSite): V2_MetaDescriptor {
29
29
  const meta = {
30
30
  title,
31
31
  description,
@@ -60,7 +60,7 @@ export function getMetaTagsForArticle_V1({
60
60
  image,
61
61
  twitter,
62
62
  keywords,
63
- }: SocialArticle): HtmlMetaDescriptor {
63
+ }: SocialArticle): V2_MetaDescriptor {
64
64
  const meta = {
65
65
  title,
66
66
  description,