@docpensieve/core 0.2.0-beta.1 → 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/package.json +2 -2
- package/src/config.js +9 -2
- package/src/generator.js +73 -1
- package/templates/layout.hbs +22 -2
- package/types/config.d.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@docpensieve/core",
|
|
3
|
-
"version": "0.2.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "DocPensieve engine: loading, MDX compilation, structured data, site generation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"types"
|
|
22
22
|
],
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@docpensieve/shared": "0.2.0
|
|
24
|
+
"@docpensieve/shared": "0.2.0",
|
|
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
|
-
|
|
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[]>} */
|
package/templates/layout.hbs
CHANGED
|
@@ -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:
|
|
49
|
-
|
|
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.
|