@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 +4 -0
- package/client/search.js +205 -0
- package/package.json +4 -3
- package/src/compiler.js +76 -2
- package/src/config.js +121 -7
- package/src/discovery.js +128 -0
- package/src/generator.js +291 -19
- 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 +5 -2
- package/templates/layout.hbs +31 -1
- package/types/compiler.d.ts +2 -1
- package/types/config.d.ts +40 -0
- 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 +14 -1
package/src/generator.js
CHANGED
|
@@ -9,6 +9,7 @@ import path from 'node:path';
|
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
|
+
ConfigError,
|
|
12
13
|
DOC_EXTENSIONS,
|
|
13
14
|
assetPathToSlug,
|
|
14
15
|
dirPathToSlug,
|
|
@@ -22,7 +23,10 @@ import Handlebars from 'handlebars';
|
|
|
22
23
|
import { Compiler } from './compiler.js';
|
|
23
24
|
import { resolveVersion } from './config.js';
|
|
24
25
|
import { DocLoader } from './loader.js';
|
|
25
|
-
import {
|
|
26
|
+
import { buildFeed, buildRobots, buildSitemap } from './discovery.js';
|
|
27
|
+
import { minifyCss } from './minify-css.js';
|
|
28
|
+
import { SEARCH_SLUG, htmlToText, searchPageContent } from './search-index.js';
|
|
29
|
+
import { buildSidebar, buildSidebarFromDescription, collectSectionTitles } from './sidebar.js';
|
|
26
30
|
import { StructuredDataBuilder } from './structured-data.js';
|
|
27
31
|
|
|
28
32
|
/** Template folder, resolved from this module rather than from the cwd. */
|
|
@@ -31,6 +35,31 @@ const TEMPLATE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..
|
|
|
31
35
|
/** Path of the stylesheet written into every version. */
|
|
32
36
|
const STYLESHEET = 'assets/docpensieve.css';
|
|
33
37
|
|
|
38
|
+
/** Index of a version, which the search page reads. */
|
|
39
|
+
const SEARCH_INDEX = 'assets/search-index.json';
|
|
40
|
+
|
|
41
|
+
/** Script of the search page, the only one a site loads. */
|
|
42
|
+
const SEARCH_SCRIPT = 'assets/search.js';
|
|
43
|
+
|
|
44
|
+
/** Source of that script, shipped with this package. */
|
|
45
|
+
const CLIENT_SEARCH = fileURLToPath(new URL('../client/search.js', import.meta.url));
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where each project image is written in a version, before its extension.
|
|
49
|
+
* @type {Record<'logo' | 'favicon' | 'socialImage', string>}
|
|
50
|
+
*/
|
|
51
|
+
const IMAGE_FILES = {
|
|
52
|
+
logo: 'assets/logo',
|
|
53
|
+
favicon: 'assets/favicon',
|
|
54
|
+
socialImage: 'assets/social-image',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Type announced to the browser, by favicon extension.
|
|
59
|
+
* @type {Record<string, string>}
|
|
60
|
+
*/
|
|
61
|
+
const FAVICON_TYPES = { '.ico': 'image/x-icon', '.png': 'image/png', '.svg': 'image/svg+xml' };
|
|
62
|
+
|
|
34
63
|
/** Collects the values of the `class` attributes of an HTML document. */
|
|
35
64
|
const CLASS_ATTRIBUTE = /class="([^"]*)"/g;
|
|
36
65
|
|
|
@@ -146,7 +175,8 @@ export class SiteGenerator {
|
|
|
146
175
|
*
|
|
147
176
|
* @param {string} versionSlug Slug of the version to generate.
|
|
148
177
|
* @param {string} outDir Output folder of that version.
|
|
149
|
-
* @returns {Promise<{ pages: number, outDir: string }>}
|
|
178
|
+
* @returns {Promise<{ pages: number, outDir: string, published: import('./discovery.js').PublishedPage[] }>}
|
|
179
|
+
* `published` lists the pages as the site serves them, for the sitemap and the feed.
|
|
150
180
|
* @throws {GeneratorError} Write failure.
|
|
151
181
|
*/
|
|
152
182
|
async buildVersion(versionSlug, outDir) {
|
|
@@ -172,7 +202,12 @@ export class SiteGenerator {
|
|
|
172
202
|
const current = this.config.versions.find((candidate) => candidate.current);
|
|
173
203
|
const notice = versionNotice(version, current, this.config.baseUrl);
|
|
174
204
|
|
|
175
|
-
|
|
205
|
+
// 'auto' follows the file tree; otherwise each version describes its menu
|
|
206
|
+
// in a file of its own, since each has its own pages.
|
|
207
|
+
const sidebar =
|
|
208
|
+
this.config.sidebar && this.config.sidebar !== 'auto'
|
|
209
|
+
? await this.#describedSidebar(sourceDir, docs, pageUrl, version.folder)
|
|
210
|
+
: buildSidebar(docs, pageUrl, { brand: this.config.projectName });
|
|
176
211
|
const breadcrumbTitles = collectSectionTitles(docs);
|
|
177
212
|
const layout = await this.#loadLayout();
|
|
178
213
|
const classes = this.#classes();
|
|
@@ -191,6 +226,47 @@ export class SiteGenerator {
|
|
|
191
226
|
[path.join(target, ...STYLESHEET.split('/')), 'the theme stylesheet'],
|
|
192
227
|
]);
|
|
193
228
|
|
|
229
|
+
// The project's images go into every version: each one stands on its
|
|
230
|
+
// own, down to the orphan branch it is published on.
|
|
231
|
+
const images = await this.#copyImages(target, versionBase, written, version);
|
|
232
|
+
|
|
233
|
+
// What every page of the version shares, the search page included.
|
|
234
|
+
const searchUrl = this.config.search !== false ? joinUrl(versionBase, SEARCH_SLUG) : '';
|
|
235
|
+
const shell = {
|
|
236
|
+
lang: this.config.lang ?? 'en',
|
|
237
|
+
// A fixed scheme is a class on <html>, which the skins and the dark
|
|
238
|
+
// variant of the utilities both obey.
|
|
239
|
+
darkModeClass: ['dark', 'light'].includes(this.config.theme?.darkMode ?? '')
|
|
240
|
+
? this.config.theme.darkMode
|
|
241
|
+
: null,
|
|
242
|
+
projectName: this.config.projectName,
|
|
243
|
+
versionName: version.name,
|
|
244
|
+
homeUrl: versionBase,
|
|
245
|
+
cssHref: joinUrl(versionBase, path.dirname(STYLESHEET)) + path.basename(STYLESHEET),
|
|
246
|
+
feedUrl: this.#feedUrl(),
|
|
247
|
+
logoUrl: images.logo ?? '',
|
|
248
|
+
favicon: images.favicon
|
|
249
|
+
? { href: images.favicon, type: FAVICON_TYPES[path.extname(images.favicon).toLowerCase()] }
|
|
250
|
+
: null,
|
|
251
|
+
// Social networks only read an absolute address: normalisation
|
|
252
|
+
// refuses a preview image without siteUrl.
|
|
253
|
+
socialImage:
|
|
254
|
+
images.socialImage && this.config.siteUrl
|
|
255
|
+
? new URL(images.socialImage, this.config.siteUrl).href
|
|
256
|
+
: '',
|
|
257
|
+
searchUrl,
|
|
258
|
+
cls: classes,
|
|
259
|
+
versions: this.#versionLinks(version.slug),
|
|
260
|
+
// A switcher offering a single choice is not a switcher.
|
|
261
|
+
showVersions: this.config.versions.length > 1,
|
|
262
|
+
// The back-to-top button is page furniture, not content: writing it in
|
|
263
|
+
// every file would repeat it everywhere, and forget it somewhere.
|
|
264
|
+
scrollToTop: this.config.scrollToTop !== false,
|
|
265
|
+
notice,
|
|
266
|
+
};
|
|
267
|
+
/** @type {{ title: string, url: string, description: string, text: string }[]} */
|
|
268
|
+
const entries = [];
|
|
269
|
+
|
|
194
270
|
for (const doc of docs) {
|
|
195
271
|
const url = pageUrl(doc);
|
|
196
272
|
|
|
@@ -209,38 +285,34 @@ export class SiteGenerator {
|
|
|
209
285
|
url,
|
|
210
286
|
dirUrl,
|
|
211
287
|
basePath: versionBase,
|
|
288
|
+
sourceDir,
|
|
212
289
|
});
|
|
213
290
|
|
|
214
291
|
const jsonld = new StructuredDataBuilder(doc.frontmatter, url, this.config, {
|
|
215
292
|
breadcrumbTitles,
|
|
216
293
|
basePath: versionBase,
|
|
217
294
|
dirUrl,
|
|
295
|
+
logo: images.logo,
|
|
218
296
|
}).toScriptTag();
|
|
219
297
|
|
|
220
298
|
// A home page has neither menu nor table of contents: those are reading
|
|
221
299
|
// landmarks within a document, not in an entrance hall.
|
|
222
300
|
const wide = pageLayout(doc) === 'home';
|
|
223
301
|
|
|
302
|
+
entries.push({
|
|
303
|
+
title: String(doc.frontmatter.title ?? this.config.projectName),
|
|
304
|
+
url,
|
|
305
|
+
description: String(doc.frontmatter.description ?? ''),
|
|
306
|
+
text: htmlToText(html),
|
|
307
|
+
});
|
|
308
|
+
|
|
224
309
|
const page = layout({
|
|
225
|
-
|
|
226
|
-
darkModeClass: null,
|
|
310
|
+
...shell,
|
|
227
311
|
title: documentTitle(doc.frontmatter.title, this.config.projectName),
|
|
228
312
|
description: doc.frontmatter.description ?? '',
|
|
229
313
|
canonical: this.config.siteUrl ? new URL(url, this.config.siteUrl).href : '',
|
|
230
|
-
projectName: this.config.projectName,
|
|
231
|
-
versionName: version.name,
|
|
232
|
-
homeUrl: versionBase,
|
|
233
314
|
currentUrl: url,
|
|
234
|
-
cssHref: joinUrl(versionBase, path.dirname(STYLESHEET)) + path.basename(STYLESHEET),
|
|
235
|
-
cls: classes,
|
|
236
|
-
versions: this.#versionLinks(version.slug),
|
|
237
|
-
// A switcher offering a single choice is not a switcher.
|
|
238
|
-
showVersions: this.config.versions.length > 1,
|
|
239
315
|
wide,
|
|
240
|
-
// The back-to-top button is page furniture, not content: writing it in
|
|
241
|
-
// every file would repeat it everywhere, and forget it somewhere.
|
|
242
|
-
scrollToTop: this.config.scrollToTop !== false,
|
|
243
|
-
notice,
|
|
244
316
|
// A version in preparation must not compete with the current one:
|
|
245
317
|
// same content, two addresses, and the wrong one comes up. "follow"
|
|
246
318
|
// still lets its links be followed.
|
|
@@ -261,13 +333,207 @@ export class SiteGenerator {
|
|
|
261
333
|
await this.#write(destination, page);
|
|
262
334
|
}
|
|
263
335
|
|
|
336
|
+
// The search page and the index it reads, built with the site: content
|
|
337
|
+
// pages load no script, and this page is useful before its own runs.
|
|
338
|
+
if (searchUrl) {
|
|
339
|
+
const destination = path.join(target, SEARCH_SLUG, 'index.html');
|
|
340
|
+
const taken = written.get(destination);
|
|
341
|
+
if (taken !== undefined) {
|
|
342
|
+
throw new GeneratorError(`"${taken}" takes the place of the search page, ${searchUrl}.`, {
|
|
343
|
+
hint: 'Rename that page, or set search: false in the configuration.',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** @param {string} file */
|
|
348
|
+
const assetUrl = (file) =>
|
|
349
|
+
joinUrl(versionBase, path.posix.dirname(file)) + path.posix.basename(file);
|
|
350
|
+
const indexFile = path.join(target, ...SEARCH_INDEX.split('/'));
|
|
351
|
+
const scriptFile = path.join(target, ...SEARCH_SCRIPT.split('/'));
|
|
352
|
+
await this.#write(indexFile, JSON.stringify(entries));
|
|
353
|
+
await mkdir(path.dirname(scriptFile), { recursive: true });
|
|
354
|
+
await copyFile(CLIENT_SEARCH, scriptFile);
|
|
355
|
+
|
|
356
|
+
const page = layout({
|
|
357
|
+
...shell,
|
|
358
|
+
title: documentTitle('Search', this.config.projectName),
|
|
359
|
+
description: `Search the pages of ${this.config.projectName} ${version.name}.`,
|
|
360
|
+
canonical: '',
|
|
361
|
+
currentUrl: searchUrl,
|
|
362
|
+
wide: false,
|
|
363
|
+
// A list of every page, and a script: nothing a search engine should
|
|
364
|
+
// offer as a result.
|
|
365
|
+
noindex: true,
|
|
366
|
+
sidebar,
|
|
367
|
+
toc: [],
|
|
368
|
+
preloads: [],
|
|
369
|
+
scripts: [assetUrl(SEARCH_SCRIPT)],
|
|
370
|
+
content: searchPageContent(entries, assetUrl(SEARCH_INDEX)),
|
|
371
|
+
jsonld: '',
|
|
372
|
+
});
|
|
373
|
+
for (const [, value] of page.matchAll(CLASS_ATTRIBUTE)) {
|
|
374
|
+
for (const token of value.split(/\s+/)) if (token) candidates.add(token);
|
|
375
|
+
}
|
|
376
|
+
await this.#write(destination, page);
|
|
377
|
+
for (const file of [destination, indexFile, scriptFile]) written.set(file, 'the search page');
|
|
378
|
+
}
|
|
379
|
+
|
|
264
380
|
await this.#copyAssets(sourceDir, target, '', written);
|
|
265
381
|
|
|
266
382
|
// The stylesheet is compiled last: it needs the classes above.
|
|
267
383
|
const { css } = await this.deps.theme.compile({ candidates: [...candidates] });
|
|
268
|
-
|
|
384
|
+
// Comments and indentation make the stylesheet readable, and heavier on
|
|
385
|
+
// every page: the reader receives it minified.
|
|
386
|
+
await this.#write(path.join(target, ...STYLESHEET.split('/')), minifyCss(css));
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
pages: docs.length,
|
|
390
|
+
outDir: target,
|
|
391
|
+
published: docs.map((doc) => ({ url: pageUrl(doc), frontmatter: doc.frontmatter })),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Reads the sidebar description of a version.
|
|
397
|
+
*
|
|
398
|
+
* @param {string} sourceDir Source folder of the version.
|
|
399
|
+
* @param {import('./loader.js').Doc[]} docs Documents of the version.
|
|
400
|
+
* @param {(doc: import('./loader.js').Doc) => string} pageUrl
|
|
401
|
+
* @param {string} folder The version's folder, as the configuration names it.
|
|
402
|
+
* @returns {Promise<import('./sidebar.js').SidebarNode[]>}
|
|
403
|
+
* @throws {ConfigError} When the file is missing, is not JSON, or describes
|
|
404
|
+
* the menu wrongly.
|
|
405
|
+
*/
|
|
406
|
+
async #describedSidebar(sourceDir, docs, pageUrl, folder) {
|
|
407
|
+
const name = this.config.sidebar;
|
|
408
|
+
const source = `${folder}/${name}`;
|
|
409
|
+
|
|
410
|
+
let text;
|
|
411
|
+
try {
|
|
412
|
+
text = await readFile(path.join(sourceDir, ...name.split('/')), 'utf8');
|
|
413
|
+
} catch (cause) {
|
|
414
|
+
throw new ConfigError(`No sidebar description at ${source}.`, {
|
|
415
|
+
cause,
|
|
416
|
+
hint: `Each version describes its own menu, since each has its own pages: create ${source}, or set sidebar: 'auto'.`,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let description;
|
|
421
|
+
try {
|
|
422
|
+
description = JSON.parse(text);
|
|
423
|
+
} catch (cause) {
|
|
424
|
+
throw new ConfigError(
|
|
425
|
+
`${source} is not valid JSON: ${/** @type {Error} */ (cause).message}`,
|
|
426
|
+
{
|
|
427
|
+
cause,
|
|
428
|
+
hint: 'JSON accepts neither comments nor a comma after the last entry.',
|
|
429
|
+
},
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return buildSidebarFromDescription(description, docs, pageUrl, { source });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Absolute address of the RSS feed, or `''` when none is written.
|
|
438
|
+
*
|
|
439
|
+
* Known before any page is rendered: every page announces the feed in its
|
|
440
|
+
* head, whereas the feed itself is written once every version is built.
|
|
441
|
+
*
|
|
442
|
+
* @returns {string}
|
|
443
|
+
*/
|
|
444
|
+
#feedUrl() {
|
|
445
|
+
if (!this.config.feed || !this.config.siteUrl) return '';
|
|
446
|
+
return new URL(`${this.config.baseUrl}feed.xml`, this.config.siteUrl).href;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Writes what search engines and feed readers read, at the root of the
|
|
451
|
+
* site: `sitemap.xml`, `robots.txt` and the RSS feed.
|
|
452
|
+
*
|
|
453
|
+
* @param {string} target Output folder.
|
|
454
|
+
* @param {Map<string, import('./discovery.js').PublishedPage[]>} published
|
|
455
|
+
* Pages of each version, by slug.
|
|
456
|
+
*/
|
|
457
|
+
async #writeDiscovery(target, published) {
|
|
458
|
+
const { siteUrl, baseUrl } = this.config;
|
|
459
|
+
if (!siteUrl) return;
|
|
460
|
+
|
|
461
|
+
if (this.config.sitemap !== false) {
|
|
462
|
+
// A version in preparation is kept out of search engines: its pages
|
|
463
|
+
// carry noindex, and listing them would contradict it.
|
|
464
|
+
const pages = this.config.versions
|
|
465
|
+
.filter((version) => !version.prerelease)
|
|
466
|
+
.flatMap((version) => published.get(version.slug) ?? []);
|
|
467
|
+
await this.#write(path.join(target, 'sitemap.xml'), buildSitemap(pages, siteUrl));
|
|
468
|
+
|
|
469
|
+
// Crawlers only read robots.txt at the root of a domain: under a
|
|
470
|
+
// sub-path, the file would be written for nobody.
|
|
471
|
+
if (baseUrl === '/') {
|
|
472
|
+
const sitemapUrl = new URL('/sitemap.xml', siteUrl).href;
|
|
473
|
+
await this.#write(path.join(target, 'robots.txt'), buildRobots(sitemapUrl));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const feedUrl = this.#feedUrl();
|
|
478
|
+
if (feedUrl) {
|
|
479
|
+
const current = resolveVersion(this.config);
|
|
480
|
+
const feed = buildFeed(published.get(current.slug) ?? [], {
|
|
481
|
+
projectName: this.config.projectName,
|
|
482
|
+
siteUrl,
|
|
483
|
+
homeUrl: new URL(joinUrl(baseUrl, 'versions', current.slug), siteUrl).href,
|
|
484
|
+
feedUrl,
|
|
485
|
+
lang: this.config.lang,
|
|
486
|
+
});
|
|
487
|
+
await this.#write(path.join(target, 'feed.xml'), feed);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Copies the project's images into a version's assets.
|
|
493
|
+
*
|
|
494
|
+
* @param {string} target Output folder of the version.
|
|
495
|
+
* @param {string} versionBase URL of the version.
|
|
496
|
+
* @param {Map<string, string>} written Files already written, for collisions.
|
|
497
|
+
* @param {import('./config.js').Version} version The version being built.
|
|
498
|
+
* @returns {Promise<Partial<Record<keyof typeof IMAGE_FILES, string>>>} URL
|
|
499
|
+
* of each declared image.
|
|
500
|
+
* @throws {GeneratorError} When a declared image does not exist.
|
|
501
|
+
*/
|
|
502
|
+
async #copyImages(target, versionBase, written, version) {
|
|
503
|
+
const rootDir = this.config.rootDir ?? process.cwd();
|
|
504
|
+
/** @type {Partial<Record<keyof typeof IMAGE_FILES, string>>} */
|
|
505
|
+
const urls = {};
|
|
506
|
+
|
|
507
|
+
for (const field of /** @type {(keyof typeof IMAGE_FILES)[]} */ (Object.keys(IMAGE_FILES))) {
|
|
508
|
+
// A version's own logo or favicon replaces the project's.
|
|
509
|
+
const declared = (field !== 'socialImage' && version[field]) || this.config[field];
|
|
510
|
+
if (!declared) continue;
|
|
511
|
+
|
|
512
|
+
const file = `${IMAGE_FILES[field]}${path.extname(declared).toLowerCase()}`;
|
|
513
|
+
const destination = path.join(target, ...file.split('/'));
|
|
514
|
+
written.set(destination, `the ${field} image`);
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
518
|
+
await copyFile(path.resolve(rootDir, declared), destination);
|
|
519
|
+
} catch (cause) {
|
|
520
|
+
const missing = /** @type {NodeJS.ErrnoException} */ (cause).code === 'ENOENT';
|
|
521
|
+
throw new GeneratorError(
|
|
522
|
+
missing
|
|
523
|
+
? `The ${field} image does not exist: "${declared}".`
|
|
524
|
+
: `Could not copy the ${field} image "${declared}".`,
|
|
525
|
+
{
|
|
526
|
+
cause,
|
|
527
|
+
hint: missing
|
|
528
|
+
? `The path starts from the project root, ${rootDir}.`
|
|
529
|
+
: 'Check the permissions on the file and on the output folder.',
|
|
530
|
+
},
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
urls[field] = joinUrl(versionBase, path.posix.dirname(file)) + path.posix.basename(file);
|
|
534
|
+
}
|
|
269
535
|
|
|
270
|
-
return
|
|
536
|
+
return urls;
|
|
271
537
|
}
|
|
272
538
|
|
|
273
539
|
/**
|
|
@@ -281,16 +547,20 @@ export class SiteGenerator {
|
|
|
281
547
|
const target = path.resolve(rootDir, this.config.outDir);
|
|
282
548
|
|
|
283
549
|
let pages = 0;
|
|
550
|
+
/** @type {Map<string, import('./discovery.js').PublishedPage[]>} */
|
|
551
|
+
const published = new Map();
|
|
284
552
|
for (const version of this.config.versions) {
|
|
285
553
|
const result = await this.buildVersion(
|
|
286
554
|
version.slug,
|
|
287
555
|
path.join(target, 'versions', version.slug),
|
|
288
556
|
);
|
|
289
557
|
pages += result.pages;
|
|
558
|
+
published.set(version.slug, result.published);
|
|
290
559
|
}
|
|
291
560
|
|
|
292
561
|
await this.#writeManifest(target);
|
|
293
562
|
await this.#writeRootRedirect(target);
|
|
563
|
+
await this.#writeDiscovery(target, published);
|
|
294
564
|
|
|
295
565
|
return { versions: this.config.versions.length, pages, outDir: target };
|
|
296
566
|
}
|
|
@@ -418,6 +688,8 @@ export class SiteGenerator {
|
|
|
418
688
|
});
|
|
419
689
|
}
|
|
420
690
|
|
|
691
|
+
// The sidebar description is read by the build, not published.
|
|
692
|
+
if (this.config.sidebar !== 'auto' && readable === this.config.sidebar) continue;
|
|
421
693
|
if (DOC_EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
|
|
422
694
|
|
|
423
695
|
const destination = path.join(target, ...assetPathToSlug(next).split('/'));
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dimensions of an image, read from the first bytes of its file.
|
|
3
|
+
*
|
|
4
|
+
* The build writes them on every image of a page: the browser then keeps the
|
|
5
|
+
* room before the image arrives, instead of shifting the text when it does.
|
|
6
|
+
* Only the header of each format is read — no decoding, no dependency.
|
|
7
|
+
*
|
|
8
|
+
* @module @docpensieve/core/image-size
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {{ width: number, height: number }} ImageSize
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {Buffer} bytes
|
|
17
|
+
* @returns {ImageSize | null}
|
|
18
|
+
*/
|
|
19
|
+
function png(bytes) {
|
|
20
|
+
// Signature, then the IHDR chunk: width and height, big-endian.
|
|
21
|
+
if (bytes.length < 24 || bytes.readUInt32BE(0) !== 0x89504e47) return null;
|
|
22
|
+
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {Buffer} bytes
|
|
27
|
+
* @returns {ImageSize | null}
|
|
28
|
+
*/
|
|
29
|
+
function gif(bytes) {
|
|
30
|
+
if (bytes.length < 10 || bytes.toString('ascii', 0, 3) !== 'GIF') return null;
|
|
31
|
+
return { width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {Buffer} bytes
|
|
36
|
+
* @returns {ImageSize | null}
|
|
37
|
+
*/
|
|
38
|
+
function jpeg(bytes) {
|
|
39
|
+
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
|
|
40
|
+
// Walk the segments up to the frame header (SOF), which holds the size.
|
|
41
|
+
let offset = 2;
|
|
42
|
+
while (offset + 9 < bytes.length) {
|
|
43
|
+
if (bytes[offset] !== 0xff) return null;
|
|
44
|
+
const marker = bytes[offset + 1];
|
|
45
|
+
const isFrame = marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker);
|
|
46
|
+
if (isFrame) {
|
|
47
|
+
return { height: bytes.readUInt16BE(offset + 5), width: bytes.readUInt16BE(offset + 7) };
|
|
48
|
+
}
|
|
49
|
+
offset += 2 + bytes.readUInt16BE(offset + 2);
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {Buffer} bytes
|
|
56
|
+
* @returns {ImageSize | null}
|
|
57
|
+
*/
|
|
58
|
+
function webp(bytes) {
|
|
59
|
+
if (bytes.length < 30 || bytes.toString('ascii', 0, 4) !== 'RIFF') return null;
|
|
60
|
+
if (bytes.toString('ascii', 8, 12) !== 'WEBP') return null;
|
|
61
|
+
const chunk = bytes.toString('ascii', 12, 16);
|
|
62
|
+
if (chunk === 'VP8 ') {
|
|
63
|
+
return { width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff };
|
|
64
|
+
}
|
|
65
|
+
if (chunk === 'VP8L') {
|
|
66
|
+
const bits = bytes.readUInt32LE(21);
|
|
67
|
+
return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
|
|
68
|
+
}
|
|
69
|
+
if (chunk === 'VP8X') {
|
|
70
|
+
return { width: bytes.readUIntLE(24, 3) + 1, height: bytes.readUIntLE(27, 3) + 1 };
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {Buffer} bytes
|
|
77
|
+
* @returns {ImageSize | null}
|
|
78
|
+
*/
|
|
79
|
+
function svg(bytes) {
|
|
80
|
+
const text = bytes.toString('utf8', 0, Math.min(bytes.length, 4096));
|
|
81
|
+
const tag = /<svg\b[^>]*>/i.exec(text)?.[0];
|
|
82
|
+
if (!tag) return null;
|
|
83
|
+
/** @param {string} name */
|
|
84
|
+
const attribute = (name) => new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, 'i').exec(tag)?.[1];
|
|
85
|
+
|
|
86
|
+
// Width and height in pixels, or failing that the proportions of the viewBox.
|
|
87
|
+
const width = Number.parseFloat(attribute('width') ?? '');
|
|
88
|
+
const height = Number.parseFloat(attribute('height') ?? '');
|
|
89
|
+
const inPixels = (/** @type {string | undefined} */ value) =>
|
|
90
|
+
!value || /^[\d.]+(px)?$/.test(value.trim());
|
|
91
|
+
if (width > 0 && height > 0 && inPixels(attribute('width')) && inPixels(attribute('height'))) {
|
|
92
|
+
return { width: Math.round(width), height: Math.round(height) };
|
|
93
|
+
}
|
|
94
|
+
const box = (attribute('viewBox') ?? '')
|
|
95
|
+
.trim()
|
|
96
|
+
.split(/[\s,]+/)
|
|
97
|
+
.map(Number);
|
|
98
|
+
if (box.length === 4 && box[2] > 0 && box[3] > 0) {
|
|
99
|
+
return { width: Math.round(box[2]), height: Math.round(box[3]) };
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Reads the dimensions of an image from its content.
|
|
106
|
+
*
|
|
107
|
+
* @param {Buffer} bytes Content of the file — its first kilobytes are enough.
|
|
108
|
+
* @param {string} extension Extension of the file, dot included.
|
|
109
|
+
* @returns {ImageSize | null} `null` for an unknown or damaged format: the
|
|
110
|
+
* image is then written without dimensions, as before.
|
|
111
|
+
*/
|
|
112
|
+
export function imageSize(bytes, extension) {
|
|
113
|
+
switch (extension.toLowerCase()) {
|
|
114
|
+
case '.png':
|
|
115
|
+
return png(bytes);
|
|
116
|
+
case '.jpg':
|
|
117
|
+
case '.jpeg':
|
|
118
|
+
return jpeg(bytes);
|
|
119
|
+
case '.gif':
|
|
120
|
+
return gif(bytes);
|
|
121
|
+
case '.webp':
|
|
122
|
+
return webp(bytes);
|
|
123
|
+
case '.svg':
|
|
124
|
+
return svg(bytes);
|
|
125
|
+
default:
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
package/src/index.js
CHANGED
|
@@ -32,4 +32,5 @@ export { DocLoader } from './loader.js';
|
|
|
32
32
|
export { Compiler } from './compiler.js';
|
|
33
33
|
export { StructuredDataBuilder } from './structured-data.js';
|
|
34
34
|
export { SiteGenerator } from './generator.js';
|
|
35
|
-
export { buildSidebar, collectSectionTitles } from './sidebar.js';
|
|
35
|
+
export { buildSidebar, buildSidebarFromDescription, collectSectionTitles } from './sidebar.js';
|
|
36
|
+
export { buildFeed, buildRobots, buildSitemap } from './discovery.js';
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minification of the produced stylesheet.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately cautious: comments go, and so does the whitespace that nothing
|
|
5
|
+
* reads — indentation, line breaks, the space around `{`, `}` and `;`. Every
|
|
6
|
+
* other space stays one space, since some carry meaning (`.a :hover` is not
|
|
7
|
+
* `.a:hover`), and the content of a string is never touched. That keeps most
|
|
8
|
+
* of the gain of a real minifier without its risk of changing what a rule
|
|
9
|
+
* means.
|
|
10
|
+
*
|
|
11
|
+
* @module @docpensieve/core/minify-css
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Characters around which no space is ever needed. */
|
|
15
|
+
const TIGHT = new Set(['{', '}', ';']);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} css
|
|
19
|
+
* @returns {string} The same rules, lighter.
|
|
20
|
+
*/
|
|
21
|
+
export function minifyCss(css) {
|
|
22
|
+
let out = '';
|
|
23
|
+
let space = false;
|
|
24
|
+
let i = 0;
|
|
25
|
+
|
|
26
|
+
while (i < css.length) {
|
|
27
|
+
const char = css[i];
|
|
28
|
+
|
|
29
|
+
// A string is copied as is, escapes included.
|
|
30
|
+
if (char === '"' || char === "'") {
|
|
31
|
+
let end = i + 1;
|
|
32
|
+
while (end < css.length && css[end] !== char) end += css[end] === '\\' ? 2 : 1;
|
|
33
|
+
if (space && out && !TIGHT.has(out[out.length - 1])) out += ' ';
|
|
34
|
+
space = false;
|
|
35
|
+
out += css.slice(i, end + 1);
|
|
36
|
+
i = end + 1;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A comment weighs its length and says nothing to the browser.
|
|
41
|
+
if (char === '/' && css[i + 1] === '*') {
|
|
42
|
+
const end = css.indexOf('*/', i + 2);
|
|
43
|
+
i = end < 0 ? css.length : end + 2;
|
|
44
|
+
space = true;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (char === ' ' || char === '\n' || char === '\r' || char === '\t' || char === '\f') {
|
|
49
|
+
space = true;
|
|
50
|
+
i += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (TIGHT.has(char)) {
|
|
55
|
+
// The last declaration of a block needs no semicolon.
|
|
56
|
+
if (char === '}' && out.endsWith(';')) out = out.slice(0, -1);
|
|
57
|
+
out += char;
|
|
58
|
+
space = false;
|
|
59
|
+
i += 1;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (space && out && !TIGHT.has(out[out.length - 1])) out += ' ';
|
|
64
|
+
space = false;
|
|
65
|
+
out += char;
|
|
66
|
+
i += 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the build prepares for search: the index of a version, and its search
|
|
3
|
+
* page.
|
|
4
|
+
*
|
|
5
|
+
* Search is built here, not in the reader's browser: the index holds the
|
|
6
|
+
* plain text of every page, and the search page already lists every page,
|
|
7
|
+
* so that it is useful before any script runs — and without one.
|
|
8
|
+
*
|
|
9
|
+
* @module @docpensieve/core/search-index
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Path of the search page within a version. */
|
|
13
|
+
export const SEARCH_SLUG = 'search';
|
|
14
|
+
|
|
15
|
+
/** @type {Record<string, string>} */
|
|
16
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Turns rendered HTML into the plain text a reader sees.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} html
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function htmlToText(html) {
|
|
25
|
+
return html
|
|
26
|
+
.replace(/<(script|style|svg)\b[\s\S]*?<\/\1>/gi, ' ')
|
|
27
|
+
.replace(/<[^>]+>/g, ' ')
|
|
28
|
+
.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, name) => {
|
|
29
|
+
if (name[0] !== '#') return ENTITIES[name.toLowerCase()] ?? entity;
|
|
30
|
+
const code =
|
|
31
|
+
name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : Number(name.slice(1));
|
|
32
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : entity;
|
|
33
|
+
})
|
|
34
|
+
.replace(/\s+/g, ' ')
|
|
35
|
+
.trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Escapes a value for HTML.
|
|
40
|
+
*
|
|
41
|
+
* @param {unknown} value
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
function escapeHtml(value) {
|
|
45
|
+
return String(value)
|
|
46
|
+
.replaceAll('&', '&')
|
|
47
|
+
.replaceAll('<', '<')
|
|
48
|
+
.replaceAll('>', '>')
|
|
49
|
+
.replaceAll('"', '"');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Renders the content of the search page: a field, and the list of every
|
|
54
|
+
* page of the version, which the script filters.
|
|
55
|
+
*
|
|
56
|
+
* @param {{ title: string, url: string, description: string }[]} entries
|
|
57
|
+
* Pages of the version, in reading order.
|
|
58
|
+
* @param {string} indexUrl URL of the version's index.
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function searchPageContent(entries, indexUrl) {
|
|
62
|
+
const items = entries.map((entry) => {
|
|
63
|
+
const description = entry.description ? `<p>${escapeHtml(entry.description)}</p>` : '';
|
|
64
|
+
return `<li data-url="${escapeHtml(entry.url)}"><a href="${escapeHtml(entry.url)}">${escapeHtml(entry.title)}</a>${description}</li>`;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return [
|
|
68
|
+
'<h1 id="search">Search</h1>',
|
|
69
|
+
`<form class="dp-search-page" role="search" data-search-page data-index="${escapeHtml(indexUrl)}">`,
|
|
70
|
+
'<label for="dp-search-query">Search the documentation</label>',
|
|
71
|
+
'<input id="dp-search-query" type="search" name="q" autocomplete="off" />',
|
|
72
|
+
'</form>',
|
|
73
|
+
`<p class="dp-search-status" data-search-status aria-live="polite">${entries.length} pages.</p>`,
|
|
74
|
+
`<ol class="dp-search-results" data-search-results>${items.join('')}</ol>`,
|
|
75
|
+
].join('');
|
|
76
|
+
}
|