react-cheminfo 0.4.0 → 0.4.1

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 (59) hide show
  1. package/lib/ecosystem/core/index.d.ts +1 -1
  2. package/lib/ecosystem/core/index.d.ts.map +1 -1
  3. package/lib/ecosystem/core/index.js +1 -1
  4. package/lib/ecosystem/core/index.js.map +1 -1
  5. package/lib/ecosystem/core/lookup.d.ts +11 -0
  6. package/lib/ecosystem/core/lookup.d.ts.map +1 -1
  7. package/lib/ecosystem/core/lookup.js +15 -0
  8. package/lib/ecosystem/core/lookup.js.map +1 -1
  9. package/lib/ecosystem/core/sites.d.ts +1 -1
  10. package/lib/ecosystem/core/sites.d.ts.map +1 -1
  11. package/lib/ecosystem/core/sites.js +4 -4
  12. package/lib/ecosystem/core/sites.js.map +1 -1
  13. package/lib/ecosystem/ui/glyphs.js +1 -1
  14. package/lib/ecosystem/ui/glyphs.js.map +1 -1
  15. package/lib/seo/core/index.d.ts +6 -0
  16. package/lib/seo/core/index.d.ts.map +1 -1
  17. package/lib/seo/core/index.js +3 -0
  18. package/lib/seo/core/index.js.map +1 -1
  19. package/lib/seo/core/pageMeta.d.ts +60 -0
  20. package/lib/seo/core/pageMeta.d.ts.map +1 -0
  21. package/lib/seo/core/pageMeta.js +91 -0
  22. package/lib/seo/core/pageMeta.js.map +1 -0
  23. package/lib/seo/core/routes.d.ts +52 -0
  24. package/lib/seo/core/routes.d.ts.map +1 -0
  25. package/lib/seo/core/routes.js +62 -0
  26. package/lib/seo/core/routes.js.map +1 -0
  27. package/lib/seo/core/siteFiles.d.ts +73 -0
  28. package/lib/seo/core/siteFiles.d.ts.map +1 -0
  29. package/lib/seo/core/siteFiles.js +100 -0
  30. package/lib/seo/core/siteFiles.js.map +1 -0
  31. package/lib/seo/vite/index.d.ts +5 -0
  32. package/lib/seo/vite/index.d.ts.map +1 -0
  33. package/lib/seo/vite/index.js +3 -0
  34. package/lib/seo/vite/index.js.map +1 -0
  35. package/lib/seo/vite/ogCard.d.ts +33 -0
  36. package/lib/seo/vite/ogCard.d.ts.map +1 -0
  37. package/lib/seo/vite/ogCard.js +72 -0
  38. package/lib/seo/vite/ogCard.js.map +1 -0
  39. package/lib/seo/vite/prerender.d.ts +58 -0
  40. package/lib/seo/vite/prerender.d.ts.map +1 -0
  41. package/lib/seo/vite/prerender.js +76 -0
  42. package/lib/seo/vite/prerender.js.map +1 -0
  43. package/lib/vite.d.ts +2 -0
  44. package/lib/vite.d.ts.map +1 -0
  45. package/lib/vite.js +2 -0
  46. package/lib/vite.js.map +1 -0
  47. package/package.json +7 -2
  48. package/src/ecosystem/core/index.ts +1 -1
  49. package/src/ecosystem/core/lookup.ts +16 -0
  50. package/src/ecosystem/core/sites.ts +5 -5
  51. package/src/ecosystem/ui/glyphs.tsx +1 -1
  52. package/src/seo/core/index.ts +20 -0
  53. package/src/seo/core/pageMeta.ts +127 -0
  54. package/src/seo/core/routes.ts +79 -0
  55. package/src/seo/core/siteFiles.ts +150 -0
  56. package/src/seo/vite/index.ts +4 -0
  57. package/src/seo/vite/ogCard.ts +92 -0
  58. package/src/seo/vite/prerender.ts +156 -0
  59. package/src/vite.ts +1 -0
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The files and blocks a crawler reads besides the head: the sitemap, the
3
+ * robots policy, the structured-data block and the list of addresses a visitor
4
+ * without JavaScript can still follow.
5
+ *
6
+ * All four are derived from the site's own record and its route table, so a
7
+ * page added to the table is added to every one of them at once.
8
+ */
9
+ import { siteById, siteDisplayName } from "../../ecosystem/core/lookup.js";
10
+ import { escapeAttribute, escapeText } from "../../share/core/escape.js";
11
+ import { trimTrailingSlash } from "./routes.js";
12
+ /** The sequence that must not appear raw inside a script element. */
13
+ const SCRIPT_SAFE_LESS_THAN = String.raw `\u003c`;
14
+ /**
15
+ * Every routed address, as the sitemap lists them.
16
+ * @param options - The site and its routes.
17
+ * @returns The `sitemap.xml` document.
18
+ */
19
+ export function sitemapXml(options) {
20
+ const origin = originOf(options);
21
+ const entries = options.routes
22
+ .map((route) => ` <url><loc>${escapeText(`${origin}${route.path}`)}</loc></url>`)
23
+ .join('\n');
24
+ return `<?xml version="1.0" encoding="UTF-8"?>
25
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
26
+ ${entries}
27
+ </urlset>
28
+ `;
29
+ }
30
+ /**
31
+ * The crawl policy.
32
+ *
33
+ * Our tools are meant to be found, so only the endpoints are disallowed — an
34
+ * API prefix and its documentation are not pages. The sitemap is named only
35
+ * because this module also writes it: a `Sitemap:` line pointing at a 404 is
36
+ * reported as an error on every fetch.
37
+ * @param options - The site and its routes.
38
+ * @param disallow - Address prefixes to keep out of the index.
39
+ * @returns The `robots.txt` document.
40
+ */
41
+ export function robotsTxt(options, disallow = []) {
42
+ const lines = ['User-agent: *', 'Allow: /'];
43
+ for (const path of disallow)
44
+ lines.push(`Disallow: ${path}`);
45
+ lines.push('', `Sitemap: ${originOf(options)}/sitemap.xml`, '');
46
+ return lines.join('\n');
47
+ }
48
+ /**
49
+ * One `application/ld+json` block describing the tool.
50
+ *
51
+ * It is the same on every page of a site — what varies per page is the head —
52
+ * so it is written into the built page once rather than per route.
53
+ * @param options - The site, and what kind of application it is.
54
+ * @returns The script tag, ready to put in the head.
55
+ */
56
+ export function structuredDataScript(options) {
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: site.tagline,
64
+ applicationCategory: options.category ?? 'EducationalApplication',
65
+ operatingSystem: options.operatingSystem ?? 'Any modern browser',
66
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
67
+ publisher: { '@type': 'Organization', name: 'cheminfo' },
68
+ };
69
+ const json = JSON.stringify(data, null, 2).replaceAll('<', SCRIPT_SAFE_LESS_THAN);
70
+ return `<script type="application/ld+json">\n${json}\n</script>`;
71
+ }
72
+ /**
73
+ * A readable page for a visitor, or a crawler, with no JavaScript.
74
+ *
75
+ * The body of our sites is an empty root element, so this is the only crawl
76
+ * path through them that costs nothing to render — and it is honest: it says
77
+ * the tool needs JavaScript, and links every address it answers.
78
+ * @param options - The site and its routes.
79
+ * @returns The `noscript` block, ready to put in the body.
80
+ */
81
+ export function noscriptIndex(options) {
82
+ const site = resolveSite(options.site);
83
+ const items = options.routes
84
+ .map((route) => ` <li><a href="${escapeAttribute(route.path)}">${escapeText(route.title)}</a></li>`)
85
+ .join('\n');
86
+ return `<noscript>
87
+ <h1>${escapeText(siteDisplayName(site))}</h1>
88
+ <p>${escapeText(site.tagline)} This tool needs JavaScript; these are the pages it offers:</p>
89
+ <ul>
90
+ ${items}
91
+ </ul>
92
+ </noscript>`;
93
+ }
94
+ function resolveSite(site) {
95
+ return typeof site === 'string' ? siteById(site) : site;
96
+ }
97
+ function originOf(options) {
98
+ return trimTrailingSlash(options.origin ?? `https://${resolveSite(options.site).host}`);
99
+ }
100
+ //# sourceMappingURL=siteFiles.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"siteFiles.js","sourceRoot":"","sources":["../../../src/seo/core/siteFiles.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ export type { OgCardOptions } from './ogCard.ts';
2
+ export { OG_HEIGHT, OG_WIDTH, ogCardHtml } from './ogCard.ts';
3
+ export type { PrerenderOptions } from './prerender.ts';
4
+ export { cheminfoPrerender } from './prerender.ts';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/seo/vite/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9D,YAAY,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { OG_HEIGHT, OG_WIDTH, ogCardHtml } from "./ogCard.js";
2
+ export { cheminfoPrerender } from "./prerender.js";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/seo/vite/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The 1200×630 card a link to a site unfurls into, as a page to screenshot.
3
+ *
4
+ * The card is the site's own mark, its two colours and its name, all read from
5
+ * its record — so it is generated rather than hand-drawn. A mark redrawn in the
6
+ * card is a mark that drifts from the one the site shows.
7
+ */
8
+ import type { EcosystemSite, SiteId } from '../../ecosystem/core/sites.ts';
9
+ /** The width every card is drawn at. */
10
+ export declare const OG_WIDTH = 1200;
11
+ /** The height every card is drawn at. */
12
+ export declare const OG_HEIGHT = 630;
13
+ /** What the card says, beyond the site's own name and mark. */
14
+ export interface OgCardOptions {
15
+ /** The site, named or passed. */
16
+ site: EcosystemSite | SiteId;
17
+ /**
18
+ * The sentence under the name.
19
+ * @default the site's tagline
20
+ */
21
+ description?: string;
22
+ }
23
+ /**
24
+ * The card, as a standalone page.
25
+ *
26
+ * Screenshot it at {@link OG_WIDTH} × {@link OG_HEIGHT} — a headless browser is
27
+ * the only thing here that can rasterise it, and every site already has one for
28
+ * its end-to-end tests.
29
+ * @param options - Which site, and what it says.
30
+ * @returns A complete HTML document.
31
+ */
32
+ export declare function ogCardHtml(options: OgCardOptions): string;
33
+ //# sourceMappingURL=ogCard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ogCard.d.ts","sourceRoot":"","sources":["../../../src/seo/vite/ogCard.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAMH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,+BAA+B,CAAC;AAI3E,wCAAwC;AACxC,eAAO,MAAM,QAAQ,OAAO,CAAC;AAE7B,yCAAyC;AACzC,eAAO,MAAM,SAAS,MAAM,CAAC;AAE7B,+DAA+D;AAC/D,MAAM,WAAW,aAAa;IAC5B,iCAAiC;IACjC,IAAI,EAAE,aAAa,GAAG,MAAM,CAAC;IAC7B;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,aAAa,GAAG,MAAM,CAiDzD"}
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The 1200×630 card a link to a site unfurls into, as a page to screenshot.
3
+ *
4
+ * The card is the site's own mark, its two colours and its name, all read from
5
+ * its record — so it is generated rather than hand-drawn. A mark redrawn in the
6
+ * card is a mark that drifts from the one the site shows.
7
+ */
8
+ import { createElement } from 'react';
9
+ import { renderToStaticMarkup } from 'react-dom/server';
10
+ import { siteById } from "../../ecosystem/core/lookup.js";
11
+ import { SiteMark } from "../../ecosystem/ui/marks.js";
12
+ import { escapeText } from "../../share/core/escape.js";
13
+ /** The width every card is drawn at. */
14
+ export const OG_WIDTH = 1200;
15
+ /** The height every card is drawn at. */
16
+ export const OG_HEIGHT = 630;
17
+ /**
18
+ * The card, as a standalone page.
19
+ *
20
+ * Screenshot it at {@link OG_WIDTH} × {@link OG_HEIGHT} — a headless browser is
21
+ * the only thing here that can rasterise it, and every site already has one for
22
+ * its end-to-end tests.
23
+ * @param options - Which site, and what it says.
24
+ * @returns A complete HTML document.
25
+ */
26
+ export function ogCardHtml(options) {
27
+ const site = typeof options.site === 'string' ? siteById(options.site) : options.site;
28
+ const description = options.description ?? site.tagline;
29
+ const mark = renderToStaticMarkup(createElement(SiteMark, { site, size: 132, colors: 'literal' }));
30
+ const dot = site.name.dot === true ? '<span class="dot">.</span>' : '';
31
+ return `<!doctype html>
32
+ <html lang="en">
33
+ <head>
34
+ <meta charset="utf-8" />
35
+ <style>
36
+ * { box-sizing: border-box; margin: 0; }
37
+ body {
38
+ display: flex;
39
+ width: ${OG_WIDTH}px;
40
+ height: ${OG_HEIGHT}px;
41
+ flex-direction: column;
42
+ justify-content: center;
43
+ padding: 88px;
44
+ background: #ffffff;
45
+ color: #16202c;
46
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
47
+ Helvetica, Arial, sans-serif;
48
+ gap: 28px;
49
+ }
50
+ h1 { font-size: 76px; font-weight: 700; letter-spacing: -0.02em; }
51
+ .lead { color: ${site.brand}; }
52
+ .alt { color: ${site.brandAlt}; }
53
+ .dot { color: #8a96a3; }
54
+ p { max-width: 900px; color: #5b6875; font-size: 34px; line-height: 1.35; }
55
+ .rule {
56
+ width: 180px;
57
+ height: 10px;
58
+ border-radius: 5px;
59
+ background: ${site.brandAlt};
60
+ }
61
+ </style>
62
+ </head>
63
+ <body>
64
+ ${mark}
65
+ <h1><span class="lead">${escapeText(site.name.lead)}</span>${dot}<span class="alt">${escapeText(site.name.alt)}</span></h1>
66
+ <div class="rule"></div>
67
+ <p>${escapeText(description)}</p>
68
+ </body>
69
+ </html>
70
+ `;
71
+ }
72
+ //# sourceMappingURL=ogCard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ogCard.js","sourceRoot":"","sources":["../../../src/seo/vite/ogCard.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AACtC,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAExD,OAAO,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAE1D,OAAO,EAAE,QAAQ,EAAE,MAAM,6BAA8B,CAAC;AACxD,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AAExD,wCAAwC;AACxC,MAAM,CAAC,MAAM,QAAQ,GAAG,IAAI,CAAC;AAE7B,yCAAyC;AACzC,MAAM,CAAC,MAAM,SAAS,GAAG,GAAG,CAAC;AAa7B;;;;;;;;GAQG;AACH,MAAM,UAAU,UAAU,CAAC,OAAsB;IAC/C,MAAM,IAAI,GACR,OAAO,OAAO,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC;IAC3E,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,IAAI,IAAI,CAAC,OAAO,CAAC;IACxD,MAAM,IAAI,GAAG,oBAAoB,CAC/B,aAAa,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,CAChE,CAAC;IACF,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,KAAK,IAAI,CAAC,CAAC,CAAC,4BAA4B,CAAC,CAAC,CAAC,EAAE,CAAC;IAEvE,OAAO;;;;;;;;iBAQQ,QAAQ;kBACP,SAAS;;;;;;;;;;;uBAWJ,IAAI,CAAC,KAAK;sBACX,IAAI,CAAC,QAAQ;;;;;;;sBAOb,IAAI,CAAC,QAAQ;;;;;MAK7B,IAAI;6BACmB,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,GAAG,qBAAqB,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC;;SAEzG,UAAU,CAAC,WAAW,CAAC;;;CAG/B,CAAC;AACF,CAAC"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Write one real HTML file per routed address, and everything else a crawler
3
+ * fetches on its own.
4
+ *
5
+ * A site served by a static image has nothing to rewrite a head per request: a
6
+ * crawler gets whatever came off the wire. Without this every address carries
7
+ * the same title and a search engine folds the whole site into one result.
8
+ *
9
+ * These files are also what makes the server's catch-all fallback unnecessary.
10
+ * Every address the tool answers is on disk, so an address that is *not* on
11
+ * disk is genuinely not a page and must 404 rather than serving the tool under
12
+ * a name it does not have.
13
+ */
14
+ import type { Plugin } from 'vite';
15
+ import type { EcosystemSite, SiteId } from '../../ecosystem/core/sites.ts';
16
+ import type { RouteMeta } from '../core/routes.ts';
17
+ /** What the build needs to know to write the site's addresses. */
18
+ export interface PrerenderOptions {
19
+ /** The site, named or passed. */
20
+ site: EcosystemSite | SiteId;
21
+ /** Every address it answers, each with its title and description. */
22
+ routes: readonly RouteMeta[];
23
+ /**
24
+ * Origin every absolute address is built on.
25
+ * @default `https://<the site's host>`
26
+ */
27
+ origin?: string;
28
+ /**
29
+ * Address prefixes `robots.txt` keeps out of the index, e.g. `/v1/`. Set to
30
+ * `false` to write no `robots.txt` at all, for a site that ships its own.
31
+ * @default []
32
+ */
33
+ robots?: false | readonly string[];
34
+ /**
35
+ * The schema.org category of the structured-data block, or `false` to write
36
+ * none.
37
+ * @default 'EducationalApplication'
38
+ */
39
+ category?: false | string;
40
+ /**
41
+ * What the tool needs to run, named in the structured-data block.
42
+ * @default 'Any modern browser'
43
+ */
44
+ operatingSystem?: string;
45
+ /**
46
+ * Whether the built page carries a `noscript` index of the addresses. It is
47
+ * the only crawl path through a site whose body is an empty root element.
48
+ * @default true
49
+ */
50
+ noscript?: boolean;
51
+ }
52
+ /**
53
+ * Prerender every routed address of a cheminfo site.
54
+ * @param options - The site, its routes, and what a crawler is told.
55
+ * @returns The Vite plugin.
56
+ */
57
+ export declare function cheminfoPrerender(options: PrerenderOptions): Plugin;
58
+ //# sourceMappingURL=prerender.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prerender.d.ts","sourceRoot":"","sources":["../../../src/seo/vite/prerender.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,OAAO,KAAK,EAAU,MAAM,EAAE,MAAM,MAAM,CAAC;AAE3C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,+BAA+B,CAAC;AAE3E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAQnD,kEAAkE;AAClE,MAAM,WAAW,gBAAgB;IAC/B,iCAAiC;IACjC,IAAI,EAAE,aAAa,GAAG,MAAM,CAAC;IAC7B,qEAAqE;IACrE,MAAM,EAAE,SAAS,SAAS,EAAE,CAAC;IAC7B;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,MAAM,CAAC,EAAE,KAAK,GAAG,SAAS,MAAM,EAAE,CAAC;IACnC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IAC1B;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,gBAAgB,GAAG,MAAM,CA+EnE"}
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Write one real HTML file per routed address, and everything else a crawler
3
+ * fetches on its own.
4
+ *
5
+ * A site served by a static image has nothing to rewrite a head per request: a
6
+ * crawler gets whatever came off the wire. Without this every address carries
7
+ * the same title and a search engine folds the whole site into one result.
8
+ *
9
+ * These files are also what makes the server's catch-all fallback unnecessary.
10
+ * Every address the tool answers is on disk, so an address that is *not* on
11
+ * disk is genuinely not a page and must 404 rather than serving the tool under
12
+ * a name it does not have.
13
+ */
14
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { dirname, join, resolve } from 'node:path';
16
+ import { injectPageMeta, insertBeforeHeadEnd } from "../core/pageMeta.js";
17
+ import { noscriptIndex, robotsTxt, sitemapXml, structuredDataScript, } from "../core/siteFiles.js";
18
+ /**
19
+ * Prerender every routed address of a cheminfo site.
20
+ * @param options - The site, its routes, and what a crawler is told.
21
+ * @returns The Vite plugin.
22
+ */
23
+ export function cheminfoPrerender(options) {
24
+ const { site, routes, origin, robots = [], category, operatingSystem, noscript = true, } = options;
25
+ let out = 'dist';
26
+ let logger = null;
27
+ return {
28
+ name: 'cheminfo:prerender',
29
+ apply: 'build',
30
+ configResolved(config) {
31
+ out = resolve(config.root, config.build.outDir);
32
+ logger = config.logger;
33
+ },
34
+ transformIndexHtml: {
35
+ order: 'post',
36
+ handler(html) {
37
+ let page = html;
38
+ if (category !== false) {
39
+ page = insertBeforeHeadEnd(page, structuredDataScript({
40
+ site,
41
+ routes,
42
+ origin,
43
+ category,
44
+ operatingSystem,
45
+ }));
46
+ }
47
+ if (noscript) {
48
+ page = insertBeforeBodyEnd(page, noscriptIndex({ site, routes, origin }));
49
+ }
50
+ return page;
51
+ },
52
+ },
53
+ closeBundle() {
54
+ const index = readFileSync(join(out, 'index.html'), 'utf8');
55
+ for (const route of routes) {
56
+ const file = route.path === '/'
57
+ ? join(out, 'index.html')
58
+ : join(out, route.path.slice(1), 'index.html');
59
+ mkdirSync(dirname(file), { recursive: true });
60
+ writeFileSync(file, injectPageMeta(index, { site, routes, origin, url: route.path }));
61
+ }
62
+ writeFileSync(join(out, 'sitemap.xml'), sitemapXml({ site, routes, origin }));
63
+ if (robots !== false) {
64
+ writeFileSync(join(out, 'robots.txt'), robotsTxt({ site, routes, origin }, robots));
65
+ }
66
+ logger?.info(`${routes.length} pages prerendered, and listed in sitemap.xml`);
67
+ },
68
+ };
69
+ }
70
+ function insertBeforeBodyEnd(html, addition) {
71
+ const body = html.lastIndexOf('</body>');
72
+ if (body === -1)
73
+ return `${html}\n${addition}\n`;
74
+ return `${html.slice(0, body)}${addition}\n${html.slice(body)}`;
75
+ }
76
+ //# sourceMappingURL=prerender.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prerender.js","sourceRoot":"","sources":["../../../src/seo/vite/prerender.ts"],"names":[],"mappings":"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"}
package/lib/vite.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './seo/vite/index.ts';
2
+ //# sourceMappingURL=vite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vite.d.ts","sourceRoot":"","sources":["../src/vite.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAC"}
package/lib/vite.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./seo/vite/index.js";
2
+ //# sourceMappingURL=vite.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vite.js","sourceRoot":"","sources":["../src/vite.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-cheminfo",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "license": "MIT",
5
5
  "description": "Assembled React components shared by the cheminfo sites: a Cite button and an ecosystem menu",
6
6
  "author": "Luc Patiny",
@@ -16,6 +16,7 @@
16
16
  "./orbital": "./lib/orbital.js",
17
17
  "./structure": "./lib/structure.js",
18
18
  "./ui": "./lib/ui.js",
19
+ "./vite": "./lib/vite.js",
19
20
  "./styles/*.css": "./styles/*.css"
20
21
  },
21
22
  "files": [
@@ -76,7 +77,8 @@
76
77
  "react": "^18.3.1 || >=19",
77
78
  "react-dom": "^18.3.1 || >=19",
78
79
  "react-ocl": "^8.7.1",
79
- "react-science": "^20.6.2 || ^21.0.0 || ^22.0.0 || ^23.0.0"
80
+ "react-science": "^20.6.2 || ^21.0.0 || ^22.0.0 || ^23.0.0",
81
+ "vite": "^7.0.0 || ^8.0.0"
80
82
  },
81
83
  "repository": {
82
84
  "type": "git",
@@ -95,6 +97,9 @@
95
97
  },
96
98
  "react-ocl": {
97
99
  "optional": true
100
+ },
101
+ "vite": {
102
+ "optional": true
98
103
  }
99
104
  }
100
105
  }
@@ -1,5 +1,5 @@
1
1
  export { renderEcosystemLinksHtml } from './links.ts';
2
- export { findSiteByHost, siteById } from './lookup.ts';
2
+ export { findSiteByHost, siteById, siteDisplayName } from './lookup.ts';
3
3
  export type {
4
4
  EcosystemSite,
5
5
  SiteId,
@@ -14,6 +14,22 @@ export function siteById(id: SiteId): EcosystemSite {
14
14
  throw new Error(`unknown ecosystem site: ${id}`);
15
15
  }
16
16
 
17
+ /**
18
+ * The site's name as prose, for a page title, a card or a citation.
19
+ *
20
+ * An address-shaped name is written as the address it is — `3d` and `cheminfo`
21
+ * are two halves of `3d.cheminfo.org`, and joining them would read `3dcheminfo`
22
+ * — while a product-shaped one is its two halves run together, as its wordmark
23
+ * sets them.
24
+ * @param site - The site being named.
25
+ * @returns What the site is called, in one string.
26
+ */
27
+ export function siteDisplayName(site: EcosystemSite): string {
28
+ return site.name.dot === true
29
+ ? site.host
30
+ : `${site.name.lead}${site.name.alt}`;
31
+ }
32
+
17
33
  /**
18
34
  * The site a page is being served from, so a header can mark itself as the
19
35
  * current one without being told which site it belongs to.
@@ -14,7 +14,7 @@ export type SiteId =
14
14
  | 'equilibrium'
15
15
  | 'polycarp'
16
16
  | '3d'
17
- | 'pt';
17
+ | 'periodic-table';
18
18
 
19
19
  export interface SiteName {
20
20
  /** First half of the name, set in the site's leading colour. */
@@ -210,10 +210,10 @@ export const ECOSYSTEM_SITES: readonly EcosystemSite[] = [
210
210
  mark: { plate: '#155e75', accent: '#f97316' },
211
211
  },
212
212
  {
213
- id: 'pt',
214
- name: { lead: 'pt', alt: 'cheminfo', dot: true },
215
- host: 'pt.cheminfo.org',
216
- repository: 'https://github.com/cheminfo/pt.cheminfo.org',
213
+ id: 'periodic-table',
214
+ name: { lead: 'Periodic', alt: 'Table' },
215
+ host: 'periodic-table.cheminfo.org',
216
+ repository: 'https://github.com/cheminfo/periodic-table.cheminfo.org',
217
217
  tagline: 'The periodic table, coloured, plotted and taken apart.',
218
218
  brand: '#15803d',
219
219
  brandAlt: '#a16207',
@@ -214,7 +214,7 @@ export const GLYPHS: Record<SiteId, (alt: string) => ReactNode> = {
214
214
  // The stepped corner of the table, with the one cell a visitor has picked
215
215
  // carrying the accent — which is what every tool of that site is: the table,
216
216
  // and one element read off it.
217
- pt: (alt) => (
217
+ 'periodic-table': (alt) => (
218
218
  <>
219
219
  <g fill="#ffffff" opacity="0.55">
220
220
  <rect x="5" y="6" width="5" height="5" rx="1" />
@@ -4,3 +4,23 @@ export {
4
4
  documentTitle,
5
5
  writeDocumentMeta,
6
6
  } from './documentMeta.ts';
7
+ export type { PageMetaOptions } from './pageMeta.ts';
8
+ export {
9
+ injectPageMeta,
10
+ insertBeforeHeadEnd,
11
+ pageDocumentMeta,
12
+ } from './pageMeta.ts';
13
+ export type { RouteMeta } from './routes.ts';
14
+ export {
15
+ homeRoute,
16
+ pageMetaFor,
17
+ routeFor,
18
+ trimTrailingSlash,
19
+ } from './routes.ts';
20
+ export type { SiteFilesOptions, StructuredDataOptions } from './siteFiles.ts';
21
+ export {
22
+ noscriptIndex,
23
+ robotsTxt,
24
+ sitemapXml,
25
+ structuredDataScript,
26
+ } from './siteFiles.ts';
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The head of the page a crawler is handed.
3
+ *
4
+ * Googlebot renders JavaScript, but Bing, a Slack unfurl, an LMS preview and
5
+ * every academic indexer read the HTML that came off the wire — so the title,
6
+ * the description and the canonical of a page must already be in it. A site
7
+ * with a server rewrites them per request; a static one writes one file per
8
+ * address at build time. Both call this, which is pure string work: no
9
+ * `window`, no `node:fs`.
10
+ */
11
+
12
+ import { siteById, siteDisplayName } from '../../ecosystem/core/lookup.ts';
13
+ import type { EcosystemSite, SiteId } from '../../ecosystem/core/sites.ts';
14
+ import { escapeAttribute, escapeText } from '../../share/core/escape.ts';
15
+
16
+ import type { DocumentMeta } from './documentMeta.ts';
17
+ import type { RouteMeta } from './routes.ts';
18
+ import { pageMetaFor, trimTrailingSlash } from './routes.ts';
19
+
20
+ /** Which site is being served, and what it answers. */
21
+ export interface PageMetaOptions {
22
+ /** The site, named or passed. */
23
+ site: EcosystemSite | SiteId;
24
+ /** Every address it answers, each with its title and description. */
25
+ routes: readonly RouteMeta[];
26
+ /** The address being written, query string included. */
27
+ url: string;
28
+ /**
29
+ * Origin every absolute address is built on. A server passes the one the
30
+ * request arrived on; a build leaves it out and the site's own host is used.
31
+ * @default `https://<the site's host>`
32
+ */
33
+ origin?: string;
34
+ /**
35
+ * The card a link to the page unfurls into, as an absolute address or a path.
36
+ * @default '/og.png'
37
+ */
38
+ image?: string;
39
+ }
40
+
41
+ /**
42
+ * Give a page the title, the description and the canonical address of the route
43
+ * it answers, plus the card a link to it unfurls into.
44
+ * @param html - The built page.
45
+ * @param options - Which site, which address, and where it is served from.
46
+ * @returns The page, with its head rewritten for that route.
47
+ */
48
+ export function injectPageMeta(html: string, options: PageMetaOptions): string {
49
+ const site = resolveSite(options.site);
50
+ const meta = pageMetaFor(options.routes, options.url);
51
+ const name = siteDisplayName(site);
52
+ const origin = trimTrailingSlash(options.origin ?? `https://${site.host}`);
53
+ const { title, canonical } = pageDocumentMeta(options);
54
+ const image = absolute(options.image ?? '/og.png', origin);
55
+
56
+ const head = [
57
+ `<link rel="canonical" href="${escapeAttribute(canonical)}" />`,
58
+ '<meta property="og:type" content="website" />',
59
+ `<meta property="og:site_name" content="${escapeAttribute(name)}" />`,
60
+ `<meta property="og:title" content="${escapeAttribute(title)}" />`,
61
+ `<meta property="og:description" content="${escapeAttribute(meta.description)}" />`,
62
+ `<meta property="og:url" content="${escapeAttribute(canonical)}" />`,
63
+ `<meta property="og:image" content="${escapeAttribute(image)}" />`,
64
+ '<meta name="twitter:card" content="summary_large_image" />',
65
+ ].join('\n');
66
+
67
+ return insertBeforeHeadEnd(
68
+ replaceDescription(replaceTitle(html, title), meta.description),
69
+ head,
70
+ );
71
+ }
72
+
73
+ /**
74
+ * What the page on screen is called and where it is indexed, for the app to
75
+ * write after an in-app move.
76
+ *
77
+ * The same title and canonical the build wrote into the file it served, so a
78
+ * click that changes the page cannot disagree with the page a crawler fetched.
79
+ * @param options - Which site, which address, and where it is served from.
80
+ * @returns The title, the description and the canonical of that address.
81
+ */
82
+ export function pageDocumentMeta(options: PageMetaOptions): Required<DocumentMeta> {
83
+ const site = resolveSite(options.site);
84
+ const meta = pageMetaFor(options.routes, options.url);
85
+ const origin = trimTrailingSlash(options.origin ?? `https://${site.host}`);
86
+ return {
87
+ title: `${meta.title} — ${siteDisplayName(site)}`,
88
+ description: meta.description,
89
+ canonical: `${origin}${meta.path}`,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Put an addition at the end of the head, where a tracking snippet and a
95
+ * structured-data block both belong.
96
+ * @param html - The page.
97
+ * @param addition - The markup to add, taken as written.
98
+ * @returns The page, with the addition before `</head>`.
99
+ */
100
+ export function insertBeforeHeadEnd(html: string, addition: string): string {
101
+ const head = html.lastIndexOf('</head>');
102
+ if (head === -1) return `${html}\n${addition}\n`;
103
+ return `${html.slice(0, head)}${addition}\n${html.slice(head)}`;
104
+ }
105
+
106
+ function resolveSite(site: EcosystemSite | SiteId): EcosystemSite {
107
+ return typeof site === 'string' ? siteById(site) : site;
108
+ }
109
+
110
+ function absolute(target: string, origin: string): string {
111
+ return target.startsWith('/') ? `${origin}${target}` : target;
112
+ }
113
+
114
+ function replaceTitle(html: string, title: string): string {
115
+ const replacement = `<title>${escapeText(title)}</title>`;
116
+ return html.includes('<title>')
117
+ ? html.replace(/<title>[\s\S]*?<\/title>/, replacement)
118
+ : insertBeforeHeadEnd(html, replacement);
119
+ }
120
+
121
+ function replaceDescription(html: string, description: string): string {
122
+ const replacement = `<meta name="description" content="${escapeAttribute(description)}" />`;
123
+ const existing = /<meta[^>]*name="description"[^>]*>/;
124
+ return existing.test(html)
125
+ ? html.replace(existing, replacement)
126
+ : insertBeforeHeadEnd(html, replacement);
127
+ }