@docpensieve/core 0.2.0 → 0.3.0-beta.2
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/authors.js +175 -0
- package/src/config.js +23 -0
- package/src/generator.js +159 -2
- package/src/index.js +3 -0
- package/src/structured-data.js +20 -6
- package/templates/layout.hbs +23 -0
- package/types/authors.d.ts +97 -0
- package/types/config.d.ts +5 -0
- package/types/generator.d.ts +27 -1
- package/types/index.d.ts +5 -0
- package/types/structured-data.d.ts +13 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@docpensieve/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-beta.2",
|
|
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.
|
|
24
|
+
"@docpensieve/shared": "0.3.0-beta.2",
|
|
25
25
|
"@mdx-js/mdx": "^3.1.1",
|
|
26
26
|
"@shikijs/rehype": "^4.4.3",
|
|
27
27
|
"gray-matter": "^4.0.3",
|
package/src/authors.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The byline of a page: who wrote it, and when it was written or last changed.
|
|
3
|
+
*
|
|
4
|
+
* The frontmatter names the authors; a JSON file of the version describes
|
|
5
|
+
* them. The split matters: a name repeated on forty pages would carry its
|
|
6
|
+
* biography forty times, and correcting it would mean forty edits.
|
|
7
|
+
*
|
|
8
|
+
* This module reads no file. The generator hands it the parsed description,
|
|
9
|
+
* as it does for the menu: without that, the engine could not be tested
|
|
10
|
+
* without a disk.
|
|
11
|
+
*
|
|
12
|
+
* @module @docpensieve/core/authors
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { ConfigError } from '@docpensieve/shared';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} Author
|
|
19
|
+
* @property {string} key Key used by the frontmatter.
|
|
20
|
+
* @property {string} name Name shown to the reader.
|
|
21
|
+
* @property {string} [bio] One or two sentences, shown under the name.
|
|
22
|
+
* @property {string} [avatar] Image path, relative to the version's folder.
|
|
23
|
+
* @property {string} [url] Personal site or profile.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} Byline
|
|
28
|
+
* @property {Author[]} authors
|
|
29
|
+
* @property {{ iso: string, label: string } | null} created
|
|
30
|
+
* @property {{ iso: string, label: string } | null} updated
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Fields an author may declare, so that a typo is caught rather than ignored. */
|
|
34
|
+
const AUTHOR_FIELDS = new Set(['name', 'bio', 'avatar', 'url']);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reads a date of the frontmatter, and gives it in both forms: the machine one
|
|
38
|
+
* for `<time datetime>`, the readable one for the reader.
|
|
39
|
+
*
|
|
40
|
+
* A date is often written unquoted in YAML, which parses it as a Date; quoted,
|
|
41
|
+
* it arrives as text. Both are accepted, anything unreadable is refused rather
|
|
42
|
+
* than shown as `Invalid Date`.
|
|
43
|
+
*
|
|
44
|
+
* @param {unknown} value
|
|
45
|
+
* @param {string} field Name of the field, for the error message.
|
|
46
|
+
* @param {string} where Page the date comes from.
|
|
47
|
+
* @returns {{ iso: string, label: string } | null} `null` when absent.
|
|
48
|
+
* @throws {ConfigError} When the value is not a date.
|
|
49
|
+
*/
|
|
50
|
+
export function readDate(value, field, where) {
|
|
51
|
+
if (value === undefined || value === null || value === '') return null;
|
|
52
|
+
|
|
53
|
+
const date = value instanceof Date ? value : new Date(String(value));
|
|
54
|
+
if (Number.isNaN(date.getTime())) {
|
|
55
|
+
throw new ConfigError(`Invalid ${field} in ${where}: "${String(value)}".`, {
|
|
56
|
+
hint: `Write a date as ${field}: 2026-09-16, which is read the same way everywhere.`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
iso: date.toISOString().slice(0, 10),
|
|
62
|
+
// Fixed locale: the page is built once and read everywhere, so the label
|
|
63
|
+
// must not depend on the machine that produced it. Day first, month
|
|
64
|
+
// spelled out — "16 September 2026" is read the same way everywhere,
|
|
65
|
+
// where 09/16 and 16/09 are the same page read two ways.
|
|
66
|
+
label: new Intl.DateTimeFormat('en-GB', {
|
|
67
|
+
day: 'numeric',
|
|
68
|
+
month: 'long',
|
|
69
|
+
year: 'numeric',
|
|
70
|
+
timeZone: 'UTC',
|
|
71
|
+
}).format(date),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Turns the JSON description of a version into a table of authors.
|
|
77
|
+
*
|
|
78
|
+
* @param {unknown} description Parsed content of the file.
|
|
79
|
+
* @param {{ source: string }} options `source` names the file in errors.
|
|
80
|
+
* @returns {Map<string, Author>}
|
|
81
|
+
* @throws {ConfigError} When the shape is wrong, naming the offending entry.
|
|
82
|
+
*/
|
|
83
|
+
export function buildAuthorTable(description, { source }) {
|
|
84
|
+
if (description === null || typeof description !== 'object' || Array.isArray(description)) {
|
|
85
|
+
throw new ConfigError(`${source} must describe authors as an object.`, {
|
|
86
|
+
hint: 'Write { "ada": { "name": "Ada Lovelace", "bio": "…" } }, one entry per author.',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @type {Map<string, Author>} */
|
|
91
|
+
const table = new Map();
|
|
92
|
+
|
|
93
|
+
for (const [key, value] of Object.entries(/** @type {Record<string, unknown>} */ (description))) {
|
|
94
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
95
|
+
throw new ConfigError(`Author "${key}" of ${source} must be an object.`, {
|
|
96
|
+
hint: `Write "${key}": { "name": "…" } — the name is the only required field.`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const entry = /** @type {Record<string, unknown>} */ (value);
|
|
101
|
+
|
|
102
|
+
for (const field of Object.keys(entry)) {
|
|
103
|
+
if (!AUTHOR_FIELDS.has(field)) {
|
|
104
|
+
throw new ConfigError(`Author "${key}" of ${source} has an unknown field "${field}".`, {
|
|
105
|
+
hint: `Known fields: ${[...AUTHOR_FIELDS].join(', ')}. A typo here would be silently ignored.`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (typeof entry.name !== 'string' || entry.name.trim() === '') {
|
|
111
|
+
throw new ConfigError(`Author "${key}" of ${source} has no name.`, {
|
|
112
|
+
hint: `Add "name": "…" — it is what the reader sees, the key is only how a page refers to it.`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const field of ['bio', 'avatar', 'url']) {
|
|
117
|
+
const given = entry[field];
|
|
118
|
+
if (given !== undefined && (typeof given !== 'string' || given.trim() === '')) {
|
|
119
|
+
throw new ConfigError(`The ${field} of author "${key}" of ${source} must be text.`, {
|
|
120
|
+
hint: `Either write "${field}": "…", or leave the field out.`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** @type {Author} */
|
|
126
|
+
const author = { key, name: entry.name.trim() };
|
|
127
|
+
if (typeof entry.bio === 'string') author.bio = entry.bio.trim();
|
|
128
|
+
if (typeof entry.avatar === 'string') author.avatar = entry.avatar.trim();
|
|
129
|
+
if (typeof entry.url === 'string') author.url = entry.url.trim();
|
|
130
|
+
table.set(key, author);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return table;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The authors of a page, in the order the frontmatter names them.
|
|
138
|
+
*
|
|
139
|
+
* A key the table does not describe is not an error: the name is shown as
|
|
140
|
+
* written. It is what lets a project name its authors before describing them,
|
|
141
|
+
* and what keeps pages written before the file working.
|
|
142
|
+
*
|
|
143
|
+
* @param {unknown} value `authors` from the frontmatter: one name or a list.
|
|
144
|
+
* @param {Map<string, Author>} [table]
|
|
145
|
+
* @returns {Author[]}
|
|
146
|
+
*/
|
|
147
|
+
export function resolvePageAuthors(value, table = new Map()) {
|
|
148
|
+
const list = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
|
|
149
|
+
|
|
150
|
+
return list
|
|
151
|
+
.map((entry) => String(entry).trim())
|
|
152
|
+
.filter(Boolean)
|
|
153
|
+
.map((key) => table.get(key) ?? { key, name: key });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Assembles what the head of a page shows, or nothing when it has none of it.
|
|
158
|
+
*
|
|
159
|
+
* @param {Record<string, any>} frontmatter
|
|
160
|
+
* @param {Map<string, Author>} [table]
|
|
161
|
+
* @param {string} [where] Page named in a date error.
|
|
162
|
+
* @returns {Byline | null}
|
|
163
|
+
* @throws {ConfigError} When a date cannot be read.
|
|
164
|
+
*/
|
|
165
|
+
export function buildByline(frontmatter, table = new Map(), where = 'this page') {
|
|
166
|
+
const authors = resolvePageAuthors(frontmatter?.authors, table);
|
|
167
|
+
const created = readDate(frontmatter?.date, 'date', where);
|
|
168
|
+
// The same field the sitemap reads for lastmod: one date, one meaning.
|
|
169
|
+
const updated = readDate(frontmatter?.modified, 'modified', where);
|
|
170
|
+
|
|
171
|
+
if (authors.length === 0 && !created && !updated) return null;
|
|
172
|
+
|
|
173
|
+
// An update on the day of writing says nothing: it is the same event.
|
|
174
|
+
return { authors, created, updated: updated && updated.iso !== created?.iso ? updated : null };
|
|
175
|
+
}
|
package/src/config.js
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
* @property {Version[]} versions At least one.
|
|
39
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
|
+
* @property {string} [authors] Path of a JSON describing the authors, read in each version folder.
|
|
41
42
|
* @property {boolean} globalComponents
|
|
42
43
|
* @property {boolean} scrollToTop Back-to-top button on every page.
|
|
43
44
|
* @property {{ enabled: boolean }} jsonld
|
|
@@ -70,6 +71,10 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
70
71
|
// The light / dark switch is on unless the project turns it off (ADR-014).
|
|
71
72
|
theme: { framework: 'tailwind', darkMode: 'class', toggle: true },
|
|
72
73
|
sidebar: 'auto',
|
|
74
|
+
// No author file by default: a page still shows the names its frontmatter
|
|
75
|
+
// gives. The file only adds what a name cannot carry — a biography, an
|
|
76
|
+
// avatar, a link.
|
|
77
|
+
authors: '',
|
|
73
78
|
globalComponents: true,
|
|
74
79
|
scrollToTop: true,
|
|
75
80
|
jsonld: { enabled: true },
|
|
@@ -240,6 +245,24 @@ export function normalizeConfig(userConfig) {
|
|
|
240
245
|
}
|
|
241
246
|
}
|
|
242
247
|
|
|
248
|
+
// Like the menu, the authors are described in each version's folder: a
|
|
249
|
+
// biography corrected in the beta must not rewrite a published version.
|
|
250
|
+
if (config.authors) {
|
|
251
|
+
const file = typeof config.authors === 'string' ? config.authors : '';
|
|
252
|
+
if (
|
|
253
|
+
!file.toLowerCase().endsWith('.json') ||
|
|
254
|
+
path.isAbsolute(file) ||
|
|
255
|
+
file.split('/').includes('..')
|
|
256
|
+
) {
|
|
257
|
+
throw new ConfigError(
|
|
258
|
+
`authors must be a .json file within each version folder: "${String(config.authors)}".`,
|
|
259
|
+
{
|
|
260
|
+
hint: "For instance authors: 'authors.json', read as docs/v1.0/authors.json for that version.",
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
243
266
|
// siteUrl feeds everything that must be absolute: canonical, JSON-LD.
|
|
244
267
|
// Invalid, it went through here and blew up further on as a raw TypeError,
|
|
245
268
|
// stack included; with an exotic scheme, it built a nonsensical prefix.
|
package/src/generator.js
CHANGED
|
@@ -20,10 +20,12 @@ import {
|
|
|
20
20
|
} from '@docpensieve/shared';
|
|
21
21
|
import Handlebars from 'handlebars';
|
|
22
22
|
|
|
23
|
+
import { buildAuthorTable, buildByline, readDate } from './authors.js';
|
|
23
24
|
import { Compiler } from './compiler.js';
|
|
24
25
|
import { resolveVersion } from './config.js';
|
|
25
26
|
import { DocLoader } from './loader.js';
|
|
26
27
|
import { buildFeed, buildRobots, buildSitemap } from './discovery.js';
|
|
28
|
+
import { imageSize } from './image-size.js';
|
|
27
29
|
import { minifyCss } from './minify-css.js';
|
|
28
30
|
import { SEARCH_SLUG, htmlToText, searchPageContent } from './search-index.js';
|
|
29
31
|
import { buildSidebar, buildSidebarFromDescription, collectSectionTitles } from './sidebar.js';
|
|
@@ -44,6 +46,12 @@ const SEARCH_SCRIPT = 'assets/search.js';
|
|
|
44
46
|
/** Source of that script, shipped with this package. */
|
|
45
47
|
const CLIENT_SEARCH = fileURLToPath(new URL('../client/search.js', import.meta.url));
|
|
46
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Targets a preview keeps as they are: another site, an anchor, a data URI.
|
|
51
|
+
* Same rule as the compiler and the components apply to a link.
|
|
52
|
+
*/
|
|
53
|
+
const EXTERNAL_PREVIEW = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
|
|
54
|
+
|
|
47
55
|
/**
|
|
48
56
|
* Where each project image is written in a version, before its extension.
|
|
49
57
|
* @type {Record<'logo' | 'favicon' | 'socialImage', string>}
|
|
@@ -153,7 +161,9 @@ export class SiteGenerator {
|
|
|
153
161
|
* compiler?: Compiler,
|
|
154
162
|
* onPage?: (page: {
|
|
155
163
|
* url: string, dirUrl?: string, basePath: string,
|
|
156
|
-
* filepath?: string, sourceDir?: string,
|
|
164
|
+
* filepath?: string, sourceDir?: string, slug?: string,
|
|
165
|
+
* pages?: { url: string, slug: string, title: string, description?: string,
|
|
166
|
+
* preview?: string, modified?: { iso: string, label: string } }[],
|
|
157
167
|
* }) => void,
|
|
158
168
|
* }} [deps]
|
|
159
169
|
* Global components and the theme are injected rather than imported:
|
|
@@ -245,6 +255,10 @@ export class SiteGenerator {
|
|
|
245
255
|
|
|
246
256
|
// What every page of the version shares, the search page included.
|
|
247
257
|
const searchUrl = this.config.search !== false ? joinUrl(versionBase, SEARCH_SLUG) : '';
|
|
258
|
+
// Described once per version, like the menu: the same authors serve every
|
|
259
|
+
// page, and a biography corrected in one version leaves the others alone.
|
|
260
|
+
const authors = await this.#readAuthors(sourceDir, version.folder, versionBase);
|
|
261
|
+
|
|
248
262
|
const shell = {
|
|
249
263
|
lang: this.config.lang ?? 'en',
|
|
250
264
|
// A fixed scheme is a class on <html>, which the skins and the dark
|
|
@@ -279,6 +293,39 @@ export class SiteGenerator {
|
|
|
279
293
|
scrollToTop: this.config.scrollToTop !== false,
|
|
280
294
|
notice,
|
|
281
295
|
};
|
|
296
|
+
// Every page of the version, for the components that list pages: one of
|
|
297
|
+
// them renders a single page at a time and could never gather this by
|
|
298
|
+
// itself. Targets are resolved here, each against the folder of the page
|
|
299
|
+
// that declares it — a preview written in one page is not relative to the
|
|
300
|
+
// page that shows it in a card.
|
|
301
|
+
const pages = docs.map((doc) => {
|
|
302
|
+
const folder = dirPathToSlug(path.relative(sourceDir, path.dirname(doc.path)));
|
|
303
|
+
const dirUrl = joinUrl(versionBase, folder);
|
|
304
|
+
const target = String(doc.frontmatter.preview ?? '');
|
|
305
|
+
const absolute = target.startsWith('/');
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
url: pageUrl(doc),
|
|
309
|
+
slug: doc.slug,
|
|
310
|
+
title: String(doc.frontmatter.title ?? this.config.projectName),
|
|
311
|
+
description: doc.frontmatter.description ? String(doc.frontmatter.description) : undefined,
|
|
312
|
+
preview:
|
|
313
|
+
target === '' || EXTERNAL_PREVIEW.test(target)
|
|
314
|
+
? target || undefined
|
|
315
|
+
: new URL(
|
|
316
|
+
absolute ? target.slice(1) : target,
|
|
317
|
+
`https://docpensieve.invalid${absolute ? versionBase : dirUrl}`,
|
|
318
|
+
).pathname,
|
|
319
|
+
// The same date the byline and the sitemap read, formatted once.
|
|
320
|
+
modified:
|
|
321
|
+
readDate(
|
|
322
|
+
doc.frontmatter.modified ?? doc.frontmatter.date,
|
|
323
|
+
'modified',
|
|
324
|
+
doc.slug || 'the home page',
|
|
325
|
+
) ?? undefined,
|
|
326
|
+
};
|
|
327
|
+
});
|
|
328
|
+
|
|
282
329
|
/** @type {{ title: string, url: string, description: string, text: string }[]} */
|
|
283
330
|
const entries = [];
|
|
284
331
|
|
|
@@ -293,7 +340,15 @@ export class SiteGenerator {
|
|
|
293
340
|
const dirUrl = joinUrl(versionBase, folder);
|
|
294
341
|
// Components need to know which page they render: a link they produce
|
|
295
342
|
// escapes the compiler plugins (ADR-006).
|
|
296
|
-
this.deps.onPage?.({
|
|
343
|
+
this.deps.onPage?.({
|
|
344
|
+
url,
|
|
345
|
+
dirUrl,
|
|
346
|
+
basePath: versionBase,
|
|
347
|
+
filepath: doc.path,
|
|
348
|
+
sourceDir,
|
|
349
|
+
slug: doc.slug,
|
|
350
|
+
pages,
|
|
351
|
+
});
|
|
297
352
|
|
|
298
353
|
const { html, toc, preloads } = await this.compiler.compile(doc.content, {
|
|
299
354
|
filepath: doc.path,
|
|
@@ -303,11 +358,19 @@ export class SiteGenerator {
|
|
|
303
358
|
sourceDir,
|
|
304
359
|
});
|
|
305
360
|
|
|
361
|
+
// Who wrote the page, and when. Read before the structured data, which
|
|
362
|
+
// describes the same people: the page and its metadata must not
|
|
363
|
+
// disagree about an author.
|
|
364
|
+
const credits = buildByline(doc.frontmatter, authors, doc.slug || 'the home page');
|
|
365
|
+
|
|
306
366
|
const jsonld = new StructuredDataBuilder(doc.frontmatter, url, this.config, {
|
|
307
367
|
breadcrumbTitles,
|
|
308
368
|
basePath: versionBase,
|
|
309
369
|
dirUrl,
|
|
310
370
|
logo: images.logo,
|
|
371
|
+
// A described author carries a biography and a link, which a bare
|
|
372
|
+
// name in the frontmatter cannot.
|
|
373
|
+
authors: credits?.authors ?? [],
|
|
311
374
|
}).toScriptTag();
|
|
312
375
|
|
|
313
376
|
// A home page has neither menu nor table of contents: those are reading
|
|
@@ -321,6 +384,17 @@ export class SiteGenerator {
|
|
|
321
384
|
text: htmlToText(html),
|
|
322
385
|
});
|
|
323
386
|
|
|
387
|
+
const byline =
|
|
388
|
+
wide || !credits
|
|
389
|
+
? null
|
|
390
|
+
: {
|
|
391
|
+
authors: credits.authors,
|
|
392
|
+
dates: [
|
|
393
|
+
credits.created && { prefix: 'Written', ...credits.created },
|
|
394
|
+
credits.updated && { prefix: 'Updated', ...credits.updated },
|
|
395
|
+
].filter(Boolean),
|
|
396
|
+
};
|
|
397
|
+
|
|
324
398
|
const page = layout({
|
|
325
399
|
...shell,
|
|
326
400
|
title: documentTitle(doc.frontmatter.title, this.config.projectName),
|
|
@@ -332,6 +406,7 @@ export class SiteGenerator {
|
|
|
332
406
|
// same content, two addresses, and the wrong one comes up. "follow"
|
|
333
407
|
// still lets its links be followed.
|
|
334
408
|
noindex: version.prerelease === true,
|
|
409
|
+
byline,
|
|
335
410
|
sidebar: wide ? [] : sidebar,
|
|
336
411
|
toc: wide ? [] : toc,
|
|
337
412
|
preloads,
|
|
@@ -407,6 +482,86 @@ export class SiteGenerator {
|
|
|
407
482
|
};
|
|
408
483
|
}
|
|
409
484
|
|
|
485
|
+
/**
|
|
486
|
+
* Reads the authors a version describes, their avatars resolved.
|
|
487
|
+
*
|
|
488
|
+
* The file is optional: without it, a page still shows the names its
|
|
489
|
+
* frontmatter gives. Named in the configuration but missing, it is an
|
|
490
|
+
* error — leaving every biography out without a word would be worse.
|
|
491
|
+
*
|
|
492
|
+
* @param {string} sourceDir Source folder of the version.
|
|
493
|
+
* @param {string} folder The version's folder, as the configuration names it.
|
|
494
|
+
* @param {string} versionBase URL of the version.
|
|
495
|
+
* @returns {Promise<Map<string, import('./authors.js').Author>>} Authors by
|
|
496
|
+
* key, their avatars resolved to a URL and measured.
|
|
497
|
+
* @throws {ConfigError} Missing file, invalid JSON, wrong author, missing avatar.
|
|
498
|
+
*/
|
|
499
|
+
async #readAuthors(sourceDir, folder, versionBase) {
|
|
500
|
+
const name = this.config.authors;
|
|
501
|
+
if (!name) return new Map();
|
|
502
|
+
|
|
503
|
+
const source = `${folder}/${name}`;
|
|
504
|
+
|
|
505
|
+
let text;
|
|
506
|
+
try {
|
|
507
|
+
text = await readFile(path.join(sourceDir, ...name.split('/')), 'utf8');
|
|
508
|
+
} catch (cause) {
|
|
509
|
+
throw new ConfigError(`No author description at ${source}.`, {
|
|
510
|
+
cause,
|
|
511
|
+
hint: `Each version describes its own authors: create ${source}, or drop the authors field.`,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
let description;
|
|
516
|
+
try {
|
|
517
|
+
description = JSON.parse(text);
|
|
518
|
+
} catch (cause) {
|
|
519
|
+
throw new ConfigError(
|
|
520
|
+
`${source} is not valid JSON: ${/** @type {Error} */ (cause).message}`,
|
|
521
|
+
{
|
|
522
|
+
cause,
|
|
523
|
+
hint: 'A trailing comma or a missing quote is enough: open it in an editor that checks JSON.',
|
|
524
|
+
},
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const table = buildAuthorTable(description, { source });
|
|
529
|
+
|
|
530
|
+
// Resolved once per version, not once per page: the same handful of
|
|
531
|
+
// images would otherwise be read and measured on every page.
|
|
532
|
+
for (const author of table.values()) {
|
|
533
|
+
if (!author.avatar) continue;
|
|
534
|
+
const segments = author.avatar.split('/').filter(Boolean);
|
|
535
|
+
|
|
536
|
+
let bytes;
|
|
537
|
+
try {
|
|
538
|
+
bytes = await readFile(path.join(sourceDir, ...segments));
|
|
539
|
+
} catch (cause) {
|
|
540
|
+
throw new ConfigError(
|
|
541
|
+
`No avatar at ${folder}/${author.avatar}, declared by author "${author.key}".`,
|
|
542
|
+
{
|
|
543
|
+
cause,
|
|
544
|
+
hint: 'The path starts at the version folder, so that the image travels with the version.',
|
|
545
|
+
},
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const entry = /** @type {Record<string, any>} */ (author);
|
|
550
|
+
entry.avatarUrl =
|
|
551
|
+
joinUrl(versionBase, segments.slice(0, -1).join('/')) + segments[segments.length - 1];
|
|
552
|
+
|
|
553
|
+
// Dimensions spare the reader a jump when the image arrives, as for
|
|
554
|
+
// every other image of a page.
|
|
555
|
+
const size = imageSize(bytes, path.extname(author.avatar));
|
|
556
|
+
if (size) {
|
|
557
|
+
entry.avatarWidth = size.width;
|
|
558
|
+
entry.avatarHeight = size.height;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return table;
|
|
563
|
+
}
|
|
564
|
+
|
|
410
565
|
/**
|
|
411
566
|
* Reads the sidebar description of a version.
|
|
412
567
|
*
|
|
@@ -762,6 +917,8 @@ export class SiteGenerator {
|
|
|
762
917
|
|
|
763
918
|
// The sidebar description is read by the build, not published.
|
|
764
919
|
if (this.config.sidebar !== 'auto' && readable === this.config.sidebar) continue;
|
|
920
|
+
// The author descriptions too: they feed the pages, they are not pages.
|
|
921
|
+
if (this.config.authors && readable === this.config.authors) continue;
|
|
765
922
|
if (DOC_EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
|
|
766
923
|
|
|
767
924
|
const destination = path.join(target, ...assetPathToSlug(next).split('/'));
|
package/src/index.js
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* @typedef {import('./compiler.js').TocEntry} TocEntry
|
|
20
20
|
* @typedef {import('./compiler.js').Preload} Preload
|
|
21
21
|
* @typedef {import('./sidebar.js').SidebarNode} SidebarNode
|
|
22
|
+
* @typedef {import('./authors.js').Author} Author
|
|
23
|
+
* @typedef {import('./authors.js').Byline} Byline
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
26
|
export {
|
|
@@ -34,3 +36,4 @@ export { StructuredDataBuilder } from './structured-data.js';
|
|
|
34
36
|
export { SiteGenerator } from './generator.js';
|
|
35
37
|
export { buildSidebar, buildSidebarFromDescription, collectSectionTitles } from './sidebar.js';
|
|
36
38
|
export { buildFeed, buildRobots, buildSitemap } from './discovery.js';
|
|
39
|
+
export { buildAuthorTable, buildByline, readDate, resolvePageAuthors } from './authors.js';
|
package/src/structured-data.js
CHANGED
|
@@ -70,15 +70,29 @@ function toList(value) {
|
|
|
70
70
|
/**
|
|
71
71
|
* Lists the authors as `Person` nodes.
|
|
72
72
|
*
|
|
73
|
-
* @param {unknown} authors Single string or array of names.
|
|
74
|
-
* @
|
|
73
|
+
* @param {unknown} authors Single string or array of names, or of keys.
|
|
74
|
+
* @param {{ key: string, name: string, bio?: string, url?: string }[]} [described]
|
|
75
|
+
* Authors the version describes, which carry what a bare name cannot.
|
|
76
|
+
* @returns {Record<string, any>[]}
|
|
75
77
|
*/
|
|
76
|
-
function toPersons(authors) {
|
|
78
|
+
function toPersons(authors, described = []) {
|
|
79
|
+
const byKey = new Map(described.map((author) => [author.key, author]));
|
|
77
80
|
const list = Array.isArray(authors) ? authors : authors ? [authors] : [];
|
|
78
81
|
return list
|
|
79
82
|
.map((name) => String(name).trim())
|
|
80
83
|
.filter(Boolean)
|
|
81
|
-
.map((name) =>
|
|
84
|
+
.map((name) => {
|
|
85
|
+
const found = byKey.get(name);
|
|
86
|
+
// Undescribed, the frontmatter entry is the name itself: that is what
|
|
87
|
+
// keeps a page written before the description file working.
|
|
88
|
+
if (!found) return { '@type': 'Person', name };
|
|
89
|
+
|
|
90
|
+
/** @type {Record<string, any>} */
|
|
91
|
+
const person = { '@type': 'Person', name: found.name };
|
|
92
|
+
if (found.bio) person.description = found.bio;
|
|
93
|
+
if (found.url) person.url = found.url;
|
|
94
|
+
return person;
|
|
95
|
+
});
|
|
82
96
|
}
|
|
83
97
|
|
|
84
98
|
/** Assembles a schema.org graph for a page. */
|
|
@@ -87,7 +101,7 @@ export class StructuredDataBuilder {
|
|
|
87
101
|
* @param {Record<string, any>} frontmatter Page frontmatter.
|
|
88
102
|
* @param {string} url Page URL on the site (`'/guide/install/'`).
|
|
89
103
|
* @param {Record<string, any>} config Normalised project config.
|
|
90
|
-
* @param {{ breadcrumbTitles?: Record<string, string>, basePath?: string, dirUrl?: string, logo?: string }} [options]
|
|
104
|
+
* @param {{ breadcrumbTitles?: Record<string, string>, basePath?: string, dirUrl?: string, logo?: string, authors?: { key: string, name: string, bio?: string, url?: string }[] }} [options]
|
|
91
105
|
* `breadcrumbTitles` maps a folder slug to its real title, so that the
|
|
92
106
|
* breadcrumb shows “Café Guide” rather than “Cafe guide”. `basePath` is
|
|
93
107
|
* the site root from which crumbs are counted: the generator sets
|
|
@@ -242,7 +256,7 @@ export class StructuredDataBuilder {
|
|
|
242
256
|
}
|
|
243
257
|
if (modified) node.dateModified = modified;
|
|
244
258
|
|
|
245
|
-
const authors = toPersons(this.frontmatter.authors);
|
|
259
|
+
const authors = toPersons(this.frontmatter.authors, this.options?.authors ?? []);
|
|
246
260
|
if (authors.length > 0) node.author = authors;
|
|
247
261
|
|
|
248
262
|
// A single tag is written without brackets, like a single author.
|
package/templates/layout.hbs
CHANGED
|
@@ -104,6 +104,29 @@
|
|
|
104
104
|
The current version is <a href="{{notice.url}}">{{notice.name}}</a>.
|
|
105
105
|
</aside>
|
|
106
106
|
{{/if}}
|
|
107
|
+
{{!-- Who wrote the page, and when. Absent when the page says neither,
|
|
108
|
+
and on a home page, where a byline under an entrance hall means
|
|
109
|
+
nothing. --}}
|
|
110
|
+
{{#if byline}}
|
|
111
|
+
<div class="{{{cls.byline}}}">
|
|
112
|
+
{{#if byline.authors.length}}
|
|
113
|
+
<ul class="{{{cls.bylineAuthors}}}">
|
|
114
|
+
{{#each byline.authors}}
|
|
115
|
+
<li class="{{{@root.cls.bylineAuthor}}}">
|
|
116
|
+
{{#if avatarUrl}}<img class="{{{@root.cls.bylineAvatar}}}" src="{{avatarUrl}}" alt="" {{#if avatarWidth}}width="{{avatarWidth}}" height="{{avatarHeight}}" {{/if}}loading="lazy" decoding="async" />{{/if}}
|
|
117
|
+
<span class="{{{@root.cls.bylineName}}}">{{#if url}}<a href="{{url}}">{{name}}</a>{{else}}{{name}}{{/if}}</span>
|
|
118
|
+
{{#if bio}}<span class="{{{@root.cls.bylineBio}}}">{{bio}}</span>{{/if}}
|
|
119
|
+
</li>
|
|
120
|
+
{{/each}}
|
|
121
|
+
</ul>
|
|
122
|
+
{{/if}}
|
|
123
|
+
{{#if byline.dates.length}}
|
|
124
|
+
<p class="{{{cls.bylineDates}}}">
|
|
125
|
+
{{#each byline.dates}}<span>{{prefix}} <time datetime="{{iso}}">{{label}}</time></span>{{/each}}
|
|
126
|
+
</p>
|
|
127
|
+
{{/if}}
|
|
128
|
+
</div>
|
|
129
|
+
{{/if}}
|
|
107
130
|
<article class="{{{cls.article}}}">{{{content}}}</article>
|
|
108
131
|
</main>
|
|
109
132
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The byline of a page: who wrote it, and when it was written or last changed.
|
|
3
|
+
*
|
|
4
|
+
* The frontmatter names the authors; a JSON file of the version describes
|
|
5
|
+
* them. The split matters: a name repeated on forty pages would carry its
|
|
6
|
+
* biography forty times, and correcting it would mean forty edits.
|
|
7
|
+
*
|
|
8
|
+
* This module reads no file. The generator hands it the parsed description,
|
|
9
|
+
* as it does for the menu: without that, the engine could not be tested
|
|
10
|
+
* without a disk.
|
|
11
|
+
*
|
|
12
|
+
* @module @docpensieve/core/authors
|
|
13
|
+
*/
|
|
14
|
+
export type Author = {
|
|
15
|
+
/**
|
|
16
|
+
* Key used by the frontmatter.
|
|
17
|
+
*/
|
|
18
|
+
key: string;
|
|
19
|
+
/**
|
|
20
|
+
* Name shown to the reader.
|
|
21
|
+
*/
|
|
22
|
+
name: string;
|
|
23
|
+
/**
|
|
24
|
+
* One or two sentences, shown under the name.
|
|
25
|
+
*/
|
|
26
|
+
bio?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Image path, relative to the version's folder.
|
|
29
|
+
*/
|
|
30
|
+
avatar?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Personal site or profile.
|
|
33
|
+
*/
|
|
34
|
+
url?: string;
|
|
35
|
+
};
|
|
36
|
+
export type Byline = {
|
|
37
|
+
authors: Author[];
|
|
38
|
+
created: {
|
|
39
|
+
iso: string;
|
|
40
|
+
label: string;
|
|
41
|
+
} | null;
|
|
42
|
+
updated: {
|
|
43
|
+
iso: string;
|
|
44
|
+
label: string;
|
|
45
|
+
} | null;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Reads a date of the frontmatter, and gives it in both forms: the machine one
|
|
49
|
+
* for `<time datetime>`, the readable one for the reader.
|
|
50
|
+
*
|
|
51
|
+
* A date is often written unquoted in YAML, which parses it as a Date; quoted,
|
|
52
|
+
* it arrives as text. Both are accepted, anything unreadable is refused rather
|
|
53
|
+
* than shown as `Invalid Date`.
|
|
54
|
+
*
|
|
55
|
+
* @param {unknown} value
|
|
56
|
+
* @param {string} field Name of the field, for the error message.
|
|
57
|
+
* @param {string} where Page the date comes from.
|
|
58
|
+
* @returns {{ iso: string, label: string } | null} `null` when absent.
|
|
59
|
+
* @throws {ConfigError} When the value is not a date.
|
|
60
|
+
*/
|
|
61
|
+
export declare function readDate(value: unknown, field: string, where: string): {
|
|
62
|
+
iso: string;
|
|
63
|
+
label: string;
|
|
64
|
+
} | null;
|
|
65
|
+
/**
|
|
66
|
+
* Turns the JSON description of a version into a table of authors.
|
|
67
|
+
*
|
|
68
|
+
* @param {unknown} description Parsed content of the file.
|
|
69
|
+
* @param {{ source: string }} options `source` names the file in errors.
|
|
70
|
+
* @returns {Map<string, Author>}
|
|
71
|
+
* @throws {ConfigError} When the shape is wrong, naming the offending entry.
|
|
72
|
+
*/
|
|
73
|
+
export declare function buildAuthorTable(description: unknown, { source }: {
|
|
74
|
+
source: string;
|
|
75
|
+
}): Map<string, Author>;
|
|
76
|
+
/**
|
|
77
|
+
* The authors of a page, in the order the frontmatter names them.
|
|
78
|
+
*
|
|
79
|
+
* A key the table does not describe is not an error: the name is shown as
|
|
80
|
+
* written. It is what lets a project name its authors before describing them,
|
|
81
|
+
* and what keeps pages written before the file working.
|
|
82
|
+
*
|
|
83
|
+
* @param {unknown} value `authors` from the frontmatter: one name or a list.
|
|
84
|
+
* @param {Map<string, Author>} [table]
|
|
85
|
+
* @returns {Author[]}
|
|
86
|
+
*/
|
|
87
|
+
export declare function resolvePageAuthors(value: unknown, table?: Map<string, Author>): Author[];
|
|
88
|
+
/**
|
|
89
|
+
* Assembles what the head of a page shows, or nothing when it has none of it.
|
|
90
|
+
*
|
|
91
|
+
* @param {Record<string, any>} frontmatter
|
|
92
|
+
* @param {Map<string, Author>} [table]
|
|
93
|
+
* @param {string} [where] Page named in a date error.
|
|
94
|
+
* @returns {Byline | null}
|
|
95
|
+
* @throws {ConfigError} When a date cannot be read.
|
|
96
|
+
*/
|
|
97
|
+
export declare function buildByline(frontmatter: Record<string, any>, table?: Map<string, Author>, where?: string): Byline | null;
|
package/types/config.d.ts
CHANGED
|
@@ -71,6 +71,10 @@ export type DocPensieveConfig = {
|
|
|
71
71
|
* `'auto'`, or the path of a description.
|
|
72
72
|
*/
|
|
73
73
|
sidebar: string;
|
|
74
|
+
/**
|
|
75
|
+
* Path of a JSON describing the authors, read in each version folder.
|
|
76
|
+
*/
|
|
77
|
+
authors?: string;
|
|
74
78
|
globalComponents: boolean;
|
|
75
79
|
/**
|
|
76
80
|
* Back-to-top button on every page.
|
|
@@ -137,6 +141,7 @@ export type DocPensieveConfig = {
|
|
|
137
141
|
* @property {Version[]} versions At least one.
|
|
138
142
|
* @property {{ framework: string, darkMode?: string, toggle?: boolean, tokens?: Record<string, string>, css?: string, source?: string }} theme
|
|
139
143
|
* @property {string} sidebar `'auto'`, or the path of a description.
|
|
144
|
+
* @property {string} [authors] Path of a JSON describing the authors, read in each version folder.
|
|
140
145
|
* @property {boolean} globalComponents
|
|
141
146
|
* @property {boolean} scrollToTop Back-to-top button on every page.
|
|
142
147
|
* @property {{ enabled: boolean }} jsonld
|
package/types/generator.d.ts
CHANGED
|
@@ -20,6 +20,18 @@ export declare class SiteGenerator {
|
|
|
20
20
|
basePath: string;
|
|
21
21
|
filepath?: string;
|
|
22
22
|
sourceDir?: string;
|
|
23
|
+
slug?: string;
|
|
24
|
+
pages?: {
|
|
25
|
+
url: string;
|
|
26
|
+
slug: string;
|
|
27
|
+
title: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
preview?: string;
|
|
30
|
+
modified?: {
|
|
31
|
+
iso: string;
|
|
32
|
+
label: string;
|
|
33
|
+
};
|
|
34
|
+
}[];
|
|
23
35
|
}) => void;
|
|
24
36
|
};
|
|
25
37
|
loader: DocLoader;
|
|
@@ -33,7 +45,9 @@ export declare class SiteGenerator {
|
|
|
33
45
|
* compiler?: Compiler,
|
|
34
46
|
* onPage?: (page: {
|
|
35
47
|
* url: string, dirUrl?: string, basePath: string,
|
|
36
|
-
* filepath?: string, sourceDir?: string,
|
|
48
|
+
* filepath?: string, sourceDir?: string, slug?: string,
|
|
49
|
+
* pages?: { url: string, slug: string, title: string, description?: string,
|
|
50
|
+
* preview?: string, modified?: { iso: string, label: string } }[],
|
|
37
51
|
* }) => void,
|
|
38
52
|
* }} [deps]
|
|
39
53
|
* Global components and the theme are injected rather than imported:
|
|
@@ -54,6 +68,18 @@ export declare class SiteGenerator {
|
|
|
54
68
|
basePath: string;
|
|
55
69
|
filepath?: string;
|
|
56
70
|
sourceDir?: string;
|
|
71
|
+
slug?: string;
|
|
72
|
+
pages?: {
|
|
73
|
+
url: string;
|
|
74
|
+
slug: string;
|
|
75
|
+
title: string;
|
|
76
|
+
description?: string;
|
|
77
|
+
preview?: string;
|
|
78
|
+
modified?: {
|
|
79
|
+
iso: string;
|
|
80
|
+
label: string;
|
|
81
|
+
};
|
|
82
|
+
}[];
|
|
57
83
|
}) => void;
|
|
58
84
|
});
|
|
59
85
|
/**
|
package/types/index.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export type CompileResult = import('./compiler.js').CompileResult;
|
|
|
14
14
|
export type TocEntry = import('./compiler.js').TocEntry;
|
|
15
15
|
export type Preload = import('./compiler.js').Preload;
|
|
16
16
|
export type SidebarNode = import('./sidebar.js').SidebarNode;
|
|
17
|
+
export type Author = import('./authors.js').Author;
|
|
18
|
+
export type Byline = import('./authors.js').Byline;
|
|
17
19
|
/**
|
|
18
20
|
* Engine types, re-exported for consumers of the published package: without
|
|
19
21
|
* this they would only be reachable through an internal path.
|
|
@@ -25,6 +27,8 @@ export type SidebarNode = import('./sidebar.js').SidebarNode;
|
|
|
25
27
|
* @typedef {import('./compiler.js').TocEntry} TocEntry
|
|
26
28
|
* @typedef {import('./compiler.js').Preload} Preload
|
|
27
29
|
* @typedef {import('./sidebar.js').SidebarNode} SidebarNode
|
|
30
|
+
* @typedef {import('./authors.js').Author} Author
|
|
31
|
+
* @typedef {import('./authors.js').Byline} Byline
|
|
28
32
|
*/
|
|
29
33
|
export { DEFAULT_CONFIG, defineConfig, loadConfig, normalizeConfig, resolveVersion, } from './config.js';
|
|
30
34
|
export { DocLoader } from './loader.js';
|
|
@@ -33,3 +37,4 @@ export { StructuredDataBuilder } from './structured-data.js';
|
|
|
33
37
|
export { SiteGenerator } from './generator.js';
|
|
34
38
|
export { buildSidebar, buildSidebarFromDescription, collectSectionTitles } from './sidebar.js';
|
|
35
39
|
export { buildFeed, buildRobots, buildSitemap } from './discovery.js';
|
|
40
|
+
export { buildAuthorTable, buildByline, readDate, resolvePageAuthors } from './authors.js';
|
|
@@ -24,6 +24,12 @@ export declare class StructuredDataBuilder {
|
|
|
24
24
|
basePath?: string;
|
|
25
25
|
dirUrl?: string;
|
|
26
26
|
logo?: string;
|
|
27
|
+
authors?: {
|
|
28
|
+
key: string;
|
|
29
|
+
name: string;
|
|
30
|
+
bio?: string;
|
|
31
|
+
url?: string;
|
|
32
|
+
}[];
|
|
27
33
|
};
|
|
28
34
|
breadcrumbTitles: Record<string, string>;
|
|
29
35
|
basePath: string;
|
|
@@ -33,7 +39,7 @@ export declare class StructuredDataBuilder {
|
|
|
33
39
|
* @param {Record<string, any>} frontmatter Page frontmatter.
|
|
34
40
|
* @param {string} url Page URL on the site (`'/guide/install/'`).
|
|
35
41
|
* @param {Record<string, any>} config Normalised project config.
|
|
36
|
-
* @param {{ breadcrumbTitles?: Record<string, string>, basePath?: string, dirUrl?: string, logo?: string }} [options]
|
|
42
|
+
* @param {{ breadcrumbTitles?: Record<string, string>, basePath?: string, dirUrl?: string, logo?: string, authors?: { key: string, name: string, bio?: string, url?: string }[] }} [options]
|
|
37
43
|
* `breadcrumbTitles` maps a folder slug to its real title, so that the
|
|
38
44
|
* breadcrumb shows “Café Guide” rather than “Cafe guide”. `basePath` is
|
|
39
45
|
* the site root from which crumbs are counted: the generator sets
|
|
@@ -46,6 +52,12 @@ export declare class StructuredDataBuilder {
|
|
|
46
52
|
basePath?: string;
|
|
47
53
|
dirUrl?: string;
|
|
48
54
|
logo?: string;
|
|
55
|
+
authors?: {
|
|
56
|
+
key: string;
|
|
57
|
+
name: string;
|
|
58
|
+
bio?: string;
|
|
59
|
+
url?: string;
|
|
60
|
+
}[];
|
|
49
61
|
});
|
|
50
62
|
/**
|
|
51
63
|
* Builds the page's schema.org graph.
|