@docpensieve/core 0.1.4 → 0.2.0-beta.1

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/sidebar.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * @module @docpensieve/core/sidebar
5
5
  */
6
6
 
7
- import { humanizeSlug } from '@docpensieve/shared';
7
+ import { ConfigError, humanizeSlug } from '@docpensieve/shared';
8
8
 
9
9
  /**
10
10
  * @typedef {object} SidebarNode
@@ -103,3 +103,184 @@ export function collectSectionTitles(docs) {
103
103
 
104
104
  return titles;
105
105
  }
106
+
107
+ /**
108
+ * An entry of a sidebar description: a page path, or an object — see
109
+ * `buildSidebarFromDescription`.
110
+ *
111
+ * @typedef {string | {
112
+ * page?: string, label?: string, items?: SidebarEntry[], href?: string, auto?: string,
113
+ * }} SidebarEntry
114
+ */
115
+
116
+ /** The kinds of entry, for the hints. */
117
+ const ENTRY_KINDS =
118
+ 'Entries: "guide/installation", { "page", "label" }, { "label", "items", "page" }, ' +
119
+ '{ "label", "href" } or { "auto": "folder" }.';
120
+
121
+ /**
122
+ * @param {string} value
123
+ * @returns {string} The value without its leading and trailing slashes.
124
+ */
125
+ function trimSlashes(value) {
126
+ let start = 0;
127
+ let end = value.length;
128
+ while (start < end && value[start] === '/') start += 1;
129
+ while (end > start && value[end - 1] === '/') end -= 1;
130
+ return value.slice(start, end);
131
+ }
132
+
133
+ /**
134
+ * Builds the navigation tree of a version from a description.
135
+ *
136
+ * The description is an array of entries, kept in the order written:
137
+ *
138
+ * - `"guide/installation"` — a page, by its path within the version, as in
139
+ * its URL; `"/"` is the home page. Its title becomes the label.
140
+ * - `{ "page": "guide/installation", "label": "Install" }` — the same, with a
141
+ * label of its own.
142
+ * - `{ "label": "Guide", "items": [ … ], "page": "guide" }` — a category,
143
+ * clickable when it names a page.
144
+ * - `{ "label": "Repository", "href": "https://…" }` — a link outside the site.
145
+ * - `{ "auto": "docpensieve" }` — the automatic tree of a folder: a section
146
+ * keeps its own menu without listing its pages one by one.
147
+ *
148
+ * A page left out stays published: it is only absent from the menu, which is
149
+ * how a page is kept off it.
150
+ *
151
+ * @param {unknown} description Parsed content of the description file.
152
+ * @param {import('./loader.js').Doc[]} docs Documents of the version.
153
+ * @param {(doc: import('./loader.js').Doc) => string} [toUrl] As for
154
+ * `buildSidebar`.
155
+ * @param {{ source?: string }} [options] `source` names the file in messages.
156
+ * @returns {SidebarNode[]}
157
+ * @throws {ConfigError} For a path that names no page, a page listed twice,
158
+ * or an entry of no known kind.
159
+ */
160
+ export function buildSidebarFromDescription(
161
+ description,
162
+ docs,
163
+ toUrl = (doc) => doc.url,
164
+ options = {},
165
+ ) {
166
+ const source = options.source ?? 'The sidebar description';
167
+ const bySlug = new Map(docs.map((doc) => [doc.slug, doc]));
168
+ /** @type {Set<string>} */
169
+ const listed = new Set();
170
+
171
+ /** @param {string} message @param {string} hint */
172
+ const fail = (message, hint) => new ConfigError(`${source}: ${message}`, { hint });
173
+
174
+ /** @param {string} slug @returns {string} Up to five known paths near it. */
175
+ const nearby = (slug) => {
176
+ const known = [...bySlug.keys()];
177
+ const first = slug.split('/')[0];
178
+ const close = known.filter((candidate) => candidate.split('/')[0] === first);
179
+ return (close.length > 0 ? close : known)
180
+ .slice(0, 5)
181
+ .map((candidate) => `"${candidate || '/'}"`)
182
+ .join(', ');
183
+ };
184
+
185
+ /** @param {string} slug */
186
+ const claim = (slug) => {
187
+ // The menu marks a single entry as the current page: listed twice, a page
188
+ // would light up in two places, or in the wrong one.
189
+ if (listed.has(slug)) {
190
+ throw fail(`the page "${slug || '/'}" is listed twice.`, 'List each page once.');
191
+ }
192
+ listed.add(slug);
193
+ };
194
+
195
+ /** @param {unknown} raw @returns {import('./loader.js').Doc} */
196
+ const pageOf = (raw) => {
197
+ const slug = trimSlashes(String(raw));
198
+ const doc = bySlug.get(slug);
199
+ if (!doc) {
200
+ throw fail(
201
+ `no page "${String(raw)}".`,
202
+ `A page is named by its path within the version, as in its URL. Close to it: ${nearby(slug)}.`,
203
+ );
204
+ }
205
+ claim(slug);
206
+ return doc;
207
+ };
208
+
209
+ /** @param {import('./loader.js').Doc} doc @returns {string} */
210
+ const titleOf = (doc) =>
211
+ String(
212
+ doc.frontmatter?.title ?? (doc.slug ? humanizeSlug(doc.slug.split('/').pop() ?? '') : 'Home'),
213
+ );
214
+
215
+ /** @param {unknown} entry @returns {SidebarNode[]} */
216
+ const expand = (entry) => {
217
+ if (typeof entry === 'string') {
218
+ const doc = pageOf(entry);
219
+ return [{ label: titleOf(doc), url: toUrl(doc), items: [] }];
220
+ }
221
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
222
+ throw fail(
223
+ `an entry must be a page path or an object: ${JSON.stringify(entry)}.`,
224
+ ENTRY_KINDS,
225
+ );
226
+ }
227
+
228
+ const item = /** @type {Record<string, unknown>} */ (entry);
229
+ const label = typeof item.label === 'string' && item.label ? item.label : undefined;
230
+
231
+ if (item.href !== undefined) {
232
+ if (!label || typeof item.href !== 'string') {
233
+ throw fail(`a link needs a label and an href: ${JSON.stringify(entry)}.`, ENTRY_KINDS);
234
+ }
235
+ return [{ label, url: item.href, items: [] }];
236
+ }
237
+
238
+ if (item.auto !== undefined) {
239
+ const folder = trimSlashes(String(item.auto));
240
+ const inside = docs.filter(
241
+ (doc) => !folder || doc.slug === folder || doc.slug.startsWith(`${folder}/`),
242
+ );
243
+ if (inside.length === 0) {
244
+ throw fail(
245
+ `the folder "${String(item.auto)}" holds no page.`,
246
+ `Name a folder of the version, as in its URLs. Close to it: ${nearby(folder)}.`,
247
+ );
248
+ }
249
+ for (const doc of inside) claim(doc.slug);
250
+
251
+ const tree = buildSidebar(inside, toUrl);
252
+ if (!folder) return tree;
253
+ // The tree starts at the version's root, one node per level down to the
254
+ // folder, since every page shares its path.
255
+ let [node] = tree;
256
+ for (let depth = 1; depth < folder.split('/').length; depth += 1) [node] = node.items;
257
+ return [{ ...node, label: label ?? node.label }];
258
+ }
259
+
260
+ if (item.items !== undefined) {
261
+ if (!label || !Array.isArray(item.items)) {
262
+ throw fail(
263
+ `a category needs a label and a list of items: ${JSON.stringify(entry)}.`,
264
+ ENTRY_KINDS,
265
+ );
266
+ }
267
+ const url = item.page !== undefined ? toUrl(pageOf(item.page)) : null;
268
+ return [{ label, url, items: item.items.flatMap(expand) }];
269
+ }
270
+
271
+ if (item.page !== undefined) {
272
+ const doc = pageOf(item.page);
273
+ return [{ label: label ?? titleOf(doc), url: toUrl(doc), items: [] }];
274
+ }
275
+
276
+ throw fail(
277
+ `${JSON.stringify(entry)} is neither a page, a category, a link nor an automatic folder.`,
278
+ ENTRY_KINDS,
279
+ );
280
+ };
281
+
282
+ if (!Array.isArray(description)) {
283
+ throw fail('it must hold an array of entries.', ENTRY_KINDS);
284
+ }
285
+ return description.flatMap(expand);
286
+ }
@@ -24,7 +24,7 @@ const ABSOLUTE_URL = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
24
24
  * @param {unknown} value
25
25
  * @returns {string | undefined} `'2026-01-15'`, or `undefined` when unusable.
26
26
  */
27
- function toISODate(value) {
27
+ export function toISODate(value) {
28
28
  if (value === undefined || value === null || value === '') return undefined;
29
29
 
30
30
  /*
@@ -87,7 +87,7 @@ export class StructuredDataBuilder {
87
87
  * @param {Record<string, any>} frontmatter Page frontmatter.
88
88
  * @param {string} url Page URL on the site (`'/guide/install/'`).
89
89
  * @param {Record<string, any>} config Normalised project config.
90
- * @param {{ breadcrumbTitles?: Record<string, string>, basePath?: string, dirUrl?: string }} [options]
90
+ * @param {{ breadcrumbTitles?: Record<string, string>, basePath?: string, dirUrl?: string, logo?: string }} [options]
91
91
  * `breadcrumbTitles` maps a folder slug to its real title, so that the
92
92
  * breadcrumb shows “Café Guide” rather than “Cafe guide”. `basePath` is
93
93
  * the site root from which crumbs are counted: the generator sets
@@ -103,6 +103,8 @@ export class StructuredDataBuilder {
103
103
  this.breadcrumbTitles = options.breadcrumbTitles ?? {};
104
104
  this.basePath = options.basePath ?? '/';
105
105
  this.dirUrl = options.dirUrl;
106
+ // URL of the project's logo, once the generator has copied it.
107
+ this.logo = options.logo;
106
108
  }
107
109
 
108
110
  /**
@@ -196,6 +198,7 @@ export class StructuredDataBuilder {
196
198
  name: this.config.projectName,
197
199
  };
198
200
  if (this.config.siteUrl) node.url = this.config.siteUrl;
201
+ if (this.logo) node.logo = this.#absolute(this.logo);
199
202
  return node;
200
203
  }
201
204
 
@@ -13,10 +13,33 @@
13
13
  {{#if canonical}}
14
14
  <link rel="canonical" href="{{canonical}}" />
15
15
  {{/if}}
16
+ {{#if feedUrl}}
17
+ <link rel="alternate" type="application/rss+xml" title="{{projectName}}" href="{{feedUrl}}" />
18
+ {{/if}}
19
+ {{#if favicon}}
20
+ <link rel="icon" href="{{favicon.href}}" type="{{favicon.type}}" />
21
+ {{/if}}
22
+ {{!-- What a social network shows of a shared page. Only with a preview
23
+ image: without one, the title and description alone are read anyway. --}}
24
+ {{#if socialImage}}
25
+ <meta property="og:type" content="website" />
26
+ <meta property="og:site_name" content="{{projectName}}" />
27
+ <meta property="og:title" content="{{title}}" />
28
+ {{#if description}}
29
+ <meta property="og:description" content="{{description}}" />
30
+ {{/if}}
31
+ <meta property="og:url" content="{{canonical}}" />
32
+ <meta property="og:image" content="{{socialImage}}" />
33
+ <meta name="twitter:card" content="summary_large_image" />
34
+ {{/if}}
16
35
  {{#each preloads}}
17
36
  <link rel="preload" as="{{as}}" href="{{href}}" />
18
37
  {{/each}}
19
38
  <link rel="stylesheet" href="{{cssHref}}" />
39
+ {{!-- Only the search page carries a script: content pages load none. --}}
40
+ {{#each scripts}}
41
+ <script type="module" src="{{this}}"></script>
42
+ {{/each}}
20
43
  {{{jsonld}}}
21
44
  </head>
22
45
  <body>
@@ -24,7 +47,8 @@
24
47
 
25
48
  {{!-- Target of the back-to-top link: bring the focus back, not only the view. --}}
26
49
  <header class="{{{cls.header}}}" id="top" tabindex="-1">
27
- <a class="{{{cls.brand}}}" href="{{homeUrl}}">{{projectName}}</a>
50
+ {{!-- The logo's alt stays empty: the name that follows already says it. --}}
51
+ <a class="{{{cls.brand}}}" href="{{homeUrl}}">{{#if logoUrl}}<img class="{{{cls.brandLogo}}}" src="{{logoUrl}}" alt="" />{{/if}}{{projectName}}</a>
28
52
  {{#if showVersions}}
29
53
  <details class="{{{cls.versions}}}">
30
54
  {{!-- The accessible name contains the visible label: when dictated by
@@ -37,6 +61,12 @@
37
61
  </ul>
38
62
  </details>
39
63
  {{/if}}
64
+ {{!-- A plain form: it leads to the search page, and needs no script. --}}
65
+ {{#if searchUrl}}
66
+ <form class="{{{cls.search}}}" role="search" action="{{searchUrl}}">
67
+ <input type="search" name="q" placeholder="Search" aria-label="Search the documentation" />
68
+ </form>
69
+ {{/if}}
40
70
  </header>
41
71
 
42
72
  <div class="{{#if wide}}{{{cls.shellWide}}}{{else}}{{{cls.shell}}}{{/if}}">
@@ -93,7 +93,7 @@ export declare class Compiler {
93
93
  * Compiles a source into an HTML fragment and a table of contents.
94
94
  *
95
95
  * @param {string} source Markdown/MDX content, frontmatter already removed.
96
- * @param {{ filepath?: string, url?: string, dirUrl?: string, basePath?: string }} [context]
96
+ * @param {{ filepath?: string, url?: string, dirUrl?: string, basePath?: string, sourceDir?: string }} [context]
97
97
  * `filepath` locates errors, `dirUrl` is the base of relative targets and
98
98
  * `basePath` prefixes absolute targets (the version root).
99
99
  * @returns {Promise<CompileResult>}
@@ -104,5 +104,6 @@ export declare class Compiler {
104
104
  url?: string;
105
105
  dirUrl?: string;
106
106
  basePath?: string;
107
+ sourceDir?: string;
107
108
  }): Promise<CompileResult>;
108
109
  }
package/types/config.d.ts CHANGED
@@ -29,6 +29,14 @@ export type Version = {
29
29
  * reference one. Its pages carry a notice and are not indexed.
30
30
  */
31
31
  prerelease?: boolean;
32
+ /**
33
+ * Logo of this version, instead of the project's.
34
+ */
35
+ logo?: string;
36
+ /**
37
+ * Favicon of this version, instead of the project's.
38
+ */
39
+ favicon?: string;
32
40
  };
33
41
  export type DocPensieveConfig = {
34
42
  /**
@@ -70,6 +78,30 @@ export type DocPensieveConfig = {
70
78
  jsonld: {
71
79
  enabled: boolean;
72
80
  };
81
+ /**
82
+ * Image beside the project name, in the header.
83
+ */
84
+ logo?: string;
85
+ /**
86
+ * Icon of the browser tab: `.ico`, `.png` or `.svg`.
87
+ */
88
+ favicon?: string;
89
+ /**
90
+ * Preview of a shared page. Needs `siteUrl`.
91
+ */
92
+ socialImage?: string;
93
+ /**
94
+ * `sitemap.xml` of the published versions.
95
+ */
96
+ sitemap?: boolean;
97
+ /**
98
+ * RSS feed of the dated pages of the current version.
99
+ */
100
+ feed?: boolean;
101
+ /**
102
+ * Search field, index and search page of each version.
103
+ */
104
+ search?: boolean;
73
105
  /**
74
106
  * Project root, set by `loadConfig`.
75
107
  */
@@ -92,6 +124,8 @@ export type DocPensieveConfig = {
92
124
  * @property {boolean} [archived] Version kept but no longer maintained.
93
125
  * @property {boolean} [prerelease] Version in preparation, not yet the
94
126
  * reference one. Its pages carry a notice and are not indexed.
127
+ * @property {string} [logo] Logo of this version, instead of the project's.
128
+ * @property {string} [favicon] Favicon of this version, instead of the project's.
95
129
  */
96
130
  /**
97
131
  * @typedef {object} DocPensieveConfig
@@ -105,6 +139,12 @@ export type DocPensieveConfig = {
105
139
  * @property {boolean} globalComponents
106
140
  * @property {boolean} scrollToTop Back-to-top button on every page.
107
141
  * @property {{ enabled: boolean }} jsonld
142
+ * @property {string} [logo] Image beside the project name, in the header.
143
+ * @property {string} [favicon] Icon of the browser tab: `.ico`, `.png` or `.svg`.
144
+ * @property {string} [socialImage] Preview of a shared page. Needs `siteUrl`.
145
+ * @property {boolean} [sitemap] `sitemap.xml` of the published versions.
146
+ * @property {boolean} [feed] RSS feed of the dated pages of the current version.
147
+ * @property {boolean} [search] Search field, index and search page of each version.
108
148
  * @property {string} [rootDir] Project root, set by `loadConfig`.
109
149
  * @property {string} [configFile] Path of the configuration file, set by `loadConfig`.
110
150
  * @property {string} [lang] Document language, `'en'` by default.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * What a site offers the programs that read it: the sitemap for search
3
+ * engines, `robots.txt` that points to it, and the RSS feed of dated pages.
4
+ *
5
+ * Pure functions: the generator gathers the published pages and writes the
6
+ * files.
7
+ *
8
+ * @module @docpensieve/core/discovery
9
+ */
10
+ export type PublishedPage = {
11
+ /**
12
+ * Page URL, deployment prefix included
13
+ * (`/docs/versions/v1.0/guide/`).
14
+ */
15
+ url: string;
16
+ /**
17
+ * Frontmatter of its source.
18
+ */
19
+ frontmatter: Record<string, any>;
20
+ };
21
+ /**
22
+ * Builds `sitemap.xml`.
23
+ *
24
+ * `lastmod` is the page's `modified` date, or failing that its `date`; a page
25
+ * that carries neither is listed without one rather than with a made-up date.
26
+ *
27
+ * @param {PublishedPage[]} pages Pages of the versions to list.
28
+ * @param {string} siteUrl Public address of the site: the sitemap only holds
29
+ * absolute addresses.
30
+ * @returns {string}
31
+ */
32
+ export declare function buildSitemap(pages: PublishedPage[], siteUrl: string): string;
33
+ /**
34
+ * Builds `robots.txt`, which lets every crawler in and names the sitemap.
35
+ *
36
+ * @param {string} sitemapUrl Absolute address of the sitemap.
37
+ * @returns {string}
38
+ */
39
+ export declare function buildRobots(sitemapUrl: string): string;
40
+ /**
41
+ * Builds the RSS feed of the dated pages, newest first.
42
+ *
43
+ * Only a page with a `date` enters it: a documentation page without one is
44
+ * reference material, not news, and dating it at build time would announce
45
+ * every page again at every build.
46
+ *
47
+ * @param {PublishedPage[]} pages Pages of the current version.
48
+ * @param {{
49
+ * projectName: string, siteUrl: string, homeUrl: string, feedUrl: string, lang?: string,
50
+ * }} site `homeUrl` and `feedUrl` are absolute.
51
+ * @returns {string}
52
+ */
53
+ export declare function buildFeed(pages: PublishedPage[], site: {
54
+ projectName: string;
55
+ siteUrl: string;
56
+ homeUrl: string;
57
+ feedUrl: string;
58
+ lang?: string;
59
+ }): string;
@@ -61,12 +61,14 @@ export declare class SiteGenerator {
61
61
  *
62
62
  * @param {string} versionSlug Slug of the version to generate.
63
63
  * @param {string} outDir Output folder of that version.
64
- * @returns {Promise<{ pages: number, outDir: string }>}
64
+ * @returns {Promise<{ pages: number, outDir: string, published: import('./discovery.js').PublishedPage[] }>}
65
+ * `published` lists the pages as the site serves them, for the sitemap and the feed.
65
66
  * @throws {GeneratorError} Write failure.
66
67
  */
67
68
  buildVersion(versionSlug: string, outDir: string): Promise<{
68
69
  pages: number;
69
70
  outDir: string;
71
+ published: import('./discovery.js').PublishedPage[];
70
72
  }>;
71
73
  /**
72
74
  * Generates every declared version, plus `versions.json` and a root that
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Dimensions of an image, read from the first bytes of its file.
3
+ *
4
+ * The build writes them on every image of a page: the browser then keeps the
5
+ * room before the image arrives, instead of shifting the text when it does.
6
+ * Only the header of each format is read — no decoding, no dependency.
7
+ *
8
+ * @module @docpensieve/core/image-size
9
+ */
10
+ export type ImageSize = {
11
+ width: number;
12
+ height: number;
13
+ };
14
+ /**
15
+ * Reads the dimensions of an image from its content.
16
+ *
17
+ * @param {Buffer} bytes Content of the file — its first kilobytes are enough.
18
+ * @param {string} extension Extension of the file, dot included.
19
+ * @returns {ImageSize | null} `null` for an unknown or damaged format: the
20
+ * image is then written without dimensions, as before.
21
+ */
22
+ export declare function imageSize(bytes: Buffer, extension: string): ImageSize | null;
package/types/index.d.ts CHANGED
@@ -31,4 +31,5 @@ export { DocLoader } from './loader.js';
31
31
  export { Compiler } from './compiler.js';
32
32
  export { StructuredDataBuilder } from './structured-data.js';
33
33
  export { SiteGenerator } from './generator.js';
34
- export { buildSidebar, collectSectionTitles } from './sidebar.js';
34
+ export { buildSidebar, buildSidebarFromDescription, collectSectionTitles } from './sidebar.js';
35
+ export { buildFeed, buildRobots, buildSitemap } from './discovery.js';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Minification of the produced stylesheet.
3
+ *
4
+ * Deliberately cautious: comments go, and so does the whitespace that nothing
5
+ * reads — indentation, line breaks, the space around `{`, `}` and `;`. Every
6
+ * other space stays one space, since some carry meaning (`.a :hover` is not
7
+ * `.a:hover`), and the content of a string is never touched. That keeps most
8
+ * of the gain of a real minifier without its risk of changing what a rule
9
+ * means.
10
+ *
11
+ * @module @docpensieve/core/minify-css
12
+ */
13
+ /**
14
+ * @param {string} css
15
+ * @returns {string} The same rules, lighter.
16
+ */
17
+ export declare function minifyCss(css: string): string;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * What the build prepares for search: the index of a version, and its search
3
+ * page.
4
+ *
5
+ * Search is built here, not in the reader's browser: the index holds the
6
+ * plain text of every page, and the search page already lists every page,
7
+ * so that it is useful before any script runs — and without one.
8
+ *
9
+ * @module @docpensieve/core/search-index
10
+ */
11
+ /** Path of the search page within a version. */
12
+ export declare const SEARCH_SLUG = "search";
13
+ /**
14
+ * Turns rendered HTML into the plain text a reader sees.
15
+ *
16
+ * @param {string} html
17
+ * @returns {string}
18
+ */
19
+ export declare function htmlToText(html: string): string;
20
+ /**
21
+ * Renders the content of the search page: a field, and the list of every
22
+ * page of the version, which the script filters.
23
+ *
24
+ * @param {{ title: string, url: string, description: string }[]} entries
25
+ * Pages of the version, in reading order.
26
+ * @param {string} indexUrl URL of the version's index.
27
+ * @returns {string}
28
+ */
29
+ export declare function searchPageContent(entries: {
30
+ title: string;
31
+ url: string;
32
+ description: string;
33
+ }[], indexUrl: string): string;
@@ -58,3 +58,40 @@ export declare function buildSidebar(docs: import('./loader.js').Doc[], toUrl?:
58
58
  * @returns {Record<string, string>} Full folder slug to title.
59
59
  */
60
60
  export declare function collectSectionTitles(docs: import('./loader.js').Doc[]): Record<string, string>;
61
+ export type SidebarEntry = string | {
62
+ page?: string;
63
+ label?: string;
64
+ items?: SidebarEntry[];
65
+ href?: string;
66
+ auto?: string;
67
+ };
68
+ /**
69
+ * Builds the navigation tree of a version from a description.
70
+ *
71
+ * The description is an array of entries, kept in the order written:
72
+ *
73
+ * - `"guide/installation"` — a page, by its path within the version, as in
74
+ * its URL; `"/"` is the home page. Its title becomes the label.
75
+ * - `{ "page": "guide/installation", "label": "Install" }` — the same, with a
76
+ * label of its own.
77
+ * - `{ "label": "Guide", "items": [ … ], "page": "guide" }` — a category,
78
+ * clickable when it names a page.
79
+ * - `{ "label": "Repository", "href": "https://…" }` — a link outside the site.
80
+ * - `{ "auto": "docpensieve" }` — the automatic tree of a folder: a section
81
+ * keeps its own menu without listing its pages one by one.
82
+ *
83
+ * A page left out stays published: it is only absent from the menu, which is
84
+ * how a page is kept off it.
85
+ *
86
+ * @param {unknown} description Parsed content of the description file.
87
+ * @param {import('./loader.js').Doc[]} docs Documents of the version.
88
+ * @param {(doc: import('./loader.js').Doc) => string} [toUrl] As for
89
+ * `buildSidebar`.
90
+ * @param {{ source?: string }} [options] `source` names the file in messages.
91
+ * @returns {SidebarNode[]}
92
+ * @throws {ConfigError} For a path that names no page, a page listed twice,
93
+ * or an entry of no known kind.
94
+ */
95
+ export declare function buildSidebarFromDescription(description: unknown, docs: import('./loader.js').Doc[], toUrl?: (doc: import('./loader.js').Doc) => string, options?: {
96
+ source?: string;
97
+ }): SidebarNode[];
@@ -3,6 +3,16 @@
3
3
  *
4
4
  * @module @docpensieve/core/structured-data
5
5
  */
6
+ /**
7
+ * Normalises a frontmatter date into a short ISO date.
8
+ *
9
+ * YAML turns `date: 2026-01-15` into a `Date` object, but a quoted date stays
10
+ * a string: both forms must come out the same.
11
+ *
12
+ * @param {unknown} value
13
+ * @returns {string | undefined} `'2026-01-15'`, or `undefined` when unusable.
14
+ */
15
+ export declare function toISODate(value: unknown): string | undefined;
6
16
  /** Assembles a schema.org graph for a page. */
7
17
  export declare class StructuredDataBuilder {
8
18
  #private;
@@ -13,15 +23,17 @@ export declare class StructuredDataBuilder {
13
23
  breadcrumbTitles?: Record<string, string>;
14
24
  basePath?: string;
15
25
  dirUrl?: string;
26
+ logo?: string;
16
27
  };
17
28
  breadcrumbTitles: Record<string, string>;
18
29
  basePath: string;
19
30
  dirUrl: string | undefined;
31
+ logo: string | undefined;
20
32
  /**
21
33
  * @param {Record<string, any>} frontmatter Page frontmatter.
22
34
  * @param {string} url Page URL on the site (`'/guide/install/'`).
23
35
  * @param {Record<string, any>} config Normalised project config.
24
- * @param {{ breadcrumbTitles?: Record<string, string>, basePath?: string, dirUrl?: string }} [options]
36
+ * @param {{ breadcrumbTitles?: Record<string, string>, basePath?: string, dirUrl?: string, logo?: string }} [options]
25
37
  * `breadcrumbTitles` maps a folder slug to its real title, so that the
26
38
  * breadcrumb shows “Café Guide” rather than “Cafe guide”. `basePath` is
27
39
  * the site root from which crumbs are counted: the generator sets
@@ -33,6 +45,7 @@ export declare class StructuredDataBuilder {
33
45
  breadcrumbTitles?: Record<string, string>;
34
46
  basePath?: string;
35
47
  dirUrl?: string;
48
+ logo?: string;
36
49
  });
37
50
  /**
38
51
  * Builds the page's schema.org graph.