react-cheminfo 0.4.1 → 0.6.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/README.md +182 -34
- package/lib/ecosystem/core/sites.d.ts +1 -1
- package/lib/ecosystem/core/sites.d.ts.map +1 -1
- package/lib/ecosystem/core/sites.js +10 -0
- package/lib/ecosystem/core/sites.js.map +1 -1
- package/lib/ecosystem/ui/glyphs.d.ts.map +1 -1
- package/lib/ecosystem/ui/glyphs.js +3 -0
- package/lib/ecosystem/ui/glyphs.js.map +1 -1
- package/lib/orbital/ui/AtomicOrbitalCanvas.d.ts +5 -0
- package/lib/orbital/ui/AtomicOrbitalCanvas.d.ts.map +1 -1
- package/lib/orbital/ui/AtomicOrbitalCanvas.js +4 -3
- package/lib/orbital/ui/AtomicOrbitalCanvas.js.map +1 -1
- package/lib/orbital/ui/AtomicOrbitalViewer.d.ts +5 -0
- package/lib/orbital/ui/AtomicOrbitalViewer.d.ts.map +1 -1
- package/lib/orbital/ui/AtomicOrbitalViewer.js.map +1 -1
- package/lib/orbital/ui/axesGeometry.d.ts +27 -0
- package/lib/orbital/ui/axesGeometry.d.ts.map +1 -0
- package/lib/orbital/ui/axesGeometry.js +74 -0
- package/lib/orbital/ui/axesGeometry.js.map +1 -0
- package/lib/orbital/ui/camera.d.ts +7 -0
- package/lib/orbital/ui/camera.d.ts.map +1 -1
- package/lib/orbital/ui/camera.js +8 -1
- package/lib/orbital/ui/camera.js.map +1 -1
- package/lib/orbital/ui/renderAxes.d.ts +53 -0
- package/lib/orbital/ui/renderAxes.d.ts.map +1 -0
- package/lib/orbital/ui/renderAxes.js +110 -0
- package/lib/orbital/ui/renderAxes.js.map +1 -0
- package/lib/orbital/ui/viewer.d.ts +16 -0
- package/lib/orbital/ui/viewer.d.ts.map +1 -1
- package/lib/orbital/ui/viewer.js +24 -2
- package/lib/orbital/ui/viewer.js.map +1 -1
- package/lib/seo/core/documentMeta.d.ts +2 -2
- package/lib/seo/core/documentMeta.js +4 -3
- package/lib/seo/core/documentMeta.js.map +1 -1
- package/lib/seo/core/index.d.ts +13 -4
- package/lib/seo/core/index.d.ts.map +1 -1
- package/lib/seo/core/index.js +8 -3
- package/lib/seo/core/index.js.map +1 -1
- package/lib/seo/core/noscript.d.ts +97 -0
- package/lib/seo/core/noscript.d.ts.map +1 -0
- package/lib/seo/core/noscript.js +93 -0
- package/lib/seo/core/noscript.js.map +1 -0
- package/lib/seo/core/pageMeta.d.ts +30 -14
- package/lib/seo/core/pageMeta.d.ts.map +1 -1
- package/lib/seo/core/pageMeta.js +40 -43
- package/lib/seo/core/pageMeta.js.map +1 -1
- package/lib/seo/core/robots.d.ts +55 -0
- package/lib/seo/core/robots.d.ts.map +1 -0
- package/lib/seo/core/robots.js +70 -0
- package/lib/seo/core/robots.js.map +1 -0
- package/lib/seo/core/routes.d.ts +73 -5
- package/lib/seo/core/routes.d.ts.map +1 -1
- package/lib/seo/core/routes.js +142 -16
- package/lib/seo/core/routes.js.map +1 -1
- package/lib/seo/core/siteFiles.d.ts +39 -43
- package/lib/seo/core/siteFiles.d.ts.map +1 -1
- package/lib/seo/core/siteFiles.js +53 -69
- package/lib/seo/core/siteFiles.js.map +1 -1
- package/lib/seo/core/startDocumentMeta.d.ts +44 -0
- package/lib/seo/core/startDocumentMeta.d.ts.map +1 -0
- package/lib/seo/core/startDocumentMeta.js +47 -0
- package/lib/seo/core/startDocumentMeta.js.map +1 -0
- package/lib/seo/core/structuredData.d.ts +48 -0
- package/lib/seo/core/structuredData.d.ts.map +1 -0
- package/lib/seo/core/structuredData.js +41 -0
- package/lib/seo/core/structuredData.js.map +1 -0
- package/lib/seo/core/template.d.ts +48 -0
- package/lib/seo/core/template.d.ts.map +1 -0
- package/lib/seo/core/template.js +53 -0
- package/lib/seo/core/template.js.map +1 -0
- package/lib/seo/vite/ogCard.d.ts +9 -1
- package/lib/seo/vite/ogCard.d.ts.map +1 -1
- package/lib/seo/vite/ogCard.js +14 -4
- package/lib/seo/vite/ogCard.js.map +1 -1
- package/lib/seo/vite/prerender.d.ts +38 -7
- package/lib/seo/vite/prerender.d.ts.map +1 -1
- package/lib/seo/vite/prerender.js +68 -30
- package/lib/seo/vite/prerender.js.map +1 -1
- package/package.json +1 -1
- package/src/ecosystem/core/sites.ts +11 -0
- package/src/ecosystem/ui/glyphs.tsx +19 -0
- package/src/orbital/ui/AtomicOrbitalCanvas.tsx +9 -2
- package/src/orbital/ui/AtomicOrbitalViewer.tsx +5 -0
- package/src/orbital/ui/axesGeometry.ts +91 -0
- package/src/orbital/ui/camera.ts +9 -1
- package/src/orbital/ui/renderAxes.ts +190 -0
- package/src/orbital/ui/viewer.ts +32 -2
- package/src/seo/core/documentMeta.ts +5 -5
- package/src/seo/core/index.ts +19 -12
- package/src/seo/core/noscript.ts +195 -0
- package/src/seo/core/pageMeta.ts +54 -53
- package/src/seo/core/robots.ts +114 -0
- package/src/seo/core/routes.ts +181 -14
- package/src/seo/core/siteFiles.ts +58 -96
- package/src/seo/core/startDocumentMeta.ts +77 -0
- package/src/seo/core/structuredData.ts +80 -0
- package/src/seo/core/template.ts +54 -0
- package/src/seo/vite/ogCard.ts +15 -5
- package/src/seo/vite/prerender.ts +105 -58
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The sitemap, and what every other file a crawler fetches on its own is
|
|
3
|
+
* derived from: which site is being written, where it is served, and the path
|
|
4
|
+
* it is mounted at.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* A deployment names where it serves the site in full — origin and mount path
|
|
7
|
+
* in one value — because the origin is what a canonical link and a sitemap
|
|
8
|
+
* entry need. The mount is read back out of it here, so the addresses these
|
|
9
|
+
* files hand out start where the site actually answers.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
|
-
import { siteById
|
|
12
|
+
import { siteById } from '../../ecosystem/core/lookup.ts';
|
|
11
13
|
import type { EcosystemSite, SiteId } from '../../ecosystem/core/sites.ts';
|
|
12
|
-
import {
|
|
14
|
+
import { basePathOf } from '../../router/core/basePath.ts';
|
|
15
|
+
import { escapeText } from '../../share/core/escape.ts';
|
|
13
16
|
|
|
14
17
|
import type { RouteMeta } from './routes.ts';
|
|
15
18
|
import { trimTrailingSlash } from './routes.ts';
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
// A crawler fetches what it is given over HTTP, so an origin is written in one
|
|
21
|
+
// of the two schemes it speaks. Parsing alone does not say that: `localhost:3000`
|
|
22
|
+
// parses, with `localhost:` as its scheme and `3000` as its path.
|
|
23
|
+
const HTTP_ORIGIN = /^https?:\/\//i;
|
|
19
24
|
|
|
20
25
|
/** What a crawler is told about the site as a whole. */
|
|
21
26
|
export interface SiteFilesOptions {
|
|
@@ -24,7 +29,9 @@ export interface SiteFilesOptions {
|
|
|
24
29
|
/** Every address it answers. */
|
|
25
30
|
routes: readonly RouteMeta[];
|
|
26
31
|
/**
|
|
27
|
-
*
|
|
32
|
+
* Where the site is served, mount path included, e.g.
|
|
33
|
+
* `https://learn.cheminfo.org/surge`. Every absolute address is built on it,
|
|
34
|
+
* and every path one of these files writes starts at its mount.
|
|
28
35
|
* @default `https://<the site's host>`
|
|
29
36
|
*/
|
|
30
37
|
origin?: string;
|
|
@@ -32,11 +39,20 @@ export interface SiteFilesOptions {
|
|
|
32
39
|
|
|
33
40
|
/**
|
|
34
41
|
* Every routed address, as the sitemap lists them.
|
|
42
|
+
*
|
|
43
|
+
* A sitemap names at least one address: `<url>` is required by the sitemaps.org
|
|
44
|
+
* schema, and `robots.txt` advertises the file, so an empty one is reported as
|
|
45
|
+
* an error on every fetch rather than read as a site with nothing to index.
|
|
35
46
|
* @param options - The site and its routes.
|
|
36
47
|
* @returns The `sitemap.xml` document.
|
|
48
|
+
* @throws {Error} When the site answers no route, or names an origin that is
|
|
49
|
+
* not an absolute address.
|
|
37
50
|
*/
|
|
38
51
|
export function sitemapXml(options: SiteFilesOptions): string {
|
|
39
52
|
const origin = originOf(options);
|
|
53
|
+
if (options.routes.length === 0) {
|
|
54
|
+
throw new Error('a sitemap lists at least one address');
|
|
55
|
+
}
|
|
40
56
|
const entries = options.routes
|
|
41
57
|
.map(
|
|
42
58
|
(route) =>
|
|
@@ -51,100 +67,46 @@ ${entries}
|
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
/**
|
|
54
|
-
* The
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* API prefix and its documentation are not pages. The sitemap is named only
|
|
58
|
-
* because this module also writes it: a `Sitemap:` line pointing at a 404 is
|
|
59
|
-
* reported as an error on every fetch.
|
|
60
|
-
* @param options - The site and its routes.
|
|
61
|
-
* @param disallow - Address prefixes to keep out of the index.
|
|
62
|
-
* @returns The `robots.txt` document.
|
|
70
|
+
* The site these files are being written for.
|
|
71
|
+
* @param site - The site, named or passed.
|
|
72
|
+
* @returns Its record.
|
|
63
73
|
*/
|
|
64
|
-
export function
|
|
65
|
-
|
|
66
|
-
disallow: readonly string[] = [],
|
|
67
|
-
): string {
|
|
68
|
-
const lines = ['User-agent: *', 'Allow: /'];
|
|
69
|
-
for (const path of disallow) lines.push(`Disallow: ${path}`);
|
|
70
|
-
lines.push('', `Sitemap: ${originOf(options)}/sitemap.xml`, '');
|
|
71
|
-
return lines.join('\n');
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** What the structured-data block says the tool is. */
|
|
75
|
-
export interface StructuredDataOptions extends SiteFilesOptions {
|
|
76
|
-
/**
|
|
77
|
-
* The schema.org application category.
|
|
78
|
-
* @default 'EducationalApplication'
|
|
79
|
-
*/
|
|
80
|
-
category?: string;
|
|
81
|
-
/**
|
|
82
|
-
* What the tool needs to run.
|
|
83
|
-
* @default 'Any modern browser'
|
|
84
|
-
*/
|
|
85
|
-
operatingSystem?: string;
|
|
74
|
+
export function resolveSite(site: EcosystemSite | SiteId): EcosystemSite {
|
|
75
|
+
return typeof site === 'string' ? siteById(site) : site;
|
|
86
76
|
}
|
|
87
77
|
|
|
88
78
|
/**
|
|
89
|
-
*
|
|
79
|
+
* Where the site is served, as an absolute address without a trailing slash.
|
|
90
80
|
*
|
|
91
|
-
* It is
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
81
|
+
* It is an absolute `http` or `https` address or it is refused: a canonical
|
|
82
|
+
* link, an `og:url` and a sitemap entry are addresses a crawler resolves on its
|
|
83
|
+
* own, and one written from an origin missing its scheme is resolved against
|
|
84
|
+
* whatever directory the page was fetched from — pointing every page of the
|
|
85
|
+
* site at a sibling of itself. A dev or staging origin written `localhost:3000`
|
|
86
|
+
* is refused for the same reason: it parses, but as a path under a `localhost:`
|
|
87
|
+
* scheme, so the mount read back off it would be `/3000`.
|
|
88
|
+
* @param options - The site and where it is served.
|
|
89
|
+
* @returns The origin, mount path included when the deployment named one.
|
|
90
|
+
* @throws {Error} When the deployment named something that is not an absolute
|
|
91
|
+
* `http` or `https` address.
|
|
95
92
|
*/
|
|
96
|
-
export function
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
applicationCategory: options.category ?? 'EducationalApplication',
|
|
105
|
-
operatingSystem: options.operatingSystem ?? 'Any modern browser',
|
|
106
|
-
offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
|
|
107
|
-
publisher: { '@type': 'Organization', name: 'cheminfo' },
|
|
108
|
-
};
|
|
109
|
-
const json = JSON.stringify(data, null, 2).replaceAll(
|
|
110
|
-
'<',
|
|
111
|
-
SCRIPT_SAFE_LESS_THAN,
|
|
112
|
-
);
|
|
113
|
-
return `<script type="application/ld+json">\n${json}\n</script>`;
|
|
93
|
+
export function originOf(options: SiteFilesOptions): string {
|
|
94
|
+
const origin = options.origin ?? `https://${resolveSite(options.site).host}`;
|
|
95
|
+
if (!HTTP_ORIGIN.test(origin) || !URL.canParse(origin)) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`an origin is an absolute address, e.g. https://surge.cheminfo.org: ${JSON.stringify(origin)}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return trimTrailingSlash(origin);
|
|
114
101
|
}
|
|
115
102
|
|
|
116
103
|
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* @param options - The site and its routes.
|
|
123
|
-
* @returns The `noscript` block, ready to put in the body.
|
|
104
|
+
* The path the deployment is mounted at, read off the address it named.
|
|
105
|
+
* @param options - The site and where it is served.
|
|
106
|
+
* @returns `''` for a site owning its host, `/surge` for one mounted under it.
|
|
107
|
+
* @throws {Error} When the deployment named something that is not an absolute
|
|
108
|
+
* address, so there is no path to read off it.
|
|
124
109
|
*/
|
|
125
|
-
export function
|
|
126
|
-
|
|
127
|
-
const items = options.routes
|
|
128
|
-
.map(
|
|
129
|
-
(route) =>
|
|
130
|
-
` <li><a href="${escapeAttribute(route.path)}">${escapeText(route.title)}</a></li>`,
|
|
131
|
-
)
|
|
132
|
-
.join('\n');
|
|
133
|
-
return `<noscript>
|
|
134
|
-
<h1>${escapeText(siteDisplayName(site))}</h1>
|
|
135
|
-
<p>${escapeText(site.tagline)} This tool needs JavaScript; these are the pages it offers:</p>
|
|
136
|
-
<ul>
|
|
137
|
-
${items}
|
|
138
|
-
</ul>
|
|
139
|
-
</noscript>`;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function resolveSite(site: EcosystemSite | SiteId): EcosystemSite {
|
|
143
|
-
return typeof site === 'string' ? siteById(site) : site;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function originOf(options: SiteFilesOptions): string {
|
|
147
|
-
return trimTrailingSlash(
|
|
148
|
-
options.origin ?? `https://${resolveSite(options.site).host}`,
|
|
149
|
-
);
|
|
110
|
+
export function mountPathOf(options: SiteFilesOptions): string {
|
|
111
|
+
return basePathOf(originOf(options));
|
|
150
112
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep the tab and the canonical link in step with the page on screen.
|
|
3
|
+
*
|
|
4
|
+
* The server, or the build that wrote one file per address, already titled the
|
|
5
|
+
* page it handed out; this is what a move inside the app changes, and what a
|
|
6
|
+
* crawler that renders the page reads afterwards. Every site did the same three
|
|
7
|
+
* things around it — read the address it is on, look it up in its route table,
|
|
8
|
+
* write the head — so all three live here, and a site says only where its
|
|
9
|
+
* address is read and how a change to it is noticed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { writeDocumentMeta } from './documentMeta.ts';
|
|
13
|
+
import type { PageMetaOptions } from './pageMeta.ts';
|
|
14
|
+
import { pageDocumentMeta } from './pageMeta.ts';
|
|
15
|
+
|
|
16
|
+
/** Where a site's address is read, and how a change to it is noticed. */
|
|
17
|
+
export interface StartDocumentMetaOptions extends Omit<
|
|
18
|
+
PageMetaOptions,
|
|
19
|
+
'url' | 'image'
|
|
20
|
+
> {
|
|
21
|
+
/**
|
|
22
|
+
* The address on screen, query string included: a path, or the absolute
|
|
23
|
+
* address read off the page. It is read again on every write, so a `follow`
|
|
24
|
+
* that tracks what it reads — a signals `effect` — notices the next page.
|
|
25
|
+
*/
|
|
26
|
+
url: () => string;
|
|
27
|
+
/**
|
|
28
|
+
* How a change of page is noticed: `effect` from `@preact/signals-react`
|
|
29
|
+
* follows whichever signals `url` reads and hands back the function that
|
|
30
|
+
* stops it. Left out, the head is written once, which is what a site calling
|
|
31
|
+
* this from its own `popstate` handler wants.
|
|
32
|
+
* @default undefined — the head is written once
|
|
33
|
+
*/
|
|
34
|
+
follow?: (write: () => void) => () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Write the head of the page on screen, and keep it in step as the page
|
|
39
|
+
* changes.
|
|
40
|
+
*
|
|
41
|
+
* Nothing happens where there is no document — a prerender script, a unit test
|
|
42
|
+
* of the route table — so this is safe to call from a module either of them
|
|
43
|
+
* imports.
|
|
44
|
+
* @param options - The site, its routes, where its address is read and how a
|
|
45
|
+
* change to it is noticed.
|
|
46
|
+
* @returns The function that stops following, which does nothing when nothing
|
|
47
|
+
* was followed.
|
|
48
|
+
* @throws {Error} When the site answers no route, or names an origin that is
|
|
49
|
+
* not an absolute address.
|
|
50
|
+
*/
|
|
51
|
+
export function startDocumentMeta(
|
|
52
|
+
options: StartDocumentMetaOptions,
|
|
53
|
+
): () => void {
|
|
54
|
+
if (typeof document === 'undefined') return stopNothing;
|
|
55
|
+
|
|
56
|
+
const write = (): void => {
|
|
57
|
+
writeDocumentMeta(
|
|
58
|
+
pageDocumentMeta({
|
|
59
|
+
site: options.site,
|
|
60
|
+
routes: options.routes,
|
|
61
|
+
url: options.url(),
|
|
62
|
+
origin: options.origin,
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return (options.follow ?? writeOnce)(write);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeOnce(write: () => void): () => void {
|
|
71
|
+
write();
|
|
72
|
+
return stopNothing;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function stopNothing(): void {
|
|
76
|
+
// Nothing was followed, so there is nothing to stop.
|
|
77
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One `application/ld+json` block describing the tool.
|
|
3
|
+
*
|
|
4
|
+
* It is the same on every page of a site — what varies per page is the head —
|
|
5
|
+
* so it is written into the built page once rather than per route.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { siteDisplayName } from '../../ecosystem/core/lookup.ts';
|
|
9
|
+
|
|
10
|
+
import type { SiteFilesOptions } from './siteFiles.ts';
|
|
11
|
+
import { originOf, resolveSite } from './siteFiles.ts';
|
|
12
|
+
|
|
13
|
+
/** The sequence that must not appear raw inside a script element. */
|
|
14
|
+
const SCRIPT_SAFE_LESS_THAN = String.raw`\u003c`;
|
|
15
|
+
|
|
16
|
+
/** What the structured-data block says the tool is. */
|
|
17
|
+
export interface StructuredDataOptions extends SiteFilesOptions {
|
|
18
|
+
/**
|
|
19
|
+
* The schema.org application category.
|
|
20
|
+
* @default 'EducationalApplication'
|
|
21
|
+
*/
|
|
22
|
+
category?: string;
|
|
23
|
+
/**
|
|
24
|
+
* What the tool needs to run.
|
|
25
|
+
* @default 'Any modern browser'
|
|
26
|
+
*/
|
|
27
|
+
operatingSystem?: string;
|
|
28
|
+
/**
|
|
29
|
+
* What the tool does, in the words a search result is read in. A site whose
|
|
30
|
+
* indexed sentence says more than the line its tile in the family menu
|
|
31
|
+
* carries writes it here.
|
|
32
|
+
* @default the site's tagline
|
|
33
|
+
*/
|
|
34
|
+
description?: string;
|
|
35
|
+
/**
|
|
36
|
+
* What a browser has to offer for the tool to run. Ours run in the page.
|
|
37
|
+
* @default 'Requires JavaScript'
|
|
38
|
+
*/
|
|
39
|
+
browserRequirements?: string;
|
|
40
|
+
/**
|
|
41
|
+
* The currency the price is quoted in. The price is zero either way, but the
|
|
42
|
+
* pair has to agree with the audience the site is read by.
|
|
43
|
+
* @default 'EUR'
|
|
44
|
+
*/
|
|
45
|
+
currency?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The structured-data block, ready to put in the head.
|
|
50
|
+
*
|
|
51
|
+
* It always says the tool is free: the price is zero, and a block that leaves
|
|
52
|
+
* that implicit is one a rich result declines to show.
|
|
53
|
+
* @param options - The site, and what kind of application it is.
|
|
54
|
+
* @returns The script tag.
|
|
55
|
+
*/
|
|
56
|
+
export function structuredDataScript(options: StructuredDataOptions): string {
|
|
57
|
+
const site = resolveSite(options.site);
|
|
58
|
+
const data = {
|
|
59
|
+
'@context': 'https://schema.org',
|
|
60
|
+
'@type': 'WebApplication',
|
|
61
|
+
name: siteDisplayName(site),
|
|
62
|
+
url: `${originOf(options)}/`,
|
|
63
|
+
description: options.description ?? site.tagline,
|
|
64
|
+
applicationCategory: options.category ?? 'EducationalApplication',
|
|
65
|
+
operatingSystem: options.operatingSystem ?? 'Any modern browser',
|
|
66
|
+
browserRequirements: options.browserRequirements ?? 'Requires JavaScript',
|
|
67
|
+
offers: {
|
|
68
|
+
'@type': 'Offer',
|
|
69
|
+
price: '0',
|
|
70
|
+
priceCurrency: options.currency ?? 'EUR',
|
|
71
|
+
},
|
|
72
|
+
isAccessibleForFree: true,
|
|
73
|
+
publisher: { '@type': 'Organization', name: 'cheminfo' },
|
|
74
|
+
};
|
|
75
|
+
const json = JSON.stringify(data, null, 2).replaceAll(
|
|
76
|
+
'<',
|
|
77
|
+
SCRIPT_SAFE_LESS_THAN,
|
|
78
|
+
);
|
|
79
|
+
return `<script type="application/ld+json">\n${json}\n</script>`;
|
|
80
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a built page lets its head and its body be written.
|
|
3
|
+
*
|
|
4
|
+
* Every address of a site needs its own title, description, canonical and card,
|
|
5
|
+
* and a static build ships one `index.html`. Rather than look for the tags a
|
|
6
|
+
* page already carries and operate on them, the template says where they go:
|
|
7
|
+
* two comments, replaced by what the build or the server writes for the address
|
|
8
|
+
* being answered.
|
|
9
|
+
*
|
|
10
|
+
* ```html
|
|
11
|
+
* <head>
|
|
12
|
+
* <meta charset="utf-8" />
|
|
13
|
+
* <link rel="icon" href="%BASE_URL%favicon.svg" />
|
|
14
|
+
* <!--cheminfo:head-->
|
|
15
|
+
* </head>
|
|
16
|
+
* <body>
|
|
17
|
+
* <div id="root"></div>
|
|
18
|
+
* <!--cheminfo:body-->
|
|
19
|
+
* </body>
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* The template carries no title and no description of its own, so nothing can
|
|
23
|
+
* be duplicated and nothing has to be taken back out. Nothing is parsed and
|
|
24
|
+
* nothing is searched for but the marker, so what the rest of the page holds — a
|
|
25
|
+
* byte order mark, an implicit head, a `</head>` its prose displays or a script
|
|
26
|
+
* quotes, an unterminated comment — cannot reach the result.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Where the head a crawler reads is written. */
|
|
30
|
+
export const PAGE_HEAD_MARKER = '<!--cheminfo:head-->';
|
|
31
|
+
|
|
32
|
+
/** Where the crawl path a visitor with no JavaScript reads is written. */
|
|
33
|
+
export const PAGE_BODY_MARKER = '<!--cheminfo:body-->';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Write content in the place the template kept for it.
|
|
37
|
+
*
|
|
38
|
+
* The marker is consumed, so a page is always filled from the template and
|
|
39
|
+
* never from a filled page: applying this twice throws rather than writing a
|
|
40
|
+
* second head, which is why idempotence is not something the caller has to
|
|
41
|
+
* defend.
|
|
42
|
+
* @param html - The template.
|
|
43
|
+
* @param marker - {@link PAGE_HEAD_MARKER} or {@link PAGE_BODY_MARKER}.
|
|
44
|
+
* @param content - The markup to write, taken as written: no `$&`, `$1` or
|
|
45
|
+
* `$<name>` is expanded.
|
|
46
|
+
* @returns The page.
|
|
47
|
+
* @throws {Error} When the template carries no such marker, rather than
|
|
48
|
+
* silently shipping a page with no head.
|
|
49
|
+
*/
|
|
50
|
+
export function fill(html: string, marker: string, content: string): string {
|
|
51
|
+
const at = html.indexOf(marker);
|
|
52
|
+
if (at === -1) throw new Error(`the page carries no ${marker}`);
|
|
53
|
+
return html.slice(0, at) + content + html.slice(at + marker.length);
|
|
54
|
+
}
|
package/src/seo/vite/ogCard.ts
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
* The card is the site's own mark, its two colours and its name, all read from
|
|
5
5
|
* its record — so it is generated rather than hand-drawn. A mark redrawn in the
|
|
6
6
|
* card is a mark that drifts from the one the site shows.
|
|
7
|
+
*
|
|
8
|
+
* React is reached for only when a card is actually drawn, and never at module
|
|
9
|
+
* load: this module sits in the same entry point as the prerender plugin, which
|
|
10
|
+
* a site must be able to import with none of the optional peers installed.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
|
-
import { createElement } from 'react';
|
|
10
|
-
import { renderToStaticMarkup } from 'react-dom/server';
|
|
11
|
-
|
|
12
13
|
import { siteById } from '../../ecosystem/core/lookup.ts';
|
|
13
14
|
import type { EcosystemSite, SiteId } from '../../ecosystem/core/sites.ts';
|
|
14
|
-
import { SiteMark } from '../../ecosystem/ui/marks.tsx';
|
|
15
15
|
import { escapeText } from '../../share/core/escape.ts';
|
|
16
16
|
|
|
17
17
|
/** The width every card is drawn at. */
|
|
@@ -37,13 +37,23 @@ export interface OgCardOptions {
|
|
|
37
37
|
* Screenshot it at {@link OG_WIDTH} × {@link OG_HEIGHT} — a headless browser is
|
|
38
38
|
* the only thing here that can rasterise it, and every site already has one for
|
|
39
39
|
* its end-to-end tests.
|
|
40
|
+
*
|
|
41
|
+
* Drawing the mark is the one thing here that needs React, so React is loaded
|
|
42
|
+
* on the call rather than by the module. The card is drawn by a build script
|
|
43
|
+
* that already awaits a browser, so the await costs it nothing.
|
|
40
44
|
* @param options - Which site, and what it says.
|
|
41
45
|
* @returns A complete HTML document.
|
|
42
46
|
*/
|
|
43
|
-
export function ogCardHtml(options: OgCardOptions): string {
|
|
47
|
+
export async function ogCardHtml(options: OgCardOptions): Promise<string> {
|
|
44
48
|
const site =
|
|
45
49
|
typeof options.site === 'string' ? siteById(options.site) : options.site;
|
|
46
50
|
const description = options.description ?? site.tagline;
|
|
51
|
+
const [{ createElement }, { renderToStaticMarkup }, { SiteMark }] =
|
|
52
|
+
await Promise.all([
|
|
53
|
+
import('react'),
|
|
54
|
+
import('react-dom/server'),
|
|
55
|
+
import('../../ecosystem/ui/marks.tsx'),
|
|
56
|
+
]);
|
|
47
57
|
const mark = renderToStaticMarkup(
|
|
48
58
|
createElement(SiteMark, { site, size: 132, colors: 'literal' }),
|
|
49
59
|
);
|