@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.
- package/package.json +53 -0
- package/src/components/Bibliography.tsx +51 -0
- package/src/components/ContentBlocks.tsx +20 -0
- package/src/components/ContentReload.tsx +66 -0
- package/src/components/DocumentOutline.tsx +187 -0
- package/src/components/ExternalOrInternalLink.tsx +30 -0
- package/src/components/FooterLinksBlock.tsx +39 -0
- package/src/components/Navigation/Loading.tsx +59 -0
- package/src/components/Navigation/Navigation.tsx +31 -0
- package/src/components/Navigation/TableOfContents.tsx +155 -0
- package/src/components/Navigation/ThemeButton.tsx +25 -0
- package/src/components/Navigation/TopNav.tsx +249 -0
- package/src/components/Navigation/index.tsx +5 -0
- package/src/components/index.ts +8 -0
- package/src/components/renderers.ts +7 -0
- package/src/hooks/index.ts +23 -0
- package/src/index.ts +8 -0
- package/src/loaders/cdn.server.ts +145 -0
- package/src/loaders/errors.server.ts +20 -0
- package/src/loaders/index.ts +5 -0
- package/src/loaders/links.ts +8 -0
- package/src/loaders/theme.server.ts +47 -0
- package/src/loaders/utils.ts +145 -0
- package/src/pages/Article.tsx +31 -0
- package/src/pages/ErrorDocumentNotFound.tsx +10 -0
- package/src/pages/ErrorProjectNotFound.tsx +10 -0
- package/src/pages/ErrorSiteNotFound.tsx +30 -0
- package/src/pages/Root.tsx +91 -0
- package/src/pages/index.ts +5 -0
- package/src/seo/analytics.tsx +34 -0
- package/src/seo/index.ts +4 -0
- package/src/seo/meta.ts +57 -0
- package/src/seo/robots.ts +24 -0
- package/src/seo/sitemap.ts +216 -0
- package/src/store.ts +21 -0
- package/src/types.ts +49 -0
- 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,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'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'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'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
|
+
}
|
package/src/seo/index.ts
ADDED
package/src/seo/meta.ts
ADDED
|
@@ -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
|
+
}
|