@myst-theme/site 0.11.0 → 0.13.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.
@@ -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') }),
@@ -1,7 +1,13 @@
1
1
  import type { SiteManifest } from 'myst-config';
2
2
  import type { SiteLoader } from '@myst-theme/common';
3
3
  import type { NodeRenderers } from '@myst-theme/providers';
4
- import { BaseUrlProvider, SiteProvider, Theme, ThemeProvider } 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,
@@ -15,7 +21,12 @@ import {
15
21
  useRouteError,
16
22
  isRouteErrorResponse,
17
23
  } from '@remix-run/react';
18
- 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';
19
30
  import { Analytics } from '../seo/index.js';
20
31
  import { Error404 } from './Error404.js';
21
32
  import { ErrorUnhandled } from './ErrorUnhandled.js';
@@ -24,7 +35,7 @@ import classNames from 'classnames';
24
35
  export function Document({
25
36
  children,
26
37
  scripts,
27
- theme,
38
+ theme: ssrTheme,
28
39
  config,
29
40
  title,
30
41
  staticBuild,
@@ -34,7 +45,7 @@ export function Document({
34
45
  }: {
35
46
  children: React.ReactNode;
36
47
  scripts?: React.ReactNode;
37
- theme: Theme;
48
+ theme?: Theme;
38
49
  config?: SiteManifest;
39
50
  title?: string;
40
51
  staticBuild?: boolean;
@@ -52,7 +63,62 @@ export function Document({
52
63
  NavLink: NavLink as any,
53
64
  };
54
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();
55
120
  return (
121
+ // Set the theme during SSR if possible, otherwise leave it up to the BlockingThemeLoader
56
122
  <html lang="en" className={classNames(theme)} style={{ scrollPadding: top }}>
57
123
  <head>
58
124
  <meta charSet="utf-8" />
@@ -64,16 +130,15 @@ export function Document({
64
130
  analytics_google={config?.options?.analytics_google}
65
131
  analytics_plausible={config?.options?.analytics_plausible}
66
132
  />
133
+ {head}
67
134
  </head>
68
135
  <body className="m-0 transition-colors duration-500 bg-white dark:bg-stone-900">
69
- <ThemeProvider theme={theme} renderers={renderers} {...links} top={top}>
70
- <BaseUrlProvider baseurl={baseurl}>
71
- <SiteProvider config={config}>{children}</SiteProvider>
72
- </BaseUrlProvider>
73
- </ThemeProvider>
136
+ <BaseUrlProvider baseurl={baseurl}>
137
+ <SiteProvider config={config}>{children}</SiteProvider>
138
+ </BaseUrlProvider>
74
139
  <ScrollRestoration />
75
140
  <Scripts />
76
- {!staticBuild && <LiveReload />}
141
+ {liveReloadListener && <LiveReload />}
77
142
  {scripts}
78
143
  </body>
79
144
  </html>