@docpensieve/core 0.1.5 → 0.2.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/README.md +4 -0
- package/client/search.js +205 -0
- package/package.json +4 -3
- package/src/compiler.js +76 -2
- package/src/config.js +81 -9
- package/src/discovery.js +128 -0
- package/src/generator.js +300 -36
- package/src/image-size.js +128 -0
- package/src/index.js +2 -1
- package/src/minify-css.js +70 -0
- package/src/search-index.js +76 -0
- package/src/sidebar.js +182 -1
- package/src/structured-data.js +1 -1
- package/templates/layout.hbs +35 -2
- package/types/compiler.d.ts +2 -1
- package/types/config.d.ts +27 -1
- package/types/discovery.d.ts +59 -0
- package/types/generator.d.ts +3 -1
- package/types/image-size.d.ts +22 -0
- package/types/index.d.ts +2 -1
- package/types/minify-css.d.ts +17 -0
- package/types/search-index.d.ts +33 -0
- package/types/sidebar.d.ts +37 -0
- package/types/structured-data.d.ts +10 -0
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
|
|
12
|
+
/** Path of the search page within a version. */
|
|
13
|
+
export const SEARCH_SLUG = 'search';
|
|
14
|
+
|
|
15
|
+
/** @type {Record<string, string>} */
|
|
16
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Turns rendered HTML into the plain text a reader sees.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} html
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function htmlToText(html) {
|
|
25
|
+
return html
|
|
26
|
+
.replace(/<(script|style|svg)\b[\s\S]*?<\/\1>/gi, ' ')
|
|
27
|
+
.replace(/<[^>]+>/g, ' ')
|
|
28
|
+
.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, name) => {
|
|
29
|
+
if (name[0] !== '#') return ENTITIES[name.toLowerCase()] ?? entity;
|
|
30
|
+
const code =
|
|
31
|
+
name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : Number(name.slice(1));
|
|
32
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : entity;
|
|
33
|
+
})
|
|
34
|
+
.replace(/\s+/g, ' ')
|
|
35
|
+
.trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Escapes a value for HTML.
|
|
40
|
+
*
|
|
41
|
+
* @param {unknown} value
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
function escapeHtml(value) {
|
|
45
|
+
return String(value)
|
|
46
|
+
.replaceAll('&', '&')
|
|
47
|
+
.replaceAll('<', '<')
|
|
48
|
+
.replaceAll('>', '>')
|
|
49
|
+
.replaceAll('"', '"');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Renders the content of the search page: a field, and the list of every
|
|
54
|
+
* page of the version, which the script filters.
|
|
55
|
+
*
|
|
56
|
+
* @param {{ title: string, url: string, description: string }[]} entries
|
|
57
|
+
* Pages of the version, in reading order.
|
|
58
|
+
* @param {string} indexUrl URL of the version's index.
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function searchPageContent(entries, indexUrl) {
|
|
62
|
+
const items = entries.map((entry) => {
|
|
63
|
+
const description = entry.description ? `<p>${escapeHtml(entry.description)}</p>` : '';
|
|
64
|
+
return `<li data-url="${escapeHtml(entry.url)}"><a href="${escapeHtml(entry.url)}">${escapeHtml(entry.title)}</a>${description}</li>`;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return [
|
|
68
|
+
'<h1 id="search">Search</h1>',
|
|
69
|
+
`<form class="dp-search-page" role="search" data-search-page data-index="${escapeHtml(indexUrl)}">`,
|
|
70
|
+
'<label for="dp-search-query">Search the documentation</label>',
|
|
71
|
+
'<input id="dp-search-query" type="search" name="q" autocomplete="off" />',
|
|
72
|
+
'</form>',
|
|
73
|
+
`<p class="dp-search-status" data-search-status aria-live="polite">${entries.length} pages.</p>`,
|
|
74
|
+
`<ol class="dp-search-results" data-search-results>${items.join('')}</ol>`,
|
|
75
|
+
].join('');
|
|
76
|
+
}
|
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
|
+
}
|
package/src/structured-data.js
CHANGED
|
@@ -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
|
/*
|
package/templates/layout.hbs
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
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}}
|
|
16
19
|
{{#if favicon}}
|
|
17
20
|
<link rel="icon" href="{{favicon.href}}" type="{{favicon.type}}" />
|
|
18
21
|
{{/if}}
|
|
@@ -33,13 +36,27 @@
|
|
|
33
36
|
<link rel="preload" as="{{as}}" href="{{href}}" />
|
|
34
37
|
{{/each}}
|
|
35
38
|
<link rel="stylesheet" href="{{cssHref}}" />
|
|
39
|
+
{{#if schemeToggle}}
|
|
40
|
+
{{!-- Before the first paint: the scheme the reader chose, not a flash of the
|
|
41
|
+
other one. --}}
|
|
42
|
+
<script>try{var s=localStorage.getItem('dp-scheme'),c=document.documentElement.classList;if(s==='dark'||s==='light'){c.remove('dark','light');c.add(s)}}catch(e){}</script>
|
|
43
|
+
{{/if}}
|
|
44
|
+
{{!-- Only the search page carries a script: content pages load none. --}}
|
|
45
|
+
{{#each scripts}}
|
|
46
|
+
<script type="module" src="{{this}}"></script>
|
|
47
|
+
{{/each}}
|
|
36
48
|
{{{jsonld}}}
|
|
37
49
|
</head>
|
|
38
50
|
<body>
|
|
39
51
|
<a class="{{{cls.skip}}}" href="#content">Skip to content</a>
|
|
40
52
|
|
|
41
|
-
{{!-- Target of the back-to-top link:
|
|
42
|
-
|
|
53
|
+
{{!-- Target of the back-to-top link: it brings the focus back, not only the
|
|
54
|
+
view. It stays out of the header on purpose — the header is sticky, so it
|
|
55
|
+
is already in view at any scroll position, and a browser asked to bring it
|
|
56
|
+
into view scrolled nowhere. --}}
|
|
57
|
+
<div id="top" tabindex="-1"></div>
|
|
58
|
+
|
|
59
|
+
<header class="{{{cls.header}}}">
|
|
43
60
|
{{!-- The logo's alt stays empty: the name that follows already says it. --}}
|
|
44
61
|
<a class="{{{cls.brand}}}" href="{{homeUrl}}">{{#if logoUrl}}<img class="{{{cls.brandLogo}}}" src="{{logoUrl}}" alt="" />{{/if}}{{projectName}}</a>
|
|
45
62
|
{{#if showVersions}}
|
|
@@ -54,6 +71,19 @@
|
|
|
54
71
|
</ul>
|
|
55
72
|
</details>
|
|
56
73
|
{{/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
|
+
{{!-- Hidden until its script runs: without JavaScript, it would do nothing. --}}
|
|
81
|
+
{{#if schemeToggle}}
|
|
82
|
+
<button class="{{{cls.schemeToggle}}}" type="button" data-scheme-toggle hidden aria-label="Switch the colour scheme">
|
|
83
|
+
<svg class="dp-scheme-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></svg>
|
|
84
|
+
<svg class="dp-scheme-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></svg>
|
|
85
|
+
</button>
|
|
86
|
+
{{/if}}
|
|
57
87
|
</header>
|
|
58
88
|
|
|
59
89
|
<div class="{{#if wide}}{{{cls.shellWide}}}{{else}}{{{cls.shell}}}{{/if}}">
|
|
@@ -107,5 +137,8 @@
|
|
|
107
137
|
</svg>
|
|
108
138
|
</a>
|
|
109
139
|
{{/if}}
|
|
140
|
+
{{#if schemeToggle}}
|
|
141
|
+
<script>(function(){var b=document.querySelector('[data-scheme-toggle]');if(!b)return;var r=document.documentElement.classList;function dark(){return r.contains('dark')||(!r.contains('light')&&matchMedia('(prefers-color-scheme: dark)').matches)}function label(){b.setAttribute('aria-label',dark()?'Switch to light mode':'Switch to dark mode')}b.hidden=false;label();b.addEventListener('click',function(){var next=dark()?'light':'dark';r.remove('dark','light');r.add(next);try{localStorage.setItem('dp-scheme',next)}catch(e){}label()})})();</script>
|
|
142
|
+
{{/if}}
|
|
110
143
|
</body>
|
|
111
144
|
</html>
|
package/types/compiler.d.ts
CHANGED
|
@@ -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
|
/**
|
|
@@ -54,6 +62,7 @@ export type DocPensieveConfig = {
|
|
|
54
62
|
theme: {
|
|
55
63
|
framework: string;
|
|
56
64
|
darkMode?: string;
|
|
65
|
+
toggle?: boolean;
|
|
57
66
|
tokens?: Record<string, string>;
|
|
58
67
|
css?: string;
|
|
59
68
|
source?: string;
|
|
@@ -82,6 +91,18 @@ export type DocPensieveConfig = {
|
|
|
82
91
|
* Preview of a shared page. Needs `siteUrl`.
|
|
83
92
|
*/
|
|
84
93
|
socialImage?: string;
|
|
94
|
+
/**
|
|
95
|
+
* `sitemap.xml` of the published versions.
|
|
96
|
+
*/
|
|
97
|
+
sitemap?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* RSS feed of the dated pages of the current version.
|
|
100
|
+
*/
|
|
101
|
+
feed?: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Search field, index and search page of each version.
|
|
104
|
+
*/
|
|
105
|
+
search?: boolean;
|
|
85
106
|
/**
|
|
86
107
|
* Project root, set by `loadConfig`.
|
|
87
108
|
*/
|
|
@@ -104,6 +125,8 @@ export type DocPensieveConfig = {
|
|
|
104
125
|
* @property {boolean} [archived] Version kept but no longer maintained.
|
|
105
126
|
* @property {boolean} [prerelease] Version in preparation, not yet the
|
|
106
127
|
* reference one. Its pages carry a notice and are not indexed.
|
|
128
|
+
* @property {string} [logo] Logo of this version, instead of the project's.
|
|
129
|
+
* @property {string} [favicon] Favicon of this version, instead of the project's.
|
|
107
130
|
*/
|
|
108
131
|
/**
|
|
109
132
|
* @typedef {object} DocPensieveConfig
|
|
@@ -112,7 +135,7 @@ export type DocPensieveConfig = {
|
|
|
112
135
|
* @property {string} baseUrl Deployment prefix, slashes included.
|
|
113
136
|
* @property {string} outDir Output folder, relative to the root.
|
|
114
137
|
* @property {Version[]} versions At least one.
|
|
115
|
-
* @property {{ framework: string, darkMode?: string, tokens?: Record<string, string>, css?: string, source?: string }} theme
|
|
138
|
+
* @property {{ framework: string, darkMode?: string, toggle?: boolean, tokens?: Record<string, string>, css?: string, source?: string }} theme
|
|
116
139
|
* @property {string} sidebar `'auto'`, or the path of a description.
|
|
117
140
|
* @property {boolean} globalComponents
|
|
118
141
|
* @property {boolean} scrollToTop Back-to-top button on every page.
|
|
@@ -120,6 +143,9 @@ export type DocPensieveConfig = {
|
|
|
120
143
|
* @property {string} [logo] Image beside the project name, in the header.
|
|
121
144
|
* @property {string} [favicon] Icon of the browser tab: `.ico`, `.png` or `.svg`.
|
|
122
145
|
* @property {string} [socialImage] Preview of a shared page. Needs `siteUrl`.
|
|
146
|
+
* @property {boolean} [sitemap] `sitemap.xml` of the published versions.
|
|
147
|
+
* @property {boolean} [feed] RSS feed of the dated pages of the current version.
|
|
148
|
+
* @property {boolean} [search] Search field, index and search page of each version.
|
|
123
149
|
* @property {string} [rootDir] Project root, set by `loadConfig`.
|
|
124
150
|
* @property {string} [configFile] Path of the configuration file, set by `loadConfig`.
|
|
125
151
|
* @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;
|
package/types/generator.d.ts
CHANGED
|
@@ -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;
|
package/types/sidebar.d.ts
CHANGED
|
@@ -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;
|