@docpensieve/core 0.3.0-beta.1 → 0.3.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/authors.js +175 -0
- package/src/config.js +65 -0
- package/src/generator.js +209 -11
- package/src/index.js +3 -0
- package/src/structured-data.js +20 -6
- package/templates/header-menu.hbs +26 -0
- package/templates/layout.hbs +45 -16
- package/types/authors.d.ts +97 -0
- package/types/config.d.ts +15 -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.0
|
|
3
|
+
"version": "0.3.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.3.0
|
|
24
|
+
"@docpensieve/shared": "0.3.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/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,9 @@ 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.
|
|
42
|
+
* @property {{ label: string, href: string, version?: string }[]} [headerLinks]
|
|
43
|
+
* Links of the header, beside the version switcher.
|
|
41
44
|
* @property {boolean} globalComponents
|
|
42
45
|
* @property {boolean} scrollToTop Back-to-top button on every page.
|
|
43
46
|
* @property {{ enabled: boolean }} jsonld
|
|
@@ -70,6 +73,13 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
70
73
|
// The light / dark switch is on unless the project turns it off (ADR-014).
|
|
71
74
|
theme: { framework: 'tailwind', darkMode: 'class', toggle: true },
|
|
72
75
|
sidebar: 'auto',
|
|
76
|
+
// No author file by default: a page still shows the names its frontmatter
|
|
77
|
+
// gives. The file only adds what a name cannot carry — a biography, an
|
|
78
|
+
// avatar, a link.
|
|
79
|
+
authors: '',
|
|
80
|
+
// No link in the header by default: the version switcher and the search
|
|
81
|
+
// field are there already.
|
|
82
|
+
headerLinks: [],
|
|
73
83
|
globalComponents: true,
|
|
74
84
|
scrollToTop: true,
|
|
75
85
|
jsonld: { enabled: true },
|
|
@@ -240,6 +250,61 @@ export function normalizeConfig(userConfig) {
|
|
|
240
250
|
}
|
|
241
251
|
}
|
|
242
252
|
|
|
253
|
+
// Like the menu, the authors are described in each version's folder: a
|
|
254
|
+
// biography corrected in the beta must not rewrite a published version.
|
|
255
|
+
if (config.authors) {
|
|
256
|
+
const file = typeof config.authors === 'string' ? config.authors : '';
|
|
257
|
+
if (
|
|
258
|
+
!file.toLowerCase().endsWith('.json') ||
|
|
259
|
+
path.isAbsolute(file) ||
|
|
260
|
+
file.split('/').includes('..')
|
|
261
|
+
) {
|
|
262
|
+
throw new ConfigError(
|
|
263
|
+
`authors must be a .json file within each version folder: "${String(config.authors)}".`,
|
|
264
|
+
{
|
|
265
|
+
hint: "For instance authors: 'authors.json', read as docs/v1.0/authors.json for that version.",
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Links of the header: site navigation, not page content. A target starts
|
|
272
|
+
// from the root of a version, or names another site; a relative one would
|
|
273
|
+
// change meaning from page to page. A link may name the version it lives in,
|
|
274
|
+
// so that a section written in one version is reachable from all of them.
|
|
275
|
+
if (config.headerLinks !== undefined) {
|
|
276
|
+
if (!Array.isArray(config.headerLinks)) {
|
|
277
|
+
throw new ConfigError('headerLinks must be a list of links.', {
|
|
278
|
+
hint: "For instance headerLinks: [{ label: 'Examples', href: '/examples/' }].",
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
const slugs = new Set(config.versions.map((version) => version.slug));
|
|
282
|
+
for (const link of config.headerLinks) {
|
|
283
|
+
if (!link || typeof link.label !== 'string' || link.label.trim() === '') {
|
|
284
|
+
throw new ConfigError(`A header link has no label: ${JSON.stringify(link)}.`, {
|
|
285
|
+
hint: "Write { label: 'Examples', href: '/examples/' }.",
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
if (
|
|
289
|
+
typeof link.href !== 'string' ||
|
|
290
|
+
!(link.href.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(link.href))
|
|
291
|
+
) {
|
|
292
|
+
throw new ConfigError(
|
|
293
|
+
`The header link "${link.label}" needs an absolute target: "${String(link.href)}".`,
|
|
294
|
+
{
|
|
295
|
+
hint: "Start from the root of the version — '/examples/' — or give a full address.",
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (link.version !== undefined && !slugs.has(link.version)) {
|
|
300
|
+
throw new ConfigError(
|
|
301
|
+
`The header link "${link.label}" names an unknown version: "${String(link.version)}".`,
|
|
302
|
+
{ hint: `Declared versions: ${[...slugs].join(', ')}.` },
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
243
308
|
// siteUrl feeds everything that must be absolute: canonical, JSON-LD.
|
|
244
309
|
// Invalid, it went through here and blew up further on as a raw TypeError,
|
|
245
310
|
// 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,25 @@ 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
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Tags of a page, as the reader sees them: in the order written, blanks and
|
|
57
|
+
* repeats dropped. A single tag may be written without brackets, as a single
|
|
58
|
+
* author may — accepting only a list lost the value without a word.
|
|
59
|
+
*
|
|
60
|
+
* @param {unknown} value `tags` from the frontmatter.
|
|
61
|
+
* @returns {string[]}
|
|
62
|
+
*/
|
|
63
|
+
function pageTags(value) {
|
|
64
|
+
const list = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
|
|
65
|
+
return [...new Set(list.map((tag) => String(tag).trim()).filter(Boolean))];
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
/**
|
|
48
69
|
* Where each project image is written in a version, before its extension.
|
|
49
70
|
* @type {Record<'logo' | 'favicon' | 'socialImage', string>}
|
|
@@ -153,7 +174,9 @@ export class SiteGenerator {
|
|
|
153
174
|
* compiler?: Compiler,
|
|
154
175
|
* onPage?: (page: {
|
|
155
176
|
* url: string, dirUrl?: string, basePath: string,
|
|
156
|
-
* filepath?: string, sourceDir?: string,
|
|
177
|
+
* filepath?: string, sourceDir?: string, slug?: string,
|
|
178
|
+
* pages?: { url: string, slug: string, title: string, description?: string,
|
|
179
|
+
* preview?: string, modified?: { iso: string, label: string } }[],
|
|
157
180
|
* }) => void,
|
|
158
181
|
* }} [deps]
|
|
159
182
|
* Global components and the theme are injected rather than imported:
|
|
@@ -217,10 +240,11 @@ export class SiteGenerator {
|
|
|
217
240
|
|
|
218
241
|
// 'auto' follows the file tree; otherwise each version describes its menu
|
|
219
242
|
// in a file of its own, since each has its own pages.
|
|
220
|
-
const
|
|
243
|
+
const described =
|
|
221
244
|
this.config.sidebar && this.config.sidebar !== 'auto'
|
|
222
245
|
? await this.#describedSidebar(sourceDir, docs, pageUrl, version.folder)
|
|
223
|
-
:
|
|
246
|
+
: null;
|
|
247
|
+
const sidebar = described ?? buildSidebar(docs, pageUrl, { brand: this.config.projectName });
|
|
224
248
|
const breadcrumbTitles = collectSectionTitles(docs);
|
|
225
249
|
const layout = await this.#loadLayout();
|
|
226
250
|
const classes = this.#classes();
|
|
@@ -245,6 +269,21 @@ export class SiteGenerator {
|
|
|
245
269
|
|
|
246
270
|
// What every page of the version shares, the search page included.
|
|
247
271
|
const searchUrl = this.config.search !== false ? joinUrl(versionBase, SEARCH_SLUG) : '';
|
|
272
|
+
// Described once per version, like the menu: the same authors serve every
|
|
273
|
+
// page, and a biography corrected in one version leaves the others alone.
|
|
274
|
+
const authors = await this.#readAuthors(sourceDir, version.folder, versionBase);
|
|
275
|
+
|
|
276
|
+
// Links of the header, resolved once per version. A link naming a version
|
|
277
|
+
// leads there from every version: a section written in one version only
|
|
278
|
+
// stays reachable from the others.
|
|
279
|
+
const headerLinks = (this.config.headerLinks ?? []).map((link) => ({
|
|
280
|
+
label: link.label,
|
|
281
|
+
href: EXTERNAL_PREVIEW.test(link.href)
|
|
282
|
+
? link.href
|
|
283
|
+
: joinUrl(this.config.baseUrl, 'versions', link.version ?? version.slug) +
|
|
284
|
+
link.href.replace(/^\/+/, ''),
|
|
285
|
+
}));
|
|
286
|
+
|
|
248
287
|
const shell = {
|
|
249
288
|
lang: this.config.lang ?? 'en',
|
|
250
289
|
// A fixed scheme is a class on <html>, which the skins and the dark
|
|
@@ -268,6 +307,9 @@ export class SiteGenerator {
|
|
|
268
307
|
? new URL(images.socialImage, this.config.siteUrl).href
|
|
269
308
|
: '',
|
|
270
309
|
searchUrl,
|
|
310
|
+
headerLinks,
|
|
311
|
+
// A menu with nothing in it would be a button that opens onto nothing.
|
|
312
|
+
headerMenu: this.config.versions.length > 1 || headerLinks.length > 0 || searchUrl !== '',
|
|
271
313
|
// The light / dark switch: a button, and the few lines of script it needs.
|
|
272
314
|
schemeToggle: this.config.theme?.toggle !== false,
|
|
273
315
|
cls: classes,
|
|
@@ -279,6 +321,39 @@ export class SiteGenerator {
|
|
|
279
321
|
scrollToTop: this.config.scrollToTop !== false,
|
|
280
322
|
notice,
|
|
281
323
|
};
|
|
324
|
+
// Every page of the version, for the components that list pages: one of
|
|
325
|
+
// them renders a single page at a time and could never gather this by
|
|
326
|
+
// itself. Targets are resolved here, each against the folder of the page
|
|
327
|
+
// that declares it — a preview written in one page is not relative to the
|
|
328
|
+
// page that shows it in a card.
|
|
329
|
+
const pages = docs.map((doc) => {
|
|
330
|
+
const folder = dirPathToSlug(path.relative(sourceDir, path.dirname(doc.path)));
|
|
331
|
+
const dirUrl = joinUrl(versionBase, folder);
|
|
332
|
+
const target = String(doc.frontmatter.preview ?? '');
|
|
333
|
+
const absolute = target.startsWith('/');
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
url: pageUrl(doc),
|
|
337
|
+
slug: doc.slug,
|
|
338
|
+
title: String(doc.frontmatter.title ?? this.config.projectName),
|
|
339
|
+
description: doc.frontmatter.description ? String(doc.frontmatter.description) : undefined,
|
|
340
|
+
preview:
|
|
341
|
+
target === '' || EXTERNAL_PREVIEW.test(target)
|
|
342
|
+
? target || undefined
|
|
343
|
+
: new URL(
|
|
344
|
+
absolute ? target.slice(1) : target,
|
|
345
|
+
`https://docpensieve.invalid${absolute ? versionBase : dirUrl}`,
|
|
346
|
+
).pathname,
|
|
347
|
+
// The same date the byline and the sitemap read, formatted once.
|
|
348
|
+
modified:
|
|
349
|
+
readDate(
|
|
350
|
+
doc.frontmatter.modified ?? doc.frontmatter.date,
|
|
351
|
+
'modified',
|
|
352
|
+
doc.slug || 'the home page',
|
|
353
|
+
) ?? undefined,
|
|
354
|
+
};
|
|
355
|
+
});
|
|
356
|
+
|
|
282
357
|
/** @type {{ title: string, url: string, description: string, text: string }[]} */
|
|
283
358
|
const entries = [];
|
|
284
359
|
|
|
@@ -293,7 +368,15 @@ export class SiteGenerator {
|
|
|
293
368
|
const dirUrl = joinUrl(versionBase, folder);
|
|
294
369
|
// Components need to know which page they render: a link they produce
|
|
295
370
|
// escapes the compiler plugins (ADR-006).
|
|
296
|
-
this.deps.onPage?.({
|
|
371
|
+
this.deps.onPage?.({
|
|
372
|
+
url,
|
|
373
|
+
dirUrl,
|
|
374
|
+
basePath: versionBase,
|
|
375
|
+
filepath: doc.path,
|
|
376
|
+
sourceDir,
|
|
377
|
+
slug: doc.slug,
|
|
378
|
+
pages,
|
|
379
|
+
});
|
|
297
380
|
|
|
298
381
|
const { html, toc, preloads } = await this.compiler.compile(doc.content, {
|
|
299
382
|
filepath: doc.path,
|
|
@@ -303,11 +386,19 @@ export class SiteGenerator {
|
|
|
303
386
|
sourceDir,
|
|
304
387
|
});
|
|
305
388
|
|
|
389
|
+
// Who wrote the page, and when. Read before the structured data, which
|
|
390
|
+
// describes the same people: the page and its metadata must not
|
|
391
|
+
// disagree about an author.
|
|
392
|
+
const credits = buildByline(doc.frontmatter, authors, doc.slug || 'the home page');
|
|
393
|
+
|
|
306
394
|
const jsonld = new StructuredDataBuilder(doc.frontmatter, url, this.config, {
|
|
307
395
|
breadcrumbTitles,
|
|
308
396
|
basePath: versionBase,
|
|
309
397
|
dirUrl,
|
|
310
398
|
logo: images.logo,
|
|
399
|
+
// A described author carries a biography and a link, which a bare
|
|
400
|
+
// name in the frontmatter cannot.
|
|
401
|
+
authors: credits?.authors ?? [],
|
|
311
402
|
}).toScriptTag();
|
|
312
403
|
|
|
313
404
|
// A home page has neither menu nor table of contents: those are reading
|
|
@@ -321,6 +412,17 @@ export class SiteGenerator {
|
|
|
321
412
|
text: htmlToText(html),
|
|
322
413
|
});
|
|
323
414
|
|
|
415
|
+
const byline =
|
|
416
|
+
wide || !credits
|
|
417
|
+
? null
|
|
418
|
+
: {
|
|
419
|
+
authors: credits.authors,
|
|
420
|
+
dates: [
|
|
421
|
+
credits.created && { prefix: 'Written', ...credits.created },
|
|
422
|
+
credits.updated && { prefix: 'Updated', ...credits.updated },
|
|
423
|
+
].filter(Boolean),
|
|
424
|
+
};
|
|
425
|
+
|
|
324
426
|
const page = layout({
|
|
325
427
|
...shell,
|
|
326
428
|
title: documentTitle(doc.frontmatter.title, this.config.projectName),
|
|
@@ -332,6 +434,8 @@ export class SiteGenerator {
|
|
|
332
434
|
// same content, two addresses, and the wrong one comes up. "follow"
|
|
333
435
|
// still lets its links be followed.
|
|
334
436
|
noindex: version.prerelease === true,
|
|
437
|
+
byline,
|
|
438
|
+
tags: wide ? [] : pageTags(doc.frontmatter.tags),
|
|
335
439
|
sidebar: wide ? [] : sidebar,
|
|
336
440
|
toc: wide ? [] : toc,
|
|
337
441
|
preloads,
|
|
@@ -407,6 +511,93 @@ export class SiteGenerator {
|
|
|
407
511
|
};
|
|
408
512
|
}
|
|
409
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Reads the authors a version describes, their avatars resolved.
|
|
516
|
+
*
|
|
517
|
+
* The file is optional: without it, a page still shows the names its
|
|
518
|
+
* frontmatter gives. Named in the configuration but missing, it is an
|
|
519
|
+
* error — leaving every biography out without a word would be worse.
|
|
520
|
+
*
|
|
521
|
+
* @param {string} sourceDir Source folder of the version.
|
|
522
|
+
* @param {string} folder The version's folder, as the configuration names it.
|
|
523
|
+
* @param {string} versionBase URL of the version.
|
|
524
|
+
* @returns {Promise<Map<string, import('./authors.js').Author>>} Authors by
|
|
525
|
+
* key, their avatars resolved to a URL and measured.
|
|
526
|
+
* @throws {ConfigError} Unreadable file, invalid JSON, wrong author, missing avatar.
|
|
527
|
+
*/
|
|
528
|
+
async #readAuthors(sourceDir, folder, versionBase) {
|
|
529
|
+
const name = this.config.authors;
|
|
530
|
+
if (!name) return new Map();
|
|
531
|
+
|
|
532
|
+
const source = `${folder}/${name}`;
|
|
533
|
+
|
|
534
|
+
let text;
|
|
535
|
+
try {
|
|
536
|
+
text = await readFile(path.join(sourceDir, ...name.split('/')), 'utf8');
|
|
537
|
+
} catch (cause) {
|
|
538
|
+
// Absent, the file has nothing to add: this version shows the names its
|
|
539
|
+
// pages give (ADR-017). Present but unreadable, it is a fault.
|
|
540
|
+
if (/** @type {{ code?: string }} */ (cause).code === 'ENOENT') return new Map();
|
|
541
|
+
throw new ConfigError(`Could not read the author description at ${source}.`, {
|
|
542
|
+
cause,
|
|
543
|
+
hint: 'Check that it is a file, and that nothing holds it open.',
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
let description;
|
|
548
|
+
try {
|
|
549
|
+
description = JSON.parse(text);
|
|
550
|
+
} catch (cause) {
|
|
551
|
+
throw new ConfigError(
|
|
552
|
+
`${source} is not valid JSON: ${/** @type {Error} */ (cause).message}`,
|
|
553
|
+
{
|
|
554
|
+
cause,
|
|
555
|
+
hint: 'A trailing comma or a missing quote is enough: open it in an editor that checks JSON.',
|
|
556
|
+
},
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const table = buildAuthorTable(description, { source });
|
|
561
|
+
|
|
562
|
+
// Resolved once per version, not once per page: the same handful of
|
|
563
|
+
// images would otherwise be read and measured on every page.
|
|
564
|
+
for (const author of table.values()) {
|
|
565
|
+
if (!author.avatar) continue;
|
|
566
|
+
const segments = author.avatar.split('/').filter(Boolean);
|
|
567
|
+
|
|
568
|
+
let bytes;
|
|
569
|
+
try {
|
|
570
|
+
bytes = await readFile(path.join(sourceDir, ...segments));
|
|
571
|
+
} catch (cause) {
|
|
572
|
+
throw new ConfigError(
|
|
573
|
+
`No avatar at ${folder}/${author.avatar}, declared by author "${author.key}".`,
|
|
574
|
+
{
|
|
575
|
+
cause,
|
|
576
|
+
hint: 'The path starts at the version folder, so that the image travels with the version.',
|
|
577
|
+
},
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Published where the asset copy puts it: folders lose their ordering
|
|
582
|
+
// prefix, as pages do. The raw source path pointed at a file that is
|
|
583
|
+
// never written — `06-examples/` is served as `examples/`.
|
|
584
|
+
const published = assetPathToSlug(author.avatar).split('/');
|
|
585
|
+
const entry = /** @type {Record<string, any>} */ (author);
|
|
586
|
+
entry.avatarUrl =
|
|
587
|
+
joinUrl(versionBase, published.slice(0, -1).join('/')) + published[published.length - 1];
|
|
588
|
+
|
|
589
|
+
// Dimensions spare the reader a jump when the image arrives, as for
|
|
590
|
+
// every other image of a page.
|
|
591
|
+
const size = imageSize(bytes, path.extname(author.avatar));
|
|
592
|
+
if (size) {
|
|
593
|
+
entry.avatarWidth = size.width;
|
|
594
|
+
entry.avatarHeight = size.height;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return table;
|
|
599
|
+
}
|
|
600
|
+
|
|
410
601
|
/**
|
|
411
602
|
* Reads the sidebar description of a version.
|
|
412
603
|
*
|
|
@@ -414,9 +605,10 @@ export class SiteGenerator {
|
|
|
414
605
|
* @param {import('./loader.js').Doc[]} docs Documents of the version.
|
|
415
606
|
* @param {(doc: import('./loader.js').Doc) => string} pageUrl
|
|
416
607
|
* @param {string} folder The version's folder, as the configuration names it.
|
|
417
|
-
* @returns {Promise<import('./sidebar.js').SidebarNode[]>}
|
|
418
|
-
*
|
|
419
|
-
*
|
|
608
|
+
* @returns {Promise<import('./sidebar.js').SidebarNode[] | null>} `null`
|
|
609
|
+
* when this version has no description, which then keeps the automatic menu.
|
|
610
|
+
* @throws {ConfigError} When the file cannot be read, is not JSON, or
|
|
611
|
+
* describes the menu wrongly.
|
|
420
612
|
*/
|
|
421
613
|
async #describedSidebar(sourceDir, docs, pageUrl, folder) {
|
|
422
614
|
const name = this.config.sidebar;
|
|
@@ -426,9 +618,12 @@ export class SiteGenerator {
|
|
|
426
618
|
try {
|
|
427
619
|
text = await readFile(path.join(sourceDir, ...name.split('/')), 'utf8');
|
|
428
620
|
} catch (cause) {
|
|
429
|
-
|
|
621
|
+
// Absent, the version keeps the menu of its folders (ADR-017): a project
|
|
622
|
+
// can describe the menu of its new version without touching the others.
|
|
623
|
+
if (/** @type {{ code?: string }} */ (cause).code === 'ENOENT') return null;
|
|
624
|
+
throw new ConfigError(`Could not read the sidebar description at ${source}.`, {
|
|
430
625
|
cause,
|
|
431
|
-
hint:
|
|
626
|
+
hint: 'Check that it is a file, and that nothing holds it open.',
|
|
432
627
|
});
|
|
433
628
|
}
|
|
434
629
|
|
|
@@ -668,8 +863,8 @@ export class SiteGenerator {
|
|
|
668
863
|
async #loadLayout() {
|
|
669
864
|
if (this.#layout) return this.#layout;
|
|
670
865
|
|
|
671
|
-
const [layout, navItems, tocItems] = await Promise.all(
|
|
672
|
-
['layout.hbs', 'nav-items.hbs', 'toc-items.hbs'].map((file) =>
|
|
866
|
+
const [layout, navItems, tocItems, headerMenu] = await Promise.all(
|
|
867
|
+
['layout.hbs', 'nav-items.hbs', 'toc-items.hbs', 'header-menu.hbs'].map((file) =>
|
|
673
868
|
readFile(path.join(TEMPLATE_DIR, file), 'utf8'),
|
|
674
869
|
),
|
|
675
870
|
);
|
|
@@ -678,6 +873,7 @@ export class SiteGenerator {
|
|
|
678
873
|
handlebars.registerHelper('eq', (a, b) => a === b);
|
|
679
874
|
handlebars.registerPartial('navItems', navItems);
|
|
680
875
|
handlebars.registerPartial('tocItems', tocItems);
|
|
876
|
+
handlebars.registerPartial('headerMenu', headerMenu);
|
|
681
877
|
|
|
682
878
|
this.#layout = handlebars.compile(layout);
|
|
683
879
|
return this.#layout;
|
|
@@ -762,6 +958,8 @@ export class SiteGenerator {
|
|
|
762
958
|
|
|
763
959
|
// The sidebar description is read by the build, not published.
|
|
764
960
|
if (this.config.sidebar !== 'auto' && readable === this.config.sidebar) continue;
|
|
961
|
+
// The author descriptions too: they feed the pages, they are not pages.
|
|
962
|
+
if (this.config.authors && readable === this.config.authors) continue;
|
|
765
963
|
if (DOC_EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
|
|
766
964
|
|
|
767
965
|
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.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{{!-- What the header offers beyond the brand: the version switcher, the links of
|
|
2
|
+
the site and the search field. Written once here, shown twice by the layout — in
|
|
3
|
+
a row on a wide screen, behind a menu button on a narrow one. --}}
|
|
4
|
+
{{#if showVersions}}
|
|
5
|
+
<details class="{{{cls.versions}}}">
|
|
6
|
+
{{!-- The accessible name contains the visible label: when dictated by voice,
|
|
7
|
+
what is read on screen must find this button. --}}
|
|
8
|
+
<summary aria-label="Version {{versionName}}, switch version">{{versionName}}</summary>
|
|
9
|
+
<ul class="{{{cls.versionsList}}}">
|
|
10
|
+
{{#each versions}}
|
|
11
|
+
<li><a href="{{url}}"{{#if current}} aria-current="true"{{/if}}>{{name}}</a></li>
|
|
12
|
+
{{/each}}
|
|
13
|
+
</ul>
|
|
14
|
+
</details>
|
|
15
|
+
{{/if}}
|
|
16
|
+
{{#if headerLinks.length}}
|
|
17
|
+
<ul class="{{{cls.headerLinks}}}">
|
|
18
|
+
{{#each headerLinks}}<li><a href="{{href}}">{{label}}</a></li>{{/each}}
|
|
19
|
+
</ul>
|
|
20
|
+
{{/if}}
|
|
21
|
+
{{!-- A plain form: it leads to the search page, and needs no script. --}}
|
|
22
|
+
{{#if searchUrl}}
|
|
23
|
+
<form class="{{{cls.search}}}" role="search" action="{{searchUrl}}">
|
|
24
|
+
<input type="search" name="q" placeholder="Search" aria-label="Search the documentation" />
|
|
25
|
+
</form>
|
|
26
|
+
{{/if}}
|
package/templates/layout.hbs
CHANGED
|
@@ -59,24 +59,22 @@
|
|
|
59
59
|
<header class="{{{cls.header}}}">
|
|
60
60
|
{{!-- The logo's alt stays empty: the name that follows already says it. --}}
|
|
61
61
|
<a class="{{{cls.brand}}}" href="{{homeUrl}}">{{#if logoUrl}}<img class="{{{cls.brandLogo}}}" src="{{logoUrl}}" alt="" />{{/if}}{{projectName}}</a>
|
|
62
|
-
{{
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
62
|
+
{{!-- Shown twice, one at a time: in a row on a wide screen, behind a button
|
|
63
|
+
on a narrow one. The button is a details element, so that the menu opens
|
|
64
|
+
without a script — as the version switcher does. --}}
|
|
65
|
+
{{#if headerMenu}}
|
|
66
|
+
<nav class="{{{cls.headerNav}}}" aria-label="Site">
|
|
67
|
+
{{> headerMenu}}
|
|
68
|
+
</nav>
|
|
69
|
+
<details class="{{{cls.menu}}}">
|
|
70
|
+
<summary aria-label="Menu">
|
|
71
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 7h16M4 12h16M4 17h16" /></svg>
|
|
72
|
+
</summary>
|
|
73
|
+
<nav class="{{{cls.menuPanel}}}" aria-label="Site">
|
|
74
|
+
{{> headerMenu}}
|
|
75
|
+
</nav>
|
|
72
76
|
</details>
|
|
73
77
|
{{/if}}
|
|
74
|
-
{{!-- A plain form: it leads to the search page, and needs no script. --}}
|
|
75
|
-
{{#if searchUrl}}
|
|
76
|
-
<form class="{{{cls.search}}}" role="search" action="{{searchUrl}}">
|
|
77
|
-
<input type="search" name="q" placeholder="Search" aria-label="Search the documentation" />
|
|
78
|
-
</form>
|
|
79
|
-
{{/if}}
|
|
80
78
|
{{!-- Hidden until its script runs: without JavaScript, it would do nothing. --}}
|
|
81
79
|
{{#if schemeToggle}}
|
|
82
80
|
<button class="{{{cls.schemeToggle}}}" type="button" data-scheme-toggle hidden aria-label="Switch the colour scheme">
|
|
@@ -104,7 +102,38 @@
|
|
|
104
102
|
The current version is <a href="{{notice.url}}">{{notice.name}}</a>.
|
|
105
103
|
</aside>
|
|
106
104
|
{{/if}}
|
|
105
|
+
{{!-- Who wrote the page, and when. Absent when the page says neither,
|
|
106
|
+
and on a home page, where a byline under an entrance hall means
|
|
107
|
+
nothing. --}}
|
|
108
|
+
{{#if byline}}
|
|
109
|
+
<div class="{{{cls.byline}}}">
|
|
110
|
+
{{#if byline.authors.length}}
|
|
111
|
+
<ul class="{{{cls.bylineAuthors}}}">
|
|
112
|
+
{{#each byline.authors}}
|
|
113
|
+
<li class="{{{@root.cls.bylineAuthor}}}">
|
|
114
|
+
{{#if avatarUrl}}<img class="{{{@root.cls.bylineAvatar}}}" src="{{avatarUrl}}" alt="" {{#if avatarWidth}}width="{{avatarWidth}}" height="{{avatarHeight}}" {{/if}}loading="lazy" decoding="async" />{{/if}}
|
|
115
|
+
<span class="{{{@root.cls.bylineName}}}">{{#if url}}<a href="{{url}}">{{name}}</a>{{else}}{{name}}{{/if}}</span>
|
|
116
|
+
{{#if bio}}<span class="{{{@root.cls.bylineBio}}}">{{bio}}</span>{{/if}}
|
|
117
|
+
</li>
|
|
118
|
+
{{/each}}
|
|
119
|
+
</ul>
|
|
120
|
+
{{/if}}
|
|
121
|
+
{{#if byline.dates.length}}
|
|
122
|
+
<p class="{{{cls.bylineDates}}}">
|
|
123
|
+
{{#each byline.dates}}<span>{{prefix}} <time datetime="{{iso}}">{{label}}</time></span>{{/each}}
|
|
124
|
+
</p>
|
|
125
|
+
{{/if}}
|
|
126
|
+
</div>
|
|
127
|
+
{{/if}}
|
|
107
128
|
<article class="{{{cls.article}}}">{{{content}}}</article>
|
|
129
|
+
{{!-- The tags of the page, below what they describe: read after the
|
|
130
|
+
text, they say what it was about. Labels, not links — there is no page
|
|
131
|
+
per tag. None on a home page. --}}
|
|
132
|
+
{{#if tags.length}}
|
|
133
|
+
<ul class="{{{cls.tags}}}" aria-label="Tags">
|
|
134
|
+
{{#each tags}}<li class="{{{@root.cls.tag}}}">{{this}}</li>{{/each}}
|
|
135
|
+
</ul>
|
|
136
|
+
{{/if}}
|
|
108
137
|
</main>
|
|
109
138
|
|
|
110
139
|
{{#if toc.length}}
|
|
@@ -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,18 @@ 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;
|
|
78
|
+
/**
|
|
79
|
+
* Links of the header, beside the version switcher.
|
|
80
|
+
*/
|
|
81
|
+
headerLinks?: {
|
|
82
|
+
label: string;
|
|
83
|
+
href: string;
|
|
84
|
+
version?: string;
|
|
85
|
+
}[];
|
|
74
86
|
globalComponents: boolean;
|
|
75
87
|
/**
|
|
76
88
|
* Back-to-top button on every page.
|
|
@@ -137,6 +149,9 @@ export type DocPensieveConfig = {
|
|
|
137
149
|
* @property {Version[]} versions At least one.
|
|
138
150
|
* @property {{ framework: string, darkMode?: string, toggle?: boolean, tokens?: Record<string, string>, css?: string, source?: string }} theme
|
|
139
151
|
* @property {string} sidebar `'auto'`, or the path of a description.
|
|
152
|
+
* @property {string} [authors] Path of a JSON describing the authors, read in each version folder.
|
|
153
|
+
* @property {{ label: string, href: string, version?: string }[]} [headerLinks]
|
|
154
|
+
* Links of the header, beside the version switcher.
|
|
140
155
|
* @property {boolean} globalComponents
|
|
141
156
|
* @property {boolean} scrollToTop Back-to-top button on every page.
|
|
142
157
|
* @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.
|