@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,47 @@
1
+ import { createCookieSessionStorage, json } from '@remix-run/node';
2
+ import { isTheme, Theme } from '@myst-theme/providers';
3
+ import type { ActionFunction } from '@remix-run/node';
4
+
5
+ export const themeStorage = createCookieSessionStorage({
6
+ cookie: {
7
+ name: 'theme',
8
+ secure: true,
9
+ secrets: ['secret'],
10
+ sameSite: 'lax',
11
+ path: '/',
12
+ httpOnly: true,
13
+ },
14
+ });
15
+
16
+ async function getThemeSession(request: Request) {
17
+ const session = await themeStorage.getSession(request.headers.get('Cookie'));
18
+ return {
19
+ getTheme: () => {
20
+ const themeValue = session.get('theme');
21
+ return isTheme(themeValue) ? themeValue : Theme.light;
22
+ },
23
+ setTheme: (theme: Theme) => session.set('theme', theme),
24
+ commit: () => themeStorage.commitSession(session, { expires: new Date('2100-01-01') }),
25
+ };
26
+ }
27
+
28
+ export { getThemeSession };
29
+
30
+ export const setThemeAPI: ActionFunction = async ({ request }) => {
31
+ const themeSession = await getThemeSession(request);
32
+ const data = await request.json();
33
+ const { theme } = data ?? {};
34
+ if (!isTheme(theme)) {
35
+ return json({
36
+ success: false,
37
+ message: `Invalid theme: "${theme}".`,
38
+ });
39
+ }
40
+ themeSession.setTheme(theme as Theme);
41
+ return json(
42
+ { success: true, theme },
43
+ {
44
+ headers: { 'Set-Cookie': await themeSession.commit() },
45
+ },
46
+ );
47
+ };
@@ -0,0 +1,145 @@
1
+ import type { MinifiedOutput } from '@curvenote/nbtx';
2
+ import { walkPaths } from '@curvenote/nbtx';
3
+ import type { SiteManifest } from 'myst-config';
4
+ import { selectAll } from 'unist-util-select';
5
+ import type { Image as ImageSpec, Link as LinkSpec } from 'myst-spec';
6
+ import type { FooterLinks, Heading, NavigationLink, PageLoader } from '../types';
7
+
8
+ type Image = ImageSpec & { urlOptimized?: string };
9
+ type Link = LinkSpec & { static?: boolean };
10
+ type Output = { data?: MinifiedOutput[] };
11
+
12
+ type ManifestProject = Required<SiteManifest>['projects'][0];
13
+ type ManifestProjectItem = ManifestProject['pages'][0];
14
+
15
+ export function getProject(
16
+ config?: SiteManifest,
17
+ projectSlug?: string,
18
+ ): ManifestProject | undefined {
19
+ if (!projectSlug || !config) return undefined;
20
+ const project = config.projects?.find((p) => p.slug === projectSlug);
21
+ return project;
22
+ }
23
+
24
+ export function getProjectHeadings(
25
+ config: SiteManifest,
26
+ projectSlug?: string,
27
+ opts: { addGroups: boolean } = { addGroups: false },
28
+ ): Heading[] | undefined {
29
+ const project = getProject(config, projectSlug);
30
+ if (!project) return undefined;
31
+ const headings: Heading[] = [
32
+ {
33
+ title: project.title,
34
+ slug: project.index,
35
+ path: `/${project.slug}`,
36
+ level: 'index',
37
+ },
38
+ ...project.pages.map((p) => {
39
+ if (!('slug' in p)) return p;
40
+ return { ...p, path: `/${project.slug}/${p.slug}` };
41
+ }),
42
+ ];
43
+ if (opts.addGroups) {
44
+ let lastTitle = project.title;
45
+ return headings.map((heading) => {
46
+ if (!heading.slug || heading.level === 'index') {
47
+ lastTitle = heading.title;
48
+ }
49
+ return { ...heading, group: lastTitle };
50
+ });
51
+ }
52
+ return headings;
53
+ }
54
+
55
+ function getHeadingLink(currentSlug: string, headings?: Heading[]): NavigationLink | undefined {
56
+ if (!headings) return undefined;
57
+ const linkIndex = headings.findIndex(({ slug }) => !!slug && slug !== currentSlug);
58
+ const link = headings[linkIndex];
59
+ if (!link?.path) return undefined;
60
+ return {
61
+ title: link.title,
62
+ url: link.path,
63
+ group: link.group,
64
+ };
65
+ }
66
+
67
+ export function getFooterLinks(
68
+ config?: SiteManifest,
69
+ projectSlug?: string,
70
+ slug?: string,
71
+ ): FooterLinks {
72
+ if (!projectSlug || !slug || !config) return {};
73
+ const pages = getProjectHeadings(config, projectSlug, {
74
+ addGroups: true,
75
+ });
76
+ const found = pages?.findIndex(({ slug: s }) => s === slug) ?? -1;
77
+ if (found === -1) return {};
78
+ const prev = getHeadingLink(slug, pages?.slice(0, found).reverse());
79
+ const next = getHeadingLink(slug, pages?.slice(found + 1));
80
+ const footer: FooterLinks = {
81
+ navigation: { prev, next },
82
+ };
83
+ return footer;
84
+ }
85
+
86
+ type UpdateUrl = (url: string) => string;
87
+
88
+ export function updateSiteManifestStaticLinksInplace(
89
+ data: SiteManifest,
90
+ updateUrl: UpdateUrl,
91
+ ): SiteManifest {
92
+ data.actions?.forEach((action) => {
93
+ if (!action.static) return;
94
+ action.url = updateUrl(action.url);
95
+ });
96
+ if (data.logo) data.logo = updateUrl(data.logo);
97
+ // Update the thumbnails to point at the CDN
98
+ data.projects?.forEach((project) => {
99
+ project.pages
100
+ .filter((page): page is ManifestProjectItem => 'slug' in page)
101
+ .forEach((page) => {
102
+ if (page.thumbnail) page.thumbnail = updateUrl(page.thumbnail);
103
+ if (page.thumbnailOptimized) page.thumbnailOptimized = updateUrl(page.thumbnailOptimized);
104
+ });
105
+ });
106
+ return data;
107
+ }
108
+
109
+ export function updatePageStaticLinksInplace(data: PageLoader, updateUrl: UpdateUrl): PageLoader {
110
+ if (data?.frontmatter?.thumbnail) {
111
+ data.frontmatter.thumbnail = updateUrl(data.frontmatter.thumbnail);
112
+ }
113
+ if (data?.frontmatter?.thumbnailOptimized) {
114
+ data.frontmatter.thumbnailOptimized = updateUrl(data.frontmatter.thumbnailOptimized);
115
+ }
116
+ if (data?.frontmatter?.exports) {
117
+ data.frontmatter.exports = data.frontmatter.exports.map((exp) => {
118
+ if (!exp.url) return exp;
119
+ return { ...exp, url: updateUrl(exp.url) };
120
+ });
121
+ }
122
+ // Fix all of the images to point to the CDN
123
+ const images = selectAll('image', data.mdast) as Image[];
124
+ images.forEach((node) => {
125
+ node.url = updateUrl(node.url);
126
+ if (node.urlOptimized) {
127
+ node.urlOptimized = updateUrl(node.urlOptimized);
128
+ }
129
+ });
130
+ const links = selectAll('link,linkBlock,card', data.mdast) as Link[];
131
+ const staticLinks = links.filter((node) => node.static);
132
+ staticLinks.forEach((node) => {
133
+ // These are static links to thinks like PDFs or other referenced files
134
+ node.url = updateUrl(node.url);
135
+ });
136
+ const outputs = selectAll('output', data.mdast) as Output[];
137
+ outputs.forEach((node) => {
138
+ if (!node.data) return;
139
+ walkPaths(node.data, (path, obj) => {
140
+ obj.path = updateUrl(path);
141
+ obj.content = updateUrl(obj.content as string);
142
+ });
143
+ });
144
+ return data;
145
+ }
@@ -0,0 +1,31 @@
1
+ import { ReferencesProvider } from '@myst-theme/providers';
2
+ import { FrontmatterBlock } from '@myst-theme/frontmatter';
3
+ import { Bibliography, ContentBlocks, FooterLinksBlock } from '../components';
4
+ import { ErrorDocumentNotFound } from './ErrorDocumentNotFound';
5
+ import { ErrorProjectNotFound } from './ErrorProjectNotFound';
6
+ import type { PageLoader } from '../types';
7
+
8
+ export function ArticlePage({ article }: { article: PageLoader }) {
9
+ const { hide_title_block, hide_footer_links } = (article.frontmatter as any)?.design ?? {};
10
+ return (
11
+ <ReferencesProvider
12
+ references={{ ...article.references, article: article.mdast }}
13
+ frontmatter={article.frontmatter}
14
+ >
15
+ {!hide_title_block && (
16
+ <FrontmatterBlock kind={article.kind} frontmatter={article.frontmatter} />
17
+ )}
18
+ <ContentBlocks mdast={article.mdast} />
19
+ <Bibliography />
20
+ {!hide_footer_links && <FooterLinksBlock links={article.footer} />}
21
+ </ReferencesProvider>
22
+ );
23
+ }
24
+
25
+ export function ProjectPageCatchBoundary() {
26
+ return <ErrorProjectNotFound />;
27
+ }
28
+
29
+ export function ArticlePageCatchBoundary() {
30
+ return <ErrorDocumentNotFound />;
31
+ }
@@ -0,0 +1,10 @@
1
+ export function ErrorDocumentNotFound() {
2
+ return (
3
+ <>
4
+ <h1>Document Not Found</h1>
5
+ <p>
6
+ Take me <a href="/">home</a>.
7
+ </p>
8
+ </>
9
+ );
10
+ }
@@ -0,0 +1,10 @@
1
+ export function ErrorProjectNotFound() {
2
+ return (
3
+ <>
4
+ <h1>Project Not Found</h1>
5
+ <p>
6
+ Take me <a href="/">home</a>.
7
+ </p>
8
+ </>
9
+ );
10
+ }
@@ -0,0 +1,30 @@
1
+ export function ErrorSiteNotFound() {
2
+ return (
3
+ <>
4
+ <h1>No Site Found</h1>
5
+ <p>No website is available at this url, please double check the url.</p>
6
+ <h3>What&apos;s next?</h3>
7
+ <p>
8
+ If you are expecting to see <span className="font-semibold">your website</span> here and you
9
+ think that something has gone wrong, please send an email to{' '}
10
+ <a
11
+ href={`mailto:support@curvenote.com?subject=Website%20Unavailable&body=${encodeURIComponent(
12
+ `My website is not available. 😥`,
13
+ )}`}
14
+ >
15
+ support@curvenote.com
16
+ </a>
17
+ , or let us know on our <a href="https://slack.curvenote.dev">community slack</a>, and
18
+ we&apos;ll help out.
19
+ </p>
20
+ <p>
21
+ Or create a new temporary website from Markdown and Jupyter Notebooks using{' '}
22
+ <a href="https://try.curvenote.com">try.curvenote.com</a>.
23
+ </p>
24
+ <p>
25
+ Or find out more about Curvenote&apos;s scientific writing, collaboration and publishing
26
+ tools at <a href="https://curvenote.com">curvenote.com</a>.
27
+ </p>
28
+ </>
29
+ );
30
+ }
@@ -0,0 +1,91 @@
1
+ import type { SiteManifest } from 'myst-config';
2
+ import type { SiteLoader } from '../types';
3
+ import { SiteProvider, Theme, ThemeProvider } from '@myst-theme/providers';
4
+ import {
5
+ Links,
6
+ LiveReload,
7
+ Meta,
8
+ Outlet,
9
+ Scripts,
10
+ ScrollRestoration,
11
+ useCatch,
12
+ useLoaderData,
13
+ } from '@remix-run/react';
14
+ import { ContentReload, renderers } from '../components';
15
+ import { Analytics } from '../seo';
16
+ import { ErrorSiteNotFound } from './ErrorSiteNotFound';
17
+
18
+ export function Document({
19
+ children,
20
+ theme,
21
+ config,
22
+ title,
23
+ CONTENT_CDN_PORT,
24
+ }: {
25
+ children: React.ReactNode;
26
+ theme: Theme;
27
+ config?: SiteManifest;
28
+ title?: string;
29
+ CONTENT_CDN_PORT?: number | string;
30
+ }) {
31
+ return (
32
+ <html lang="en" className={theme}>
33
+ <head>
34
+ <meta charSet="utf-8" />
35
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
36
+ {title && <title>{title}</title>}
37
+ <Meta />
38
+ <Links />
39
+ <Analytics
40
+ analytics_google={config?.analytics_google}
41
+ analytics_plausible={config?.analytics_plausible}
42
+ />
43
+ </head>
44
+ <body className="m-0 transition-colors duration-500 bg-white dark:bg-stone-900">
45
+ <ThemeProvider theme={theme} renderers={renderers}>
46
+ <SiteProvider config={config}>{children}</SiteProvider>
47
+ </ThemeProvider>
48
+ <ScrollRestoration />
49
+ <Scripts />
50
+ <LiveReload />
51
+ <ContentReload port={CONTENT_CDN_PORT} />
52
+ </body>
53
+ </html>
54
+ );
55
+ }
56
+
57
+ export function App() {
58
+ const { theme, config, CONTENT_CDN_PORT } = useLoaderData<SiteLoader>();
59
+ return (
60
+ <Document theme={theme} config={config} CONTENT_CDN_PORT={CONTENT_CDN_PORT}>
61
+ <Outlet />
62
+ </Document>
63
+ );
64
+ }
65
+
66
+ export function AppCatchBoundary() {
67
+ const caught = useCatch();
68
+ return (
69
+ <Document theme={Theme.light} title={caught.statusText}>
70
+ <article className="content">
71
+ <main className="error-content">
72
+ <ErrorSiteNotFound />
73
+ </main>
74
+ </article>
75
+ </Document>
76
+ );
77
+ }
78
+
79
+ export function AppDebugErrorBoundary({ error }: { error: { message: string; stack: string } }) {
80
+ return (
81
+ <Document theme={Theme.light} title="Error">
82
+ <div className="mt-16">
83
+ <main className="error-content">
84
+ <h1>An Error Occurred</h1>
85
+ <code>{error.message}</code>
86
+ <pre>{error.stack}</pre>
87
+ </main>
88
+ </div>
89
+ </Document>
90
+ );
91
+ }
@@ -0,0 +1,5 @@
1
+ export { ErrorProjectNotFound } from './ErrorProjectNotFound';
2
+ export { ErrorDocumentNotFound } from './ErrorDocumentNotFound';
3
+ export { ErrorSiteNotFound } from './ErrorSiteNotFound';
4
+ export { ArticlePage, ArticlePageCatchBoundary, ProjectPageCatchBoundary } from './Article';
5
+ export { App, Document, AppCatchBoundary, AppDebugErrorBoundary } from './Root';
@@ -0,0 +1,34 @@
1
+ type SiteAnalytics = {
2
+ analytics_google?: string;
3
+ analytics_plausible?: string;
4
+ };
5
+
6
+ const getGoogleAnalyticsScript = (tag: string) =>
7
+ `window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', '${tag}');`;
8
+
9
+ export function Analytics({ analytics_google, analytics_plausible }: SiteAnalytics) {
10
+ return (
11
+ <>
12
+ {analytics_plausible && (
13
+ <script
14
+ defer
15
+ data-domain={analytics_plausible}
16
+ src="https://plausible.io/js/plausible.js"
17
+ ></script>
18
+ )}
19
+ {analytics_google && (
20
+ <>
21
+ <script
22
+ async
23
+ src={`https://www.googletagmanager.com/gtag/js?id=${analytics_google}`}
24
+ ></script>
25
+ <script
26
+ dangerouslySetInnerHTML={{
27
+ __html: getGoogleAnalyticsScript(analytics_google),
28
+ }}
29
+ />
30
+ </>
31
+ )}
32
+ </>
33
+ );
34
+ }
@@ -0,0 +1,4 @@
1
+ export * from './analytics';
2
+ export * from './meta';
3
+ export * from './sitemap';
4
+ export * from './robots';
@@ -0,0 +1,57 @@
1
+ import type { HtmlMetaDescriptor } from '@remix-run/react';
2
+
3
+ type SocialSite = {
4
+ title: string;
5
+ twitter?: string;
6
+ };
7
+
8
+ type SocialArticle = {
9
+ origin: string;
10
+ url: string;
11
+ // TODO: canonical
12
+ title: string;
13
+ description?: string;
14
+ image?: string | null;
15
+ twitter?: string;
16
+ keywords?: string[];
17
+ };
18
+
19
+ function allDefined(meta: Record<string, string | null | undefined>): HtmlMetaDescriptor {
20
+ return Object.fromEntries(Object.entries(meta).filter(([, v]) => v)) as HtmlMetaDescriptor;
21
+ }
22
+
23
+ export function getMetaTagsForSite({ title, twitter }: SocialSite): HtmlMetaDescriptor {
24
+ const meta = {
25
+ title,
26
+ 'twitter:site': twitter ? `@${twitter.replace('@', '')}` : undefined,
27
+ };
28
+ return allDefined(meta);
29
+ }
30
+
31
+ export function getMetaTagsForArticle({
32
+ origin,
33
+ url,
34
+ title,
35
+ description,
36
+ image,
37
+ twitter,
38
+ keywords,
39
+ }: SocialArticle): HtmlMetaDescriptor {
40
+ const meta = {
41
+ title,
42
+ description,
43
+ keywords: keywords?.join(', '),
44
+ image,
45
+ 'og:url': origin && url ? `${origin}${url}` : undefined,
46
+ 'og:title': title,
47
+ 'og:description': description,
48
+ 'og:image': image,
49
+ 'twitter:card': image ? 'summary_large_image' : 'summary',
50
+ 'twitter:creator': twitter ? `@${twitter.replace('@', '')}` : undefined,
51
+ 'twitter:title': title,
52
+ 'twitter:description': description,
53
+ 'twitter:image': image,
54
+ 'twitter:alt': title,
55
+ };
56
+ return allDefined(meta);
57
+ }
@@ -0,0 +1,24 @@
1
+ type Options = { allow?: string[]; disallow?: string[]; sitemap?: string[] };
2
+
3
+ export function createRobotsTxt(domain: string, options?: Options) {
4
+ const allow = options?.allow ?? ['/'];
5
+ const disallow = options?.disallow ?? [];
6
+ const sitemap = options?.sitemap ?? ['/sitemap.xml'];
7
+ const rules = [
8
+ ...allow.map((path) => `Allow: ${path}`),
9
+ ...disallow.map((path) => `Disallow: ${path}`),
10
+ ...sitemap.map((path) => `Sitemap: ${domain}${path}`),
11
+ ];
12
+ return `# https://www.robotstxt.org/robotstxt.html
13
+
14
+ User-agent: *
15
+ ${rules.join('\n')}
16
+ `;
17
+ }
18
+
19
+ export function createRobotsTxtResponse(domain: string, options?: Options) {
20
+ return new Response(createRobotsTxt(domain, options), {
21
+ status: 200,
22
+ headers: { 'Content-Type': 'text/plain' },
23
+ });
24
+ }