@docpensieve/core 0.1.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/LICENSE +21 -0
- package/README.md +38 -0
- package/package.json +58 -0
- package/src/compiler.js +415 -0
- package/src/config.js +265 -0
- package/src/generator.js +517 -0
- package/src/index.js +35 -0
- package/src/loader.js +260 -0
- package/src/sidebar.js +105 -0
- package/src/structured-data.js +334 -0
- package/templates/layout.hbs +94 -0
- package/templates/nav-items.hbs +14 -0
- package/templates/toc-items.hbs +10 -0
- package/types/compiler.d.ts +108 -0
- package/types/config.d.ts +149 -0
- package/types/generator.d.ts +82 -0
- package/types/index.d.ts +34 -0
- package/types/loader.d.ts +66 -0
- package/types/sidebar.d.ts +60 -0
- package/types/structured-data.d.ts +51 -0
package/src/generator.js
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestration: loader → compiler → Handlebars shell → disk.
|
|
3
|
+
*
|
|
4
|
+
* @module @docpensieve/core/generator
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { copyFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
DOC_EXTENSIONS,
|
|
13
|
+
assetPathToSlug,
|
|
14
|
+
dirPathToSlug,
|
|
15
|
+
GeneratorError,
|
|
16
|
+
PAGE_LAYOUTS,
|
|
17
|
+
ThemeError,
|
|
18
|
+
VERSIONS_MANIFEST,
|
|
19
|
+
} from '@docpensieve/shared';
|
|
20
|
+
import Handlebars from 'handlebars';
|
|
21
|
+
|
|
22
|
+
import { Compiler } from './compiler.js';
|
|
23
|
+
import { resolveVersion } from './config.js';
|
|
24
|
+
import { DocLoader } from './loader.js';
|
|
25
|
+
import { buildSidebar, collectSectionTitles } from './sidebar.js';
|
|
26
|
+
import { StructuredDataBuilder } from './structured-data.js';
|
|
27
|
+
|
|
28
|
+
/** Template folder, resolved from this module rather than from the cwd. */
|
|
29
|
+
const TEMPLATE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'templates');
|
|
30
|
+
|
|
31
|
+
/** Path of the stylesheet written into every version. */
|
|
32
|
+
const STYLESHEET = 'assets/docpensieve.css';
|
|
33
|
+
|
|
34
|
+
/** Collects the values of the `class` attributes of an HTML document. */
|
|
35
|
+
const CLASS_ATTRIBUTE = /class="([^"]*)"/g;
|
|
36
|
+
|
|
37
|
+
/** Folders never copied from the sources. */
|
|
38
|
+
const IGNORED_DIRS = new Set(['node_modules', 'dist', 'coverage']);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Joins a URL path while avoiding doubled slashes.
|
|
42
|
+
*
|
|
43
|
+
* @param {...string} parts
|
|
44
|
+
* @returns {string} Path starting and ending with `/`.
|
|
45
|
+
*/
|
|
46
|
+
function joinUrl(...parts) {
|
|
47
|
+
const segments = parts.flatMap((part) => String(part).split('/')).filter(Boolean);
|
|
48
|
+
return segments.length > 0 ? `/${segments.join('/')}/` : '/';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Contents of the `<title>` tag.
|
|
53
|
+
*
|
|
54
|
+
* The page title followed by the project name, which tells tabs and search
|
|
55
|
+
* results apart — unless both are the same, as on a home page named after the
|
|
56
|
+
* project: "DocPensieve · DocPensieve" said nothing more.
|
|
57
|
+
*
|
|
58
|
+
* @param {string | undefined} title Title from the frontmatter.
|
|
59
|
+
* @param {string} projectName
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function documentTitle(title, projectName) {
|
|
63
|
+
if (!title || title === projectName) return projectName;
|
|
64
|
+
return projectName ? `${title} · ${projectName}` : title;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Layout requested by a page.
|
|
69
|
+
*
|
|
70
|
+
* @param {import('./loader.js').Doc} doc
|
|
71
|
+
* @returns {string} A value of `PAGE_LAYOUTS`.
|
|
72
|
+
* @throws {GeneratorError} When the frontmatter asks for another one: a typo
|
|
73
|
+
* would otherwise render the page in a layout other than the intended one,
|
|
74
|
+
* without a word.
|
|
75
|
+
*/
|
|
76
|
+
function pageLayout(doc) {
|
|
77
|
+
const requested = doc.frontmatter?.layout;
|
|
78
|
+
if (requested === undefined || requested === null || requested === '') return 'doc';
|
|
79
|
+
|
|
80
|
+
if (!PAGE_LAYOUTS.includes(requested)) {
|
|
81
|
+
throw new GeneratorError(`Unknown layout in "${doc.path}": "${requested}".`, {
|
|
82
|
+
hint: `Accepted values: ${PAGE_LAYOUTS.join(', ')}.`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return requested;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Notice to put on the pages of a version that is not the current one.
|
|
90
|
+
*
|
|
91
|
+
* A version in preparation, or an archived one, looks exactly like the one
|
|
92
|
+
* that counts. Someone landing there from a search engine has no way of
|
|
93
|
+
* noticing: they must be told, and given somewhere to go.
|
|
94
|
+
*
|
|
95
|
+
* @param {import('./config.js').Version} version
|
|
96
|
+
* @param {import('./config.js').Version | undefined} current
|
|
97
|
+
* @param {string} baseUrl
|
|
98
|
+
* @returns {{ prerelease: boolean, name: string, url: string } | null}
|
|
99
|
+
*/
|
|
100
|
+
function versionNotice(version, current, baseUrl) {
|
|
101
|
+
if (version.current) return null;
|
|
102
|
+
if (!version.prerelease && !version.archived) return null;
|
|
103
|
+
// Without a current version, the notice would have nowhere to point to.
|
|
104
|
+
if (!current || current.slug === version.slug) return null;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
prerelease: version.prerelease === true,
|
|
108
|
+
name: current.name,
|
|
109
|
+
url: joinUrl(baseUrl, 'versions', current.slug),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Generates the static site of one or more versions. */
|
|
114
|
+
export class SiteGenerator {
|
|
115
|
+
/** @type {((data: any) => string) | null} */
|
|
116
|
+
#layout = null;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {import('./config.js').DocPensieveConfig} config Normalised config.
|
|
120
|
+
* @param {{
|
|
121
|
+
* components?: Record<string, Function>,
|
|
122
|
+
* theme?: any,
|
|
123
|
+
* loader?: DocLoader,
|
|
124
|
+
* compiler?: Compiler,
|
|
125
|
+
* onPage?: (page: {
|
|
126
|
+
* url: string, dirUrl?: string, basePath: string,
|
|
127
|
+
* filepath?: string, sourceDir?: string,
|
|
128
|
+
* }) => void,
|
|
129
|
+
* }} [deps]
|
|
130
|
+
* Global components and the theme are injected rather than imported:
|
|
131
|
+
* `core` stays independent of `components` and `theme` (ADR-002). `loader`
|
|
132
|
+
* and `compiler` only serve tests. `onPage` is called before every page:
|
|
133
|
+
* components learn from it the URL they render, which the compiler
|
|
134
|
+
* plugins cannot tell them (ADR-006), and how to find a file of the
|
|
135
|
+
* version.
|
|
136
|
+
*/
|
|
137
|
+
constructor(config, deps = {}) {
|
|
138
|
+
this.config = config;
|
|
139
|
+
this.deps = deps;
|
|
140
|
+
this.loader = deps.loader ?? new DocLoader();
|
|
141
|
+
this.compiler = deps.compiler ?? new Compiler({ components: deps.components ?? {} });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Generates one version into a folder.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} versionSlug Slug of the version to generate.
|
|
148
|
+
* @param {string} outDir Output folder of that version.
|
|
149
|
+
* @returns {Promise<{ pages: number, outDir: string }>}
|
|
150
|
+
* @throws {GeneratorError} Write failure.
|
|
151
|
+
*/
|
|
152
|
+
async buildVersion(versionSlug, outDir) {
|
|
153
|
+
const version = resolveVersion(this.config, versionSlug);
|
|
154
|
+
const rootDir = this.config.rootDir ?? process.cwd();
|
|
155
|
+
const target = path.resolve(rootDir, outDir);
|
|
156
|
+
|
|
157
|
+
const sourceDir = path.resolve(rootDir, version.folder);
|
|
158
|
+
const docs = await this.loader.load(sourceDir);
|
|
159
|
+
|
|
160
|
+
// A version without pages still published a redirect to itself: the site
|
|
161
|
+
// root led to a page that did not exist.
|
|
162
|
+
if (docs.length === 0) {
|
|
163
|
+
throw new GeneratorError(`Version "${version.slug}" has no page to publish.`, {
|
|
164
|
+
hint: `Add a page to ${version.folder}, or remove "draft: true" from the existing ones.`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const versionBase = joinUrl(this.config.baseUrl, 'versions', version.slug);
|
|
169
|
+
/** @param {import('./loader.js').Doc} doc */
|
|
170
|
+
const pageUrl = (doc) => joinUrl(versionBase, doc.slug);
|
|
171
|
+
|
|
172
|
+
const current = this.config.versions.find((candidate) => candidate.current);
|
|
173
|
+
const notice = versionNotice(version, current, this.config.baseUrl);
|
|
174
|
+
|
|
175
|
+
const sidebar = buildSidebar(docs, pageUrl, { brand: this.config.projectName });
|
|
176
|
+
const breadcrumbTitles = collectSectionTitles(docs);
|
|
177
|
+
const layout = await this.#loadLayout();
|
|
178
|
+
const classes = this.#classes();
|
|
179
|
+
|
|
180
|
+
// The classes of every rendered page are collected along the way: a
|
|
181
|
+
// utility provider such as Tailwind only emits the rules actually used,
|
|
182
|
+
// including those written by hand in the MDX.
|
|
183
|
+
/** @type {Set<string>} */
|
|
184
|
+
const candidates = new Set();
|
|
185
|
+
|
|
186
|
+
// Everything written into the version, and where it comes from. An asset
|
|
187
|
+
// landing on a page, on another asset or on the theme stylesheet
|
|
188
|
+
// overwrote it without a word: the last one written won.
|
|
189
|
+
/** @type {Map<string, string>} */
|
|
190
|
+
const written = new Map([
|
|
191
|
+
[path.join(target, ...STYLESHEET.split('/')), 'the theme stylesheet'],
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
for (const doc of docs) {
|
|
195
|
+
const url = pageUrl(doc);
|
|
196
|
+
|
|
197
|
+
// Base of relative targets: the source file's folder, mapped into URL
|
|
198
|
+
// space. Not the page URL, which has one more level — `./diagram.png`
|
|
199
|
+
// written in `guide/install.md` would otherwise point to
|
|
200
|
+
// `/guide/install/diagram.png`, whereas the file is output under `/guide/`.
|
|
201
|
+
const folder = dirPathToSlug(path.relative(sourceDir, path.dirname(doc.path)));
|
|
202
|
+
const dirUrl = joinUrl(versionBase, folder);
|
|
203
|
+
// Components need to know which page they render: a link they produce
|
|
204
|
+
// escapes the compiler plugins (ADR-006).
|
|
205
|
+
this.deps.onPage?.({ url, dirUrl, basePath: versionBase, filepath: doc.path, sourceDir });
|
|
206
|
+
|
|
207
|
+
const { html, toc, preloads } = await this.compiler.compile(doc.content, {
|
|
208
|
+
filepath: doc.path,
|
|
209
|
+
url,
|
|
210
|
+
dirUrl,
|
|
211
|
+
basePath: versionBase,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const jsonld = new StructuredDataBuilder(doc.frontmatter, url, this.config, {
|
|
215
|
+
breadcrumbTitles,
|
|
216
|
+
basePath: versionBase,
|
|
217
|
+
dirUrl,
|
|
218
|
+
}).toScriptTag();
|
|
219
|
+
|
|
220
|
+
// A home page has neither menu nor table of contents: those are reading
|
|
221
|
+
// landmarks within a document, not in an entrance hall.
|
|
222
|
+
const wide = pageLayout(doc) === 'home';
|
|
223
|
+
|
|
224
|
+
const page = layout({
|
|
225
|
+
lang: this.config.lang ?? 'en',
|
|
226
|
+
darkModeClass: null,
|
|
227
|
+
title: documentTitle(doc.frontmatter.title, this.config.projectName),
|
|
228
|
+
description: doc.frontmatter.description ?? '',
|
|
229
|
+
canonical: this.config.siteUrl ? new URL(url, this.config.siteUrl).href : '',
|
|
230
|
+
projectName: this.config.projectName,
|
|
231
|
+
versionName: version.name,
|
|
232
|
+
homeUrl: versionBase,
|
|
233
|
+
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
|
+
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
|
+
// A version in preparation must not compete with the current one:
|
|
245
|
+
// same content, two addresses, and the wrong one comes up. "follow"
|
|
246
|
+
// still lets its links be followed.
|
|
247
|
+
noindex: version.prerelease === true,
|
|
248
|
+
sidebar: wide ? [] : sidebar,
|
|
249
|
+
toc: wide ? [] : toc,
|
|
250
|
+
preloads,
|
|
251
|
+
content: html,
|
|
252
|
+
jsonld,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
for (const [, value] of page.matchAll(CLASS_ATTRIBUTE)) {
|
|
256
|
+
for (const token of value.split(/\s+/)) if (token) candidates.add(token);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const destination = path.join(target, ...doc.slug.split('/').filter(Boolean), 'index.html');
|
|
260
|
+
written.set(destination, path.relative(sourceDir, doc.path).split(path.sep).join('/'));
|
|
261
|
+
await this.#write(destination, page);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await this.#copyAssets(sourceDir, target, '', written);
|
|
265
|
+
|
|
266
|
+
// The stylesheet is compiled last: it needs the classes above.
|
|
267
|
+
const { css } = await this.deps.theme.compile({ candidates: [...candidates] });
|
|
268
|
+
await this.#write(path.join(target, ...STYLESHEET.split('/')), css);
|
|
269
|
+
|
|
270
|
+
return { pages: docs.length, outDir: target };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Generates every declared version, plus `versions.json` and a root that
|
|
275
|
+
* redirects to the current version.
|
|
276
|
+
*
|
|
277
|
+
* @returns {Promise<{ versions: number, pages: number, outDir: string }>}
|
|
278
|
+
*/
|
|
279
|
+
async buildAll() {
|
|
280
|
+
const rootDir = this.config.rootDir ?? process.cwd();
|
|
281
|
+
const target = path.resolve(rootDir, this.config.outDir);
|
|
282
|
+
|
|
283
|
+
let pages = 0;
|
|
284
|
+
for (const version of this.config.versions) {
|
|
285
|
+
const result = await this.buildVersion(
|
|
286
|
+
version.slug,
|
|
287
|
+
path.join(target, 'versions', version.slug),
|
|
288
|
+
);
|
|
289
|
+
pages += result.pages;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
await this.#writeManifest(target);
|
|
293
|
+
await this.#writeRootRedirect(target);
|
|
294
|
+
|
|
295
|
+
return { versions: this.config.versions.length, pages, outDir: target };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Class aliases of the injected theme.
|
|
300
|
+
*
|
|
301
|
+
* The theme is a dependency, not an option: without it the site would come
|
|
302
|
+
* out with no style and no classes, which would be noticed much later than
|
|
303
|
+
* an error here.
|
|
304
|
+
*
|
|
305
|
+
* @returns {Record<string, string>}
|
|
306
|
+
* @throws {ThemeError} When no theme was injected.
|
|
307
|
+
*/
|
|
308
|
+
#classes() {
|
|
309
|
+
if (!this.deps.theme?.compile) {
|
|
310
|
+
throw new ThemeError('No theme injected into the generator.', {
|
|
311
|
+
hint: 'Pass a ThemeEngine in the second argument of SiteGenerator.',
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return this.deps.theme.classes ?? {};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Loads and compiles the shell once per generator.
|
|
319
|
+
*
|
|
320
|
+
* Partials are registered on an isolated Handlebars environment: the
|
|
321
|
+
* package's global singleton is shared by the whole process, including
|
|
322
|
+
* application code that asked for nothing.
|
|
323
|
+
*
|
|
324
|
+
* @returns {Promise<(data: any) => string>}
|
|
325
|
+
*/
|
|
326
|
+
async #loadLayout() {
|
|
327
|
+
if (this.#layout) return this.#layout;
|
|
328
|
+
|
|
329
|
+
const [layout, navItems, tocItems] = await Promise.all(
|
|
330
|
+
['layout.hbs', 'nav-items.hbs', 'toc-items.hbs'].map((file) =>
|
|
331
|
+
readFile(path.join(TEMPLATE_DIR, file), 'utf8'),
|
|
332
|
+
),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const handlebars = Handlebars.create();
|
|
336
|
+
handlebars.registerHelper('eq', (a, b) => a === b);
|
|
337
|
+
handlebars.registerPartial('navItems', navItems);
|
|
338
|
+
handlebars.registerPartial('tocItems', tocItems);
|
|
339
|
+
|
|
340
|
+
this.#layout = handlebars.compile(layout);
|
|
341
|
+
return this.#layout;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Links of the version switcher.
|
|
346
|
+
*
|
|
347
|
+
* @param {string} currentSlug
|
|
348
|
+
* @returns {{ slug: string, name: string, url: string, current: boolean }[]}
|
|
349
|
+
*/
|
|
350
|
+
#versionLinks(currentSlug) {
|
|
351
|
+
return this.config.versions.map((version) => ({
|
|
352
|
+
slug: version.slug,
|
|
353
|
+
name: version.name,
|
|
354
|
+
url: joinUrl(this.config.baseUrl, 'versions', version.slug),
|
|
355
|
+
current: version.slug === currentSlug,
|
|
356
|
+
prerelease: version.prerelease === true,
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Writes a file, creating its folder on the way.
|
|
362
|
+
*
|
|
363
|
+
* @param {string} filepath
|
|
364
|
+
* @param {string} contents
|
|
365
|
+
*/
|
|
366
|
+
async #write(filepath, contents) {
|
|
367
|
+
try {
|
|
368
|
+
await mkdir(path.dirname(filepath), { recursive: true });
|
|
369
|
+
await writeFile(filepath, contents, 'utf8');
|
|
370
|
+
} catch (cause) {
|
|
371
|
+
throw new GeneratorError(`Could not write ${filepath}.`, {
|
|
372
|
+
cause,
|
|
373
|
+
hint: 'Check the permissions on the output folder.',
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Copies everything that is not a page: images, PDFs, attachments.
|
|
380
|
+
*
|
|
381
|
+
* Folders go through the same transformation as pages: without it, a
|
|
382
|
+
* folder's ordering prefix — `02-guide` — would vanish from the URLs of the
|
|
383
|
+
* pages but stay in those of their images, and every relative reference
|
|
384
|
+
* would miss. The file name itself stays untouched: it is the one the
|
|
385
|
+
* author wrote.
|
|
386
|
+
*
|
|
387
|
+
* @param {string} sourceDir
|
|
388
|
+
* @param {string} target
|
|
389
|
+
* @param {string} [relative] Current sub-path, for recursion.
|
|
390
|
+
* @param {Map<string, string>} [written] Destinations already taken, and their origin.
|
|
391
|
+
* @returns {Promise<number>} Number of files copied.
|
|
392
|
+
* @throws {GeneratorError} Destination collision, symbolic link, copy failure.
|
|
393
|
+
*/
|
|
394
|
+
async #copyAssets(sourceDir, target, relative = '', written = new Map()) {
|
|
395
|
+
const current = path.join(sourceDir, relative);
|
|
396
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
397
|
+
let copied = 0;
|
|
398
|
+
|
|
399
|
+
for (const entry of entries) {
|
|
400
|
+
if (entry.name.startsWith('.')) continue;
|
|
401
|
+
const next = path.join(relative, entry.name);
|
|
402
|
+
// For messages: the same path, separated as in a URL whatever the
|
|
403
|
+
// system — that is how the author reads it in their pages.
|
|
404
|
+
const readable = next.split(path.sep).join('/');
|
|
405
|
+
|
|
406
|
+
if (entry.isDirectory()) {
|
|
407
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
408
|
+
copied += await this.#copyAssets(sourceDir, target, next, written);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// A symbolic link is not followed: it could lead outside the version.
|
|
413
|
+
// Copying it blindly crashed the copy on a linked folder, stack
|
|
414
|
+
// included, and its pages went missing without anything saying so.
|
|
415
|
+
if (!entry.isFile()) {
|
|
416
|
+
throw new GeneratorError(`Symbolic link not followed: "${readable}".`, {
|
|
417
|
+
hint: 'Put its content in the version folder rather than linking to it.',
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (DOC_EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
|
|
422
|
+
|
|
423
|
+
const destination = path.join(target, ...assetPathToSlug(next).split('/'));
|
|
424
|
+
const existing = written.get(destination);
|
|
425
|
+
if (existing !== undefined) {
|
|
426
|
+
throw new GeneratorError(
|
|
427
|
+
`"${readable}" and "${existing}" would be written to the same place.`,
|
|
428
|
+
{ hint: 'Rename or move one of them: the second would overwrite the first.' },
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
written.set(destination, readable);
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
435
|
+
await copyFile(path.join(current, entry.name), destination);
|
|
436
|
+
} catch (cause) {
|
|
437
|
+
throw new GeneratorError(`Could not copy "${readable}".`, {
|
|
438
|
+
cause,
|
|
439
|
+
hint: 'Check the permissions on the file and on the output folder.',
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
copied += 1;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return copied;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Writes the manifest the version switcher will read (roadmap § 4.1).
|
|
450
|
+
*
|
|
451
|
+
* @param {string} target
|
|
452
|
+
*/
|
|
453
|
+
async #writeManifest(target) {
|
|
454
|
+
const manifest = {
|
|
455
|
+
versions: this.config.versions.map((version) => ({
|
|
456
|
+
slug: version.slug,
|
|
457
|
+
name: version.name,
|
|
458
|
+
url: joinUrl(this.config.baseUrl, 'versions', version.slug),
|
|
459
|
+
current: version.current === true,
|
|
460
|
+
archived: version.archived === true,
|
|
461
|
+
prerelease: version.prerelease === true,
|
|
462
|
+
})),
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
await this.#write(
|
|
466
|
+
path.join(target, VERSIONS_MANIFEST),
|
|
467
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Writes a root that leads to the current version.
|
|
473
|
+
*
|
|
474
|
+
* An HTML redirect rather than a server rule: the output must stay
|
|
475
|
+
* publishable on any static hosting, GitHub Pages included.
|
|
476
|
+
*
|
|
477
|
+
* @param {string} target
|
|
478
|
+
*/
|
|
479
|
+
async #writeRootRedirect(target) {
|
|
480
|
+
const current = resolveVersion(this.config);
|
|
481
|
+
const url = joinUrl(this.config.baseUrl, 'versions', current.slug);
|
|
482
|
+
|
|
483
|
+
// The project and version names come from the configuration: a "<" or a
|
|
484
|
+
// "&" in them would break the page.
|
|
485
|
+
/** @param {unknown} value */
|
|
486
|
+
const escape = (value) =>
|
|
487
|
+
String(value)
|
|
488
|
+
.replaceAll('&', '&')
|
|
489
|
+
.replaceAll('<', '<')
|
|
490
|
+
.replaceAll('>', '>')
|
|
491
|
+
.replaceAll('"', '"');
|
|
492
|
+
const name = escape(this.config.projectName);
|
|
493
|
+
|
|
494
|
+
await this.#write(
|
|
495
|
+
path.join(target, 'index.html'),
|
|
496
|
+
[
|
|
497
|
+
'<!doctype html>',
|
|
498
|
+
`<html lang="${escape(this.config.lang ?? 'en')}">`,
|
|
499
|
+
' <head>',
|
|
500
|
+
' <meta charset="utf-8" />',
|
|
501
|
+
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
502
|
+
` <meta http-equiv="refresh" content="0; url=${url}" />`,
|
|
503
|
+
` <link rel="canonical" href="${url}" />`,
|
|
504
|
+
` <title>${name}</title>`,
|
|
505
|
+
' </head>',
|
|
506
|
+
' <body>',
|
|
507
|
+
` <h1>${name}</h1>`,
|
|
508
|
+
// A label that says where it goes, rather than a raw URL that some
|
|
509
|
+
// screen readers spell out character by character.
|
|
510
|
+
` <p><a href="${url}">Read version ${escape(current.name)}</a></p>`,
|
|
511
|
+
' </body>',
|
|
512
|
+
'</html>',
|
|
513
|
+
'',
|
|
514
|
+
].join('\n'),
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @docpensieve/core — the generation engine.
|
|
3
|
+
*
|
|
4
|
+
* Depends on `@docpensieve/shared` only. Global components and the theme are
|
|
5
|
+
* injected by the caller (the CLI), which keeps the engine testable without
|
|
6
|
+
* React or CSS.
|
|
7
|
+
*
|
|
8
|
+
* @module @docpensieve/core
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Engine types, re-exported for consumers of the published package: without
|
|
13
|
+
* this they would only be reachable through an internal path.
|
|
14
|
+
*
|
|
15
|
+
* @typedef {import('./config.js').DocPensieveConfig} DocPensieveConfig
|
|
16
|
+
* @typedef {import('./config.js').Version} Version
|
|
17
|
+
* @typedef {import('./loader.js').Doc} Doc
|
|
18
|
+
* @typedef {import('./compiler.js').CompileResult} CompileResult
|
|
19
|
+
* @typedef {import('./compiler.js').TocEntry} TocEntry
|
|
20
|
+
* @typedef {import('./compiler.js').Preload} Preload
|
|
21
|
+
* @typedef {import('./sidebar.js').SidebarNode} SidebarNode
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
DEFAULT_CONFIG,
|
|
26
|
+
defineConfig,
|
|
27
|
+
loadConfig,
|
|
28
|
+
normalizeConfig,
|
|
29
|
+
resolveVersion,
|
|
30
|
+
} from './config.js';
|
|
31
|
+
export { DocLoader } from './loader.js';
|
|
32
|
+
export { Compiler } from './compiler.js';
|
|
33
|
+
export { StructuredDataBuilder } from './structured-data.js';
|
|
34
|
+
export { SiteGenerator } from './generator.js';
|
|
35
|
+
export { buildSidebar, collectSectionTitles } from './sidebar.js';
|