@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
@@ -0,0 +1,155 @@
1
+ import React from 'react';
2
+ import classNames from 'classnames';
3
+ import { NavLink, useParams, useLocation } from '@remix-run/react';
4
+ import type { SiteManifest } from 'myst-config';
5
+ import { CreatedInCurvenote } from '@curvenote/icons';
6
+ import { useNavOpen, useSiteManifest, useUrlbase, withUrlbase } from '@myst-theme/providers';
7
+ import { getProjectHeadings } from '../../loaders';
8
+ import type { Heading } from '../../types';
9
+
10
+ type Props = {
11
+ folder?: string;
12
+ headings: Heading[];
13
+ sections?: ManifestProject[];
14
+ };
15
+
16
+ type ManifestProject = Required<SiteManifest>['projects'][0];
17
+
18
+ const HeadingLink = ({
19
+ path,
20
+ isIndex,
21
+ title,
22
+ children,
23
+ }: {
24
+ path: string;
25
+ isIndex?: boolean;
26
+ title?: string;
27
+ children: React.ReactNode;
28
+ }) => {
29
+ const { pathname } = useLocation();
30
+ const exact = pathname === path;
31
+ const urlbase = useUrlbase();
32
+ const [, setOpen] = useNavOpen();
33
+ return (
34
+ <NavLink
35
+ prefetch="intent"
36
+ title={title}
37
+ className={({ isActive }: { isActive: boolean }) =>
38
+ classNames('block break-words', {
39
+ 'text-blue-600 dark:text-white': !isIndex && isActive,
40
+ 'font-semibold': isActive,
41
+ 'hover:text-slate-800 dark:hover:text-slate-100': !isActive,
42
+ 'border-b pb-1': isIndex,
43
+ 'border-stone-200 dark:border-stone-700': isIndex && !exact,
44
+ 'border-blue-500': isIndex && exact,
45
+ })
46
+ }
47
+ to={withUrlbase(path, urlbase)}
48
+ suppressHydrationWarning // The pathname is not defined on the server always.
49
+ onClick={() => {
50
+ // Close the nav panel if it is open
51
+ setOpen(false);
52
+ }}
53
+ >
54
+ {children}
55
+ </NavLink>
56
+ );
57
+ };
58
+
59
+ const HEADING_CLASSES = 'text-slate-900 text-lg leading-6 dark:text-slate-100';
60
+ const Headings = ({ folder, headings, sections }: Props) => {
61
+ const secs = sections || [];
62
+ return (
63
+ <ul className="text-slate-500 dark:text-slate-300 leading-6">
64
+ {secs.map((sec) => {
65
+ if (sec.slug === folder) {
66
+ return headings.map((heading, index) => (
67
+ <li
68
+ key={heading.slug || index}
69
+ className={classNames('p-1', {
70
+ [HEADING_CLASSES]: heading.level === 'index',
71
+ 'font-semibold': heading.level === 'index',
72
+ 'pl-4': heading.level === 2,
73
+ 'pl-6': heading.level === 3,
74
+ 'pl-8': heading.level === 4,
75
+ 'pl-10': heading.level === 5,
76
+ 'pl-12': heading.level === 6,
77
+ })}
78
+ >
79
+ {heading.path ? (
80
+ <HeadingLink
81
+ title={heading.title}
82
+ path={heading.path}
83
+ isIndex={heading.level === 'index'}
84
+ >
85
+ {heading.title}
86
+ </HeadingLink>
87
+ ) : (
88
+ <h5 className="text-slate-900 font-semibold my-2 text-md leading-6 dark:text-slate-100 break-words">
89
+ {heading.title}
90
+ </h5>
91
+ )}
92
+ </li>
93
+ ));
94
+ }
95
+ return (
96
+ <li key={sec.slug} className={classNames('p-1 my-2 lg:hidden', HEADING_CLASSES)}>
97
+ <HeadingLink path={`/${sec.slug}`}>{sec.title}</HeadingLink>
98
+ </li>
99
+ );
100
+ })}
101
+ </ul>
102
+ );
103
+ };
104
+
105
+ export const TableOfContents = ({
106
+ projectSlug,
107
+ top,
108
+ height,
109
+ showFooter = true,
110
+ }: {
111
+ top?: number;
112
+ height?: number;
113
+ projectSlug?: string;
114
+ showFooter?: boolean;
115
+ }) => {
116
+ const [open] = useNavOpen();
117
+ const config = useSiteManifest();
118
+ const { folder, project } = useParams();
119
+ const resolvedProjectSlug = projectSlug || (folder ?? project);
120
+ if (!config) return null;
121
+ const headings = getProjectHeadings(config, resolvedProjectSlug, {
122
+ addGroups: false,
123
+ });
124
+ if (!headings) return null;
125
+ return (
126
+ <div
127
+ className={classNames('toc overflow-hidden', {
128
+ flex: open,
129
+ 'bg-white dark:bg-stone-900': open, // just apply when open, so that theme can transition
130
+ 'hidden xl:flex': !open,
131
+ })}
132
+ style={{
133
+ top: top ?? 0,
134
+ height:
135
+ typeof document === 'undefined' || (height && height > window.innerHeight - (top ?? 0))
136
+ ? undefined
137
+ : height,
138
+ }}
139
+ suppressHydrationWarning
140
+ >
141
+ <nav
142
+ aria-label="Table of Contents"
143
+ className="flex-grow pt-10 pb-3 px-8 overflow-y-auto transition-opacity"
144
+ style={{ opacity: height && height > 150 ? undefined : 0 }}
145
+ >
146
+ <Headings folder={resolvedProjectSlug} headings={headings} sections={config?.projects} />
147
+ </nav>
148
+ {showFooter && (
149
+ <div className="flex-none py-4">
150
+ <CreatedInCurvenote />
151
+ </div>
152
+ )}
153
+ </div>
154
+ );
155
+ };
@@ -0,0 +1,25 @@
1
+ import { useTheme } from '@myst-theme/providers';
2
+ import { MoonIcon } from '@heroicons/react/24/solid';
3
+ import { SunIcon } from '@heroicons/react/24/outline';
4
+ import classNames from 'classnames';
5
+
6
+ export function ThemeButton({ className = 'mx-3 h-8 w-8' }: { className?: string }) {
7
+ const { isDark, nextTheme } = useTheme();
8
+ return (
9
+ <button
10
+ className={classNames(
11
+ 'theme rounded-full border border-white border-solid overflow-hidden text-white hover:text-stone-500 hover:bg-white',
12
+ className,
13
+ )}
14
+ title={`Change theme to ${isDark ? 'light' : 'dark'} mode.`}
15
+ aria-label={`Change theme to ${isDark ? 'light' : 'dark'} mode.`}
16
+ onClick={nextTheme}
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
+ )}
23
+ </button>
24
+ );
25
+ }
@@ -0,0 +1,249 @@
1
+ import { Link, NavLink } from '@remix-run/react';
2
+ import { Fragment } from 'react';
3
+ import classNames from 'classnames';
4
+ import { Menu, Transition } from '@headlessui/react';
5
+ import {
6
+ EllipsisVerticalIcon,
7
+ Bars3Icon as MenuIcon,
8
+ ChevronDownIcon,
9
+ } from '@heroicons/react/24/solid';
10
+ import type { SiteManifest, SiteNavItem } from 'myst-config';
11
+ import { ThemeButton } from './ThemeButton';
12
+ import { useNavOpen, useSiteManifest } from '@myst-theme/providers';
13
+ import { CurvenoteLogo } from '@curvenote/icons';
14
+ import { LoadingBar } from './Loading';
15
+
16
+ export const DEFAULT_NAV_HEIGHT = 60;
17
+
18
+ function ExternalOrInternalLink({
19
+ to,
20
+ className,
21
+ children,
22
+ nav,
23
+ prefetch = 'intent',
24
+ }: {
25
+ to: string;
26
+ className?: string | ((props: { isActive: boolean }) => string);
27
+ children: React.ReactNode;
28
+ nav?: boolean;
29
+ prefetch?: 'intent' | 'render' | 'none';
30
+ }) {
31
+ const staticClass = typeof className === 'function' ? className({ isActive: false }) : className;
32
+ if (to.startsWith('http') || to.startsWith('mailto:')) {
33
+ return (
34
+ <a href={to} target="_blank" rel="noopener noreferrer" className={staticClass}>
35
+ {children}
36
+ </a>
37
+ );
38
+ }
39
+ if (nav) {
40
+ return (
41
+ <NavLink prefetch={prefetch} to={to} className={className}>
42
+ {children}
43
+ </NavLink>
44
+ );
45
+ }
46
+ return (
47
+ <Link prefetch={prefetch} to={to} className={staticClass}>
48
+ {children}
49
+ </Link>
50
+ );
51
+ }
52
+
53
+ function NavItem({ item }: { item: SiteNavItem }) {
54
+ if (!('children' in item)) {
55
+ return (
56
+ <div className="relative grow-0 inline-block mx-2">
57
+ <ExternalOrInternalLink
58
+ nav
59
+ to={item.url ?? ''}
60
+ className={({ isActive }) =>
61
+ classNames(
62
+ 'inline-flex items-center justify-center w-full mx-2 py-1 text-md font-medium text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75',
63
+ {
64
+ 'border-b border-stone-200': isActive,
65
+ },
66
+ )
67
+ }
68
+ >
69
+ {item.title}
70
+ </ExternalOrInternalLink>
71
+ </div>
72
+ );
73
+ }
74
+ return (
75
+ <Menu as="div" className="relative grow-0 inline-block mx-2">
76
+ <div className="inline-block">
77
+ <Menu.Button className="inline-flex items-center justify-center w-full mx-2 py-1 text-md font-medium text-white rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75">
78
+ <span>{item.title}</span>
79
+ <ChevronDownIcon
80
+ className="w-5 h-5 ml-2 -mr-1 text-violet-200 hover:text-violet-100"
81
+ aria-hidden="true"
82
+ />
83
+ </Menu.Button>
84
+ </div>
85
+ <Transition
86
+ as={Fragment}
87
+ enter="transition ease-out duration-100"
88
+ enterFrom="transform opacity-0 scale-95"
89
+ enterTo="transform opacity-100 scale-100"
90
+ leave="transition ease-in duration-75"
91
+ leaveFrom="transform opacity-100 scale-100"
92
+ leaveTo="transform opacity-0 scale-95"
93
+ >
94
+ <Menu.Items className="origin-top-left absolute left-4 mt-2 w-48 rounded-sm shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
95
+ {item.children?.map((action) => (
96
+ <Menu.Item key={action.url}>
97
+ {/* This is really ugly, BUT, the action needs to be defined HERE or the click away doesn't work for some reason */}
98
+ {action.url?.startsWith('http') ? (
99
+ <a
100
+ href={action.url || ''}
101
+ className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-black"
102
+ target="_blank"
103
+ rel="noopener noreferrer"
104
+ >
105
+ {action.title}
106
+ </a>
107
+ ) : (
108
+ <NavLink
109
+ to={action.url || ''}
110
+ className={({ isActive }) =>
111
+ classNames(
112
+ 'block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-black',
113
+ {
114
+ 'text-black font-bold': isActive,
115
+ },
116
+ )
117
+ }
118
+ >
119
+ {action.title}
120
+ </NavLink>
121
+ )}
122
+ </Menu.Item>
123
+ ))}
124
+ </Menu.Items>
125
+ </Transition>
126
+ </Menu>
127
+ );
128
+ }
129
+
130
+ function NavItems({ nav }: { nav?: SiteManifest['nav'] }) {
131
+ if (!nav) return null;
132
+ return (
133
+ <div className="text-md flex-grow hidden lg:block">
134
+ {nav.map((item) => {
135
+ return <NavItem key={'url' in item ? item.url : item.title} item={item} />;
136
+ })}
137
+ </div>
138
+ );
139
+ }
140
+
141
+ function ActionMenu({ actions }: { actions?: SiteManifest['actions'] }) {
142
+ if (!actions || actions.length === 0) return null;
143
+ return (
144
+ <Menu as="div" className="relative">
145
+ <div>
146
+ <Menu.Button className="bg-transparent flex text-sm rounded-full focus:outline-none">
147
+ <span className="sr-only">Open Menu</span>
148
+ <div className="flex items-center text-stone-200 hover:text-white">
149
+ <EllipsisVerticalIcon className="h-8 w-8 p-1" />
150
+ </div>
151
+ </Menu.Button>
152
+ </div>
153
+ <Transition
154
+ as={Fragment}
155
+ enter="transition ease-out duration-100"
156
+ enterFrom="transform opacity-0 scale-95"
157
+ enterTo="transform opacity-100 scale-100"
158
+ leave="transition ease-in duration-75"
159
+ leaveFrom="transform opacity-100 scale-100"
160
+ leaveTo="transform opacity-0 scale-95"
161
+ >
162
+ <Menu.Items className="origin-top-right absolute right-0 mt-2 w-48 rounded-sm shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
163
+ {actions?.map((action) => (
164
+ <Menu.Item key={action.url}>
165
+ {({ active }) => (
166
+ <a
167
+ href={action.url}
168
+ className={classNames(
169
+ active ? 'bg-gray-100' : '',
170
+ 'block px-4 py-2 text-sm text-gray-700',
171
+ )}
172
+ >
173
+ {action.title}
174
+ </a>
175
+ )}
176
+ </Menu.Item>
177
+ ))}
178
+ </Menu.Items>
179
+ </Transition>
180
+ </Menu>
181
+ );
182
+ }
183
+
184
+ function HomeLink({ logo, logoText, name }: { logo?: string; logoText?: string; name?: string }) {
185
+ const nothingSet = !logo && !logoText;
186
+ return (
187
+ <Link
188
+ className="flex items-center text-white w-fit ml-3 md:ml-5 xl:ml-7"
189
+ to="/"
190
+ prefetch="intent"
191
+ >
192
+ {logo && <img src={logo} className="h-9 mr-3" alt={logoText || name} height="2.25rem"></img>}
193
+ {nothingSet && <CurvenoteLogo className="mr-3" fill="#FFF" size={30} />}
194
+ <span
195
+ className={classNames('text-md sm:text-xl tracking-tight sm:mr-5', {
196
+ 'sr-only': !(logoText || nothingSet),
197
+ })}
198
+ >
199
+ {logoText || 'Curvenote'}
200
+ </span>
201
+ </Link>
202
+ );
203
+ }
204
+
205
+ export function TopNav() {
206
+ const [open, setOpen] = useNavOpen();
207
+ const config = useSiteManifest();
208
+ const { logo, logo_text, logoText, actions, title, nav } = config ?? ({} as SiteManifest);
209
+ return (
210
+ <div className="bg-stone-700 p-3 md:px-8 fixed w-screen top-0 z-30 h-[60px]">
211
+ <nav className="flex items-center justify-between flex-wrap max-w-[1440px] mx-auto">
212
+ <div className="flex flex-row xl:min-w-[19.5rem] mr-2 sm:mr-7 justify-start items-center">
213
+ <div className="block xl:hidden">
214
+ <button
215
+ className="flex items-center text-stone-200 border-stone-400 hover:text-white"
216
+ onClick={() => {
217
+ setOpen(!open);
218
+ }}
219
+ >
220
+ <span className="sr-only">Open Menu</span>
221
+ <MenuIcon className="fill-current h-8 w-8 p-1" />
222
+ </button>
223
+ </div>
224
+ <HomeLink name={title} logo={logo} logoText={logo_text || logoText} />
225
+ </div>
226
+ <div className="flex-grow flex items-center w-auto">
227
+ <NavItems nav={nav} />
228
+ <div className="block flex-grow"></div>
229
+ <ThemeButton />
230
+ <div className="block sm:hidden">
231
+ <ActionMenu actions={actions} />
232
+ </div>
233
+ <div className="hidden sm:block">
234
+ {actions?.map((action, index) => (
235
+ <ExternalOrInternalLink
236
+ key={action.url || index}
237
+ className="inline-block text-md px-4 py-2 mx-1 leading-none border rounded text-white border-white hover:border-transparent hover:text-stone-500 hover:bg-white mt-0"
238
+ to={action.url}
239
+ >
240
+ {action.title}
241
+ </ExternalOrInternalLink>
242
+ ))}
243
+ </div>
244
+ </div>
245
+ </nav>
246
+ <LoadingBar />
247
+ </div>
248
+ );
249
+ }
@@ -0,0 +1,5 @@
1
+ export { ThemeButton } from './ThemeButton';
2
+ export { TopNav, DEFAULT_NAV_HEIGHT } from './TopNav';
3
+ export { Navigation } from './Navigation';
4
+ export { TableOfContents } from './TableOfContents';
5
+ export { LoadingBar } from './Loading';
@@ -0,0 +1,8 @@
1
+ export { ContentBlocks } from './ContentBlocks';
2
+ export { DocumentOutline } from './DocumentOutline';
3
+ export { FooterLinksBlock } from './FooterLinksBlock';
4
+ export { ContentReload } from './ContentReload';
5
+ export { Bibliography } from './Bibliography';
6
+ export { ExternalOrInternalLink } from './ExternalOrInternalLink';
7
+ export * from './Navigation';
8
+ export { renderers } from './renderers';
@@ -0,0 +1,7 @@
1
+ import { DEFAULT_RENDERERS } from 'myst-to-react';
2
+ import { MystDemoRenderer } from 'myst-demo';
3
+
4
+ export const renderers = {
5
+ ...DEFAULT_RENDERERS,
6
+ myst: MystDemoRenderer,
7
+ };
@@ -0,0 +1,23 @@
1
+ import { useTransition } from '@remix-run/react';
2
+ import { useEffect, useRef, useState } from 'react';
3
+
4
+ export function useNavigationHeight<T extends HTMLElement = HTMLElement>() {
5
+ const ref = useRef<T>(null);
6
+ const [height, setHeightState] = useState(1000);
7
+ const transitionState = useTransition().state;
8
+ const setHeight = () => {
9
+ if (ref.current) {
10
+ setHeightState(ref.current.offsetHeight - window.scrollY);
11
+ }
12
+ };
13
+ useEffect(() => {
14
+ setHeight();
15
+ setTimeout(setHeight, 100); // Some lag sometimes
16
+ const handleScroll = () => setHeight();
17
+ window.addEventListener('scroll', handleScroll);
18
+ return () => {
19
+ window.removeEventListener('scroll', handleScroll);
20
+ };
21
+ }, [ref, transitionState]);
22
+ return { ref, height };
23
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from './utils';
2
+ export * from './types';
3
+ export * from './loaders';
4
+ export * from './components';
5
+ export * from './pages';
6
+ export * from './seo';
7
+ export * from './hooks';
8
+ export * from './store';
@@ -0,0 +1,145 @@
1
+ import fetch from 'node-fetch';
2
+ import NodeCache from 'node-cache';
3
+ import type { SiteManifest as Config } from 'myst-config';
4
+ import { responseNoArticle, responseNoSite } from './errors.server';
5
+ import {
6
+ getFooterLinks,
7
+ getProject,
8
+ updatePageStaticLinksInplace,
9
+ updateSiteManifestStaticLinksInplace,
10
+ } from './utils';
11
+ import { redirect } from '@remix-run/node';
12
+ import type { PageLoader } from '../types';
13
+
14
+ interface CdnRouter {
15
+ cdn?: string;
16
+ }
17
+
18
+ declare global {
19
+ // Disable multiple caches when this file is rebuilt
20
+ // eslint-disable-next-line
21
+ var cdnRouterCache: NodeCache | undefined, configCache: NodeCache | undefined;
22
+ }
23
+
24
+ const CDN = 'https://cdn.curvenote.com/';
25
+
26
+ function getCdnRouterCache() {
27
+ if (global.cdnRouterCache) return global.cdnRouterCache;
28
+ console.log('Creating cdnRouterCache');
29
+ // The router should update every minute
30
+ global.cdnRouterCache = new NodeCache({ stdTTL: 30 });
31
+ return global.cdnRouterCache;
32
+ }
33
+
34
+ function getConfigCache() {
35
+ if (global.configCache) return global.configCache;
36
+ console.log('Creating configCache');
37
+ // The config can be long lived as it is static (0 == ∞)
38
+ global.configCache = new NodeCache({ stdTTL: 0 });
39
+ return global.configCache;
40
+ }
41
+
42
+ async function getCdnPath(hostname: string): Promise<string | undefined> {
43
+ const cached = getCdnRouterCache().get<CdnRouter>(hostname);
44
+ if (cached) return cached.cdn;
45
+ const response = await fetch(`https://api.curvenote.com/routers/${hostname}`);
46
+ if (response.status === 404) {
47
+ // Always hit the API again if it is not found!
48
+ return;
49
+ }
50
+ const data = (await response.json()) as CdnRouter;
51
+ getCdnRouterCache().set<CdnRouter>(hostname, data);
52
+ return data.cdn;
53
+ }
54
+
55
+ function withCDN<T extends string | undefined>(id: string, url: T): T {
56
+ if (!url) return url;
57
+ return `${CDN}${id}/public${url}` as T;
58
+ }
59
+
60
+ /**
61
+ * Basic comparison for checking that the title and (possible) slug are the same
62
+ */
63
+ function foldTitleString(title?: string): string | undefined {
64
+ return title?.replace(/[-\s_]/g, '').toLowerCase();
65
+ }
66
+
67
+ /**
68
+ * If the site title and the first nav item are the same, remove it.
69
+ */
70
+ function removeSingleNavItems(config: Config) {
71
+ if (
72
+ config?.nav?.length === 1 &&
73
+ foldTitleString(config.nav[0].title) === foldTitleString(config.title)
74
+ ) {
75
+ config.nav = [];
76
+ }
77
+ }
78
+
79
+ export async function getConfig(hostname: string): Promise<Config> {
80
+ const id = await getCdnPath(hostname);
81
+ if (!id) throw responseNoSite();
82
+ const cached = getConfigCache().get<Config>(id);
83
+ // Load the data from an in memory cache.
84
+ if (cached) return cached;
85
+ const response = await fetch(`${CDN}${id}/config.json`);
86
+ if (response.status === 404) throw responseNoSite();
87
+ const data = (await response.json()) as Config;
88
+ data.id = id;
89
+ removeSingleNavItems(data);
90
+ updateSiteManifestStaticLinksInplace(data, (url) => withCDN(id, url));
91
+ getConfigCache().set<Config>(id, data);
92
+ return data;
93
+ }
94
+
95
+ export async function getObjectsInv(hostname: string): Promise<Buffer | undefined> {
96
+ const id = await getCdnPath(hostname);
97
+ if (!id) return;
98
+ const url = `${CDN}${id}/objects.inv`;
99
+ const response = await fetch(url);
100
+ if (response.status === 404) return;
101
+ const buffer = await response.buffer();
102
+ return buffer;
103
+ }
104
+
105
+ export async function getData(
106
+ config?: Config,
107
+ project?: string,
108
+ slug?: string,
109
+ ): Promise<PageLoader | null> {
110
+ if (!project || !slug || !config) throw responseNoArticle();
111
+ const { id } = config;
112
+ if (!id) throw responseNoSite();
113
+ const response = await fetch(`${CDN}${id}/content/${project}/${slug}.json`);
114
+ if (response.status === 404) throw responseNoArticle();
115
+ const data = (await response.json()) as PageLoader;
116
+ return updatePageStaticLinksInplace(data, (url) => withCDN(id, url));
117
+ }
118
+
119
+ export async function getPage(
120
+ hostname: string,
121
+ opts: {
122
+ domain?: string;
123
+ project?: string;
124
+ loadIndexPage?: boolean;
125
+ slug?: string;
126
+ redirect?: boolean | string;
127
+ },
128
+ ): Promise<PageLoader | Response | null> {
129
+ const projectName = opts.project;
130
+ const config = await getConfig(hostname);
131
+ if (!config) throw responseNoSite();
132
+ const project = getProject(config, projectName);
133
+ if (!project) throw responseNoArticle();
134
+ if (opts.slug === project.index && opts.redirect) {
135
+ return redirect(`${typeof opts.redirect === 'string' ? opts.redirect : '/'}${projectName}`);
136
+ }
137
+ const slug = opts.loadIndexPage || opts.slug == null ? project.index : opts.slug;
138
+ const loader = await getData(config, projectName, slug).catch((e) => {
139
+ console.error(e);
140
+ return null;
141
+ });
142
+ if (!loader) throw responseNoArticle();
143
+ const footer = getFooterLinks(config, projectName, slug);
144
+ return { ...loader, footer, domain: opts.domain as string };
145
+ }
@@ -0,0 +1,20 @@
1
+ export enum ErrorStatus {
2
+ noSite = 'Site was not found',
3
+ noArticle = 'Article was not found',
4
+ }
5
+
6
+ export function responseNoSite(): Response {
7
+ // note: error boundary logic is dependent on the string sent here
8
+ return new Response(ErrorStatus.noSite, {
9
+ status: 404,
10
+ statusText: ErrorStatus.noSite,
11
+ });
12
+ }
13
+
14
+ export function responseNoArticle() {
15
+ // note: error boundary logic is dependent on the string sent here
16
+ return new Response(ErrorStatus.noArticle, {
17
+ status: 404,
18
+ statusText: ErrorStatus.noArticle,
19
+ });
20
+ }
@@ -0,0 +1,5 @@
1
+ export * from './errors.server';
2
+ export * as cdn from './cdn.server';
3
+ export * from './utils';
4
+ export * from './links';
5
+ export * from './theme.server';
@@ -0,0 +1,8 @@
1
+ import type { HtmlLinkDescriptor } from '@remix-run/react';
2
+
3
+ export const KatexCSS: HtmlLinkDescriptor = {
4
+ rel: 'stylesheet',
5
+ href: 'https://cdn.jsdelivr.net/npm/katex@0.15.2/dist/katex.min.css',
6
+ integrity: 'sha384-MlJdn/WNKDGXveldHDdyRP1R4CTHr3FeuDNfhsLPYrq2t0UBkUdK2jyTnXPEK1NQ',
7
+ crossOrigin: 'anonymous',
8
+ };