@docpensieve/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/package.json +58 -0
- package/src/compiler.js +415 -0
- package/src/config.js +265 -0
- package/src/generator.js +517 -0
- package/src/index.js +35 -0
- package/src/loader.js +260 -0
- package/src/sidebar.js +105 -0
- package/src/structured-data.js +334 -0
- package/templates/layout.hbs +94 -0
- package/templates/nav-items.hbs +14 -0
- package/templates/toc-items.hbs +10 -0
- package/types/compiler.d.ts +108 -0
- package/types/config.d.ts +149 -0
- package/types/generator.d.ts +82 -0
- package/types/index.d.ts +34 -0
- package/types/loader.d.ts +66 -0
- package/types/sidebar.d.ts +60 -0
- package/types/structured-data.d.ts +51 -0
package/src/loader.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads the Markdown/MDX sources of a version.
|
|
3
|
+
*
|
|
4
|
+
* @module @docpensieve/core/loader
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
blankSegments,
|
|
12
|
+
DOC_EXTENSIONS,
|
|
13
|
+
INDEX_SLUGS,
|
|
14
|
+
LoaderError,
|
|
15
|
+
filePathToSlug,
|
|
16
|
+
orderOf,
|
|
17
|
+
slugToUrl,
|
|
18
|
+
} from '@docpensieve/shared';
|
|
19
|
+
import matter from 'gray-matter';
|
|
20
|
+
|
|
21
|
+
/** Folders never walked, whatever they contain. */
|
|
22
|
+
const IGNORED_DIRS = new Set(['node_modules', 'dist', 'coverage']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} Doc
|
|
26
|
+
* @property {string} slug Page slug (`'guide/install'`).
|
|
27
|
+
* @property {string} path Absolute path of the source file.
|
|
28
|
+
* @property {string} url Site URL (`'/guide/install/'`).
|
|
29
|
+
* @property {Record<string, any>} frontmatter Frontmatter parsed by gray-matter.
|
|
30
|
+
* @property {string} content Raw Markdown/MDX body, frontmatter removed.
|
|
31
|
+
* @property {number} order Ordering weight for the sidebar.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compares two entries of the same folder for reading order.
|
|
36
|
+
*
|
|
37
|
+
* Three rules, in this order: the folder's index page comes first, then the
|
|
38
|
+
* numeric prefix (`01-`, `02-`), then alphabetical order.
|
|
39
|
+
*
|
|
40
|
+
* @param {import('node:fs').Dirent} a
|
|
41
|
+
* @param {import('node:fs').Dirent} b
|
|
42
|
+
* @returns {number}
|
|
43
|
+
*/
|
|
44
|
+
function compareEntries(a, b) {
|
|
45
|
+
const indexDelta = Number(isIndexEntry(b)) - Number(isIndexEntry(a));
|
|
46
|
+
if (indexDelta !== 0) return indexDelta;
|
|
47
|
+
|
|
48
|
+
const orderA = orderOf(a.name);
|
|
49
|
+
const orderB = orderOf(b.name);
|
|
50
|
+
// `orderOf` returns Infinity without a prefix: a subtraction would give NaN
|
|
51
|
+
// for two unprefixed files, which silently breaks the sort.
|
|
52
|
+
if (orderA !== orderB) return orderA - orderB;
|
|
53
|
+
|
|
54
|
+
return a.name.localeCompare(b.name, 'en');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Is an entry the index page of its folder (`index.md`, `readme.mdx`)?
|
|
59
|
+
*
|
|
60
|
+
* @param {import('node:fs').Dirent} entry
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
function isIndexEntry(entry) {
|
|
64
|
+
if (entry.isDirectory()) return false;
|
|
65
|
+
const base = path.basename(entry.name, path.extname(entry.name)).toLowerCase();
|
|
66
|
+
return INDEX_SLUGS.includes(base);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Walks a version folder and produces the list of documents. */
|
|
70
|
+
export class DocLoader {
|
|
71
|
+
/**
|
|
72
|
+
* @param {{ extensions?: string[], includeDrafts?: boolean }} [options]
|
|
73
|
+
* `extensions` replaces the default list (`.md`, `.mdx`);
|
|
74
|
+
* `includeDrafts` keeps the pages marked `draft: true`.
|
|
75
|
+
*/
|
|
76
|
+
constructor(options = {}) {
|
|
77
|
+
this.options = options;
|
|
78
|
+
this.extensions = (options.extensions ?? DOC_EXTENSIONS).map((ext) => ext.toLowerCase());
|
|
79
|
+
this.includeDrafts = options.includeDrafts ?? false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Recursively loads every document of a folder.
|
|
84
|
+
*
|
|
85
|
+
* Documents come back in the site's reading order: at each level, the index
|
|
86
|
+
* page first, then numeric prefixes, then alphabetical. That is the order
|
|
87
|
+
* the sidebar needs, hence sorting during the walk rather than a flat sort
|
|
88
|
+
* of the result.
|
|
89
|
+
*
|
|
90
|
+
* The URLs produced carry no version prefix: the loader does not know which
|
|
91
|
+
* version it works on, the generator adds it.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} dir Version folder (e.g. `docs/v1.0`).
|
|
94
|
+
* @returns {Promise<Doc[]>}
|
|
95
|
+
* @throws {LoaderError} Missing folder, invalid frontmatter, colliding slugs.
|
|
96
|
+
*/
|
|
97
|
+
async load(dir) {
|
|
98
|
+
const root = path.resolve(dir);
|
|
99
|
+
|
|
100
|
+
let stats;
|
|
101
|
+
try {
|
|
102
|
+
stats = await stat(root);
|
|
103
|
+
} catch (cause) {
|
|
104
|
+
throw new LoaderError(`Documentation folder not found: ${root}.`, {
|
|
105
|
+
cause,
|
|
106
|
+
hint: 'Check the "folder" field of the version in docpensieve.config.js.',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (!stats.isDirectory()) {
|
|
110
|
+
throw new LoaderError(`${root} is not a folder.`, {
|
|
111
|
+
hint: 'The "folder" field must point to a folder, not a file.',
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const files = await this.#collect(root);
|
|
116
|
+
const docs = await Promise.all(files.map((file) => this.#read(root, file)));
|
|
117
|
+
|
|
118
|
+
// Filtering comes before collision detection: a draft left out of the
|
|
119
|
+
// build conflicts with nothing.
|
|
120
|
+
const kept = this.includeDrafts ? docs : docs.filter((doc) => doc.frontmatter.draft !== true);
|
|
121
|
+
this.#assertNoSlugCollision(kept);
|
|
122
|
+
return kept;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Lists the documentation files, depth first, already sorted.
|
|
127
|
+
*
|
|
128
|
+
* @param {string} dir
|
|
129
|
+
* @returns {Promise<string[]>} Absolute paths.
|
|
130
|
+
*/
|
|
131
|
+
async #collect(dir) {
|
|
132
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
133
|
+
const files = [];
|
|
134
|
+
|
|
135
|
+
for (const entry of entries.sort(compareEntries)) {
|
|
136
|
+
// Hidden entries are not documentation: tool folders, editor metadata,
|
|
137
|
+
// file-system leftovers.
|
|
138
|
+
if (entry.name.startsWith('.')) continue;
|
|
139
|
+
|
|
140
|
+
// Safety net: a misconfigured "folder" pointing at the project root
|
|
141
|
+
// would swallow thousands of dependency files.
|
|
142
|
+
if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
|
|
143
|
+
|
|
144
|
+
const full = path.join(dir, entry.name);
|
|
145
|
+
if (entry.isDirectory()) {
|
|
146
|
+
files.push(...(await this.#collect(full)));
|
|
147
|
+
} else if (this.extensions.includes(path.extname(entry.name).toLowerCase())) {
|
|
148
|
+
files.push(full);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return files;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Reads a file and turns it into a document.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} root Root of the version folder, for the relative slug.
|
|
159
|
+
* @param {string} absolutePath
|
|
160
|
+
* @returns {Promise<Doc>}
|
|
161
|
+
*/
|
|
162
|
+
async #read(root, absolutePath) {
|
|
163
|
+
const relative = path.relative(root, absolutePath);
|
|
164
|
+
|
|
165
|
+
let raw;
|
|
166
|
+
try {
|
|
167
|
+
raw = await readFile(absolutePath, 'utf8');
|
|
168
|
+
} catch (cause) {
|
|
169
|
+
throw new LoaderError(`Could not read ${relative}.`, { cause });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let parsed;
|
|
173
|
+
try {
|
|
174
|
+
parsed = matter(raw);
|
|
175
|
+
} catch (cause) {
|
|
176
|
+
throw new LoaderError(`Invalid frontmatter in ${relative}.`, {
|
|
177
|
+
cause,
|
|
178
|
+
// gray-matter surfaces the raw YAML error: repeat it as is, it is the
|
|
179
|
+
// one that gives the offending line.
|
|
180
|
+
hint: cause instanceof Error ? cause.message : undefined,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The frontmatter must be a map of fields. A string or a list went
|
|
185
|
+
// through, and every field of the page was ignored without a word.
|
|
186
|
+
const data = parsed.data;
|
|
187
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
188
|
+
throw new LoaderError(`Frontmatter of ${relative}: a map of fields is expected.`, {
|
|
189
|
+
hint: 'Write "key: value" pairs, one per line — title: Installation.',
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
/** @type {Record<string, any>} */
|
|
193
|
+
const frontmatter = { ...data };
|
|
194
|
+
|
|
195
|
+
// The title feeds <title>, the menu and the JSON-LD: it must be text. A
|
|
196
|
+
// list came out as "one,two", an object as "[object Object]". A number
|
|
197
|
+
// reads as text; an empty title counts as none.
|
|
198
|
+
const title = frontmatter.title;
|
|
199
|
+
if (
|
|
200
|
+
title === null ||
|
|
201
|
+
title === undefined ||
|
|
202
|
+
(typeof title === 'string' && title.trim() === '')
|
|
203
|
+
) {
|
|
204
|
+
delete frontmatter.title;
|
|
205
|
+
} else if (typeof title === 'number') {
|
|
206
|
+
frontmatter.title = String(title);
|
|
207
|
+
} else if (typeof title === 'string') {
|
|
208
|
+
frontmatter.title = title.trim();
|
|
209
|
+
} else {
|
|
210
|
+
throw new LoaderError(`Invalid title in ${relative}: text is expected.`, {
|
|
211
|
+
hint: 'title: My title — without brackets or braces.',
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// A name without a Latin letter or a digit yields no URL: the page
|
|
216
|
+
// silently took the home page's place, or its folder vanished from the
|
|
217
|
+
// address.
|
|
218
|
+
const blank = blankSegments(relative);
|
|
219
|
+
if (blank.length > 0) {
|
|
220
|
+
throw new LoaderError(`"${relative}" yields no URL for "${blank.join('", "')}".`, {
|
|
221
|
+
hint: 'Give the file or folder a name that contains Latin letters or digits.',
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const slug = filePathToSlug(relative);
|
|
226
|
+
return {
|
|
227
|
+
slug,
|
|
228
|
+
path: absolutePath,
|
|
229
|
+
url: slugToUrl(slug),
|
|
230
|
+
frontmatter,
|
|
231
|
+
content: parsed.content,
|
|
232
|
+
order: orderOf(path.basename(absolutePath)),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Refuses two documents that would produce the same URL.
|
|
238
|
+
*
|
|
239
|
+
* The classic case is `guide.md` and `guide/index.md`: two legitimate files,
|
|
240
|
+
* a single slug. Better to say so at build time than to ship a page
|
|
241
|
+
* overwritten by the other.
|
|
242
|
+
*
|
|
243
|
+
* @param {Doc[]} docs
|
|
244
|
+
* @throws {LoaderError}
|
|
245
|
+
*/
|
|
246
|
+
#assertNoSlugCollision(docs) {
|
|
247
|
+
/** @type {Map<string, string>} */
|
|
248
|
+
const seen = new Map();
|
|
249
|
+
|
|
250
|
+
for (const doc of docs) {
|
|
251
|
+
const previous = seen.get(doc.slug);
|
|
252
|
+
if (previous !== undefined) {
|
|
253
|
+
throw new LoaderError(`Two files produce the same slug "${doc.slug || '(root)'}".`, {
|
|
254
|
+
hint: `Conflicting: ${previous} and ${doc.path}. Rename one of them.`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
seen.set(doc.slug, doc.path);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
package/src/sidebar.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the sidebar from the loaded documents.
|
|
3
|
+
*
|
|
4
|
+
* @module @docpensieve/core/sidebar
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { humanizeSlug } from '@docpensieve/shared';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} SidebarNode
|
|
11
|
+
* @property {string} label Displayed title.
|
|
12
|
+
* @property {string | null} url Page URL, or `null` for a folder without an
|
|
13
|
+
* index page — the category is then a mere grouping.
|
|
14
|
+
* @property {SidebarNode[]} items Child entries.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Builds the navigation tree of a version.
|
|
19
|
+
*
|
|
20
|
+
* The order is the `DocLoader`'s, which has already sorted: index page first,
|
|
21
|
+
* then numeric prefixes, then alphabetical. Nothing is re-sorted here, which
|
|
22
|
+
* guarantees that the sidebar follows the reading order of the files exactly.
|
|
23
|
+
*
|
|
24
|
+
* That order has a useful consequence: since `guide/index.md` is loaded
|
|
25
|
+
* before `guide/installation.md`, the “guide” category receives its real
|
|
26
|
+
* title before a child page creates it with a default one.
|
|
27
|
+
*
|
|
28
|
+
* @param {import('./loader.js').Doc[]} docs Documents in loader order.
|
|
29
|
+
* @param {(doc: import('./loader.js').Doc) => string} [toUrl] Turns a
|
|
30
|
+
* document into a URL. By default, the document's URL as is.
|
|
31
|
+
* @param {{ brand?: string }} [options] `brand` is the name shown in the
|
|
32
|
+
* header: a root entry carrying exactly that title is dropped, since the
|
|
33
|
+
* brand already leads to that page. The same word twice, an inch apart,
|
|
34
|
+
* tells the reader nothing.
|
|
35
|
+
* @returns {SidebarNode[]}
|
|
36
|
+
*/
|
|
37
|
+
export function buildSidebar(docs, toUrl = (doc) => doc.url, options = {}) {
|
|
38
|
+
/** @type {SidebarNode[]} */
|
|
39
|
+
const root = [];
|
|
40
|
+
/** @type {Map<string, SidebarNode>} */
|
|
41
|
+
const byPath = new Map();
|
|
42
|
+
|
|
43
|
+
for (const doc of docs) {
|
|
44
|
+
const label = doc.frontmatter?.title;
|
|
45
|
+
const segments = doc.slug ? doc.slug.split('/') : [];
|
|
46
|
+
|
|
47
|
+
// The root page has no segment: it becomes a top-level entry rather than
|
|
48
|
+
// the category that would hold everything else.
|
|
49
|
+
if (segments.length === 0) {
|
|
50
|
+
if (options.brand && label === options.brand) continue;
|
|
51
|
+
root.push({ label: label ?? 'Home', url: toUrl(doc), items: [] });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let level = root;
|
|
56
|
+
let path = '';
|
|
57
|
+
|
|
58
|
+
segments.forEach((segment, index) => {
|
|
59
|
+
path = path ? `${path}/${segment}` : segment;
|
|
60
|
+
|
|
61
|
+
let node = byPath.get(path);
|
|
62
|
+
if (!node) {
|
|
63
|
+
node = { label: humanizeSlug(segment), url: null, items: [] };
|
|
64
|
+
byPath.set(path, node);
|
|
65
|
+
level.push(node);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (index === segments.length - 1) {
|
|
69
|
+
if (label) node.label = label;
|
|
70
|
+
node.url = toUrl(doc);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
level = node.items;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return root;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Collects folder titles, for the breadcrumb.
|
|
82
|
+
*
|
|
83
|
+
* Only folders with an index page have a known title; the others will be
|
|
84
|
+
* humanised from their slug by `StructuredDataBuilder`.
|
|
85
|
+
*
|
|
86
|
+
* @param {import('./loader.js').Doc[]} docs
|
|
87
|
+
* @returns {Record<string, string>} Full folder slug to title.
|
|
88
|
+
*/
|
|
89
|
+
export function collectSectionTitles(docs) {
|
|
90
|
+
/** @type {Record<string, string>} */
|
|
91
|
+
const titles = {};
|
|
92
|
+
|
|
93
|
+
for (const doc of docs) {
|
|
94
|
+
const title = doc.frontmatter?.title;
|
|
95
|
+
if (!doc.slug || !title) continue;
|
|
96
|
+
|
|
97
|
+
// `guide/advanced/index.md` has the slug “guide/advanced”: that whole path
|
|
98
|
+
// names the folder. Keyed by its last segment alone, two folders sharing a
|
|
99
|
+
// name — `api/advanced` and `guide/advanced` — swapped their titles in the
|
|
100
|
+
// breadcrumb, first come first served.
|
|
101
|
+
if (titles[doc.slug] === undefined) titles[doc.slug] = String(title);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return titles;
|
|
105
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the JSON-LD from the frontmatter.
|
|
3
|
+
*
|
|
4
|
+
* @module @docpensieve/core/structured-data
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { JSONLD_TYPES, StructuredDataError, humanizeSlug, slugify } from '@docpensieve/shared';
|
|
8
|
+
|
|
9
|
+
/** Article type used when the frontmatter names none. */
|
|
10
|
+
const DEFAULT_TYPE = 'Article';
|
|
11
|
+
|
|
12
|
+
/** Name of the first crumb of the breadcrumb. */
|
|
13
|
+
const HOME_LABEL = 'Home';
|
|
14
|
+
|
|
15
|
+
/** Matches a URL that is already absolute (with a scheme) or protocol-relative. */
|
|
16
|
+
const ABSOLUTE_URL = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Normalises a frontmatter date into a short ISO date.
|
|
20
|
+
*
|
|
21
|
+
* YAML turns `date: 2026-01-15` into a `Date` object, but a quoted date stays
|
|
22
|
+
* a string: both forms must come out the same.
|
|
23
|
+
*
|
|
24
|
+
* @param {unknown} value
|
|
25
|
+
* @returns {string | undefined} `'2026-01-15'`, or `undefined` when unusable.
|
|
26
|
+
*/
|
|
27
|
+
function toISODate(value) {
|
|
28
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
* A number is not a date: `new Date('42')` nevertheless returns
|
|
32
|
+
* 31 December 2041, and `new Date('0')` the year 2000. YAML turns an
|
|
33
|
+
* unquoted `date: 42` into a number, and the page ended up dated in the
|
|
34
|
+
* next century without anything saying so.
|
|
35
|
+
*/
|
|
36
|
+
if (typeof value === 'number' || typeof value === 'boolean') return undefined;
|
|
37
|
+
if (!(value instanceof Date) && typeof value !== 'string') return undefined;
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
* A bare date is read in UTC, not in the machine's time zone. Otherwise
|
|
41
|
+
* `2026-01-15 00:30` is read as local time then truncated to UTC: the
|
|
42
|
+
* published date moves back one day east of Greenwich, and the same source
|
|
43
|
+
* gives two different outputs depending on where it is built.
|
|
44
|
+
*/
|
|
45
|
+
if (value instanceof Date) {
|
|
46
|
+
return Number.isNaN(value.getTime()) ? undefined : value.toISOString().slice(0, 10);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const text = value.trim();
|
|
50
|
+
// A calendar date, with or without a time: keep only the day, read in UTC.
|
|
51
|
+
const dateOnly = /^\d{4}-\d{2}-\d{2}([ T].*)?$/.test(text);
|
|
52
|
+
const date = dateOnly ? new Date(`${text.slice(0, 10)}T00:00:00Z`) : new Date(text);
|
|
53
|
+
|
|
54
|
+
if (Number.isNaN(date.getTime())) return undefined;
|
|
55
|
+
|
|
56
|
+
return date.toISOString().slice(0, 10);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Brings a value down to a list of non-empty strings.
|
|
61
|
+
*
|
|
62
|
+
* @param {unknown} value Single string, array, or nothing.
|
|
63
|
+
* @returns {string[]}
|
|
64
|
+
*/
|
|
65
|
+
function toList(value) {
|
|
66
|
+
const list = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
|
|
67
|
+
return list.map((entry) => String(entry).trim()).filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Lists the authors as `Person` nodes.
|
|
72
|
+
*
|
|
73
|
+
* @param {unknown} authors Single string or array of names.
|
|
74
|
+
* @returns {{ '@type': string, name: string }[]}
|
|
75
|
+
*/
|
|
76
|
+
function toPersons(authors) {
|
|
77
|
+
const list = Array.isArray(authors) ? authors : authors ? [authors] : [];
|
|
78
|
+
return list
|
|
79
|
+
.map((name) => String(name).trim())
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.map((name) => ({ '@type': 'Person', name }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Assembles a schema.org graph for a page. */
|
|
85
|
+
export class StructuredDataBuilder {
|
|
86
|
+
/**
|
|
87
|
+
* @param {Record<string, any>} frontmatter Page frontmatter.
|
|
88
|
+
* @param {string} url Page URL on the site (`'/guide/install/'`).
|
|
89
|
+
* @param {Record<string, any>} config Normalised project config.
|
|
90
|
+
* @param {{ breadcrumbTitles?: Record<string, string>, basePath?: string, dirUrl?: string }} [options]
|
|
91
|
+
* `breadcrumbTitles` maps a folder slug to its real title, so that the
|
|
92
|
+
* breadcrumb shows “Café Guide” rather than “Cafe guide”. `basePath` is
|
|
93
|
+
* the site root from which crumbs are counted: the generator sets
|
|
94
|
+
* `/versions/v1.0/`, otherwise the breadcrumb would show “Versions” and
|
|
95
|
+
* “V1.0”, which are neither pages nor titles. `dirUrl` is the source
|
|
96
|
+
* file's folder as a URL: the base of the frontmatter's relative targets.
|
|
97
|
+
*/
|
|
98
|
+
constructor(frontmatter, url, config, options = {}) {
|
|
99
|
+
this.frontmatter = frontmatter ?? {};
|
|
100
|
+
this.url = url;
|
|
101
|
+
this.config = config ?? {};
|
|
102
|
+
this.options = options;
|
|
103
|
+
this.breadcrumbTitles = options.breadcrumbTitles ?? {};
|
|
104
|
+
this.basePath = options.basePath ?? '/';
|
|
105
|
+
this.dirUrl = options.dirUrl;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Builds the page's schema.org graph.
|
|
110
|
+
*
|
|
111
|
+
* @returns {Record<string, any> | null} Object ready to serialise, or
|
|
112
|
+
* `null` when structured data is turned off in the configuration.
|
|
113
|
+
* @throws {StructuredDataError} Unknown article type, or malformed FAQ.
|
|
114
|
+
*/
|
|
115
|
+
build() {
|
|
116
|
+
if (this.config.jsonld?.enabled === false) return null;
|
|
117
|
+
|
|
118
|
+
const graph = [this.#organization(), this.#article()];
|
|
119
|
+
|
|
120
|
+
const breadcrumbs = this.#breadcrumbs();
|
|
121
|
+
if (breadcrumbs) graph.push(breadcrumbs);
|
|
122
|
+
|
|
123
|
+
const faq = this.#faq();
|
|
124
|
+
if (faq) graph.push(faq);
|
|
125
|
+
|
|
126
|
+
return { '@context': 'https://schema.org', '@graph': graph };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Serialises the graph into a tag ready for the `<head>`.
|
|
131
|
+
*
|
|
132
|
+
* @returns {string} `<script>` tag, or an empty string when disabled.
|
|
133
|
+
*/
|
|
134
|
+
toScriptTag() {
|
|
135
|
+
const graph = this.build();
|
|
136
|
+
if (!graph) return '';
|
|
137
|
+
|
|
138
|
+
// A "</script>" or a "<!--" inside a title would close the tag. Escaping
|
|
139
|
+
// "<" is enough and stays valid JSON.
|
|
140
|
+
const json = JSON.stringify(graph).replaceAll('<', '\\u003c');
|
|
141
|
+
return `<script type="application/ld+json">${json}</script>`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Makes a site URL absolute.
|
|
146
|
+
*
|
|
147
|
+
* Without a configured `siteUrl`, URLs stay relative: the graph is then less
|
|
148
|
+
* useful to search engines, but generation is not blocked for all that.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} target
|
|
151
|
+
* @returns {string}
|
|
152
|
+
*/
|
|
153
|
+
#absolute(target) {
|
|
154
|
+
if (!this.config.siteUrl || ABSOLUTE_URL.test(target)) return target;
|
|
155
|
+
return new URL(target, this.config.siteUrl).href;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolves a target written by the author, following the link rules
|
|
160
|
+
* (ADR-006).
|
|
161
|
+
*
|
|
162
|
+
* A relative target starts from the page's folder, an absolute one from the
|
|
163
|
+
* version root. Resolved against the site root, a “preview: ./thumb.png”
|
|
164
|
+
* pointed to an image that does not exist.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} target
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
#target(target) {
|
|
170
|
+
if (ABSOLUTE_URL.test(target)) return target;
|
|
171
|
+
const root = this.basePath.endsWith('/') ? this.basePath.slice(0, -1) : this.basePath;
|
|
172
|
+
if (target.startsWith('/')) {
|
|
173
|
+
return this.basePath === '/' || target.startsWith(this.basePath) ? target : root + target;
|
|
174
|
+
}
|
|
175
|
+
const resolved = new URL(target, `https://docpensieve.invalid${this.dirUrl ?? this.url}`);
|
|
176
|
+
return resolved.pathname + resolved.search + resolved.hash;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** @returns {string} Stable identifier of the organisation in the graph. */
|
|
180
|
+
#organizationId() {
|
|
181
|
+
/*
|
|
182
|
+
* The deployment prefix is part of the identity. Without it, two sites
|
|
183
|
+
* hosted on the same origin — two projects of the same pages account —
|
|
184
|
+
* would claim the same identifier under different names, and an engine
|
|
185
|
+
* reconciling by identifier would merge them.
|
|
186
|
+
*/
|
|
187
|
+
return `${this.#absolute(this.config.baseUrl)}#organization`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @returns {Record<string, any>} `Organization` node. */
|
|
191
|
+
#organization() {
|
|
192
|
+
/** @type {Record<string, any>} */
|
|
193
|
+
const node = {
|
|
194
|
+
'@type': 'Organization',
|
|
195
|
+
'@id': this.#organizationId(),
|
|
196
|
+
name: this.config.projectName,
|
|
197
|
+
};
|
|
198
|
+
if (this.config.siteUrl) node.url = this.config.siteUrl;
|
|
199
|
+
return node;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @returns {Record<string, any>} `Article`, `TechArticle` or `BlogPosting` node.
|
|
204
|
+
* @throws {StructuredDataError} When `jsonld.type` is not recognised.
|
|
205
|
+
*/
|
|
206
|
+
#article() {
|
|
207
|
+
const type = this.frontmatter.jsonld?.type ?? DEFAULT_TYPE;
|
|
208
|
+
if (!JSONLD_TYPES.includes(type)) {
|
|
209
|
+
throw new StructuredDataError(`Unknown JSON-LD type: "${type}".`, {
|
|
210
|
+
hint: `Accepted values for jsonld.type in the frontmatter: ${JSONLD_TYPES.join(', ')}.`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const pageUrl = this.#absolute(this.url);
|
|
215
|
+
/** @type {Record<string, any>} */
|
|
216
|
+
const node = {
|
|
217
|
+
'@type': type,
|
|
218
|
+
'@id': `${pageUrl}#article`,
|
|
219
|
+
headline: this.frontmatter.title ?? this.config.projectName,
|
|
220
|
+
mainEntityOfPage: { '@type': 'WebPage', '@id': pageUrl },
|
|
221
|
+
publisher: { '@id': this.#organizationId() },
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
if (this.frontmatter.description) node.description = this.frontmatter.description;
|
|
225
|
+
|
|
226
|
+
const published = toISODate(this.frontmatter.date);
|
|
227
|
+
if (published) node.datePublished = published;
|
|
228
|
+
|
|
229
|
+
// Without an explicit modification date, the publication date stands: an
|
|
230
|
+
// article never edited was indeed “modified” on the day it came out.
|
|
231
|
+
const modified = toISODate(this.frontmatter.modified) ?? published;
|
|
232
|
+
// A modification earlier than the publication describes an impossibility:
|
|
233
|
+
// publishing it would assert it.
|
|
234
|
+
if (published && modified && modified < published) {
|
|
235
|
+
throw new StructuredDataError(
|
|
236
|
+
`Modification date earlier than publication: ${modified} before ${published}.`,
|
|
237
|
+
{ hint: 'Fix "modified" or "date" in the page frontmatter.' },
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (modified) node.dateModified = modified;
|
|
241
|
+
|
|
242
|
+
const authors = toPersons(this.frontmatter.authors);
|
|
243
|
+
if (authors.length > 0) node.author = authors;
|
|
244
|
+
|
|
245
|
+
// A single tag is written without brackets, like a single author.
|
|
246
|
+
// Accepting only the array lost the value without a word.
|
|
247
|
+
const tags = toList(this.frontmatter.tags);
|
|
248
|
+
if (tags.length > 0) node.keywords = tags;
|
|
249
|
+
|
|
250
|
+
if (this.frontmatter.preview) {
|
|
251
|
+
node.image = this.#absolute(this.#target(String(this.frontmatter.preview)));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return node;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Derives the breadcrumb from the URL path.
|
|
259
|
+
*
|
|
260
|
+
* @returns {Record<string, any> | null} `null` on the home page, where a
|
|
261
|
+
* single crumb would tell nothing, or when the frontmatter turns them off.
|
|
262
|
+
*/
|
|
263
|
+
#breadcrumbs() {
|
|
264
|
+
if (this.frontmatter.jsonld?.breadcrumbs === false) return null;
|
|
265
|
+
|
|
266
|
+
const relative = this.url.startsWith(this.basePath)
|
|
267
|
+
? this.url.slice(this.basePath.length)
|
|
268
|
+
: this.url;
|
|
269
|
+
const segments = relative.split('/').filter(Boolean);
|
|
270
|
+
if (segments.length === 0) return null;
|
|
271
|
+
|
|
272
|
+
const items = [{ name: HOME_LABEL, url: this.#absolute(this.basePath) }];
|
|
273
|
+
|
|
274
|
+
let currentPath = this.basePath.replace(/\/$/, '');
|
|
275
|
+
segments.forEach((segment, index) => {
|
|
276
|
+
currentPath += `/${segment}`;
|
|
277
|
+
const isLast = index === segments.length - 1;
|
|
278
|
+
items.push({
|
|
279
|
+
name: isLast
|
|
280
|
+
? (this.frontmatter.title ?? humanizeSlug(segment))
|
|
281
|
+
: (this.breadcrumbTitles[segments.slice(0, index + 1).join('/')] ??
|
|
282
|
+
humanizeSlug(segment)),
|
|
283
|
+
url: this.#absolute(`${currentPath}/`),
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
'@type': 'BreadcrumbList',
|
|
289
|
+
'@id': `${this.#absolute(this.url)}#breadcrumb`,
|
|
290
|
+
itemListElement: items.map((item, index) => ({
|
|
291
|
+
'@type': 'ListItem',
|
|
292
|
+
position: index + 1,
|
|
293
|
+
name: item.name,
|
|
294
|
+
item: item.url,
|
|
295
|
+
})),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* @returns {Record<string, any> | null} `FAQPage` node, or `null` without FAQ.
|
|
301
|
+
* @throws {StructuredDataError} When an entry lacks a question or an answer.
|
|
302
|
+
*/
|
|
303
|
+
#faq() {
|
|
304
|
+
const entries = this.frontmatter.jsonld?.faq;
|
|
305
|
+
if (!Array.isArray(entries) || entries.length === 0) return null;
|
|
306
|
+
|
|
307
|
+
const questions = entries.map((entry, index) => {
|
|
308
|
+
if (!entry?.question || !entry?.answer) {
|
|
309
|
+
throw new StructuredDataError(
|
|
310
|
+
`Incomplete FAQ entry at position ${index + 1} (page ${this.url}).`,
|
|
311
|
+
{ hint: 'Every jsonld.faq entry must carry "question" and "answer".' },
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
'@type': 'Question',
|
|
316
|
+
// The rank is always a suffix: two questions that only punctuation
|
|
317
|
+
// tells apart give the same slug, and two nodes with the same
|
|
318
|
+
// identifier are one to a JSON-LD processor — the second disappears.
|
|
319
|
+
'@id': `${this.#absolute(this.url)}#faq-${index + 1}-${slugify(entry.question)}`.replace(
|
|
320
|
+
/-$/,
|
|
321
|
+
'',
|
|
322
|
+
),
|
|
323
|
+
name: String(entry.question),
|
|
324
|
+
acceptedAnswer: { '@type': 'Answer', text: String(entry.answer) },
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
'@type': 'FAQPage',
|
|
330
|
+
'@id': `${this.#absolute(this.url)}#faq`,
|
|
331
|
+
mainEntity: questions,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
}
|