@docpensieve/core 0.1.4 → 0.2.0-beta.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.
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/Juniors017/docpensieve/main/branding/logo.jpg" alt="DocPensieve — Documentation &amp; Magical Memory" width="180">
3
+ </p>
4
+
1
5
  # @docpensieve/core
2
6
 
3
7
  > Generation engine
@@ -0,0 +1,205 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Search, in the reader's browser: the one script a DocPensieve site loads,
4
+ * and only on its search page.
5
+ *
6
+ * The build does the heavy work. It writes an index of each version, and a
7
+ * search page that already lists every page. This script only reads the
8
+ * query, filters that list against the index and shows an excerpt. Without
9
+ * it, the page stays the full list: search adds to the site, nothing depends
10
+ * on it.
11
+ *
12
+ * The functions below are exported so that the ranking can be tested outside
13
+ * a browser; the page wiring only runs where there is a document. The DOM
14
+ * types are referenced here, the only file that needs them.
15
+ */
16
+
17
+ /**
18
+ * A page of the index, as the build writes it.
19
+ *
20
+ * @typedef {object} IndexEntry
21
+ * @property {string} title
22
+ * @property {string} url
23
+ * @property {string} description
24
+ * @property {string} text Plain text of the page.
25
+ */
26
+
27
+ /**
28
+ * Lowercases and strips accents, so that "ete" finds "été".
29
+ *
30
+ * An accented letter written as one character stays one character: the
31
+ * normalised text keeps the offsets of the original, which the excerpt
32
+ * relies on.
33
+ *
34
+ * @param {string} text
35
+ * @returns {string}
36
+ */
37
+ export function normalize(text) {
38
+ return text
39
+ .normalize('NFD')
40
+ .replace(/\p{Diacritic}/gu, '')
41
+ .toLowerCase();
42
+ }
43
+
44
+ /**
45
+ * @param {string} query
46
+ * @returns {string[]} The distinct words of the query.
47
+ */
48
+ export function tokenize(query) {
49
+ return [
50
+ ...new Set(
51
+ normalize(query)
52
+ .split(/[^\p{L}\p{N}]+/u)
53
+ .filter(Boolean),
54
+ ),
55
+ ];
56
+ }
57
+
58
+ /**
59
+ * The pages that hold every word of the query, best first.
60
+ *
61
+ * A word in the title weighs more than one in the description, which weighs
62
+ * more than one in the text; repetitions in the text count up to five, so
63
+ * that a long page does not win by length alone.
64
+ *
65
+ * @param {IndexEntry[]} entries
66
+ * @param {string} query
67
+ * @returns {IndexEntry[]}
68
+ */
69
+ export function rank(entries, query) {
70
+ const words = tokenize(query);
71
+ if (words.length === 0) return [];
72
+
73
+ /** @type {{ entry: IndexEntry, score: number }[]} */
74
+ const scored = [];
75
+ for (const entry of entries) {
76
+ const title = normalize(entry.title);
77
+ const description = normalize(entry.description);
78
+ const text = normalize(entry.text);
79
+
80
+ let score = 0;
81
+ let everyWord = true;
82
+ for (const word of words) {
83
+ const inTitle = title.includes(word);
84
+ const inDescription = description.includes(word);
85
+ const inText = text.split(word).length - 1;
86
+ if (!inTitle && !inDescription && inText === 0) {
87
+ everyWord = false;
88
+ break;
89
+ }
90
+ score += (inTitle ? 10 : 0) + (inDescription ? 3 : 0) + Math.min(inText, 5);
91
+ }
92
+ if (everyWord) scored.push({ entry, score });
93
+ }
94
+
95
+ return scored.sort((a, b) => b.score - a.score).map((item) => item.entry);
96
+ }
97
+
98
+ /**
99
+ * The passage of a text around the first word of the query found in it.
100
+ *
101
+ * @param {string} text
102
+ * @param {string} query
103
+ * @param {number} [radius] Characters kept on each side.
104
+ * @returns {{ before: string, match: string, after: string } | null}
105
+ */
106
+ export function excerpt(text, query, radius = 80) {
107
+ const normalized = normalize(text);
108
+ for (const word of tokenize(query)) {
109
+ const at = normalized.indexOf(word);
110
+ if (at < 0) continue;
111
+ const start = Math.max(0, at - radius);
112
+ const end = Math.min(text.length, at + word.length + radius);
113
+ return {
114
+ before: (start > 0 ? '…' : '') + text.slice(start, at),
115
+ match: text.slice(at, at + word.length),
116
+ after: text.slice(at + word.length, end) + (end < text.length ? '…' : ''),
117
+ };
118
+ }
119
+ return null;
120
+ }
121
+
122
+ /** Wires the search page: reads the query, filters the list, shows excerpts. */
123
+ async function init() {
124
+ const form = /** @type {HTMLFormElement | null} */ (
125
+ document.querySelector('form[data-search-page]')
126
+ );
127
+ const input = /** @type {HTMLInputElement | null | undefined} */ (
128
+ form?.querySelector('input[name="q"]')
129
+ );
130
+ const list = /** @type {HTMLElement | null} */ (document.querySelector('[data-search-results]'));
131
+ const status = /** @type {HTMLElement | null} */ (document.querySelector('[data-search-status]'));
132
+ if (!form || !input || !list || !status) return;
133
+
134
+ /** @type {Map<string, HTMLElement>} */
135
+ const items = new Map();
136
+ const listed = /** @type {NodeListOf<HTMLElement>} */ (list.querySelectorAll('li[data-url]'));
137
+ for (const item of listed) items.set(item.dataset.url ?? '', item);
138
+
139
+ /** @type {IndexEntry[]} */
140
+ let entries;
141
+ try {
142
+ const response = await fetch(form.dataset.index ?? '');
143
+ entries = await response.json();
144
+ } catch {
145
+ status.textContent = 'The search index could not be loaded: every page is listed below.';
146
+ return;
147
+ }
148
+
149
+ /** @param {HTMLElement} item */
150
+ const reset = (item) => {
151
+ item.hidden = false;
152
+ item.querySelector('.dp-search-excerpt')?.remove();
153
+ };
154
+
155
+ const show = () => {
156
+ const query = input.value.trim();
157
+
158
+ // Without a query, the page is the full list it was without the script.
159
+ if (!query) {
160
+ for (const item of items.values()) {
161
+ reset(item);
162
+ list.append(item);
163
+ }
164
+ status.textContent = `${items.size} pages.`;
165
+ return;
166
+ }
167
+
168
+ const found = rank(entries, query);
169
+ for (const item of items.values()) item.hidden = true;
170
+ for (const entry of found) {
171
+ const item = items.get(entry.url);
172
+ if (!item) continue;
173
+ reset(item);
174
+ const passage = excerpt(entry.text, query);
175
+ if (passage) {
176
+ const paragraph = document.createElement('p');
177
+ paragraph.className = 'dp-search-excerpt';
178
+ const mark = document.createElement('mark');
179
+ mark.textContent = passage.match;
180
+ paragraph.append(passage.before, mark, passage.after);
181
+ item.append(paragraph);
182
+ }
183
+ // Appending moves the item: the list ends up best first.
184
+ list.append(item);
185
+ }
186
+
187
+ status.textContent =
188
+ found.length === 0
189
+ ? `No page matches “${query}”.`
190
+ : `${found.length} ${found.length === 1 ? 'page' : 'pages'} for “${query}”.`;
191
+ };
192
+
193
+ input.value = new URLSearchParams(location.search).get('q') ?? '';
194
+ input.addEventListener('input', show);
195
+ // Searching again needs no reload, and the address follows the query, so
196
+ // that a search can be shared.
197
+ form.addEventListener('submit', (event) => {
198
+ event.preventDefault();
199
+ history.replaceState(null, '', `?q=${encodeURIComponent(input.value.trim())}`);
200
+ show();
201
+ });
202
+ show();
203
+ }
204
+
205
+ if (typeof document !== 'undefined') init();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docpensieve/core",
3
- "version": "0.1.4",
3
+ "version": "0.2.0-beta.1",
4
4
  "description": "DocPensieve engine: loading, MDX compilation, structured data, site generation",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,11 +16,12 @@
16
16
  },
17
17
  "files": [
18
18
  "src",
19
+ "client",
19
20
  "templates",
20
21
  "types"
21
22
  ],
22
23
  "dependencies": {
23
- "@docpensieve/shared": "0.1.4",
24
+ "@docpensieve/shared": "0.2.0-beta.1",
24
25
  "@mdx-js/mdx": "^3.1.1",
25
26
  "@shikijs/rehype": "^4.4.3",
26
27
  "gray-matter": "^4.0.3",
@@ -43,7 +44,7 @@
43
44
  "url": "git+https://github.com/Juniors017/docpensieve.git",
44
45
  "directory": "packages/core"
45
46
  },
46
- "homepage": "https://github.com/Juniors017/docpensieve#readme",
47
+ "homepage": "https://docpensieve.com",
47
48
  "bugs": {
48
49
  "url": "https://github.com/Juniors017/docpensieve/issues"
49
50
  },
package/src/compiler.js CHANGED
@@ -8,6 +8,8 @@
8
8
  * @module @docpensieve/core/compiler
9
9
  */
10
10
 
11
+ import { closeSync, openSync, readSync } from 'node:fs';
12
+ import path from 'node:path';
11
13
  import { createElement } from 'react';
12
14
  import { renderToStaticMarkup } from 'react-dom/server';
13
15
  import * as runtime from 'react/jsx-runtime';
@@ -17,6 +19,8 @@ import { evaluate } from '@mdx-js/mdx';
17
19
  import rehypeShiki from '@shikijs/rehype';
18
20
  import remarkGfm from 'remark-gfm';
19
21
 
22
+ import { imageSize } from './image-size.js';
23
+
20
24
  /**
21
25
  * Default Shiki themes: the dual theme follows dark mode without JS.
22
26
  *
@@ -256,6 +260,75 @@ function rehypeSiteLinks({ url, dirUrl, basePath }) {
256
260
  };
257
261
  }
258
262
 
263
+ /**
264
+ * Prepares the images of a page for the browser: their dimensions, read from
265
+ * their file, so that the text does not jump when they arrive; and a lazy
266
+ * loading for all but the first, which is often in view — and which React
267
+ * then stops preloading, as it does for every image not loaded lazily.
268
+ *
269
+ * It runs before the targets are rewritten, while `src` is still the path
270
+ * the author wrote next to the page.
271
+ *
272
+ * @param {{ filepath?: string, sourceDir?: string }} context `sourceDir` is
273
+ * the version's folder, from which an absolute `src` starts.
274
+ * @returns {() => (tree: any) => void}
275
+ */
276
+ function rehypeImages({ filepath, sourceDir }) {
277
+ /** @param {string} src @returns {string | null} */
278
+ const fileOf = (src) => {
279
+ let clean;
280
+ try {
281
+ clean = decodeURI(src.split('?')[0].split('#')[0]);
282
+ } catch {
283
+ return null;
284
+ }
285
+ if (clean.startsWith('/')) return sourceDir ? path.join(sourceDir, clean) : null;
286
+ return filepath ? path.resolve(path.dirname(filepath), clean) : null;
287
+ };
288
+
289
+ /** @param {string} file */
290
+ const sizeOf = (file) => {
291
+ try {
292
+ const handle = openSync(file, 'r');
293
+ try {
294
+ const bytes = Buffer.alloc(65536);
295
+ const read = readSync(handle, bytes, 0, bytes.length, 0);
296
+ return imageSize(bytes.subarray(0, read), path.extname(file));
297
+ } finally {
298
+ closeSync(handle);
299
+ }
300
+ } catch {
301
+ // A missing image is the link check's business, not this one's.
302
+ return null;
303
+ }
304
+ };
305
+
306
+ return () => (tree) => {
307
+ let first = true;
308
+ walk(tree, (node) => {
309
+ if (node.type !== 'element' || node.tagName !== 'img') return;
310
+ const properties = (node.properties ??= {});
311
+
312
+ const src = properties.src;
313
+ if (typeof src === 'string' && src !== '' && !EXTERNAL_TARGET.test(src)) {
314
+ const file = fileOf(src);
315
+ const size = file ? sizeOf(file) : null;
316
+ if (size && properties.width === undefined && properties.height === undefined) {
317
+ properties.width = size.width;
318
+ properties.height = size.height;
319
+ }
320
+ }
321
+
322
+ if (first) {
323
+ first = false;
324
+ return;
325
+ }
326
+ properties.loading ??= 'lazy';
327
+ properties.decoding ??= 'async';
328
+ });
329
+ };
330
+ }
331
+
259
332
  /**
260
333
  * Nests a flat list of headings into a tree, by depth.
261
334
  *
@@ -329,20 +402,21 @@ export class Compiler {
329
402
  * Compiles a source into an HTML fragment and a table of contents.
330
403
  *
331
404
  * @param {string} source Markdown/MDX content, frontmatter already removed.
332
- * @param {{ filepath?: string, url?: string, dirUrl?: string, basePath?: string }} [context]
405
+ * @param {{ filepath?: string, url?: string, dirUrl?: string, basePath?: string, sourceDir?: string }} [context]
333
406
  * `filepath` locates errors, `dirUrl` is the base of relative targets and
334
407
  * `basePath` prefixes absolute targets (the version root).
335
408
  * @returns {Promise<CompileResult>}
336
409
  * @throws {CompileError} Invalid syntax, or a component unknown at use.
337
410
  */
338
411
  async compile(source, context = {}) {
339
- const { filepath, url, dirUrl, basePath } = context;
412
+ const { filepath, url, dirUrl, basePath, sourceDir } = context;
340
413
 
341
414
  /** @type {{ id: string, text: string, depth: number }[]} */
342
415
  const headings = [];
343
416
 
344
417
  const rehypePlugins = [
345
418
  rehypeHeadingIds(headings),
419
+ rehypeImages({ filepath, sourceDir }),
346
420
  rehypeSiteLinks({ url, dirUrl, basePath }),
347
421
  rehypeTableScroll(),
348
422
  ...(this.highlight ? [[rehypeShiki, this.highlight]] : []),
package/src/config.js CHANGED
@@ -13,7 +13,6 @@ import {
13
13
  CONFIG_FILENAMES,
14
14
  ConfigError,
15
15
  DEFAULT_OUT_DIR,
16
- NotImplementedError,
17
16
  THEME_FRAMEWORKS,
18
17
  } from '@docpensieve/shared';
19
18
 
@@ -26,6 +25,8 @@ import {
26
25
  * @property {boolean} [archived] Version kept but no longer maintained.
27
26
  * @property {boolean} [prerelease] Version in preparation, not yet the
28
27
  * reference one. Its pages carry a notice and are not indexed.
28
+ * @property {string} [logo] Logo of this version, instead of the project's.
29
+ * @property {string} [favicon] Favicon of this version, instead of the project's.
29
30
  */
30
31
 
31
32
  /**
@@ -40,6 +41,12 @@ import {
40
41
  * @property {boolean} globalComponents
41
42
  * @property {boolean} scrollToTop Back-to-top button on every page.
42
43
  * @property {{ enabled: boolean }} jsonld
44
+ * @property {string} [logo] Image beside the project name, in the header.
45
+ * @property {string} [favicon] Icon of the browser tab: `.ico`, `.png` or `.svg`.
46
+ * @property {string} [socialImage] Preview of a shared page. Needs `siteUrl`.
47
+ * @property {boolean} [sitemap] `sitemap.xml` of the published versions.
48
+ * @property {boolean} [feed] RSS feed of the dated pages of the current version.
49
+ * @property {boolean} [search] Search field, index and search page of each version.
43
50
  * @property {string} [rootDir] Project root, set by `loadConfig`.
44
51
  * @property {string} [configFile] Path of the configuration file, set by `loadConfig`.
45
52
  * @property {string} [lang] Document language, `'en'` by default.
@@ -65,8 +72,41 @@ export const DEFAULT_CONFIG = Object.freeze({
65
72
  globalComponents: true,
66
73
  scrollToTop: true,
67
74
  jsonld: { enabled: true },
75
+ logo: '',
76
+ favicon: '',
77
+ socialImage: '',
78
+ // On by default, but only written once siteUrl is set: it lists absolute
79
+ // addresses.
80
+ sitemap: true,
81
+ // Off by default: most documentation pages carry no date, and a feed that
82
+ // is always empty would be announced in every page.
83
+ feed: false,
84
+ search: true,
68
85
  });
69
86
 
87
+ /** Values of `theme.darkMode`. */
88
+ const DARK_MODES = ['class', 'dark', 'light'];
89
+
90
+ /**
91
+ * Extensions accepted for each project image, and what to do otherwise.
92
+ *
93
+ * @type {Record<'logo' | 'favicon' | 'socialImage', { extensions: string[], hint: string }>}
94
+ */
95
+ const IMAGE_KINDS = {
96
+ logo: {
97
+ extensions: ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif'],
98
+ hint: 'Give an image the browser displays: SVG, PNG, JPEG, WebP, GIF or AVIF.',
99
+ },
100
+ favicon: {
101
+ extensions: ['.ico', '.png', '.svg'],
102
+ hint: 'Browser tabs show .ico, .png and .svg icons.',
103
+ },
104
+ socialImage: {
105
+ extensions: ['.png', '.jpg', '.jpeg', '.webp', '.gif'],
106
+ hint: 'Social networks read neither SVG nor AVIF: export a PNG or a JPEG, 1200 × 630 pixels.',
107
+ },
108
+ };
109
+
70
110
  /**
71
111
  * Identity over the config, used only for autocompletion and type checking
72
112
  * in the editor.
@@ -137,6 +177,19 @@ export function normalizeConfig(userConfig) {
137
177
  hint: 'Letters, digits, dot, dash and underscore, starting with a letter or a digit — "v1.0", "next".',
138
178
  });
139
179
  }
180
+ // A version may carry its own logo and favicon — a beta told apart at a
181
+ // glance. Checked like the project's.
182
+ for (const field of /** @type {const} */ (['logo', 'favicon'])) {
183
+ const value = version[field];
184
+ if (value === undefined) continue;
185
+ const { extensions, hint } = IMAGE_KINDS[field];
186
+ if (typeof value !== 'string' || !extensions.includes(path.extname(value).toLowerCase())) {
187
+ throw new ConfigError(
188
+ `The ${field} of version "${version.slug}" must be a ${extensions.join(', ')} file: "${String(value)}".`,
189
+ { hint },
190
+ );
191
+ }
192
+ }
140
193
  if (seen.has(version.slug)) {
141
194
  throw new ConfigError(`The version slug "${version.slug}" is declared twice.`);
142
195
  }
@@ -167,13 +220,23 @@ export function normalizeConfig(userConfig) {
167
220
  config.versions[0].current = true;
168
221
  }
169
222
 
170
- // The sidebar is derived from the file tree, and nothing else is written
171
- // yet. Accepting a path without reading it would suggest it is used.
223
+ // 'auto' derives the sidebar from the file tree. Anything else names a JSON
224
+ // description, which the generator reads from each version's folder: each
225
+ // version has its own pages, so its own menu.
172
226
  if (config.sidebar !== 'auto') {
173
- throw new NotImplementedError(
174
- `A sidebar described by a file ("${config.sidebar}")`,
175
- '4.2 Explicit sidebar',
176
- );
227
+ const file = typeof config.sidebar === 'string' ? config.sidebar : '';
228
+ if (
229
+ !file.toLowerCase().endsWith('.json') ||
230
+ path.isAbsolute(file) ||
231
+ file.split('/').includes('..')
232
+ ) {
233
+ throw new ConfigError(
234
+ `sidebar must be 'auto' or a .json file within each version folder: "${String(config.sidebar)}".`,
235
+ {
236
+ hint: "For instance sidebar: 'sidebar.json', read as docs/v1.0/sidebar.json for that version.",
237
+ },
238
+ );
239
+ }
177
240
  }
178
241
 
179
242
  // siteUrl feeds everything that must be absolute: canonical, JSON-LD.
@@ -208,6 +271,57 @@ export function normalizeConfig(userConfig) {
208
271
  '/',
209
272
  );
210
273
 
274
+ // The project's images: paths from the root, checked here for their kind.
275
+ // Whether they exist is checked when the build copies them.
276
+ for (const field of /** @type {const} */ (['logo', 'favicon', 'socialImage'])) {
277
+ const value = config[field];
278
+ if (value === undefined || value === '') continue;
279
+ const { extensions, hint } = IMAGE_KINDS[field];
280
+ if (typeof value !== 'string') {
281
+ throw new ConfigError(`${field} must be the path of an image, from the project root.`, {
282
+ hint,
283
+ });
284
+ }
285
+ if (!extensions.includes(path.extname(value).toLowerCase())) {
286
+ throw new ConfigError(`${field} must be a ${extensions.join(', ')} file: "${value}".`, {
287
+ hint,
288
+ });
289
+ }
290
+ }
291
+ // Both list absolute addresses. Asked for explicitly without siteUrl, they
292
+ // could only be written wrong; left to their default, they wait for it.
293
+ for (const field of /** @type {const} */ (['sitemap', 'feed'])) {
294
+ if (typeof config[field] !== 'boolean') {
295
+ throw new ConfigError(`${field} must be true or false.`, {
296
+ hint: `For instance ${field}: true.`,
297
+ });
298
+ }
299
+ if (userConfig[field] === true && !config.siteUrl) {
300
+ throw new ConfigError(`${field} needs siteUrl.`, {
301
+ hint: 'It lists absolute addresses: set siteUrl, the public address of the site.',
302
+ });
303
+ }
304
+ }
305
+
306
+ if (typeof config.search !== 'boolean') {
307
+ throw new ConfigError('search must be true or false.', { hint: 'For instance search: false.' });
308
+ }
309
+
310
+ if (config.socialImage && !config.siteUrl) {
311
+ throw new ConfigError('socialImage needs siteUrl.', {
312
+ hint: 'Social networks only read an absolute address: set siteUrl, the public address of the site.',
313
+ });
314
+ }
315
+
316
+ // 'class' follows the reader's system, and a dark or light class on <html>
317
+ // wins; 'dark' and 'light' set that class at build time, for a site that
318
+ // keeps one look whatever the system.
319
+ if (!DARK_MODES.includes(config.theme.darkMode ?? 'class')) {
320
+ throw new ConfigError(`Unknown darkMode: "${config.theme.darkMode}".`, {
321
+ hint: `Accepted values: ${DARK_MODES.join(', ')}.`,
322
+ });
323
+ }
324
+
211
325
  if (!THEME_FRAMEWORKS.includes(config.theme.framework)) {
212
326
  throw new ConfigError(`Unknown theme framework: "${config.theme.framework}".`, {
213
327
  hint: `Accepted values: ${THEME_FRAMEWORKS.join(', ')}.`,
@@ -0,0 +1,128 @@
1
+ /**
2
+ * What a site offers the programs that read it: the sitemap for search
3
+ * engines, `robots.txt` that points to it, and the RSS feed of dated pages.
4
+ *
5
+ * Pure functions: the generator gathers the published pages and writes the
6
+ * files.
7
+ *
8
+ * @module @docpensieve/core/discovery
9
+ */
10
+
11
+ import { toISODate } from './structured-data.js';
12
+
13
+ /**
14
+ * A page as the site publishes it.
15
+ *
16
+ * @typedef {object} PublishedPage
17
+ * @property {string} url Page URL, deployment prefix included
18
+ * (`/docs/versions/v1.0/guide/`).
19
+ * @property {Record<string, any>} frontmatter Frontmatter of its source.
20
+ */
21
+
22
+ /**
23
+ * Escapes a value for an XML text or attribute.
24
+ *
25
+ * @param {unknown} value
26
+ * @returns {string}
27
+ */
28
+ function escapeXml(value) {
29
+ return String(value)
30
+ .replaceAll('&', '&amp;')
31
+ .replaceAll('<', '&lt;')
32
+ .replaceAll('>', '&gt;')
33
+ .replaceAll('"', '&quot;')
34
+ .replaceAll("'", '&apos;');
35
+ }
36
+
37
+ /**
38
+ * Builds `sitemap.xml`.
39
+ *
40
+ * `lastmod` is the page's `modified` date, or failing that its `date`; a page
41
+ * that carries neither is listed without one rather than with a made-up date.
42
+ *
43
+ * @param {PublishedPage[]} pages Pages of the versions to list.
44
+ * @param {string} siteUrl Public address of the site: the sitemap only holds
45
+ * absolute addresses.
46
+ * @returns {string}
47
+ */
48
+ export function buildSitemap(pages, siteUrl) {
49
+ const lines = [
50
+ '<?xml version="1.0" encoding="UTF-8"?>',
51
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
52
+ ];
53
+ for (const page of pages) {
54
+ const modified = toISODate(page.frontmatter.modified) ?? toISODate(page.frontmatter.date);
55
+ lines.push(' <url>', ` <loc>${escapeXml(new URL(page.url, siteUrl).href)}</loc>`);
56
+ if (modified) lines.push(` <lastmod>${modified}</lastmod>`);
57
+ lines.push(' </url>');
58
+ }
59
+ lines.push('</urlset>', '');
60
+ return lines.join('\n');
61
+ }
62
+
63
+ /**
64
+ * Builds `robots.txt`, which lets every crawler in and names the sitemap.
65
+ *
66
+ * @param {string} sitemapUrl Absolute address of the sitemap.
67
+ * @returns {string}
68
+ */
69
+ export function buildRobots(sitemapUrl) {
70
+ return ['User-agent: *', 'Allow: /', `Sitemap: ${sitemapUrl}`, ''].join('\n');
71
+ }
72
+
73
+ /**
74
+ * Builds the RSS feed of the dated pages, newest first.
75
+ *
76
+ * Only a page with a `date` enters it: a documentation page without one is
77
+ * reference material, not news, and dating it at build time would announce
78
+ * every page again at every build.
79
+ *
80
+ * @param {PublishedPage[]} pages Pages of the current version.
81
+ * @param {{
82
+ * projectName: string, siteUrl: string, homeUrl: string, feedUrl: string, lang?: string,
83
+ * }} site `homeUrl` and `feedUrl` are absolute.
84
+ * @returns {string}
85
+ */
86
+ export function buildFeed(pages, site) {
87
+ /** @type {{ page: PublishedPage, date: string }[]} */
88
+ const dated = [];
89
+ for (const page of pages) {
90
+ const date = toISODate(page.frontmatter.date);
91
+ if (date) dated.push({ page, date });
92
+ }
93
+ // ISO dates sort as strings; newest first, as a feed reader expects.
94
+ dated.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
95
+
96
+ /** @param {string} iso */
97
+ const rfc822 = (iso) => new Date(`${iso}T00:00:00Z`).toUTCString();
98
+
99
+ const lines = [
100
+ '<?xml version="1.0" encoding="UTF-8"?>',
101
+ '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
102
+ ' <channel>',
103
+ ` <title>${escapeXml(site.projectName)}</title>`,
104
+ ` <link>${escapeXml(site.homeUrl)}</link>`,
105
+ ` <description>${escapeXml(`Dated pages of ${site.projectName}`)}</description>`,
106
+ ` <language>${escapeXml(site.lang ?? 'en')}</language>`,
107
+ ` <atom:link href="${escapeXml(site.feedUrl)}" rel="self" type="application/rss+xml" />`,
108
+ ];
109
+ if (dated.length > 0) lines.push(` <lastBuildDate>${rfc822(dated[0].date)}</lastBuildDate>`);
110
+
111
+ for (const { page, date } of dated) {
112
+ const link = new URL(page.url, site.siteUrl).href;
113
+ lines.push(
114
+ ' <item>',
115
+ ` <title>${escapeXml(page.frontmatter.title ?? site.projectName)}</title>`,
116
+ ` <link>${escapeXml(link)}</link>`,
117
+ ` <guid isPermaLink="true">${escapeXml(link)}</guid>`,
118
+ ` <pubDate>${rfc822(date)}</pubDate>`,
119
+ );
120
+ if (page.frontmatter.description) {
121
+ lines.push(` <description>${escapeXml(page.frontmatter.description)}</description>`);
122
+ }
123
+ lines.push(' </item>');
124
+ }
125
+
126
+ lines.push(' </channel>', '</rss>', '');
127
+ return lines.join('\n');
128
+ }