@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/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
+ }