react-cheminfo 0.37.0 → 0.38.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 (104) hide show
  1. package/README.md +77 -1
  2. package/bin/check-seo.mjs +43 -1
  3. package/lib/core.d.ts +1 -0
  4. package/lib/core.d.ts.map +1 -1
  5. package/lib/core.js +1 -0
  6. package/lib/core.js.map +1 -1
  7. package/lib/i18n/core/chromeCatalog.d.ts +1 -1
  8. package/lib/i18n/core/chromeCatalog.d.ts.map +1 -1
  9. package/lib/locales/de.json +9 -1
  10. package/lib/locales/en.json +9 -1
  11. package/lib/locales/es.json +9 -1
  12. package/lib/locales/fr.json +9 -1
  13. package/lib/locales/it.json +9 -1
  14. package/lib/number/core/index.d.ts +1 -1
  15. package/lib/number/core/index.d.ts.map +1 -1
  16. package/lib/number/core/index.js +1 -1
  17. package/lib/number/core/index.js.map +1 -1
  18. package/lib/number/core/numberText.d.ts +13 -0
  19. package/lib/number/core/numberText.d.ts.map +1 -1
  20. package/lib/number/core/numberText.js +20 -0
  21. package/lib/number/core/numberText.js.map +1 -1
  22. package/lib/range/core/index.d.ts +4 -0
  23. package/lib/range/core/index.d.ts.map +1 -0
  24. package/lib/range/core/index.js +3 -0
  25. package/lib/range/core/index.js.map +1 -0
  26. package/lib/range/core/rangeBounds.d.ts +79 -0
  27. package/lib/range/core/rangeBounds.d.ts.map +1 -0
  28. package/lib/range/core/rangeBounds.js +110 -0
  29. package/lib/range/core/rangeBounds.js.map +1 -0
  30. package/lib/range/core/rangeHistogram.d.ts +13 -0
  31. package/lib/range/core/rangeHistogram.d.ts.map +1 -0
  32. package/lib/range/core/rangeHistogram.js +39 -0
  33. package/lib/range/core/rangeHistogram.js.map +1 -0
  34. package/lib/range/ui/RangeHistogram.d.ts +20 -0
  35. package/lib/range/ui/RangeHistogram.d.ts.map +1 -0
  36. package/lib/range/ui/RangeHistogram.js +18 -0
  37. package/lib/range/ui/RangeHistogram.js.map +1 -0
  38. package/lib/range/ui/RangeSlider.d.ts +77 -0
  39. package/lib/range/ui/RangeSlider.d.ts.map +1 -0
  40. package/lib/range/ui/RangeSlider.js +60 -0
  41. package/lib/range/ui/RangeSlider.js.map +1 -0
  42. package/lib/range/ui/RangeValue.d.ts +41 -0
  43. package/lib/range/ui/RangeValue.d.ts.map +1 -0
  44. package/lib/range/ui/RangeValue.js +73 -0
  45. package/lib/range/ui/RangeValue.js.map +1 -0
  46. package/lib/range/ui/index.d.ts +3 -0
  47. package/lib/range/ui/index.d.ts.map +1 -0
  48. package/lib/range/ui/index.js +2 -0
  49. package/lib/range/ui/index.js.map +1 -0
  50. package/lib/seo/core/documentMeta.d.ts +16 -0
  51. package/lib/seo/core/documentMeta.d.ts.map +1 -1
  52. package/lib/seo/core/documentMeta.js +18 -0
  53. package/lib/seo/core/documentMeta.js.map +1 -1
  54. package/lib/seo/core/index.d.ts +3 -1
  55. package/lib/seo/core/index.d.ts.map +1 -1
  56. package/lib/seo/core/index.js +2 -1
  57. package/lib/seo/core/index.js.map +1 -1
  58. package/lib/seo/core/noscript.d.ts +8 -0
  59. package/lib/seo/core/noscript.d.ts.map +1 -1
  60. package/lib/seo/core/noscript.js +10 -3
  61. package/lib/seo/core/noscript.js.map +1 -1
  62. package/lib/seo/core/pageMeta.d.ts +10 -2
  63. package/lib/seo/core/pageMeta.d.ts.map +1 -1
  64. package/lib/seo/core/pageMeta.js +12 -3
  65. package/lib/seo/core/pageMeta.js.map +1 -1
  66. package/lib/seo/core/siteFiles.d.ts +32 -3
  67. package/lib/seo/core/siteFiles.d.ts.map +1 -1
  68. package/lib/seo/core/siteFiles.js +13 -4
  69. package/lib/seo/core/siteFiles.js.map +1 -1
  70. package/lib/seo/core/startDocumentMeta.js +8 -7
  71. package/lib/seo/core/startDocumentMeta.js.map +1 -1
  72. package/lib/seo/vite/prerender.d.ts +26 -4
  73. package/lib/seo/vite/prerender.d.ts.map +1 -1
  74. package/lib/seo/vite/prerender.js +100 -30
  75. package/lib/seo/vite/prerender.js.map +1 -1
  76. package/lib/ui.d.ts +1 -0
  77. package/lib/ui.d.ts.map +1 -1
  78. package/lib/ui.js +1 -0
  79. package/lib/ui.js.map +1 -1
  80. package/package.json +1 -1
  81. package/src/core.ts +1 -0
  82. package/src/locales/de.json +9 -1
  83. package/src/locales/en.json +9 -1
  84. package/src/locales/es.json +9 -1
  85. package/src/locales/fr.json +9 -1
  86. package/src/locales/it.json +9 -1
  87. package/src/number/core/index.ts +1 -0
  88. package/src/number/core/numberText.ts +20 -0
  89. package/src/range/core/index.ts +11 -0
  90. package/src/range/core/rangeBounds.ts +143 -0
  91. package/src/range/core/rangeHistogram.ts +38 -0
  92. package/src/range/ui/RangeHistogram.tsx +49 -0
  93. package/src/range/ui/RangeSlider.tsx +209 -0
  94. package/src/range/ui/RangeValue.tsx +166 -0
  95. package/src/range/ui/index.ts +2 -0
  96. package/src/seo/core/documentMeta.ts +22 -0
  97. package/src/seo/core/index.ts +8 -1
  98. package/src/seo/core/noscript.ts +20 -2
  99. package/src/seo/core/pageMeta.ts +29 -5
  100. package/src/seo/core/siteFiles.ts +45 -10
  101. package/src/seo/core/startDocumentMeta.ts +9 -9
  102. package/src/seo/vite/prerender.ts +160 -36
  103. package/src/ui.ts +1 -0
  104. package/styles/chrome.css +135 -0
@@ -0,0 +1,166 @@
1
+ import type { KeyboardEvent, ReactElement } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
+
4
+ import { useChromeT } from '../../i18n/ui/useT.ts';
5
+ import { NumberInput } from '../../number/ui/NumberInput.tsx';
6
+ import { joinClassNames } from '../../shared/ui/joinClassNames.ts';
7
+
8
+ /** What one end of a range slider's row of values needs. */
9
+ export interface RangeValueProps {
10
+ /** Which end this is. */
11
+ side: 'min' | 'max';
12
+ /** The bound on this side, or `null` when the side is open. */
13
+ bound: number | null;
14
+ /** The end of the track on this side, shown faint while the side is open. */
15
+ end: number;
16
+ /** The quantity the slider bounds, for a screen reader. */
17
+ label: string;
18
+ /** How a number is written. */
19
+ format: (value: number) => string;
20
+ /** The bound on the other side, which a typed value may not cross. */
21
+ limit: number | null;
22
+ /** What one press of an arrow key adds in the box. */
23
+ step: number;
24
+ /** Whether only whole numbers are wanted. */
25
+ integer: boolean;
26
+ /** Whether the value can be edited. */
27
+ disabled: boolean;
28
+ /** Whether the box is open for typing. */
29
+ editing: boolean;
30
+ /** Open the box. */
31
+ onEdit: () => void;
32
+ /**
33
+ * Close the box, with what it holds — `undefined` for an emptied box — and
34
+ * whether Escape closed it, in which case nothing typed is kept.
35
+ */
36
+ onDone: (value: number | undefined, cancelled: boolean) => void;
37
+ }
38
+
39
+ /**
40
+ * One end of a range, written under the track: a bound, or the end of the
41
+ * track in faint ink when the side is open. A click turns it into a box, so a
42
+ * value the handles cannot land on is still one click and a few keystrokes
43
+ * away.
44
+ * @param props - See {@link RangeValueProps}.
45
+ * @returns The value, or the box it is typed into.
46
+ */
47
+ export function RangeValue(props: RangeValueProps): ReactElement {
48
+ const { side, bound, end, label, format, disabled, editing, onEdit } = props;
49
+ const t = useChromeT();
50
+ if (editing) {
51
+ const { limit, step, integer, onDone } = props;
52
+ return (
53
+ <RangeValueEditor
54
+ side={side}
55
+ bound={bound}
56
+ end={end}
57
+ label={label}
58
+ format={format}
59
+ limit={limit}
60
+ step={step}
61
+ integer={integer}
62
+ onDone={onDone}
63
+ />
64
+ );
65
+ }
66
+
67
+ const open = bound === null;
68
+ const shown = open ? end : bound;
69
+ const text = Number.isFinite(shown) ? format(shown) : '–';
70
+ const name = open
71
+ ? t(side === 'min' ? 'range.noLowerBound' : 'range.noUpperBound', {
72
+ label,
73
+ })
74
+ : t(side === 'min' ? 'range.lowestIs' : 'range.highestIs', {
75
+ label,
76
+ value: text,
77
+ });
78
+
79
+ return (
80
+ <button
81
+ type="button"
82
+ className={joinClassNames(
83
+ 'range-slider__value',
84
+ open && 'range-slider__value--open',
85
+ )}
86
+ disabled={disabled}
87
+ title={t('range.typeValue')}
88
+ aria-label={name}
89
+ onClick={onEdit}
90
+ >
91
+ {text}
92
+ </button>
93
+ );
94
+ }
95
+
96
+ /** What the box a value is typed into needs. */
97
+ type RangeValueEditorProps = Omit<
98
+ RangeValueProps,
99
+ 'disabled' | 'editing' | 'onEdit'
100
+ >;
101
+
102
+ /**
103
+ * The box a value is typed into. Enter or leaving it keeps what it holds;
104
+ * Escape drops it.
105
+ * @param props - See {@link RangeValueEditorProps}.
106
+ * @returns The box, focused and selected.
107
+ */
108
+ function RangeValueEditor(props: RangeValueEditorProps): ReactElement {
109
+ const { side, bound, end, label, format, limit, step, integer } = props;
110
+ const { onDone } = props;
111
+ const t = useChromeT();
112
+ const [typed, setTyped] = useState<number | undefined>(bound ?? undefined);
113
+ // The box settles its number — rounded, held inside its bounds — as it is
114
+ // left, and that happens in the same blur this wrapper hears afterwards,
115
+ // before any re-render: refs are what carry it across.
116
+ const latest = useRef(typed);
117
+ const cancelled = useRef(false);
118
+ const host = useRef<HTMLSpanElement>(null);
119
+
120
+ useEffect(() => {
121
+ const input = host.current?.querySelector('input');
122
+ input?.focus();
123
+ input?.select();
124
+ }, []);
125
+
126
+ function press(event: KeyboardEvent<HTMLSpanElement>) {
127
+ if (event.key !== 'Enter' && event.key !== 'Escape') return;
128
+ event.preventDefault();
129
+ // An Escape that closes the box must not also close a dialog around it.
130
+ event.stopPropagation();
131
+ cancelled.current = event.key === 'Escape';
132
+ if (event.target instanceof HTMLElement) event.target.blur();
133
+ }
134
+
135
+ return (
136
+ <span
137
+ ref={host}
138
+ className={joinClassNames(
139
+ 'range-slider__editor',
140
+ `range-slider__editor--${side}`,
141
+ )}
142
+ onKeyDown={press}
143
+ onBlur={() => onDone(latest.current, cancelled.current)}
144
+ >
145
+ <NumberInput
146
+ allowEmpty
147
+ size="small"
148
+ buttons={false}
149
+ fill
150
+ value={typed}
151
+ step={step}
152
+ integer={integer}
153
+ min={side === 'max' ? (limit ?? undefined) : undefined}
154
+ max={side === 'min' ? (limit ?? undefined) : undefined}
155
+ placeholder={Number.isFinite(end) ? format(end) : undefined}
156
+ ariaLabel={t(side === 'min' ? 'range.lowest' : 'range.highest', {
157
+ label,
158
+ })}
159
+ onChange={(next) => {
160
+ latest.current = next;
161
+ setTyped(next);
162
+ }}
163
+ />
164
+ </span>
165
+ );
166
+ }
@@ -0,0 +1,2 @@
1
+ export type { RangeSliderProps } from './RangeSlider.tsx';
2
+ export { RangeSlider } from './RangeSlider.tsx';
@@ -15,6 +15,22 @@ export function writeDocumentMeta(meta: DocumentMeta): void {
15
15
  if (meta.title) documentTitle(meta.title);
16
16
  if (meta.description) metaDescription(meta.description);
17
17
  if (meta.canonical) canonicalLink(meta.canonical);
18
+ if (meta.language) documentLanguage(meta.language);
19
+ }
20
+
21
+ /**
22
+ * Say which language the page on screen is in.
23
+ *
24
+ * The file the build wrote already says it, and a click that switches language
25
+ * has to move it with the rest of the head: a page whose `lang` lies is read
26
+ * aloud wrong by a screen reader and offered to the wrong reader by a search
27
+ * engine, and nothing on screen shows it.
28
+ * @param language - The language tag, e.g. `fr`.
29
+ */
30
+ export function documentLanguage(language: string): void {
31
+ if (typeof document === 'undefined') return;
32
+ const root = document.documentElement;
33
+ if (root) root.lang = language;
18
34
  }
19
35
 
20
36
  /**
@@ -71,6 +87,12 @@ export interface DocumentMeta {
71
87
  * @default undefined
72
88
  */
73
89
  canonical?: string;
90
+ /**
91
+ * The language the page is written in, for `<html lang>`. Left out, the one
92
+ * the page was served with stays as it is.
93
+ * @default undefined
94
+ */
95
+ language?: string;
74
96
  }
75
97
 
76
98
  function metaDescription(content: string): void {
@@ -1,3 +1,5 @@
1
+ export type { AlternateOptions } from './alternates.ts';
2
+ export { alternateLinkTags } from './alternates.ts';
1
3
  export type { DocumentMeta } from './documentMeta.ts';
2
4
  export { writeDocumentMeta } from './documentMeta.ts';
3
5
  export type {
@@ -9,7 +11,12 @@ export type {
9
11
  } from './noscript.ts';
10
12
  export { noscriptIndex } from './noscript.ts';
11
13
  export type { PageMetaOptions } from './pageMeta.ts';
12
- export { injectPageMeta, pageDocumentMeta, pageHeadTags } from './pageMeta.ts';
14
+ export {
15
+ injectPageMeta,
16
+ pageDocumentMeta,
17
+ pageHeadTags,
18
+ pageLanguage,
19
+ } from './pageMeta.ts';
13
20
  export type { PageContent, PageSection, PageTable } from './pageProse.ts';
14
21
  export { pageProseHtml } from './pageProse.ts';
15
22
  export type { PlainDescriptionOptions } from './plainDescription.ts';
@@ -9,6 +9,9 @@
9
9
  import { siteById, siteDisplayName } from '../../ecosystem/core/lookup.ts';
10
10
  import type { EcosystemSite, SiteId } from '../../ecosystem/core/sites.ts';
11
11
  import { ECOSYSTEM_SITES, siteUrl } from '../../ecosystem/core/sites.ts';
12
+ import { CHROME_CATALOG } from '../../i18n/core/chromeCatalog.ts';
13
+ import type { Language } from '../../i18n/core/languages.ts';
14
+ import { DEFAULT_LANGUAGE } from '../../i18n/core/languages.ts';
12
15
  import { joinBasePath } from '../../router/core/basePath.ts';
13
16
  import { escapeAttribute, escapeText } from '../../share/core/escape.ts';
14
17
 
@@ -70,6 +73,13 @@ export interface NoscriptText {
70
73
  * @default false
71
74
  */
72
75
  ecosystem?: boolean | NoscriptEcosystem;
76
+ /**
77
+ * The language the block is written in, which is what the family list's own
78
+ * heading is translated into. The site's own prose is passed already written;
79
+ * this is only for the words that belong to the chrome.
80
+ * @default the default language
81
+ */
82
+ language?: Language;
73
83
  /**
74
84
  * How the site's own addresses are written. `'absolute'` writes them from the
75
85
  * root of the host, under the mount the origin names. `'relative'` writes
@@ -119,6 +129,7 @@ export interface NoscriptOptions
119
129
  */
120
130
  export function noscriptIndex(options: NoscriptOptions): string {
121
131
  const site = resolveSite(options.site);
132
+ const language = options.language ?? DEFAULT_LANGUAGE;
122
133
  const hrefs = options.hrefs ?? 'absolute';
123
134
  const mount = hrefs === 'absolute' ? mountPathOf(options) : '';
124
135
  const content = options.content;
@@ -135,7 +146,7 @@ export function noscriptIndex(options: NoscriptOptions): string {
135
146
 
136
147
  return `<noscript>
137
148
  <h1>${heading}</h1>${prose}
138
- <p>${intro}</p>${pageList(options.routes, mount, hrefs, ' ')}${familyList(site.id, options.ecosystem)}
149
+ <p>${intro}</p>${pageList(options.routes, mount, hrefs, ' ')}${familyList(site.id, options.ecosystem, language)}
139
150
  </noscript>`;
140
151
  }
141
152
 
@@ -185,6 +196,7 @@ function labelOf(route: NoscriptRoute): string {
185
196
  function familyList(
186
197
  current: string,
187
198
  ecosystem: boolean | NoscriptEcosystem | undefined,
199
+ language: Language,
188
200
  ): string {
189
201
  if (ecosystem === undefined || ecosystem === false) return '';
190
202
  const listed = ecosystem === true ? {} : ecosystem;
@@ -198,8 +210,14 @@ function familyList(
198
210
  );
199
211
  }
200
212
  if (items.length === 0) return '';
213
+ // The chrome's own words, so one translation in this package writes the crawl
214
+ // path of every site of the family rather than eighteen catalogs carrying the
215
+ // same two words.
216
+ const heading = escapeText(
217
+ CHROME_CATALOG.translate('ecosystem.otherTools', language),
218
+ );
201
219
  return `
202
- <h2>Our other tools</h2>
220
+ <h2>${heading}</h2>
203
221
  <ul>
204
222
  ${items.join('\n')}
205
223
  </ul>`;
@@ -38,8 +38,16 @@ const ABSOLUTE_URL = /^[a-z][\d+.a-z-]*:\/\//i;
38
38
  export interface PageMetaOptions {
39
39
  /** The site, named or passed. */
40
40
  site: SiteRecord | SiteId;
41
- /** Every address it answers, each with its title and description. */
42
- routes: readonly RouteMeta[];
41
+ /**
42
+ * Every address it answers, each with its title and description.
43
+ *
44
+ * A translated site passes a function instead, exactly as `cheminfoPrerender`
45
+ * takes it: the language is read off the address first, and that language's
46
+ * table is what the page is titled from. Passed as a value by a site whose
47
+ * prose is per-language, a language switch would leave the title and the
48
+ * description in whichever language the table was built in.
49
+ */
50
+ routes: readonly RouteMeta[] | ((language: Language) => readonly RouteMeta[]);
43
51
  /**
44
52
  * The address being written, query string included: a path, or the absolute
45
53
  * address an app reads off the page it is on.
@@ -148,10 +156,12 @@ export function pageDocumentMeta(
148
156
  ): Required<DocumentMeta> {
149
157
  const site = resolveSite(options.site);
150
158
  const meta = routeMetaOf(options);
159
+ const language = pageLanguage(options);
151
160
  return {
152
161
  title: `${meta.title} — ${siteDisplayName(site)}`,
153
162
  description: meta.description,
154
- canonical: `${originOf(options)}${withLanguagePath(pageLanguage(options), meta.path)}`,
163
+ canonical: `${originOf(options)}${withLanguagePath(language, meta.path)}`,
164
+ language,
155
165
  };
156
166
  }
157
167
 
@@ -173,8 +183,22 @@ export function pageLanguage(options: PageMetaOptions): Language {
173
183
  // route table is written from the site's own root, so the mount the origin
174
184
  // carries is taken off it before the table is read.
175
185
  function routeMetaOf(options: PageMetaOptions): RouteMeta {
176
- const { path } = readLanguagePath(ownPath(options), languagesOf(options));
177
- return pageMetaFor(options.routes, path);
186
+ const { language, path } = readLanguagePath(
187
+ ownPath(options),
188
+ languagesOf(options),
189
+ );
190
+ return pageMetaFor(tableOf(options, language), path);
191
+ }
192
+
193
+ // The table the page is titled from: the one the site passed, or the one its
194
+ // function returns for the language the address names.
195
+ function tableOf(
196
+ options: PageMetaOptions,
197
+ language: Language,
198
+ ): readonly RouteMeta[] {
199
+ return typeof options.routes === 'function'
200
+ ? options.routes(language)
201
+ : options.routes;
178
202
  }
179
203
 
180
204
  // The address from the site's own root, the mount taken off, with the query
@@ -11,6 +11,9 @@
11
11
 
12
12
  import { siteById } from '../../ecosystem/core/lookup.ts';
13
13
  import type { SiteId, SiteRecord } from '../../ecosystem/core/sites.ts';
14
+ import type { Language } from '../../i18n/core/languages.ts';
15
+ import { DEFAULT_LANGUAGE } from '../../i18n/core/languages.ts';
16
+ import { withLanguagePath } from '../../language/core/languagePath.ts';
14
17
  import { trimTrailingSlash } from '../../router/core/address.ts';
15
18
  import { basePathOf } from '../../router/core/basePath.ts';
16
19
  import { escapeText } from '../../share/core/escape.ts';
@@ -22,8 +25,27 @@ import type { RouteMeta } from './routes.ts';
22
25
  // parses, with `localhost:` as its scheme and `3000` as its path.
23
26
  const HTTP_ORIGIN = /^https?:\/\//i;
24
27
 
28
+ /**
29
+ * Which site is being served, and where.
30
+ *
31
+ * The half of the options that says nothing about the pages, so the helpers
32
+ * that only resolve an address take this rather than the whole record — a
33
+ * caller whose `routes` is a function of the language is then still one of
34
+ * these.
35
+ */
36
+ export interface SiteOrigin {
37
+ /** The site, named or passed. */
38
+ site: SiteRecord | SiteId;
39
+ /**
40
+ * Where the site is served, mount path included, e.g.
41
+ * `https://learn.cheminfo.org/surge`.
42
+ * @default `https://<the site's host>`
43
+ */
44
+ origin?: string;
45
+ }
46
+
25
47
  /** What a crawler is told about the site as a whole. */
26
- export interface SiteFilesOptions {
48
+ export interface SiteFilesOptions extends SiteOrigin {
27
49
  /** The site, named or passed. */
28
50
  site: SiteRecord | SiteId;
29
51
  /** Every address it answers. */
@@ -35,6 +57,16 @@ export interface SiteFilesOptions {
35
57
  * @default `https://<the site's host>`
36
58
  */
37
59
  origin?: string;
60
+ /**
61
+ * Every language the site is written in, the default one included.
62
+ *
63
+ * A translated page is a page per language, so the sitemap lists every one of
64
+ * them: a variant it leaves out is a variant nothing points a crawler at, and
65
+ * the translation may as well not have been written. The `hreflang` set in
66
+ * each page's head is what then ties them back together.
67
+ * @default [the default language]
68
+ */
69
+ languages?: readonly Language[];
38
70
  }
39
71
 
40
72
  /**
@@ -53,13 +85,16 @@ export function sitemapXml(options: SiteFilesOptions): string {
53
85
  if (options.routes.every((route) => route.indexed === false)) {
54
86
  throw new Error('a sitemap lists at least one address');
55
87
  }
56
- const entries = options.routes
57
- .filter((route) => route.indexed !== false)
58
- .map(
59
- (route) =>
60
- ` <url><loc>${escapeText(`${origin}${route.path}`)}</loc></url>`,
61
- )
62
- .join('\n');
88
+ const languages = options.languages ?? [DEFAULT_LANGUAGE];
89
+ const lines: string[] = [];
90
+ for (const route of options.routes) {
91
+ if (route.indexed === false) continue;
92
+ for (const language of languages) {
93
+ const address = `${origin}${withLanguagePath(language, route.path)}`;
94
+ lines.push(` <url><loc>${escapeText(address)}</loc></url>`);
95
+ }
96
+ }
97
+ const entries = lines.join('\n');
63
98
  return `<?xml version="1.0" encoding="UTF-8"?>
64
99
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
65
100
  ${entries}
@@ -91,7 +126,7 @@ export function resolveSite(site: SiteRecord | SiteId): SiteRecord {
91
126
  * @throws {Error} When the deployment named something that is not an absolute
92
127
  * `http` or `https` address.
93
128
  */
94
- export function originOf(options: SiteFilesOptions): string {
129
+ export function originOf(options: SiteOrigin): string {
95
130
  const origin = options.origin ?? `https://${resolveSite(options.site).host}`;
96
131
  if (!HTTP_ORIGIN.test(origin) || !URL.canParse(origin)) {
97
132
  throw new Error(
@@ -108,6 +143,6 @@ export function originOf(options: SiteFilesOptions): string {
108
143
  * @throws {Error} When the deployment named something that is not an absolute
109
144
  * address, so there is no path to read off it.
110
145
  */
111
- export function mountPathOf(options: SiteFilesOptions): string {
146
+ export function mountPathOf(options: SiteOrigin): string {
112
147
  return basePathOf(originOf(options));
113
148
  }
@@ -52,18 +52,18 @@ export function startDocumentMeta(
52
52
  ): () => void {
53
53
  if (typeof document === 'undefined') return stopNothing;
54
54
 
55
+ // Everything the caller passed goes through, rather than the four fields this
56
+ // once named one by one: an option added to `PageMetaOptions` and forgotten
57
+ // here is dropped in silence. `languages` was, and every translated page took
58
+ // the English home page's title and canonical — type-checked, linted and unit
59
+ // tested all the way.
60
+ const { url, follow, ...page } = options;
61
+
55
62
  const write = (): void => {
56
- writeDocumentMeta(
57
- pageDocumentMeta({
58
- site: options.site,
59
- routes: options.routes,
60
- url: options.url(),
61
- origin: options.origin,
62
- }),
63
- );
63
+ writeDocumentMeta(pageDocumentMeta({ ...page, url: url() }));
64
64
  };
65
65
 
66
- return (options.follow ?? writeOnce)(write);
66
+ return (follow ?? writeOnce)(write);
67
67
  }
68
68
 
69
69
  function writeOnce(write: () => void): () => void {