react-cheminfo 0.35.0 → 0.36.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 (55) hide show
  1. package/bin/check-seo.mjs +333 -0
  2. package/lib/language/core/index.d.ts +2 -0
  3. package/lib/language/core/index.d.ts.map +1 -1
  4. package/lib/language/core/index.js +1 -0
  5. package/lib/language/core/index.js.map +1 -1
  6. package/lib/language/core/languagePath.d.ts +52 -0
  7. package/lib/language/core/languagePath.d.ts.map +1 -0
  8. package/lib/language/core/languagePath.js +65 -0
  9. package/lib/language/core/languagePath.js.map +1 -0
  10. package/lib/seo/core/alternates.d.ts +40 -0
  11. package/lib/seo/core/alternates.d.ts.map +1 -0
  12. package/lib/seo/core/alternates.js +42 -0
  13. package/lib/seo/core/alternates.js.map +1 -0
  14. package/lib/seo/core/index.d.ts +3 -0
  15. package/lib/seo/core/index.d.ts.map +1 -1
  16. package/lib/seo/core/index.js +2 -0
  17. package/lib/seo/core/index.js.map +1 -1
  18. package/lib/seo/core/noscript.d.ts +9 -0
  19. package/lib/seo/core/noscript.d.ts.map +1 -1
  20. package/lib/seo/core/noscript.js +7 -2
  21. package/lib/seo/core/noscript.js.map +1 -1
  22. package/lib/seo/core/pageMeta.d.ts +23 -0
  23. package/lib/seo/core/pageMeta.d.ts.map +1 -1
  24. package/lib/seo/core/pageMeta.js +52 -3
  25. package/lib/seo/core/pageMeta.js.map +1 -1
  26. package/lib/seo/core/pageProse.d.ts +76 -0
  27. package/lib/seo/core/pageProse.d.ts.map +1 -0
  28. package/lib/seo/core/pageProse.js +92 -0
  29. package/lib/seo/core/pageProse.js.map +1 -0
  30. package/lib/seo/core/routeProblems.d.ts +33 -0
  31. package/lib/seo/core/routeProblems.d.ts.map +1 -0
  32. package/lib/seo/core/routeProblems.js +89 -0
  33. package/lib/seo/core/routeProblems.js.map +1 -0
  34. package/lib/seo/core/routes.d.ts +14 -0
  35. package/lib/seo/core/routes.d.ts.map +1 -1
  36. package/lib/seo/core/routes.js.map +1 -1
  37. package/lib/seo/core/siteFiles.d.ts.map +1 -1
  38. package/lib/seo/core/siteFiles.js +2 -1
  39. package/lib/seo/core/siteFiles.js.map +1 -1
  40. package/lib/seo/vite/prerender.d.ts +13 -0
  41. package/lib/seo/vite/prerender.d.ts.map +1 -1
  42. package/lib/seo/vite/prerender.js +17 -10
  43. package/lib/seo/vite/prerender.js.map +1 -1
  44. package/package.json +2 -1
  45. package/src/language/core/index.ts +2 -0
  46. package/src/language/core/languagePath.ts +78 -0
  47. package/src/seo/core/alternates.ts +65 -0
  48. package/src/seo/core/index.ts +3 -0
  49. package/src/seo/core/noscript.ts +18 -2
  50. package/src/seo/core/pageMeta.ts +74 -3
  51. package/src/seo/core/pageProse.ts +146 -0
  52. package/src/seo/core/routeProblems.ts +111 -0
  53. package/src/seo/core/routes.ts +14 -0
  54. package/src/seo/core/siteFiles.ts +2 -1
  55. package/src/seo/vite/prerender.ts +30 -11
@@ -1 +1 @@
1
- {"version":3,"file":"prerender.d.ts","sourceRoot":"","sources":["../../../src/seo/vite/prerender.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAKH,OAAO,KAAK,EAAU,MAAM,EAAE,MAAM,MAAM,CAAC;AAE3C,OAAO,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAC;AAExE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGxD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAExD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAMnD,kEAAkE;AAClE,MAAM,WAAW,gBAAgB;IAC/B,iCAAiC;IACjC,IAAI,EAAE,UAAU,GAAG,MAAM,CAAC;IAC1B,qEAAqE;IACrE,MAAM,EAAE,SAAS,SAAS,EAAE,CAAC;IAC7B;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,KAAK,GAAG,aAAa,CAAC,MAAM,GAAG,cAAc,CAAC,CAAC;IACxD;;;;OAIG;IACH,QAAQ,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IAC1B;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,OAAO,GAAG,YAAY,CAAC;CACnC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,gBAAgB,GAAG,MAAM,CA6EnE"}
1
+ {"version":3,"file":"prerender.d.ts","sourceRoot":"","sources":["../../../src/seo/vite/prerender.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAKH,OAAO,KAAK,EAAU,MAAM,EAAE,MAAM,MAAM,CAAC;AAE3C,OAAO,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAC;AAExE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGxD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAExD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAMnD,kEAAkE;AAClE,MAAM,WAAW,gBAAgB;IAC/B,iCAAiC;IACjC,IAAI,EAAE,UAAU,GAAG,MAAM,CAAC;IAC1B,qEAAqE;IACrE,MAAM,EAAE,SAAS,SAAS,EAAE,CAAC;IAC7B;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,KAAK,GAAG,aAAa,CAAC,MAAM,GAAG,cAAc,CAAC,CAAC;IACxD;;;;OAIG;IACH,QAAQ,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IAC1B;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,OAAO,GAAG,YAAY,CAAC;IAClC;;;;;;;;;;OAUG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,WAAW,GAAG,SAAS,CAAC;CACzD;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,gBAAgB,GAAG,MAAM,CA4EnE"}
@@ -37,12 +37,12 @@ export function cheminfoPrerender(options) {
37
37
  const { site, routes, origin, robots = [] } = options;
38
38
  assertRoutes(routes);
39
39
  const structuredData = structuredDataOf(options);
40
- const crawlPath = crawlPathOf(options);
41
40
  let out = 'dist';
42
41
  let serve = false;
43
42
  let logger = null;
44
- const page = (template, url) => {
45
- const head = fill(template, PAGE_HEAD_MARKER, `${pageHeadTags({ site, routes, origin, url })}${structuredData}`);
43
+ const page = (template, route) => {
44
+ const head = fill(template, PAGE_HEAD_MARKER, `${pageHeadTags({ site, routes, origin, url: route.path })}${structuredData}`);
45
+ const crawlPath = crawlPathOf(options, route);
46
46
  return crawlPath === '' ? head : fill(head, PAGE_BODY_MARKER, crawlPath);
47
47
  };
48
48
  return {
@@ -56,22 +56,22 @@ export function cheminfoPrerender(options) {
56
56
  // from the home route rather than shipped with its markers showing.
57
57
  transformIndexHtml: {
58
58
  order: 'post',
59
- handler: (html) => serve ? page(html, homeRoute(routes).path) : html,
59
+ handler: (html) => (serve ? page(html, homeRoute(routes)) : html),
60
60
  },
61
61
  closeBundle() {
62
62
  if (serve)
63
63
  return;
64
64
  const template = readFileSync(join(out, 'index.html'), 'utf8');
65
- const write = (url, file) => {
65
+ const write = (route, file) => {
66
66
  mkdirSync(dirname(file), { recursive: true });
67
- writeFileSync(file, page(template, url));
67
+ writeFileSync(file, page(template, route));
68
68
  };
69
69
  let root = false;
70
70
  for (const route of routes) {
71
71
  const address = trimTrailingSlash(route.path);
72
72
  if (address === '/')
73
73
  root = true;
74
- write(route.path, address === '/'
74
+ write(route, address === '/'
75
75
  ? join(out, 'index.html')
76
76
  : join(out, address.slice(1), 'index.html'));
77
77
  }
@@ -79,7 +79,7 @@ export function cheminfoPrerender(options) {
79
79
  // no root would otherwise leave the template vite built, and ship a site
80
80
  // whose front page carries its markers instead of a head.
81
81
  if (!root)
82
- write(homeRoute(routes).path, join(out, 'index.html'));
82
+ write(homeRoute(routes), join(out, 'index.html'));
83
83
  writeFileSync(join(out, 'sitemap.xml'), sitemapXml({ site, routes, origin }));
84
84
  if (robots !== false) {
85
85
  writeFileSync(join(out, 'robots.txt'), robotsTxt({ site, routes, origin }, robots));
@@ -105,11 +105,18 @@ function structuredDataOf(options) {
105
105
  currency,
106
106
  })}`;
107
107
  }
108
- function crawlPathOf(options) {
108
+ function crawlPathOf(options, route) {
109
109
  const { site, routes, origin, noscript = true } = options;
110
110
  if (noscript === false)
111
111
  return '';
112
+ const content = options.content?.(route);
112
113
  const { routes: listed, ...prose } = noscript === true ? {} : noscript;
113
- return noscriptIndex({ site, origin, ...prose, routes: listed ?? routes });
114
+ return noscriptIndex({
115
+ site,
116
+ origin,
117
+ ...prose,
118
+ routes: listed ?? routes,
119
+ content,
120
+ });
114
121
  }
115
122
  //# sourceMappingURL=prerender.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"prerender.js","sourceRoot":"","sources":["../../../src/seo/vite/prerender.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"prerender.js","sourceRoot":"","sources":["../../../src/seo/vite/prerender.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-cheminfo",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "license": "MIT",
5
5
  "description": "React components, hooks and framework-free helpers shared by the cheminfo sites: site chrome, citations, sharing, routing, pedagogy, charts, and the structure, orbital and 3D molecule viewers",
6
6
  "author": "Luc Patiny",
@@ -33,6 +33,7 @@
33
33
  "bin": {
34
34
  "cheminfo-check-deploy": "bin/check-deploy.mjs",
35
35
  "cheminfo-check-messages": "bin/check-messages.mjs",
36
+ "cheminfo-check-seo": "bin/check-seo.mjs",
36
37
  "cheminfo-check-tokens": "bin/check-tokens.mjs"
37
38
  },
38
39
  "files": [
@@ -1,4 +1,6 @@
1
1
  export { languageName } from './languageName.ts';
2
+ export type { LanguagePath } from './languagePath.ts';
3
+ export { readLanguagePath, withLanguagePath } from './languagePath.ts';
2
4
  export {
3
5
  LANGUAGE_PARAM,
4
6
  isLanguageTag,
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The language a translated site routes by: a prefix on its own addresses.
3
+ *
4
+ * `/fr/tutorial` is the French tutorial, and English is written with no prefix
5
+ * at all, so every link already handed out in a course stays alive and the site
6
+ * does not acquire a second spelling of its own front page.
7
+ *
8
+ * This is the half of the design that makes a translation *findable*. A site
9
+ * that keeps its language in a query parameter, in the hash or in
10
+ * `localStorage` serves five languages at one address: a crawler sees one page,
11
+ * indexes one language, and the other four may as well not have been written.
12
+ * The parameter stays — it is how the language travels to a sibling that may
13
+ * not speak it (`./languageParam.ts`) — but it is not how a translated site
14
+ * routes.
15
+ *
16
+ * The prefix sits between the mount and the route, so a tool served as one of
17
+ * several on a shared host reads `/surge/fr/exercises`: the mount comes off
18
+ * first (`router/core/basePath.ts`), then the language, then the table is read.
19
+ */
20
+
21
+ import type { Language } from '../../i18n/core/languages.ts';
22
+ import { DEFAULT_LANGUAGE } from '../../i18n/core/languages.ts';
23
+ import { trimTrailingSlash } from '../../router/core/address.ts';
24
+
25
+ /** An address split into the language it names and the page it opens. */
26
+ export interface LanguagePath {
27
+ /** The language named, or the default one when the address names none. */
28
+ language: Language;
29
+ /** The address from the site's own root, with the prefix taken off. */
30
+ path: string;
31
+ }
32
+
33
+ /**
34
+ * Read the language prefix off one of the site's own addresses.
35
+ *
36
+ * Only a language the site actually speaks is read as a prefix: on a site with
37
+ * no Italian, `/it` is whatever page is called `it`, not an empty Italian front
38
+ * page. So the list is the site's, never the family's.
39
+ * @param path - An address from the site's own root, the mount already off,
40
+ * e.g. `/fr/tutorial`.
41
+ * @param languages - The languages this site is written in, the default one
42
+ * included.
43
+ * @returns The language it names and the page it opens.
44
+ */
45
+ export function readLanguagePath(
46
+ path: string,
47
+ languages: readonly Language[],
48
+ ): LanguagePath {
49
+ const opened = path.startsWith('/') ? path : `/${path}`;
50
+ const cut = opened.indexOf('/', 1);
51
+ const first = cut === -1 ? opened.slice(1) : opened.slice(1, cut);
52
+
53
+ for (const language of languages) {
54
+ if (language === DEFAULT_LANGUAGE || language !== first) continue;
55
+ const rest = cut === -1 ? '/' : opened.slice(cut);
56
+ return { language, path: trimTrailingSlash(rest) || '/' };
57
+ }
58
+ return {
59
+ language: DEFAULT_LANGUAGE,
60
+ path: trimTrailingSlash(opened) || '/',
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Write one of the site's own addresses in a language.
66
+ *
67
+ * The default language is written with no prefix: an address naming no language
68
+ * is English, which is what keeps every link handed out before the site was
69
+ * translated pointing at the same page.
70
+ * @param language - The language to write it in.
71
+ * @param path - The address from the site's own root, e.g. `/tutorial`.
72
+ * @returns The address under that language, e.g. `/fr/tutorial`.
73
+ */
74
+ export function withLanguagePath(language: Language, path: string): string {
75
+ const opened = path.startsWith('/') ? path : `/${path}`;
76
+ if (language === DEFAULT_LANGUAGE) return opened;
77
+ return opened === '/' ? `/${language}` : `/${language}${opened}`;
78
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The tags that tell a search engine five addresses are one page written five
3
+ * times.
4
+ *
5
+ * Without them a translated site is read as five sites that happen to look
6
+ * alike: the French tutorial competes with the English one instead of being
7
+ * offered to a French reader, and a search engine picks one of them to keep.
8
+ * With them each address is served to the audience it was written for, and the
9
+ * four it was not stop diluting it.
10
+ *
11
+ * Every page names **every** language including itself — a set that does not
12
+ * list the page it is on is ignored whole — plus `x-default`, which is where a
13
+ * reader whose language we do not speak is sent.
14
+ */
15
+
16
+ import type { Language } from '../../i18n/core/languages.ts';
17
+ import { DEFAULT_LANGUAGE } from '../../i18n/core/languages.ts';
18
+ import { withLanguagePath } from '../../language/core/languagePath.ts';
19
+ import { escapeAttribute } from '../../share/core/escape.ts';
20
+
21
+ /** Which page, in which languages, served from where. */
22
+ export interface AlternateOptions {
23
+ /**
24
+ * Where the site is served, mount path included and no trailing slash, e.g.
25
+ * `https://3d.cheminfo.org`.
26
+ */
27
+ origin: string;
28
+ /**
29
+ * The page, from the site's own root and with no language prefix on it, e.g.
30
+ * `/tutorial`.
31
+ */
32
+ path: string;
33
+ /**
34
+ * Every language the site is written in. One language alone means there is
35
+ * nothing to point at, and no tag is written.
36
+ */
37
+ languages: readonly Language[];
38
+ }
39
+
40
+ /**
41
+ * The `rel="alternate"` block for one page of a translated site.
42
+ * @param options - The page, its languages and where the site is served.
43
+ * @returns The tags, one per line, or `''` when the site speaks one language.
44
+ */
45
+ export function alternateLinkTags(options: AlternateOptions): string {
46
+ const { origin, path, languages } = options;
47
+ if (languages.length < 2) return '';
48
+
49
+ const tags: string[] = [];
50
+ for (const language of languages) {
51
+ tags.push(linkTag(language, address(origin, language, path)));
52
+ }
53
+ // A reader we have no language for gets the unprefixed address, which is the
54
+ // one every link handed out before the site was translated already points at.
55
+ tags.push(linkTag('x-default', address(origin, DEFAULT_LANGUAGE, path)));
56
+ return tags.join('\n');
57
+ }
58
+
59
+ function address(origin: string, language: Language, path: string): string {
60
+ return `${origin}${withLanguagePath(language, path)}`;
61
+ }
62
+
63
+ function linkTag(hreflang: string, href: string): string {
64
+ return `<link rel="alternate" hreflang="${escapeAttribute(hreflang)}" href="${escapeAttribute(href)}" />`;
65
+ }
@@ -10,10 +10,13 @@ export type {
10
10
  export { noscriptIndex } from './noscript.ts';
11
11
  export type { PageMetaOptions } from './pageMeta.ts';
12
12
  export { injectPageMeta, pageDocumentMeta, pageHeadTags } from './pageMeta.ts';
13
+ export type { PageContent, PageSection, PageTable } from './pageProse.ts';
14
+ export { pageProseHtml } from './pageProse.ts';
13
15
  export type { PlainDescriptionOptions } from './plainDescription.ts';
14
16
  export { plainDescription, plainProse } from './plainDescription.ts';
15
17
  export type { RobotsDisallow } from './robots.ts';
16
18
  export { robotsTxt } from './robots.ts';
19
+ export { routeProblems, withheldPhrase } from './routeProblems.ts';
17
20
  export type { RouteMeta } from './routes.ts';
18
21
  export { assertRoutes, homeRoute, pageMetaFor, routeFor } from './routes.ts';
19
22
  export type { SiteFilesOptions } from './siteFiles.ts';
@@ -12,6 +12,8 @@ import { ECOSYSTEM_SITES, siteUrl } from '../../ecosystem/core/sites.ts';
12
12
  import { joinBasePath } from '../../router/core/basePath.ts';
13
13
  import { escapeAttribute, escapeText } from '../../share/core/escape.ts';
14
14
 
15
+ import type { PageContent } from './pageProse.ts';
16
+ import { pageProseHtml } from './pageProse.ts';
15
17
  import type { RouteMeta } from './routes.ts';
16
18
  import type { SiteFilesOptions } from './siteFiles.ts';
17
19
  import { mountPathOf, resolveSite } from './siteFiles.ts';
@@ -93,6 +95,14 @@ export interface NoscriptOptions
93
95
  extends Omit<SiteFilesOptions, 'routes'>, Omit<NoscriptText, 'routes'> {
94
96
  /** The addresses it links, each with the label it is linked under. */
95
97
  routes: readonly NoscriptRoute[];
98
+ /**
99
+ * What the page being written says, above the crawl path. The block is the
100
+ * only text a crawler is handed before anything runs, so a page with nothing
101
+ * of its own here is indexed on its title and on this site's menu — which
102
+ * every other page of the site carries too.
103
+ * @default undefined — the block is the menu alone
104
+ */
105
+ content?: PageContent;
96
106
  }
97
107
 
98
108
  /**
@@ -111,14 +121,20 @@ export function noscriptIndex(options: NoscriptOptions): string {
111
121
  const site = resolveSite(options.site);
112
122
  const hrefs = options.hrefs ?? 'absolute';
113
123
  const mount = hrefs === 'absolute' ? mountPathOf(options) : '';
114
- const heading = escapeText(options.heading ?? siteDisplayName(site));
124
+ const content = options.content;
125
+ // The page's own name answers the title it is indexed under, so it wins over
126
+ // a heading the site writes for every one of its pages at once.
127
+ const heading = escapeText(
128
+ content?.heading ?? options.heading ?? siteDisplayName(site),
129
+ );
115
130
  const intro = escapeText(
116
131
  options.intro ??
117
132
  `${site.tagline} This tool needs JavaScript; these are the pages it offers:`,
118
133
  );
134
+ const prose = content === undefined ? '' : pageProseHtml(content, ' ');
119
135
 
120
136
  return `<noscript>
121
- <h1>${heading}</h1>
137
+ <h1>${heading}</h1>${prose}
122
138
  <p>${intro}</p>${pageList(options.routes, mount, hrefs, ' ')}${familyList(site.id, options.ecosystem)}
123
139
  </noscript>`;
124
140
  }
@@ -14,14 +14,26 @@
14
14
 
15
15
  import { siteDisplayName } from '../../ecosystem/core/lookup.ts';
16
16
  import type { SiteId, SiteRecord } from '../../ecosystem/core/sites.ts';
17
+ import type { Language } from '../../i18n/core/languages.ts';
18
+ import { DEFAULT_LANGUAGE } from '../../i18n/core/languages.ts';
19
+ import {
20
+ readLanguagePath,
21
+ withLanguagePath,
22
+ } from '../../language/core/languagePath.ts';
23
+ import { withoutQueryOrFragment } from '../../router/core/address.ts';
24
+ import { stripBasePath } from '../../router/core/basePath.ts';
17
25
  import { escapeAttribute, escapeText } from '../../share/core/escape.ts';
18
26
 
27
+ import { alternateLinkTags } from './alternates.ts';
19
28
  import type { DocumentMeta } from './documentMeta.ts';
20
29
  import type { RouteMeta } from './routes.ts';
21
30
  import { pageMetaFor } from './routes.ts';
22
31
  import { mountPathOf, originOf, resolveSite } from './siteFiles.ts';
23
32
  import { PAGE_HEAD_MARKER, fill } from './template.ts';
24
33
 
34
+ // A scheme and an authority: what `location.href` hands out.
35
+ const ABSOLUTE_URL = /^[a-z][\d+.a-z-]*:\/\//i;
36
+
25
37
  /** Which site is being served, and what it answers. */
26
38
  export interface PageMetaOptions {
27
39
  /** The site, named or passed. */
@@ -48,6 +60,17 @@ export interface PageMetaOptions {
48
60
  * @default '/og.png'
49
61
  */
50
62
  image?: string;
63
+ /**
64
+ * Every language the site is written in, the default one included.
65
+ *
66
+ * The language is read off the address — `/fr/tutorial` is the French
67
+ * tutorial — so the canonical, the card and the `hreflang` set are written
68
+ * for the page actually being served, and the routes passed are the table in
69
+ * that language. A site writing one language leaves this out and nothing
70
+ * about its head changes.
71
+ * @default [the default language]
72
+ */
73
+ languages?: readonly Language[];
51
74
  }
52
75
 
53
76
  /**
@@ -74,12 +97,27 @@ export function injectPageMeta(html: string, options: PageMetaOptions): string {
74
97
  */
75
98
  export function pageHeadTags(options: PageMetaOptions): string {
76
99
  const name = siteDisplayName(resolveSite(options.site));
77
- const description = routeMetaOf(options).description;
100
+ const route = routeMetaOf(options);
101
+ const description = route.description;
78
102
  const origin = originOf(options);
79
103
  const { title, canonical } = pageDocumentMeta(options);
80
104
  const image = absolute(options.image ?? '/og.png', origin);
105
+ const alternates = alternateLinkTags({
106
+ origin,
107
+ path: route.path,
108
+ languages: languagesOf(options),
109
+ });
110
+
111
+ // A maintenance screen says so on the page itself. `robots.txt` cannot: it
112
+ // stops the crawl, and an address nobody crawled is still listed from
113
+ // whatever links to it, with no description to show for it.
114
+ const robots =
115
+ route.indexed === false
116
+ ? ['<meta name="robots" content="noindex, nofollow" />']
117
+ : [];
81
118
 
82
119
  return [
120
+ ...robots,
83
121
  `<title>${escapeText(title)}</title>`,
84
122
  `<meta name="description" content="${escapeAttribute(description)}" />`,
85
123
  `<link rel="canonical" href="${escapeAttribute(canonical)}" />`,
@@ -90,6 +128,7 @@ export function pageHeadTags(options: PageMetaOptions): string {
90
128
  `<meta property="og:url" content="${escapeAttribute(canonical)}" />`,
91
129
  `<meta property="og:image" content="${escapeAttribute(image)}" />`,
92
130
  '<meta name="twitter:card" content="summary_large_image" />',
131
+ ...(alternates === '' ? [] : [alternates]),
93
132
  ].join('\n');
94
133
  }
95
134
 
@@ -112,15 +151,47 @@ export function pageDocumentMeta(
112
151
  return {
113
152
  title: `${meta.title} — ${siteDisplayName(site)}`,
114
153
  description: meta.description,
115
- canonical: `${originOf(options)}${meta.path}`,
154
+ canonical: `${originOf(options)}${withLanguagePath(pageLanguage(options), meta.path)}`,
116
155
  };
117
156
  }
118
157
 
158
+ /**
159
+ * The language an address is being served in.
160
+ *
161
+ * Read off the address itself rather than passed beside it, so the catalog a
162
+ * server picks and the canonical it writes cannot disagree.
163
+ * @param options - Which site, which address, and the languages it speaks.
164
+ * @returns The language named by the address, or the default one.
165
+ * @throws {Error} When the deployment names an origin that is not an absolute
166
+ * address.
167
+ */
168
+ export function pageLanguage(options: PageMetaOptions): Language {
169
+ return readLanguagePath(ownPath(options), languagesOf(options)).language;
170
+ }
171
+
119
172
  // A server behind a mount is handed the address the browser asked for, and the
120
173
  // route table is written from the site's own root, so the mount the origin
121
174
  // carries is taken off it before the table is read.
122
175
  function routeMetaOf(options: PageMetaOptions): RouteMeta {
123
- return pageMetaFor(options.routes, options.url, mountPathOf(options));
176
+ const { path } = readLanguagePath(ownPath(options), languagesOf(options));
177
+ return pageMetaFor(options.routes, path);
178
+ }
179
+
180
+ // The address from the site's own root, the mount taken off, with the query
181
+ // string and the fragment gone: what the language prefix is read off. An app
182
+ // handing over `location.href` is answered too, so the one reading serves a
183
+ // server, a build and a click alike.
184
+ function ownPath(options: PageMetaOptions): string {
185
+ const url = options.url;
186
+ const path =
187
+ ABSOLUTE_URL.test(url) && URL.canParse(url)
188
+ ? new URL(url).pathname
189
+ : withoutQueryOrFragment(url);
190
+ return stripBasePath(mountPathOf(options), path);
191
+ }
192
+
193
+ function languagesOf(options: PageMetaOptions): readonly Language[] {
194
+ return options.languages ?? [DEFAULT_LANGUAGE];
124
195
  }
125
196
 
126
197
  function absolute(target: string, origin: string): string {
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The text a page is indexed on, written into the HTML before anything runs.
3
+ *
4
+ * A prerendered site writes one file per address, and each of them carried the
5
+ * same body: the site's crawl path, and nothing else. A search engine clusters
6
+ * pages by the text it is handed, so a hundred and eighteen element pages
7
+ * differing only in their title are a hundred and seventeen duplicates — and
8
+ * which one it keeps is not ours to choose. A page heavy enough that the
9
+ * renderer never gets to it is indexed on that body alone.
10
+ *
11
+ * So what a site writes here is the text its running app already shows, drawn
12
+ * from the same data: the prose is not written twice, it is written where the
13
+ * build can reach it. Text only, and escaped — the addresses a reader follows
14
+ * are the crawl path's business.
15
+ */
16
+
17
+ import { escapeText } from '../../share/core/escape.ts';
18
+
19
+ /** A table of facts under the prose: the rows a page would show anyway. */
20
+ export interface PageTable {
21
+ /** Header cells, left to right. */
22
+ columns: readonly string[];
23
+ /** Body rows, each as many cells as there are columns. */
24
+ rows: ReadonlyArray<readonly string[]>;
25
+ /**
26
+ * The sentence under the table, saying where the numbers come from.
27
+ * @default undefined — the table stands on its own
28
+ */
29
+ caption?: string;
30
+ }
31
+
32
+ /** One run of the page's text: a heading, prose, and the facts under it. */
33
+ export interface PageSection {
34
+ /**
35
+ * The heading it opens with, as an `h2`.
36
+ * @default undefined — the run carries no heading
37
+ */
38
+ heading?: string;
39
+ /**
40
+ * The paragraphs under it, each taken as written.
41
+ * @default undefined — no prose
42
+ */
43
+ paragraphs?: readonly string[];
44
+ /**
45
+ * A list under the prose, one item per line.
46
+ * @default undefined — no list
47
+ */
48
+ list?: readonly string[];
49
+ /**
50
+ * A table under the prose.
51
+ * @default undefined — no table
52
+ */
53
+ table?: PageTable;
54
+ }
55
+
56
+ /** The whole of one page's text, as the page itself would say it. */
57
+ export interface PageContent extends PageSection {
58
+ /**
59
+ * The page's own name, written as its `h1`. It answers the title the page is
60
+ * indexed under, so a reader who searched for it reads the same words again.
61
+ */
62
+ heading: string;
63
+ /**
64
+ * The runs under the opening prose, in reading order.
65
+ * @default undefined — the page is its opening prose
66
+ */
67
+ sections?: readonly PageSection[];
68
+ }
69
+
70
+ /**
71
+ * The page's text as HTML, everything below its heading.
72
+ *
73
+ * The heading itself is left to the caller, which already writes the one `h1`
74
+ * the page carries — `noscriptIndex` does.
75
+ * @param content - What the page says.
76
+ * @param indent - The indentation every line is written at.
77
+ * @default ' '
78
+ * @returns The HTML, opening with a newline, or `''` when the page says nothing
79
+ * below its heading.
80
+ */
81
+ export function pageProseHtml(content: PageContent, indent = ' '): string {
82
+ // The page's own heading is the caller's `h1`; what is left of its opening run
83
+ // is prose like any section's.
84
+ const opening: PageSection = {
85
+ paragraphs: content.paragraphs,
86
+ list: content.list,
87
+ table: content.table,
88
+ };
89
+ const parts = [
90
+ sectionHtml(opening, indent),
91
+ ...(content.sections ?? []).map((section) => sectionHtml(section, indent)),
92
+ ];
93
+ return parts.join('');
94
+ }
95
+
96
+ function sectionHtml(section: PageSection, indent: string): string {
97
+ const lines: string[] = [];
98
+ if (section.heading !== undefined && section.heading.trim() !== '') {
99
+ lines.push(`${indent}<h2>${escapeText(section.heading)}</h2>`);
100
+ }
101
+ for (const paragraph of section.paragraphs ?? []) {
102
+ if (paragraph.trim() === '') continue;
103
+ lines.push(`${indent}<p>${escapeText(paragraph)}</p>`);
104
+ }
105
+ const list = listHtml(section.list, indent);
106
+ if (list !== '') lines.push(list);
107
+ const table = tableHtml(section.table, indent);
108
+ if (table !== '') lines.push(table);
109
+ return lines.length === 0 ? '' : `\n${lines.join('\n')}`;
110
+ }
111
+
112
+ function listHtml(list: readonly string[] | undefined, indent: string): string {
113
+ // A list with no item is not a list: `<ul>` holds at least one `<li>`.
114
+ if (list === undefined || list.length === 0) return '';
115
+ const items = list
116
+ .map((item) => `${indent} <li>${escapeText(item)}</li>`)
117
+ .join('\n');
118
+ return `${indent}<ul>\n${items}\n${indent}</ul>`;
119
+ }
120
+
121
+ function tableHtml(table: PageTable | undefined, indent: string): string {
122
+ // A table with no row says nothing, and an empty `<tbody>` is not markup a
123
+ // crawler is owed.
124
+ if (table === undefined || table.rows.length === 0) return '';
125
+ const caption =
126
+ table.caption === undefined || table.caption.trim() === ''
127
+ ? ''
128
+ : `\n${indent} <caption>${escapeText(table.caption)}</caption>`;
129
+ const head = table.columns
130
+ .map((column) => `<th>${escapeText(column)}</th>`)
131
+ .join('');
132
+ const body = table.rows
133
+ .map(
134
+ (row) =>
135
+ `${indent} <tr>${row.map((cell) => `<td>${escapeText(cell)}</td>`).join('')}</tr>`,
136
+ )
137
+ .join('\n');
138
+ return `${indent}<table>${caption}
139
+ ${indent} <thead>
140
+ ${indent} <tr>${head}</tr>
141
+ ${indent} </thead>
142
+ ${indent} <tbody>
143
+ ${body}
144
+ ${indent} </tbody>
145
+ ${indent}</table>`;
146
+ }