@docpensieve/core 0.1.5 → 0.2.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.
- package/README.md +4 -0
- package/client/search.js +205 -0
- package/package.json +4 -3
- package/src/compiler.js +76 -2
- package/src/config.js +81 -9
- package/src/discovery.js +128 -0
- package/src/generator.js +300 -36
- package/src/image-size.js +128 -0
- package/src/index.js +2 -1
- package/src/minify-css.js +70 -0
- package/src/search-index.js +76 -0
- package/src/sidebar.js +182 -1
- package/src/structured-data.js +1 -1
- package/templates/layout.hbs +35 -2
- package/types/compiler.d.ts +2 -1
- package/types/config.d.ts +27 -1
- package/types/discovery.d.ts +59 -0
- package/types/generator.d.ts +3 -1
- package/types/image-size.d.ts +22 -0
- package/types/index.d.ts +2 -1
- package/types/minify-css.d.ts +17 -0
- package/types/search-index.d.ts +33 -0
- package/types/sidebar.d.ts +37 -0
- package/types/structured-data.d.ts +10 -0
package/README.md
CHANGED
package/client/search.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.2.0",
|
|
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.
|
|
24
|
+
"@docpensieve/shared": "0.2.0",
|
|
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://
|
|
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
|
/**
|
|
@@ -35,7 +36,7 @@ import {
|
|
|
35
36
|
* @property {string} baseUrl Deployment prefix, slashes included.
|
|
36
37
|
* @property {string} outDir Output folder, relative to the root.
|
|
37
38
|
* @property {Version[]} versions At least one.
|
|
38
|
-
* @property {{ framework: string, darkMode?: string, tokens?: Record<string, string>, css?: string, source?: string }} theme
|
|
39
|
+
* @property {{ framework: string, darkMode?: string, toggle?: boolean, tokens?: Record<string, string>, css?: string, source?: string }} theme
|
|
39
40
|
* @property {string} sidebar `'auto'`, or the path of a description.
|
|
40
41
|
* @property {boolean} globalComponents
|
|
41
42
|
* @property {boolean} scrollToTop Back-to-top button on every page.
|
|
@@ -43,6 +44,9 @@ import {
|
|
|
43
44
|
* @property {string} [logo] Image beside the project name, in the header.
|
|
44
45
|
* @property {string} [favicon] Icon of the browser tab: `.ico`, `.png` or `.svg`.
|
|
45
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.
|
|
46
50
|
* @property {string} [rootDir] Project root, set by `loadConfig`.
|
|
47
51
|
* @property {string} [configFile] Path of the configuration file, set by `loadConfig`.
|
|
48
52
|
* @property {string} [lang] Document language, `'en'` by default.
|
|
@@ -63,7 +67,8 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
63
67
|
baseUrl: '/',
|
|
64
68
|
outDir: DEFAULT_OUT_DIR,
|
|
65
69
|
versions: [],
|
|
66
|
-
|
|
70
|
+
// The light / dark switch is on unless the project turns it off (ADR-014).
|
|
71
|
+
theme: { framework: 'tailwind', darkMode: 'class', toggle: true },
|
|
67
72
|
sidebar: 'auto',
|
|
68
73
|
globalComponents: true,
|
|
69
74
|
scrollToTop: true,
|
|
@@ -71,8 +76,18 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
71
76
|
logo: '',
|
|
72
77
|
favicon: '',
|
|
73
78
|
socialImage: '',
|
|
79
|
+
// On by default, but only written once siteUrl is set: it lists absolute
|
|
80
|
+
// addresses.
|
|
81
|
+
sitemap: true,
|
|
82
|
+
// Off by default: most documentation pages carry no date, and a feed that
|
|
83
|
+
// is always empty would be announced in every page.
|
|
84
|
+
feed: false,
|
|
85
|
+
search: true,
|
|
74
86
|
});
|
|
75
87
|
|
|
88
|
+
/** Values of `theme.darkMode`. */
|
|
89
|
+
const DARK_MODES = ['class', 'dark', 'light'];
|
|
90
|
+
|
|
76
91
|
/**
|
|
77
92
|
* Extensions accepted for each project image, and what to do otherwise.
|
|
78
93
|
*
|
|
@@ -163,6 +178,19 @@ export function normalizeConfig(userConfig) {
|
|
|
163
178
|
hint: 'Letters, digits, dot, dash and underscore, starting with a letter or a digit — "v1.0", "next".',
|
|
164
179
|
});
|
|
165
180
|
}
|
|
181
|
+
// A version may carry its own logo and favicon — a beta told apart at a
|
|
182
|
+
// glance. Checked like the project's.
|
|
183
|
+
for (const field of /** @type {const} */ (['logo', 'favicon'])) {
|
|
184
|
+
const value = version[field];
|
|
185
|
+
if (value === undefined) continue;
|
|
186
|
+
const { extensions, hint } = IMAGE_KINDS[field];
|
|
187
|
+
if (typeof value !== 'string' || !extensions.includes(path.extname(value).toLowerCase())) {
|
|
188
|
+
throw new ConfigError(
|
|
189
|
+
`The ${field} of version "${version.slug}" must be a ${extensions.join(', ')} file: "${String(value)}".`,
|
|
190
|
+
{ hint },
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
166
194
|
if (seen.has(version.slug)) {
|
|
167
195
|
throw new ConfigError(`The version slug "${version.slug}" is declared twice.`);
|
|
168
196
|
}
|
|
@@ -193,13 +221,23 @@ export function normalizeConfig(userConfig) {
|
|
|
193
221
|
config.versions[0].current = true;
|
|
194
222
|
}
|
|
195
223
|
|
|
196
|
-
//
|
|
197
|
-
//
|
|
224
|
+
// 'auto' derives the sidebar from the file tree. Anything else names a JSON
|
|
225
|
+
// description, which the generator reads from each version's folder: each
|
|
226
|
+
// version has its own pages, so its own menu.
|
|
198
227
|
if (config.sidebar !== 'auto') {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
'
|
|
202
|
-
|
|
228
|
+
const file = typeof config.sidebar === 'string' ? config.sidebar : '';
|
|
229
|
+
if (
|
|
230
|
+
!file.toLowerCase().endsWith('.json') ||
|
|
231
|
+
path.isAbsolute(file) ||
|
|
232
|
+
file.split('/').includes('..')
|
|
233
|
+
) {
|
|
234
|
+
throw new ConfigError(
|
|
235
|
+
`sidebar must be 'auto' or a .json file within each version folder: "${String(config.sidebar)}".`,
|
|
236
|
+
{
|
|
237
|
+
hint: "For instance sidebar: 'sidebar.json', read as docs/v1.0/sidebar.json for that version.",
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
}
|
|
203
241
|
}
|
|
204
242
|
|
|
205
243
|
// siteUrl feeds everything that must be absolute: canonical, JSON-LD.
|
|
@@ -251,12 +289,46 @@ export function normalizeConfig(userConfig) {
|
|
|
251
289
|
});
|
|
252
290
|
}
|
|
253
291
|
}
|
|
292
|
+
// Both list absolute addresses. Asked for explicitly without siteUrl, they
|
|
293
|
+
// could only be written wrong; left to their default, they wait for it.
|
|
294
|
+
for (const field of /** @type {const} */ (['sitemap', 'feed'])) {
|
|
295
|
+
if (typeof config[field] !== 'boolean') {
|
|
296
|
+
throw new ConfigError(`${field} must be true or false.`, {
|
|
297
|
+
hint: `For instance ${field}: true.`,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
if (userConfig[field] === true && !config.siteUrl) {
|
|
301
|
+
throw new ConfigError(`${field} needs siteUrl.`, {
|
|
302
|
+
hint: 'It lists absolute addresses: set siteUrl, the public address of the site.',
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (typeof config.search !== 'boolean') {
|
|
308
|
+
throw new ConfigError('search must be true or false.', { hint: 'For instance search: false.' });
|
|
309
|
+
}
|
|
310
|
+
|
|
254
311
|
if (config.socialImage && !config.siteUrl) {
|
|
255
312
|
throw new ConfigError('socialImage needs siteUrl.', {
|
|
256
313
|
hint: 'Social networks only read an absolute address: set siteUrl, the public address of the site.',
|
|
257
314
|
});
|
|
258
315
|
}
|
|
259
316
|
|
|
317
|
+
// 'class' follows the reader's system, and a dark or light class on <html>
|
|
318
|
+
// wins; 'dark' and 'light' set that class at build time, for a site that
|
|
319
|
+
// keeps one look whatever the system.
|
|
320
|
+
if (!DARK_MODES.includes(config.theme.darkMode ?? 'class')) {
|
|
321
|
+
throw new ConfigError(`Unknown darkMode: "${config.theme.darkMode}".`, {
|
|
322
|
+
hint: `Accepted values: ${DARK_MODES.join(', ')}.`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (config.theme.toggle !== undefined && typeof config.theme.toggle !== 'boolean') {
|
|
327
|
+
throw new ConfigError('theme.toggle must be true or false.', {
|
|
328
|
+
hint: 'true adds a light / dark button to the header, with a few lines of inline script.',
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
260
332
|
if (!THEME_FRAMEWORKS.includes(config.theme.framework)) {
|
|
261
333
|
throw new ConfigError(`Unknown theme framework: "${config.theme.framework}".`, {
|
|
262
334
|
hint: `Accepted values: ${THEME_FRAMEWORKS.join(', ')}.`,
|
package/src/discovery.js
ADDED
|
@@ -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('&', '&')
|
|
31
|
+
.replaceAll('<', '<')
|
|
32
|
+
.replaceAll('>', '>')
|
|
33
|
+
.replaceAll('"', '"')
|
|
34
|
+
.replaceAll("'", ''');
|
|
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
|
+
}
|