@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/src/generator.js
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* @module @docpensieve/core/generator
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { copyFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
8
8
|
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,15 @@ 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
|
+
|
|
34
47
|
/**
|
|
35
48
|
* Where each project image is written in a version, before its extension.
|
|
36
49
|
* @type {Record<'logo' | 'favicon' | 'socialImage', string>}
|
|
@@ -162,7 +175,8 @@ export class SiteGenerator {
|
|
|
162
175
|
*
|
|
163
176
|
* @param {string} versionSlug Slug of the version to generate.
|
|
164
177
|
* @param {string} outDir Output folder of that version.
|
|
165
|
-
* @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.
|
|
166
180
|
* @throws {GeneratorError} Write failure.
|
|
167
181
|
*/
|
|
168
182
|
async buildVersion(versionSlug, outDir) {
|
|
@@ -170,6 +184,19 @@ export class SiteGenerator {
|
|
|
170
184
|
const rootDir = this.config.rootDir ?? process.cwd();
|
|
171
185
|
const target = path.resolve(rootDir, outDir);
|
|
172
186
|
|
|
187
|
+
// A page removed from the sources must not stay online: the version's
|
|
188
|
+
// folder is emptied before it is written — once it is certain to hold
|
|
189
|
+
// nothing but what a build wrote there.
|
|
190
|
+
this.#guardOutput(target);
|
|
191
|
+
try {
|
|
192
|
+
await rm(target, { recursive: true, force: true });
|
|
193
|
+
} catch (cause) {
|
|
194
|
+
throw new GeneratorError(`Could not empty the output folder "${target}".`, {
|
|
195
|
+
cause,
|
|
196
|
+
hint: 'Check that the path is a folder, and that nothing holds it open.',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
173
200
|
const sourceDir = path.resolve(rootDir, version.folder);
|
|
174
201
|
const docs = await this.loader.load(sourceDir);
|
|
175
202
|
|
|
@@ -188,7 +215,12 @@ export class SiteGenerator {
|
|
|
188
215
|
const current = this.config.versions.find((candidate) => candidate.current);
|
|
189
216
|
const notice = versionNotice(version, current, this.config.baseUrl);
|
|
190
217
|
|
|
191
|
-
|
|
218
|
+
// 'auto' follows the file tree; otherwise each version describes its menu
|
|
219
|
+
// in a file of its own, since each has its own pages.
|
|
220
|
+
const sidebar =
|
|
221
|
+
this.config.sidebar && this.config.sidebar !== 'auto'
|
|
222
|
+
? await this.#describedSidebar(sourceDir, docs, pageUrl, version.folder)
|
|
223
|
+
: buildSidebar(docs, pageUrl, { brand: this.config.projectName });
|
|
192
224
|
const breadcrumbTitles = collectSectionTitles(docs);
|
|
193
225
|
const layout = await this.#loadLayout();
|
|
194
226
|
const classes = this.#classes();
|
|
@@ -209,7 +241,46 @@ export class SiteGenerator {
|
|
|
209
241
|
|
|
210
242
|
// The project's images go into every version: each one stands on its
|
|
211
243
|
// own, down to the orphan branch it is published on.
|
|
212
|
-
const images = await this.#copyImages(target, versionBase, written);
|
|
244
|
+
const images = await this.#copyImages(target, versionBase, written, version);
|
|
245
|
+
|
|
246
|
+
// What every page of the version shares, the search page included.
|
|
247
|
+
const searchUrl = this.config.search !== false ? joinUrl(versionBase, SEARCH_SLUG) : '';
|
|
248
|
+
const shell = {
|
|
249
|
+
lang: this.config.lang ?? 'en',
|
|
250
|
+
// A fixed scheme is a class on <html>, which the skins and the dark
|
|
251
|
+
// variant of the utilities both obey.
|
|
252
|
+
darkModeClass: ['dark', 'light'].includes(this.config.theme?.darkMode ?? '')
|
|
253
|
+
? this.config.theme.darkMode
|
|
254
|
+
: null,
|
|
255
|
+
projectName: this.config.projectName,
|
|
256
|
+
versionName: version.name,
|
|
257
|
+
homeUrl: versionBase,
|
|
258
|
+
cssHref: joinUrl(versionBase, path.dirname(STYLESHEET)) + path.basename(STYLESHEET),
|
|
259
|
+
feedUrl: this.#feedUrl(),
|
|
260
|
+
logoUrl: images.logo ?? '',
|
|
261
|
+
favicon: images.favicon
|
|
262
|
+
? { href: images.favicon, type: FAVICON_TYPES[path.extname(images.favicon).toLowerCase()] }
|
|
263
|
+
: null,
|
|
264
|
+
// Social networks only read an absolute address: normalisation
|
|
265
|
+
// refuses a preview image without siteUrl.
|
|
266
|
+
socialImage:
|
|
267
|
+
images.socialImage && this.config.siteUrl
|
|
268
|
+
? new URL(images.socialImage, this.config.siteUrl).href
|
|
269
|
+
: '',
|
|
270
|
+
searchUrl,
|
|
271
|
+
// The light / dark switch: a button, and the few lines of script it needs.
|
|
272
|
+
schemeToggle: this.config.theme?.toggle !== false,
|
|
273
|
+
cls: classes,
|
|
274
|
+
versions: this.#versionLinks(version.slug),
|
|
275
|
+
// A switcher offering a single choice is not a switcher.
|
|
276
|
+
showVersions: this.config.versions.length > 1,
|
|
277
|
+
// The back-to-top button is page furniture, not content: writing it in
|
|
278
|
+
// every file would repeat it everywhere, and forget it somewhere.
|
|
279
|
+
scrollToTop: this.config.scrollToTop !== false,
|
|
280
|
+
notice,
|
|
281
|
+
};
|
|
282
|
+
/** @type {{ title: string, url: string, description: string, text: string }[]} */
|
|
283
|
+
const entries = [];
|
|
213
284
|
|
|
214
285
|
for (const doc of docs) {
|
|
215
286
|
const url = pageUrl(doc);
|
|
@@ -229,6 +300,7 @@ export class SiteGenerator {
|
|
|
229
300
|
url,
|
|
230
301
|
dirUrl,
|
|
231
302
|
basePath: versionBase,
|
|
303
|
+
sourceDir,
|
|
232
304
|
});
|
|
233
305
|
|
|
234
306
|
const jsonld = new StructuredDataBuilder(doc.frontmatter, url, this.config, {
|
|
@@ -242,39 +314,20 @@ export class SiteGenerator {
|
|
|
242
314
|
// landmarks within a document, not in an entrance hall.
|
|
243
315
|
const wide = pageLayout(doc) === 'home';
|
|
244
316
|
|
|
317
|
+
entries.push({
|
|
318
|
+
title: String(doc.frontmatter.title ?? this.config.projectName),
|
|
319
|
+
url,
|
|
320
|
+
description: String(doc.frontmatter.description ?? ''),
|
|
321
|
+
text: htmlToText(html),
|
|
322
|
+
});
|
|
323
|
+
|
|
245
324
|
const page = layout({
|
|
246
|
-
|
|
247
|
-
darkModeClass: null,
|
|
325
|
+
...shell,
|
|
248
326
|
title: documentTitle(doc.frontmatter.title, this.config.projectName),
|
|
249
327
|
description: doc.frontmatter.description ?? '',
|
|
250
328
|
canonical: this.config.siteUrl ? new URL(url, this.config.siteUrl).href : '',
|
|
251
|
-
projectName: this.config.projectName,
|
|
252
|
-
versionName: version.name,
|
|
253
|
-
homeUrl: versionBase,
|
|
254
329
|
currentUrl: url,
|
|
255
|
-
cssHref: joinUrl(versionBase, path.dirname(STYLESHEET)) + path.basename(STYLESHEET),
|
|
256
|
-
logoUrl: images.logo ?? '',
|
|
257
|
-
favicon: images.favicon
|
|
258
|
-
? {
|
|
259
|
-
href: images.favicon,
|
|
260
|
-
type: FAVICON_TYPES[path.extname(images.favicon).toLowerCase()],
|
|
261
|
-
}
|
|
262
|
-
: null,
|
|
263
|
-
// Social networks only read an absolute address: normalisation
|
|
264
|
-
// refuses a preview image without siteUrl.
|
|
265
|
-
socialImage:
|
|
266
|
-
images.socialImage && this.config.siteUrl
|
|
267
|
-
? new URL(images.socialImage, this.config.siteUrl).href
|
|
268
|
-
: '',
|
|
269
|
-
cls: classes,
|
|
270
|
-
versions: this.#versionLinks(version.slug),
|
|
271
|
-
// A switcher offering a single choice is not a switcher.
|
|
272
|
-
showVersions: this.config.versions.length > 1,
|
|
273
330
|
wide,
|
|
274
|
-
// The back-to-top button is page furniture, not content: writing it in
|
|
275
|
-
// every file would repeat it everywhere, and forget it somewhere.
|
|
276
|
-
scrollToTop: this.config.scrollToTop !== false,
|
|
277
|
-
notice,
|
|
278
331
|
// A version in preparation must not compete with the current one:
|
|
279
332
|
// same content, two addresses, and the wrong one comes up. "follow"
|
|
280
333
|
// still lets its links be followed.
|
|
@@ -295,13 +348,199 @@ export class SiteGenerator {
|
|
|
295
348
|
await this.#write(destination, page);
|
|
296
349
|
}
|
|
297
350
|
|
|
351
|
+
// The search page and the index it reads, built with the site: content
|
|
352
|
+
// pages load no script, and this page is useful before its own runs.
|
|
353
|
+
if (searchUrl) {
|
|
354
|
+
const destination = path.join(target, SEARCH_SLUG, 'index.html');
|
|
355
|
+
const taken = written.get(destination);
|
|
356
|
+
if (taken !== undefined) {
|
|
357
|
+
throw new GeneratorError(`"${taken}" takes the place of the search page, ${searchUrl}.`, {
|
|
358
|
+
hint: 'Rename that page, or set search: false in the configuration.',
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** @param {string} file */
|
|
363
|
+
const assetUrl = (file) =>
|
|
364
|
+
joinUrl(versionBase, path.posix.dirname(file)) + path.posix.basename(file);
|
|
365
|
+
const indexFile = path.join(target, ...SEARCH_INDEX.split('/'));
|
|
366
|
+
const scriptFile = path.join(target, ...SEARCH_SCRIPT.split('/'));
|
|
367
|
+
await this.#write(indexFile, JSON.stringify(entries));
|
|
368
|
+
await mkdir(path.dirname(scriptFile), { recursive: true });
|
|
369
|
+
await copyFile(CLIENT_SEARCH, scriptFile);
|
|
370
|
+
|
|
371
|
+
const page = layout({
|
|
372
|
+
...shell,
|
|
373
|
+
title: documentTitle('Search', this.config.projectName),
|
|
374
|
+
description: `Search the pages of ${this.config.projectName} ${version.name}.`,
|
|
375
|
+
canonical: '',
|
|
376
|
+
currentUrl: searchUrl,
|
|
377
|
+
wide: false,
|
|
378
|
+
// A list of every page, and a script: nothing a search engine should
|
|
379
|
+
// offer as a result.
|
|
380
|
+
noindex: true,
|
|
381
|
+
sidebar,
|
|
382
|
+
toc: [],
|
|
383
|
+
preloads: [],
|
|
384
|
+
scripts: [assetUrl(SEARCH_SCRIPT)],
|
|
385
|
+
content: searchPageContent(entries, assetUrl(SEARCH_INDEX)),
|
|
386
|
+
jsonld: '',
|
|
387
|
+
});
|
|
388
|
+
for (const [, value] of page.matchAll(CLASS_ATTRIBUTE)) {
|
|
389
|
+
for (const token of value.split(/\s+/)) if (token) candidates.add(token);
|
|
390
|
+
}
|
|
391
|
+
await this.#write(destination, page);
|
|
392
|
+
for (const file of [destination, indexFile, scriptFile]) written.set(file, 'the search page');
|
|
393
|
+
}
|
|
394
|
+
|
|
298
395
|
await this.#copyAssets(sourceDir, target, '', written);
|
|
299
396
|
|
|
300
397
|
// The stylesheet is compiled last: it needs the classes above.
|
|
301
398
|
const { css } = await this.deps.theme.compile({ candidates: [...candidates] });
|
|
302
|
-
|
|
399
|
+
// Comments and indentation make the stylesheet readable, and heavier on
|
|
400
|
+
// every page: the reader receives it minified.
|
|
401
|
+
await this.#write(path.join(target, ...STYLESHEET.split('/')), minifyCss(css));
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
pages: docs.length,
|
|
405
|
+
outDir: target,
|
|
406
|
+
published: docs.map((doc) => ({ url: pageUrl(doc), frontmatter: doc.frontmatter })),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Reads the sidebar description of a version.
|
|
412
|
+
*
|
|
413
|
+
* @param {string} sourceDir Source folder of the version.
|
|
414
|
+
* @param {import('./loader.js').Doc[]} docs Documents of the version.
|
|
415
|
+
* @param {(doc: import('./loader.js').Doc) => string} pageUrl
|
|
416
|
+
* @param {string} folder The version's folder, as the configuration names it.
|
|
417
|
+
* @returns {Promise<import('./sidebar.js').SidebarNode[]>}
|
|
418
|
+
* @throws {ConfigError} When the file is missing, is not JSON, or describes
|
|
419
|
+
* the menu wrongly.
|
|
420
|
+
*/
|
|
421
|
+
async #describedSidebar(sourceDir, docs, pageUrl, folder) {
|
|
422
|
+
const name = this.config.sidebar;
|
|
423
|
+
const source = `${folder}/${name}`;
|
|
424
|
+
|
|
425
|
+
let text;
|
|
426
|
+
try {
|
|
427
|
+
text = await readFile(path.join(sourceDir, ...name.split('/')), 'utf8');
|
|
428
|
+
} catch (cause) {
|
|
429
|
+
throw new ConfigError(`No sidebar description at ${source}.`, {
|
|
430
|
+
cause,
|
|
431
|
+
hint: `Each version describes its own menu, since each has its own pages: create ${source}, or set sidebar: 'auto'.`,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let description;
|
|
436
|
+
try {
|
|
437
|
+
description = JSON.parse(text);
|
|
438
|
+
} catch (cause) {
|
|
439
|
+
throw new ConfigError(
|
|
440
|
+
`${source} is not valid JSON: ${/** @type {Error} */ (cause).message}`,
|
|
441
|
+
{
|
|
442
|
+
cause,
|
|
443
|
+
hint: 'JSON accepts neither comments nor a comma after the last entry.',
|
|
444
|
+
},
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return buildSidebarFromDescription(description, docs, pageUrl, { source });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Refuses an output folder the build could not empty without harm: the
|
|
453
|
+
* project itself, a folder above it, or one that holds a version's pages —
|
|
454
|
+
* or lies inside them. Checked before anything is deleted.
|
|
455
|
+
*
|
|
456
|
+
* @param {string} folder Absolute path.
|
|
457
|
+
* @throws {GeneratorError}
|
|
458
|
+
*/
|
|
459
|
+
#guardOutput(folder) {
|
|
460
|
+
const rootDir = path.resolve(this.config.rootDir ?? process.cwd());
|
|
461
|
+
/** @param {string} child @param {string} parent */
|
|
462
|
+
const within = (child, parent) => {
|
|
463
|
+
const relative = path.relative(parent, child);
|
|
464
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
if (within(rootDir, folder)) {
|
|
468
|
+
throw new GeneratorError(`The output folder "${folder}" holds the project itself.`, {
|
|
469
|
+
hint: 'Point outDir to a folder of its own, such as "dist": the build empties the folders it writes there.',
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
for (const version of this.config.versions) {
|
|
473
|
+
const sources = path.resolve(rootDir, version.folder);
|
|
474
|
+
if (within(sources, folder) || within(folder, sources)) {
|
|
475
|
+
throw new GeneratorError(
|
|
476
|
+
`The output folder "${folder}" overlaps the pages of version "${version.slug}".`,
|
|
477
|
+
{
|
|
478
|
+
hint: 'Keep outDir apart from the documentation folders: the build empties what it writes.',
|
|
479
|
+
},
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Absolute address of the RSS feed, or `''` when none is written.
|
|
487
|
+
*
|
|
488
|
+
* Known before any page is rendered: every page announces the feed in its
|
|
489
|
+
* head, whereas the feed itself is written once every version is built.
|
|
490
|
+
*
|
|
491
|
+
* @returns {string}
|
|
492
|
+
*/
|
|
493
|
+
#feedUrl() {
|
|
494
|
+
if (!this.config.feed || !this.config.siteUrl) return '';
|
|
495
|
+
return new URL(`${this.config.baseUrl}feed.xml`, this.config.siteUrl).href;
|
|
496
|
+
}
|
|
303
497
|
|
|
304
|
-
|
|
498
|
+
/**
|
|
499
|
+
* Writes what search engines and feed readers read, at the root of the
|
|
500
|
+
* site: `sitemap.xml`, `robots.txt` and the RSS feed.
|
|
501
|
+
*
|
|
502
|
+
* @param {string} target Output folder.
|
|
503
|
+
* @param {Map<string, import('./discovery.js').PublishedPage[]>} published
|
|
504
|
+
* Pages of each version, by slug.
|
|
505
|
+
*/
|
|
506
|
+
async #writeDiscovery(target, published) {
|
|
507
|
+
// Written anew every time: a sitemap or a feed turned off since the last
|
|
508
|
+
// build must not linger at the root of the site.
|
|
509
|
+
for (const file of ['sitemap.xml', 'robots.txt', 'feed.xml']) {
|
|
510
|
+
await rm(path.join(target, file), { force: true });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const { siteUrl, baseUrl } = this.config;
|
|
514
|
+
if (!siteUrl) return;
|
|
515
|
+
|
|
516
|
+
if (this.config.sitemap !== false) {
|
|
517
|
+
// A version in preparation is kept out of search engines: its pages
|
|
518
|
+
// carry noindex, and listing them would contradict it.
|
|
519
|
+
const pages = this.config.versions
|
|
520
|
+
.filter((version) => !version.prerelease)
|
|
521
|
+
.flatMap((version) => published.get(version.slug) ?? []);
|
|
522
|
+
await this.#write(path.join(target, 'sitemap.xml'), buildSitemap(pages, siteUrl));
|
|
523
|
+
|
|
524
|
+
// Crawlers only read robots.txt at the root of a domain: under a
|
|
525
|
+
// sub-path, the file would be written for nobody.
|
|
526
|
+
if (baseUrl === '/') {
|
|
527
|
+
const sitemapUrl = new URL('/sitemap.xml', siteUrl).href;
|
|
528
|
+
await this.#write(path.join(target, 'robots.txt'), buildRobots(sitemapUrl));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const feedUrl = this.#feedUrl();
|
|
533
|
+
if (feedUrl) {
|
|
534
|
+
const current = resolveVersion(this.config);
|
|
535
|
+
const feed = buildFeed(published.get(current.slug) ?? [], {
|
|
536
|
+
projectName: this.config.projectName,
|
|
537
|
+
siteUrl,
|
|
538
|
+
homeUrl: new URL(joinUrl(baseUrl, 'versions', current.slug), siteUrl).href,
|
|
539
|
+
feedUrl,
|
|
540
|
+
lang: this.config.lang,
|
|
541
|
+
});
|
|
542
|
+
await this.#write(path.join(target, 'feed.xml'), feed);
|
|
543
|
+
}
|
|
305
544
|
}
|
|
306
545
|
|
|
307
546
|
/**
|
|
@@ -310,17 +549,19 @@ export class SiteGenerator {
|
|
|
310
549
|
* @param {string} target Output folder of the version.
|
|
311
550
|
* @param {string} versionBase URL of the version.
|
|
312
551
|
* @param {Map<string, string>} written Files already written, for collisions.
|
|
552
|
+
* @param {import('./config.js').Version} version The version being built.
|
|
313
553
|
* @returns {Promise<Partial<Record<keyof typeof IMAGE_FILES, string>>>} URL
|
|
314
554
|
* of each declared image.
|
|
315
555
|
* @throws {GeneratorError} When a declared image does not exist.
|
|
316
556
|
*/
|
|
317
|
-
async #copyImages(target, versionBase, written) {
|
|
557
|
+
async #copyImages(target, versionBase, written, version) {
|
|
318
558
|
const rootDir = this.config.rootDir ?? process.cwd();
|
|
319
559
|
/** @type {Partial<Record<keyof typeof IMAGE_FILES, string>>} */
|
|
320
560
|
const urls = {};
|
|
321
561
|
|
|
322
562
|
for (const field of /** @type {(keyof typeof IMAGE_FILES)[]} */ (Object.keys(IMAGE_FILES))) {
|
|
323
|
-
|
|
563
|
+
// A version's own logo or favicon replaces the project's.
|
|
564
|
+
const declared = (field !== 'socialImage' && version[field]) || this.config[field];
|
|
324
565
|
if (!declared) continue;
|
|
325
566
|
|
|
326
567
|
const file = `${IMAGE_FILES[field]}${path.extname(declared).toLowerCase()}`;
|
|
@@ -359,18 +600,39 @@ export class SiteGenerator {
|
|
|
359
600
|
async buildAll() {
|
|
360
601
|
const rootDir = this.config.rootDir ?? process.cwd();
|
|
361
602
|
const target = path.resolve(rootDir, this.config.outDir);
|
|
603
|
+
this.#guardOutput(target);
|
|
604
|
+
|
|
605
|
+
// The folder of a version no longer declared would stay online, unlisted
|
|
606
|
+
// but reachable. Everything else in the output folder is left alone.
|
|
607
|
+
const declared = new Set(this.config.versions.map((version) => version.slug));
|
|
608
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
609
|
+
let existing;
|
|
610
|
+
try {
|
|
611
|
+
existing = await readdir(path.join(target, 'versions'), { withFileTypes: true });
|
|
612
|
+
} catch {
|
|
613
|
+
existing = [];
|
|
614
|
+
}
|
|
615
|
+
for (const entry of existing) {
|
|
616
|
+
if (entry.isDirectory() && !declared.has(entry.name)) {
|
|
617
|
+
await rm(path.join(target, 'versions', entry.name), { recursive: true, force: true });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
362
620
|
|
|
363
621
|
let pages = 0;
|
|
622
|
+
/** @type {Map<string, import('./discovery.js').PublishedPage[]>} */
|
|
623
|
+
const published = new Map();
|
|
364
624
|
for (const version of this.config.versions) {
|
|
365
625
|
const result = await this.buildVersion(
|
|
366
626
|
version.slug,
|
|
367
627
|
path.join(target, 'versions', version.slug),
|
|
368
628
|
);
|
|
369
629
|
pages += result.pages;
|
|
630
|
+
published.set(version.slug, result.published);
|
|
370
631
|
}
|
|
371
632
|
|
|
372
633
|
await this.#writeManifest(target);
|
|
373
634
|
await this.#writeRootRedirect(target);
|
|
635
|
+
await this.#writeDiscovery(target, published);
|
|
374
636
|
|
|
375
637
|
return { versions: this.config.versions.length, pages, outDir: target };
|
|
376
638
|
}
|
|
@@ -498,6 +760,8 @@ export class SiteGenerator {
|
|
|
498
760
|
});
|
|
499
761
|
}
|
|
500
762
|
|
|
763
|
+
// The sidebar description is read by the build, not published.
|
|
764
|
+
if (this.config.sidebar !== 'auto' && readable === this.config.sidebar) continue;
|
|
501
765
|
if (DOC_EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
|
|
502
766
|
|
|
503
767
|
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
|
+
}
|