@docpensieve/core 0.2.0-beta.1 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docpensieve/core",
3
- "version": "0.2.0-beta.1",
3
+ "version": "0.3.0-beta.1",
4
4
  "description": "DocPensieve engine: loading, MDX compilation, structured data, site generation",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  "types"
22
22
  ],
23
23
  "dependencies": {
24
- "@docpensieve/shared": "0.2.0-beta.1",
24
+ "@docpensieve/shared": "0.3.0-beta.1",
25
25
  "@mdx-js/mdx": "^3.1.1",
26
26
  "@shikijs/rehype": "^4.4.3",
27
27
  "gray-matter": "^4.0.3",
package/src/config.js CHANGED
@@ -36,7 +36,7 @@ import {
36
36
  * @property {string} baseUrl Deployment prefix, slashes included.
37
37
  * @property {string} outDir Output folder, relative to the root.
38
38
  * @property {Version[]} versions At least one.
39
- * @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
40
40
  * @property {string} sidebar `'auto'`, or the path of a description.
41
41
  * @property {boolean} globalComponents
42
42
  * @property {boolean} scrollToTop Back-to-top button on every page.
@@ -67,7 +67,8 @@ export const DEFAULT_CONFIG = Object.freeze({
67
67
  baseUrl: '/',
68
68
  outDir: DEFAULT_OUT_DIR,
69
69
  versions: [],
70
- theme: { framework: 'tailwind', darkMode: 'class' },
70
+ // The light / dark switch is on unless the project turns it off (ADR-014).
71
+ theme: { framework: 'tailwind', darkMode: 'class', toggle: true },
71
72
  sidebar: 'auto',
72
73
  globalComponents: true,
73
74
  scrollToTop: true,
@@ -322,6 +323,12 @@ export function normalizeConfig(userConfig) {
322
323
  });
323
324
  }
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
+
325
332
  if (!THEME_FRAMEWORKS.includes(config.theme.framework)) {
326
333
  throw new ConfigError(`Unknown theme framework: "${config.theme.framework}".`, {
327
334
  hint: `Accepted values: ${THEME_FRAMEWORKS.join(', ')}.`,
package/src/generator.js CHANGED
@@ -4,7 +4,7 @@
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
 
@@ -184,6 +184,19 @@ export class SiteGenerator {
184
184
  const rootDir = this.config.rootDir ?? process.cwd();
185
185
  const target = path.resolve(rootDir, outDir);
186
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
+
187
200
  const sourceDir = path.resolve(rootDir, version.folder);
188
201
  const docs = await this.loader.load(sourceDir);
189
202
 
@@ -255,6 +268,8 @@ export class SiteGenerator {
255
268
  ? new URL(images.socialImage, this.config.siteUrl).href
256
269
  : '',
257
270
  searchUrl,
271
+ // The light / dark switch: a button, and the few lines of script it needs.
272
+ schemeToggle: this.config.theme?.toggle !== false,
258
273
  cls: classes,
259
274
  versions: this.#versionLinks(version.slug),
260
275
  // A switcher offering a single choice is not a switcher.
@@ -433,6 +448,40 @@ export class SiteGenerator {
433
448
  return buildSidebarFromDescription(description, docs, pageUrl, { source });
434
449
  }
435
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
+
436
485
  /**
437
486
  * Absolute address of the RSS feed, or `''` when none is written.
438
487
  *
@@ -455,6 +504,12 @@ export class SiteGenerator {
455
504
  * Pages of each version, by slug.
456
505
  */
457
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
+
458
513
  const { siteUrl, baseUrl } = this.config;
459
514
  if (!siteUrl) return;
460
515
 
@@ -545,6 +600,23 @@ export class SiteGenerator {
545
600
  async buildAll() {
546
601
  const rootDir = this.config.rootDir ?? process.cwd();
547
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
+ }
548
620
 
549
621
  let pages = 0;
550
622
  /** @type {Map<string, import('./discovery.js').PublishedPage[]>} */
@@ -36,6 +36,11 @@
36
36
  <link rel="preload" as="{{as}}" href="{{href}}" />
37
37
  {{/each}}
38
38
  <link rel="stylesheet" href="{{cssHref}}" />
39
+ {{#if schemeToggle}}
40
+ {{!-- Before the first paint: the scheme the reader chose, not a flash of the
41
+ other one. --}}
42
+ <script>try{var s=localStorage.getItem('dp-scheme'),c=document.documentElement.classList;if(s==='dark'||s==='light'){c.remove('dark','light');c.add(s)}}catch(e){}</script>
43
+ {{/if}}
39
44
  {{!-- Only the search page carries a script: content pages load none. --}}
40
45
  {{#each scripts}}
41
46
  <script type="module" src="{{this}}"></script>
@@ -45,8 +50,13 @@
45
50
  <body>
46
51
  <a class="{{{cls.skip}}}" href="#content">Skip to content</a>
47
52
 
48
- {{!-- Target of the back-to-top link: bring the focus back, not only the view. --}}
49
- <header class="{{{cls.header}}}" id="top" tabindex="-1">
53
+ {{!-- Target of the back-to-top link: it brings the focus back, not only the
54
+ view. It stays out of the header on purpose — the header is sticky, so it
55
+ is already in view at any scroll position, and a browser asked to bring it
56
+ into view scrolled nowhere. --}}
57
+ <div id="top" tabindex="-1"></div>
58
+
59
+ <header class="{{{cls.header}}}">
50
60
  {{!-- The logo's alt stays empty: the name that follows already says it. --}}
51
61
  <a class="{{{cls.brand}}}" href="{{homeUrl}}">{{#if logoUrl}}<img class="{{{cls.brandLogo}}}" src="{{logoUrl}}" alt="" />{{/if}}{{projectName}}</a>
52
62
  {{#if showVersions}}
@@ -67,6 +77,13 @@
67
77
  <input type="search" name="q" placeholder="Search" aria-label="Search the documentation" />
68
78
  </form>
69
79
  {{/if}}
80
+ {{!-- Hidden until its script runs: without JavaScript, it would do nothing. --}}
81
+ {{#if schemeToggle}}
82
+ <button class="{{{cls.schemeToggle}}}" type="button" data-scheme-toggle hidden aria-label="Switch the colour scheme">
83
+ <svg class="dp-scheme-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></svg>
84
+ <svg class="dp-scheme-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></svg>
85
+ </button>
86
+ {{/if}}
70
87
  </header>
71
88
 
72
89
  <div class="{{#if wide}}{{{cls.shellWide}}}{{else}}{{{cls.shell}}}{{/if}}">
@@ -120,5 +137,8 @@
120
137
  </svg>
121
138
  </a>
122
139
  {{/if}}
140
+ {{#if schemeToggle}}
141
+ <script>(function(){var b=document.querySelector('[data-scheme-toggle]');if(!b)return;var r=document.documentElement.classList;function dark(){return r.contains('dark')||(!r.contains('light')&&matchMedia('(prefers-color-scheme: dark)').matches)}function label(){b.setAttribute('aria-label',dark()?'Switch to light mode':'Switch to dark mode')}b.hidden=false;label();b.addEventListener('click',function(){var next=dark()?'light':'dark';r.remove('dark','light');r.add(next);try{localStorage.setItem('dp-scheme',next)}catch(e){}label()})})();</script>
142
+ {{/if}}
123
143
  </body>
124
144
  </html>
package/types/config.d.ts CHANGED
@@ -62,6 +62,7 @@ export type DocPensieveConfig = {
62
62
  theme: {
63
63
  framework: string;
64
64
  darkMode?: string;
65
+ toggle?: boolean;
65
66
  tokens?: Record<string, string>;
66
67
  css?: string;
67
68
  source?: string;
@@ -134,7 +135,7 @@ export type DocPensieveConfig = {
134
135
  * @property {string} baseUrl Deployment prefix, slashes included.
135
136
  * @property {string} outDir Output folder, relative to the root.
136
137
  * @property {Version[]} versions At least one.
137
- * @property {{ framework: string, darkMode?: string, tokens?: Record<string, string>, css?: string, source?: string }} theme
138
+ * @property {{ framework: string, darkMode?: string, toggle?: boolean, tokens?: Record<string, string>, css?: string, source?: string }} theme
138
139
  * @property {string} sidebar `'auto'`, or the path of a description.
139
140
  * @property {boolean} globalComponents
140
141
  * @property {boolean} scrollToTop Back-to-top button on every page.