@docpensieve/core 0.1.5 → 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/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 { buildSidebar, collectSectionTitles } from './sidebar.js';
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) {
@@ -188,7 +202,12 @@ export class SiteGenerator {
188
202
  const current = this.config.versions.find((candidate) => candidate.current);
189
203
  const notice = versionNotice(version, current, this.config.baseUrl);
190
204
 
191
- const sidebar = buildSidebar(docs, pageUrl, { brand: this.config.projectName });
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 });
192
211
  const breadcrumbTitles = collectSectionTitles(docs);
193
212
  const layout = await this.#loadLayout();
194
213
  const classes = this.#classes();
@@ -209,7 +228,44 @@ export class SiteGenerator {
209
228
 
210
229
  // The project's images go into every version: each one stands on its
211
230
  // own, down to the orphan branch it is published on.
212
- const images = await this.#copyImages(target, versionBase, written);
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 = [];
213
269
 
214
270
  for (const doc of docs) {
215
271
  const url = pageUrl(doc);
@@ -229,6 +285,7 @@ export class SiteGenerator {
229
285
  url,
230
286
  dirUrl,
231
287
  basePath: versionBase,
288
+ sourceDir,
232
289
  });
233
290
 
234
291
  const jsonld = new StructuredDataBuilder(doc.frontmatter, url, this.config, {
@@ -242,39 +299,20 @@ export class SiteGenerator {
242
299
  // landmarks within a document, not in an entrance hall.
243
300
  const wide = pageLayout(doc) === 'home';
244
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
+
245
309
  const page = layout({
246
- lang: this.config.lang ?? 'en',
247
- darkModeClass: null,
310
+ ...shell,
248
311
  title: documentTitle(doc.frontmatter.title, this.config.projectName),
249
312
  description: doc.frontmatter.description ?? '',
250
313
  canonical: this.config.siteUrl ? new URL(url, this.config.siteUrl).href : '',
251
- projectName: this.config.projectName,
252
- versionName: version.name,
253
- homeUrl: versionBase,
254
314
  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
315
  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
316
  // A version in preparation must not compete with the current one:
279
317
  // same content, two addresses, and the wrong one comes up. "follow"
280
318
  // still lets its links be followed.
@@ -295,13 +333,159 @@ export class SiteGenerator {
295
333
  await this.#write(destination, page);
296
334
  }
297
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
+
298
380
  await this.#copyAssets(sourceDir, target, '', written);
299
381
 
300
382
  // The stylesheet is compiled last: it needs the classes above.
301
383
  const { css } = await this.deps.theme.compile({ candidates: [...candidates] });
302
- await this.#write(path.join(target, ...STYLESHEET.split('/')), css);
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
+ }
303
394
 
304
- return { pages: docs.length, outDir: target };
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
+ }
305
489
  }
306
490
 
307
491
  /**
@@ -310,17 +494,19 @@ export class SiteGenerator {
310
494
  * @param {string} target Output folder of the version.
311
495
  * @param {string} versionBase URL of the version.
312
496
  * @param {Map<string, string>} written Files already written, for collisions.
497
+ * @param {import('./config.js').Version} version The version being built.
313
498
  * @returns {Promise<Partial<Record<keyof typeof IMAGE_FILES, string>>>} URL
314
499
  * of each declared image.
315
500
  * @throws {GeneratorError} When a declared image does not exist.
316
501
  */
317
- async #copyImages(target, versionBase, written) {
502
+ async #copyImages(target, versionBase, written, version) {
318
503
  const rootDir = this.config.rootDir ?? process.cwd();
319
504
  /** @type {Partial<Record<keyof typeof IMAGE_FILES, string>>} */
320
505
  const urls = {};
321
506
 
322
507
  for (const field of /** @type {(keyof typeof IMAGE_FILES)[]} */ (Object.keys(IMAGE_FILES))) {
323
- const declared = this.config[field];
508
+ // A version's own logo or favicon replaces the project's.
509
+ const declared = (field !== 'socialImage' && version[field]) || this.config[field];
324
510
  if (!declared) continue;
325
511
 
326
512
  const file = `${IMAGE_FILES[field]}${path.extname(declared).toLowerCase()}`;
@@ -361,16 +547,20 @@ export class SiteGenerator {
361
547
  const target = path.resolve(rootDir, this.config.outDir);
362
548
 
363
549
  let pages = 0;
550
+ /** @type {Map<string, import('./discovery.js').PublishedPage[]>} */
551
+ const published = new Map();
364
552
  for (const version of this.config.versions) {
365
553
  const result = await this.buildVersion(
366
554
  version.slug,
367
555
  path.join(target, 'versions', version.slug),
368
556
  );
369
557
  pages += result.pages;
558
+ published.set(version.slug, result.published);
370
559
  }
371
560
 
372
561
  await this.#writeManifest(target);
373
562
  await this.#writeRootRedirect(target);
563
+ await this.#writeDiscovery(target, published);
374
564
 
375
565
  return { versions: this.config.versions.length, pages, outDir: target };
376
566
  }
@@ -498,6 +688,8 @@ export class SiteGenerator {
498
688
  });
499
689
  }
500
690
 
691
+ // The sidebar description is read by the build, not published.
692
+ if (this.config.sidebar !== 'auto' && readable === this.config.sidebar) continue;
501
693
  if (DOC_EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
502
694
 
503
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('&', '&amp;')
47
+ .replaceAll('<', '&lt;')
48
+ .replaceAll('>', '&gt;')
49
+ .replaceAll('"', '&quot;');
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
+ }