@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.
@@ -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('&', '&amp;')
489
+ .replaceAll('<', '&lt;')
490
+ .replaceAll('>', '&gt;')
491
+ .replaceAll('"', '&quot;');
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';