react-cheminfo 0.4.0 → 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.
Files changed (119) hide show
  1. package/README.md +182 -34
  2. package/lib/ecosystem/core/index.d.ts +1 -1
  3. package/lib/ecosystem/core/index.d.ts.map +1 -1
  4. package/lib/ecosystem/core/index.js +1 -1
  5. package/lib/ecosystem/core/index.js.map +1 -1
  6. package/lib/ecosystem/core/lookup.d.ts +11 -0
  7. package/lib/ecosystem/core/lookup.d.ts.map +1 -1
  8. package/lib/ecosystem/core/lookup.js +15 -0
  9. package/lib/ecosystem/core/lookup.js.map +1 -1
  10. package/lib/ecosystem/core/sites.d.ts +1 -1
  11. package/lib/ecosystem/core/sites.d.ts.map +1 -1
  12. package/lib/ecosystem/core/sites.js +14 -4
  13. package/lib/ecosystem/core/sites.js.map +1 -1
  14. package/lib/ecosystem/ui/glyphs.d.ts.map +1 -1
  15. package/lib/ecosystem/ui/glyphs.js +4 -1
  16. package/lib/ecosystem/ui/glyphs.js.map +1 -1
  17. package/lib/orbital/ui/AtomicOrbitalCanvas.d.ts +5 -0
  18. package/lib/orbital/ui/AtomicOrbitalCanvas.d.ts.map +1 -1
  19. package/lib/orbital/ui/AtomicOrbitalCanvas.js +4 -3
  20. package/lib/orbital/ui/AtomicOrbitalCanvas.js.map +1 -1
  21. package/lib/orbital/ui/AtomicOrbitalViewer.d.ts +5 -0
  22. package/lib/orbital/ui/AtomicOrbitalViewer.d.ts.map +1 -1
  23. package/lib/orbital/ui/AtomicOrbitalViewer.js.map +1 -1
  24. package/lib/orbital/ui/axesGeometry.d.ts +27 -0
  25. package/lib/orbital/ui/axesGeometry.d.ts.map +1 -0
  26. package/lib/orbital/ui/axesGeometry.js +74 -0
  27. package/lib/orbital/ui/axesGeometry.js.map +1 -0
  28. package/lib/orbital/ui/camera.d.ts +7 -0
  29. package/lib/orbital/ui/camera.d.ts.map +1 -1
  30. package/lib/orbital/ui/camera.js +8 -1
  31. package/lib/orbital/ui/camera.js.map +1 -1
  32. package/lib/orbital/ui/renderAxes.d.ts +53 -0
  33. package/lib/orbital/ui/renderAxes.d.ts.map +1 -0
  34. package/lib/orbital/ui/renderAxes.js +110 -0
  35. package/lib/orbital/ui/renderAxes.js.map +1 -0
  36. package/lib/orbital/ui/viewer.d.ts +16 -0
  37. package/lib/orbital/ui/viewer.d.ts.map +1 -1
  38. package/lib/orbital/ui/viewer.js +24 -2
  39. package/lib/orbital/ui/viewer.js.map +1 -1
  40. package/lib/seo/core/documentMeta.d.ts +2 -2
  41. package/lib/seo/core/documentMeta.js +4 -3
  42. package/lib/seo/core/documentMeta.js.map +1 -1
  43. package/lib/seo/core/index.d.ts +15 -0
  44. package/lib/seo/core/index.d.ts.map +1 -1
  45. package/lib/seo/core/index.js +8 -0
  46. package/lib/seo/core/index.js.map +1 -1
  47. package/lib/seo/core/noscript.d.ts +97 -0
  48. package/lib/seo/core/noscript.d.ts.map +1 -0
  49. package/lib/seo/core/noscript.js +93 -0
  50. package/lib/seo/core/noscript.js.map +1 -0
  51. package/lib/seo/core/pageMeta.d.ts +76 -0
  52. package/lib/seo/core/pageMeta.d.ts.map +1 -0
  53. package/lib/seo/core/pageMeta.js +88 -0
  54. package/lib/seo/core/pageMeta.js.map +1 -0
  55. package/lib/seo/core/robots.d.ts +55 -0
  56. package/lib/seo/core/robots.d.ts.map +1 -0
  57. package/lib/seo/core/robots.js +70 -0
  58. package/lib/seo/core/robots.js.map +1 -0
  59. package/lib/seo/core/routes.d.ts +120 -0
  60. package/lib/seo/core/routes.d.ts.map +1 -0
  61. package/lib/seo/core/routes.js +188 -0
  62. package/lib/seo/core/routes.js.map +1 -0
  63. package/lib/seo/core/siteFiles.d.ts +69 -0
  64. package/lib/seo/core/siteFiles.d.ts.map +1 -0
  65. package/lib/seo/core/siteFiles.js +84 -0
  66. package/lib/seo/core/siteFiles.js.map +1 -0
  67. package/lib/seo/core/startDocumentMeta.d.ts +44 -0
  68. package/lib/seo/core/startDocumentMeta.d.ts.map +1 -0
  69. package/lib/seo/core/startDocumentMeta.js +47 -0
  70. package/lib/seo/core/startDocumentMeta.js.map +1 -0
  71. package/lib/seo/core/structuredData.d.ts +48 -0
  72. package/lib/seo/core/structuredData.d.ts.map +1 -0
  73. package/lib/seo/core/structuredData.js +41 -0
  74. package/lib/seo/core/structuredData.js.map +1 -0
  75. package/lib/seo/core/template.d.ts +48 -0
  76. package/lib/seo/core/template.d.ts.map +1 -0
  77. package/lib/seo/core/template.js +53 -0
  78. package/lib/seo/core/template.js.map +1 -0
  79. package/lib/seo/vite/index.d.ts +5 -0
  80. package/lib/seo/vite/index.d.ts.map +1 -0
  81. package/lib/seo/vite/index.js +3 -0
  82. package/lib/seo/vite/index.js.map +1 -0
  83. package/lib/seo/vite/ogCard.d.ts +41 -0
  84. package/lib/seo/vite/ogCard.d.ts.map +1 -0
  85. package/lib/seo/vite/ogCard.js +82 -0
  86. package/lib/seo/vite/ogCard.js.map +1 -0
  87. package/lib/seo/vite/prerender.d.ts +89 -0
  88. package/lib/seo/vite/prerender.d.ts.map +1 -0
  89. package/lib/seo/vite/prerender.js +114 -0
  90. package/lib/seo/vite/prerender.js.map +1 -0
  91. package/lib/vite.d.ts +2 -0
  92. package/lib/vite.d.ts.map +1 -0
  93. package/lib/vite.js +2 -0
  94. package/lib/vite.js.map +1 -0
  95. package/package.json +7 -2
  96. package/src/ecosystem/core/index.ts +1 -1
  97. package/src/ecosystem/core/lookup.ts +16 -0
  98. package/src/ecosystem/core/sites.ts +16 -5
  99. package/src/ecosystem/ui/glyphs.tsx +20 -1
  100. package/src/orbital/ui/AtomicOrbitalCanvas.tsx +9 -2
  101. package/src/orbital/ui/AtomicOrbitalViewer.tsx +5 -0
  102. package/src/orbital/ui/axesGeometry.ts +91 -0
  103. package/src/orbital/ui/camera.ts +9 -1
  104. package/src/orbital/ui/renderAxes.ts +190 -0
  105. package/src/orbital/ui/viewer.ts +32 -2
  106. package/src/seo/core/documentMeta.ts +5 -5
  107. package/src/seo/core/index.ts +27 -0
  108. package/src/seo/core/noscript.ts +195 -0
  109. package/src/seo/core/pageMeta.ts +128 -0
  110. package/src/seo/core/robots.ts +114 -0
  111. package/src/seo/core/routes.ts +246 -0
  112. package/src/seo/core/siteFiles.ts +112 -0
  113. package/src/seo/core/startDocumentMeta.ts +77 -0
  114. package/src/seo/core/structuredData.ts +80 -0
  115. package/src/seo/core/template.ts +54 -0
  116. package/src/seo/vite/index.ts +4 -0
  117. package/src/seo/vite/ogCard.ts +102 -0
  118. package/src/seo/vite/prerender.ts +203 -0
  119. package/src/vite.ts +1 -0
@@ -0,0 +1,128 @@
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 writes 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
+ * The page it is given is the template, which declares where its head goes and
12
+ * carries none of its own, so this only ever writes — see `./template.ts`.
13
+ */
14
+
15
+ import { siteDisplayName } from '../../ecosystem/core/lookup.ts';
16
+ import type { EcosystemSite, SiteId } from '../../ecosystem/core/sites.ts';
17
+ import { escapeAttribute, escapeText } from '../../share/core/escape.ts';
18
+
19
+ import type { DocumentMeta } from './documentMeta.ts';
20
+ import type { RouteMeta } from './routes.ts';
21
+ import { pageMetaFor } from './routes.ts';
22
+ import { mountPathOf, originOf, resolveSite } from './siteFiles.ts';
23
+ import { PAGE_HEAD_MARKER, fill } from './template.ts';
24
+
25
+ /** Which site is being served, and what it answers. */
26
+ export interface PageMetaOptions {
27
+ /** The site, named or passed. */
28
+ site: EcosystemSite | SiteId;
29
+ /** Every address it answers, each with its title and description. */
30
+ routes: readonly RouteMeta[];
31
+ /**
32
+ * The address being written, query string included: a path, or the absolute
33
+ * address an app reads off the page it is on.
34
+ */
35
+ url: string;
36
+ /**
37
+ * Where the site is served, mount path included, e.g.
38
+ * `https://learn.cheminfo.org/surge`. A server passes the one the request
39
+ * arrived on; a build leaves it out and the site's own host is used. Every
40
+ * address written here is composed on it, so the mount is carried by the
41
+ * origin rather than applied a second time. It is an absolute address, or it
42
+ * is refused.
43
+ * @default `https://<the site's host>`
44
+ */
45
+ origin?: string;
46
+ /**
47
+ * The card a link to the page unfurls into, as an absolute address or a path.
48
+ * @default '/og.png'
49
+ */
50
+ image?: string;
51
+ }
52
+
53
+ /**
54
+ * Give a page the title, the description and the canonical address of the route
55
+ * it answers, plus the card a link to it unfurls into.
56
+ * @param html - The built template.
57
+ * @param options - Which site, which address, and where it is served from.
58
+ * @returns The page, with its head written for that route.
59
+ * @throws {Error} When the page carries no `<!--cheminfo:head-->`, when the
60
+ * site answers no route, or when it names an origin that is not an absolute
61
+ * address.
62
+ */
63
+ export function injectPageMeta(html: string, options: PageMetaOptions): string {
64
+ return fill(html, PAGE_HEAD_MARKER, pageHeadTags(options));
65
+ }
66
+
67
+ /**
68
+ * The head a route is indexed and shared under, for a caller writing more into
69
+ * the same place — a structured-data block, a tracking snippet.
70
+ * @param options - Which site, which address, and where it is served from.
71
+ * @returns The tags, one per line.
72
+ * @throws {Error} When the site answers no route, or names an origin that is
73
+ * not an absolute address.
74
+ */
75
+ export function pageHeadTags(options: PageMetaOptions): string {
76
+ const name = siteDisplayName(resolveSite(options.site));
77
+ const description = routeMetaOf(options).description;
78
+ const origin = originOf(options);
79
+ const { title, canonical } = pageDocumentMeta(options);
80
+ const image = absolute(options.image ?? '/og.png', origin);
81
+
82
+ return [
83
+ `<title>${escapeText(title)}</title>`,
84
+ `<meta name="description" content="${escapeAttribute(description)}" />`,
85
+ `<link rel="canonical" href="${escapeAttribute(canonical)}" />`,
86
+ '<meta property="og:type" content="website" />',
87
+ `<meta property="og:site_name" content="${escapeAttribute(name)}" />`,
88
+ `<meta property="og:title" content="${escapeAttribute(title)}" />`,
89
+ `<meta property="og:description" content="${escapeAttribute(description)}" />`,
90
+ `<meta property="og:url" content="${escapeAttribute(canonical)}" />`,
91
+ `<meta property="og:image" content="${escapeAttribute(image)}" />`,
92
+ '<meta name="twitter:card" content="summary_large_image" />',
93
+ ].join('\n');
94
+ }
95
+
96
+ /**
97
+ * What the page on screen is called and where it is indexed, for the app to
98
+ * write after an in-app move.
99
+ *
100
+ * The same title and canonical the build wrote into the file it served, so a
101
+ * click that changes the page cannot disagree with the page a crawler fetched.
102
+ * @param options - Which site, which address, and where it is served from.
103
+ * @returns The title, the description and the canonical of that address.
104
+ * @throws {Error} When the site answers no route, or names an origin that is
105
+ * not an absolute address.
106
+ */
107
+ export function pageDocumentMeta(
108
+ options: PageMetaOptions,
109
+ ): Required<DocumentMeta> {
110
+ const site = resolveSite(options.site);
111
+ const meta = routeMetaOf(options);
112
+ return {
113
+ title: `${meta.title} — ${siteDisplayName(site)}`,
114
+ description: meta.description,
115
+ canonical: `${originOf(options)}${meta.path}`,
116
+ };
117
+ }
118
+
119
+ // A server behind a mount is handed the address the browser asked for, and the
120
+ // route table is written from the site's own root, so the mount the origin
121
+ // carries is taken off it before the table is read.
122
+ function routeMetaOf(options: PageMetaOptions): RouteMeta {
123
+ return pageMetaFor(options.routes, options.url, mountPathOf(options));
124
+ }
125
+
126
+ function absolute(target: string, origin: string): string {
127
+ return target.startsWith('/') ? `${origin}${target}` : target;
128
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The crawl policy.
3
+ *
4
+ * Our tools are meant to be found, so only the endpoints are kept out of the
5
+ * index — an API prefix and its documentation are not pages — and each one may
6
+ * say in a comment why, because a policy nobody can read is a policy nobody
7
+ * maintains.
8
+ *
9
+ * Every line of this file is a directive, so nothing an author writes may
10
+ * become one they did not: a path is a single line that says something, and
11
+ * carries no fragment, or it is refused; and a comment is folded onto the one
12
+ * line it is written as.
13
+ */
14
+
15
+ import { joinBasePath } from '../../router/core/basePath.ts';
16
+
17
+ import type { SiteFilesOptions } from './siteFiles.ts';
18
+ import { mountPathOf, originOf } from './siteFiles.ts';
19
+
20
+ /** One address kept out of the index, and why. */
21
+ export interface RobotsDisallow {
22
+ /**
23
+ * The address prefix, from the site's own root, e.g. `/v1/`. It is a path,
24
+ * on one line, saying something, carrying no `#`, and padded by nothing: a
25
+ * blank one would read as `Disallow: /` and keep the whole site out of the
26
+ * index — RFC 9309 eats the trailing whitespace of a line, so `" "` is read
27
+ * as nothing at all — one carrying a line break would write whatever follows
28
+ * it as a directive of its own, everything from a `#` onwards is read as a
29
+ * comment, which truncates the path silently, and a padded one is not the
30
+ * address it looks like: `" /v1/"` does not start at the site root, so it is
31
+ * written `Disallow: / /v1/` and matches no address at all, leaving the
32
+ * endpoint crawled by the very line meant to keep it out.
33
+ */
34
+ path: string;
35
+ /**
36
+ * One sentence written as a `#` line above the directive. The `#` is added
37
+ * when it is not already there, every run of whitespace is folded to a single
38
+ * space so the sentence stays on its own line, and a blank one is written as
39
+ * no line at all.
40
+ * @default undefined — the directive is written on its own
41
+ */
42
+ comment?: string;
43
+ }
44
+
45
+ const NEWLINE = /[\n\r]/;
46
+
47
+ const WHITESPACE = /\s+/g;
48
+
49
+ /**
50
+ * The crawl policy of the address the site is served at.
51
+ *
52
+ * Every path is written under the mount, so a build published as one tool among
53
+ * several on a shared host allows what it actually answers rather than claiming
54
+ * the whole host. The sitemap is named only because this module also writes it:
55
+ * a `Sitemap:` line pointing at a 404 is reported as an error on every fetch.
56
+ * @param options - The site, its routes, and where it is served.
57
+ * @param disallow - Addresses to keep out of the index, each optionally with
58
+ * the sentence saying why.
59
+ * @returns The `robots.txt` document.
60
+ * @throws {Error} When a disallowed address is blank, spans more than one line,
61
+ * carries a `#` or is padded with whitespace, or when the deployment named an
62
+ * origin that is not an absolute address.
63
+ */
64
+ export function robotsTxt(
65
+ options: SiteFilesOptions,
66
+ disallow: ReadonlyArray<string | RobotsDisallow> = [],
67
+ ): string {
68
+ const mount = mountPathOf(options);
69
+ const lines = ['User-agent: *', `Allow: ${joinBasePath(mount, '/')}`];
70
+
71
+ for (const entry of disallow) {
72
+ const rule = typeof entry === 'string' ? { path: entry } : entry;
73
+ const comment =
74
+ rule.comment === undefined ? undefined : commentLine(rule.comment);
75
+ if (comment !== undefined) lines.push(comment);
76
+ lines.push(`Disallow: ${joinBasePath(mount, disallowPath(rule.path))}`);
77
+ }
78
+
79
+ lines.push('', `Sitemap: ${originOf(options)}/sitemap.xml`, '');
80
+ return lines.join('\n');
81
+ }
82
+
83
+ function disallowPath(path: string): string {
84
+ if (path === '') {
85
+ throw new Error('a disallowed address is a path, never the empty string');
86
+ }
87
+ if (path.trim() === '') {
88
+ throw new Error(
89
+ `a disallowed address is a path, never blank: ${JSON.stringify(path)}`,
90
+ );
91
+ }
92
+ if (NEWLINE.test(path)) {
93
+ throw new Error(
94
+ `a disallowed address is written on one line: ${JSON.stringify(path)}`,
95
+ );
96
+ }
97
+ if (path.includes('#')) {
98
+ throw new Error(
99
+ `a disallowed address carries no fragment: ${JSON.stringify(path)}`,
100
+ );
101
+ }
102
+ if (path !== path.trim()) {
103
+ throw new Error(
104
+ `a disallowed address is written without padding: ${JSON.stringify(path)}`,
105
+ );
106
+ }
107
+ return path;
108
+ }
109
+
110
+ function commentLine(comment: string): string | undefined {
111
+ const text = comment.replaceAll(WHITESPACE, ' ').trim();
112
+ if (text === '') return undefined;
113
+ return text.startsWith('#') ? text : `# ${text}`;
114
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * The addresses a site answers, each with the name and the sentence it is
3
+ * indexed under.
4
+ *
5
+ * One table per site, read by three things: the build, which writes an HTML
6
+ * file per entry and the sitemap listing them; the head injector; and the
7
+ * running app, which retitles the tab after an in-app move. A page missing from
8
+ * the table is a page a search engine only ever sees as the home page.
9
+ */
10
+
11
+ import { stripBasePath } from '../../router/core/basePath.ts';
12
+
13
+ const QUERY_OR_FRAGMENT = /[?#]/;
14
+
15
+ const TRAILING_SLASHES = /\/+$/;
16
+
17
+ // A scheme and an authority: what `location.href` hands out, and the one shape
18
+ // that cannot be confused with a path. `//host/path` is left as a path, because
19
+ // a route table is free to name one.
20
+ const ABSOLUTE_URL = /^[a-z][\d+.a-z-]*:\/\//i;
21
+
22
+ /** A page, as a crawler and a shared card see it. */
23
+ export interface RouteMeta {
24
+ /** Absolute path, without a trailing slash and without a query string. */
25
+ path: string;
26
+ /** Under ~60 characters: the site name is appended to it. */
27
+ title: string;
28
+ /** One sentence, in the words someone would search for. */
29
+ description: string;
30
+ /**
31
+ * The label the page is linked under where a title written for a search
32
+ * result is too long to read as a menu entry — the `noscript` index.
33
+ * @default the route's own title
34
+ */
35
+ short?: string;
36
+ /**
37
+ * What the page is for, written after an em dash next to its link in the
38
+ * `noscript` index.
39
+ * @default undefined — the link stands on its own
40
+ */
41
+ note?: string;
42
+ /**
43
+ * Whether the route also answers every address beneath it, so a section
44
+ * carrying more pages than a table can hold — an entry per structure, per
45
+ * ligand, per identifier — is indexed under the section rather than under the
46
+ * home page. Those addresses are canonical to the section itself.
47
+ * @default false
48
+ */
49
+ prefix?: boolean;
50
+ }
51
+
52
+ /**
53
+ * The route an address names.
54
+ *
55
+ * An address a route claims exactly always wins over one that claims it as a
56
+ * subtree, and between two subtrees the longer claim wins, so `/molecules/HEM`
57
+ * is a molecule rather than whatever `/` answers.
58
+ * @param routes - Every address the site answers.
59
+ * @param path - Absolute path, without a query string.
60
+ * @returns Its entry, or `undefined` when the site does not know the address.
61
+ */
62
+ export function routeFor(
63
+ routes: readonly RouteMeta[],
64
+ path: string,
65
+ ): RouteMeta | undefined {
66
+ return exactRoute(routes, path) ?? prefixRoute(routes, path);
67
+ }
68
+
69
+ /**
70
+ * The page an address opens.
71
+ *
72
+ * An address the site does not know is described as the home page rather than
73
+ * invented on the fly, which is what the router does with it too. The query
74
+ * string never reaches the answer: the structure being drawn and the
75
+ * configuration a shared link carries are not pages of their own. An absolute
76
+ * address is read for its path, so an app handing over `location.href` after an
77
+ * in-app move is answered rather than silently described as the home page.
78
+ *
79
+ * The route table is written from the site's own root, and a server behind a
80
+ * mount is handed the address the browser asked for — `/surge/exercises` for a
81
+ * table that names `/exercises`. So the address is read at the site's own root
82
+ * first, and the four lookups run in this order:
83
+ *
84
+ * 1. the mount taken off, claimed exactly;
85
+ * 2. the address as written, claimed exactly;
86
+ * 3. the mount taken off, claimed as a subtree;
87
+ * 4. the address as written, claimed as a subtree.
88
+ *
89
+ * Exact before subtree, or a `prefix` route — a home page answering everything
90
+ * beneath it above all — would claim every mounted address and the mount would
91
+ * never come off. Stripped before as-written, or the mount itself would open
92
+ * whichever page happens to carry the mount's own name rather than the site's
93
+ * front page. Taking the address as written second is what leaves an unmounted
94
+ * caller answering exactly as before, and lets a table whose own paths start
95
+ * with the mount's name still be read.
96
+ * @param routes - Every address the site answers.
97
+ * @param url - The address, query string and fragment included, either as a
98
+ * path or as an absolute `scheme://host/path` address.
99
+ * @param basePath - The path the site is mounted at, when the address carries
100
+ * it, written `surge`, `/surge` or `/surge/`.
101
+ * @default '' — the address is already written from the site's own root
102
+ * @returns The route it is indexed as.
103
+ * @throws {Error} When the table is empty, so there is no page to fall back to.
104
+ */
105
+ export function pageMetaFor(
106
+ routes: readonly RouteMeta[],
107
+ url: string,
108
+ basePath = '',
109
+ ): RouteMeta {
110
+ const home = homeRoute(routes);
111
+ const path = pathOf(url);
112
+ const own = stripBasePath(basePath, path);
113
+ return (
114
+ exactRoute(routes, own) ??
115
+ exactRoute(routes, path) ??
116
+ prefixRoute(routes, own) ??
117
+ prefixRoute(routes, path) ??
118
+ home
119
+ );
120
+ }
121
+
122
+ /**
123
+ * The page an unknown address falls back to.
124
+ * @param routes - Every address the site answers.
125
+ * @returns The `/` entry, or the first one when the table names no root.
126
+ * @throws {Error} When the table is empty.
127
+ */
128
+ export function homeRoute(routes: readonly RouteMeta[]): RouteMeta {
129
+ const first = routes[0];
130
+ if (first === undefined) throw new Error('a site answers at least one route');
131
+ return exactRoute(routes, '/') ?? first;
132
+ }
133
+
134
+ /**
135
+ * Check a route table before a build reads it as a set of file names.
136
+ *
137
+ * An address written twice ships two sitemap entries and two links to a page
138
+ * only the first entry describes, and one carrying a `..` segment writes its
139
+ * file outside the build output — a real build asked for `/../escaped` and got
140
+ * a sibling of `dist`. Two addresses that differ only in an empty segment or in
141
+ * case are the same defect wearing a disguise: `//x` and `/x` both write
142
+ * `dist/x/index.html`, and so do `/About` and `/about` on the case-insensitive
143
+ * filesystem macOS and Windows ship by default — one file, two sitemap entries,
144
+ * and only one of the two descriptions survives. All of it is author
145
+ * configuration read at build time, so it is refused where it is written rather
146
+ * than repaired where it lands.
147
+ * @param routes - Every address the site answers.
148
+ * @throws {Error} When the table is empty, names one address twice — under any
149
+ * of those spellings — or carries a path that is not one.
150
+ */
151
+ export function assertRoutes(routes: readonly RouteMeta[]): void {
152
+ if (routes.length === 0) throw new Error('a site answers at least one route');
153
+
154
+ const claimed = new Set<string>();
155
+ const folded = new Map<string, string>();
156
+ for (const route of routes) {
157
+ const written = JSON.stringify(route.path);
158
+ assertPath(route.path, written);
159
+ const address = trimTrailingSlash(route.path) || '/';
160
+ if (address.includes('//')) {
161
+ throw new Error(`a route path names no empty segment: ${written}`);
162
+ }
163
+ if (claimed.has(address)) {
164
+ throw new Error(`a route path is written once: ${written}`);
165
+ }
166
+ const first = folded.get(address.toLowerCase());
167
+ if (first !== undefined) {
168
+ throw new Error(
169
+ `two route paths name one file on a case-insensitive disk: ${first} and ${written}`,
170
+ );
171
+ }
172
+ claimed.add(address);
173
+ folded.set(address.toLowerCase(), written);
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Drop the trailing slashes, so `/about/` and `/about` are one page and an
179
+ * origin written `https://host/surge//` composes one address rather than one
180
+ * with an empty segment in it.
181
+ * @param value - A path or an origin.
182
+ * @returns It, without the trailing slashes `/` itself keeps.
183
+ */
184
+ export function trimTrailingSlash(value: string): string {
185
+ const trimmed = value.replace(TRAILING_SLASHES, '');
186
+ return trimmed === '' && value !== '' ? '/' : trimmed;
187
+ }
188
+
189
+ function assertPath(path: string, written: string): void {
190
+ if (!path.startsWith('/')) {
191
+ throw new Error(`a route path starts at the site root: ${written}`);
192
+ }
193
+ if (QUERY_OR_FRAGMENT.test(path)) {
194
+ throw new Error(
195
+ `a route path carries no query string and no fragment: ${written}`,
196
+ );
197
+ }
198
+ if (path.split('/').includes('..')) {
199
+ throw new Error(`a route path stays inside the site: ${written}`);
200
+ }
201
+ }
202
+
203
+ // The path half of whatever the caller had at hand: an absolute address, or a
204
+ // path already, with the query string and the fragment cut off either way.
205
+ function pathOf(url: string): string {
206
+ if (ABSOLUTE_URL.test(url) && URL.canParse(url)) return new URL(url).pathname;
207
+ const cut = url.search(QUERY_OR_FRAGMENT);
208
+ return cut === -1 ? url : url.slice(0, cut);
209
+ }
210
+
211
+ function exactRoute(
212
+ routes: readonly RouteMeta[],
213
+ path: string,
214
+ ): RouteMeta | undefined {
215
+ const wanted = trimTrailingSlash(path) || '/';
216
+ for (const route of routes) {
217
+ if ((trimTrailingSlash(route.path) || '/') === wanted) return route;
218
+ }
219
+ return undefined;
220
+ }
221
+
222
+ function prefixRoute(
223
+ routes: readonly RouteMeta[],
224
+ path: string,
225
+ ): RouteMeta | undefined {
226
+ const wanted = trimTrailingSlash(path) || '/';
227
+ let claimed: RouteMeta | undefined;
228
+ let claimedLength = -1;
229
+
230
+ for (const route of routes) {
231
+ if (route.prefix !== true) continue;
232
+ const routePath = trimTrailingSlash(route.path) || '/';
233
+ if (!isUnder(routePath, wanted)) continue;
234
+ if (routePath.length > claimedLength) {
235
+ claimed = route;
236
+ claimedLength = routePath.length;
237
+ }
238
+ }
239
+ return claimed;
240
+ }
241
+
242
+ function isUnder(routePath: string, path: string): boolean {
243
+ // `/surgeon` is not a page of `/surge`, so a claim only holds when what
244
+ // follows it is a path of its own.
245
+ return routePath === '/' || path.startsWith(`${routePath}/`);
246
+ }
@@ -0,0 +1,112 @@
1
+ /**
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
+ *
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.
10
+ */
11
+
12
+ import { siteById } from '../../ecosystem/core/lookup.ts';
13
+ import type { EcosystemSite, SiteId } from '../../ecosystem/core/sites.ts';
14
+ import { basePathOf } from '../../router/core/basePath.ts';
15
+ import { escapeText } from '../../share/core/escape.ts';
16
+
17
+ import type { RouteMeta } from './routes.ts';
18
+ import { trimTrailingSlash } from './routes.ts';
19
+
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;
24
+
25
+ /** What a crawler is told about the site as a whole. */
26
+ export interface SiteFilesOptions {
27
+ /** The site, named or passed. */
28
+ site: EcosystemSite | SiteId;
29
+ /** Every address it answers. */
30
+ routes: readonly RouteMeta[];
31
+ /**
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.
35
+ * @default `https://<the site's host>`
36
+ */
37
+ origin?: string;
38
+ }
39
+
40
+ /**
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.
46
+ * @param options - The site and its routes.
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.
50
+ */
51
+ export function sitemapXml(options: SiteFilesOptions): string {
52
+ const origin = originOf(options);
53
+ if (options.routes.length === 0) {
54
+ throw new Error('a sitemap lists at least one address');
55
+ }
56
+ const entries = options.routes
57
+ .map(
58
+ (route) =>
59
+ ` <url><loc>${escapeText(`${origin}${route.path}`)}</loc></url>`,
60
+ )
61
+ .join('\n');
62
+ return `<?xml version="1.0" encoding="UTF-8"?>
63
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
64
+ ${entries}
65
+ </urlset>
66
+ `;
67
+ }
68
+
69
+ /**
70
+ * The site these files are being written for.
71
+ * @param site - The site, named or passed.
72
+ * @returns Its record.
73
+ */
74
+ export function resolveSite(site: EcosystemSite | SiteId): EcosystemSite {
75
+ return typeof site === 'string' ? siteById(site) : site;
76
+ }
77
+
78
+ /**
79
+ * Where the site is served, as an absolute address without a trailing slash.
80
+ *
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.
92
+ */
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);
101
+ }
102
+
103
+ /**
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.
109
+ */
110
+ export function mountPathOf(options: SiteFilesOptions): string {
111
+ return basePathOf(originOf(options));
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
+ }