@docpensieve/core 0.4.0-beta.2 → 0.5.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/client/search.js +22 -4
- package/package.json +2 -2
- package/src/authors.js +15 -10
- package/src/config.js +178 -0
- package/src/generator.js +401 -262
- package/src/search-index.js +19 -5
- package/templates/header-menu.hbs +20 -2
- package/templates/layout.hbs +30 -18
- package/types/authors.d.ts +4 -2
- package/types/config.d.ts +30 -0
- package/types/search-index.d.ts +3 -1
package/client/search.js
CHANGED
|
@@ -131,6 +131,24 @@ async function init() {
|
|
|
131
131
|
const status = /** @type {HTMLElement | null} */ (document.querySelector('[data-search-status]'));
|
|
132
132
|
if (!form || !input || !list || !status) return;
|
|
133
133
|
|
|
134
|
+
// The wording of the page, put there by the build: this file is copied as
|
|
135
|
+
// it is into every site, whatever its language.
|
|
136
|
+
const words = {
|
|
137
|
+
lang: 'en',
|
|
138
|
+
failed: 'The search index could not be loaded: every page is listed below.',
|
|
139
|
+
pages: { one: 'page', other: 'pages' },
|
|
140
|
+
noResultFor: 'No page matches “{query}”.',
|
|
141
|
+
resultsFor: '{count} for “{query}”.',
|
|
142
|
+
...JSON.parse(form.dataset.strings || '{}'),
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// The plural comes from the language, not from a comparison with one: that
|
|
146
|
+
// rule is English, and gets French wrong on zero — "0 page".
|
|
147
|
+
const plural = new Intl.PluralRules(words.lang);
|
|
148
|
+
|
|
149
|
+
/** @param {number} n */
|
|
150
|
+
const count = (n) => `${n} ${words.pages[plural.select(n)] ?? words.pages.other}`;
|
|
151
|
+
|
|
134
152
|
/** @type {Map<string, HTMLElement>} */
|
|
135
153
|
const items = new Map();
|
|
136
154
|
const listed = /** @type {NodeListOf<HTMLElement>} */ (list.querySelectorAll('li[data-url]'));
|
|
@@ -142,7 +160,7 @@ async function init() {
|
|
|
142
160
|
const response = await fetch(form.dataset.index ?? '');
|
|
143
161
|
entries = await response.json();
|
|
144
162
|
} catch {
|
|
145
|
-
status.textContent =
|
|
163
|
+
status.textContent = words.failed;
|
|
146
164
|
return;
|
|
147
165
|
}
|
|
148
166
|
|
|
@@ -161,7 +179,7 @@ async function init() {
|
|
|
161
179
|
reset(item);
|
|
162
180
|
list.append(item);
|
|
163
181
|
}
|
|
164
|
-
status.textContent = `${items.size}
|
|
182
|
+
status.textContent = `${count(items.size)}.`;
|
|
165
183
|
return;
|
|
166
184
|
}
|
|
167
185
|
|
|
@@ -186,8 +204,8 @@ async function init() {
|
|
|
186
204
|
|
|
187
205
|
status.textContent =
|
|
188
206
|
found.length === 0
|
|
189
|
-
?
|
|
190
|
-
:
|
|
207
|
+
? words.noResultFor.replace('{query}', query)
|
|
208
|
+
: words.resultsFor.replace('{count}', count(found.length)).replace('{query}', query);
|
|
191
209
|
};
|
|
192
210
|
|
|
193
211
|
input.value = new URLSearchParams(location.search).get('q') ?? '';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@docpensieve/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-beta.1",
|
|
4
4
|
"description": "DocPensieve engine: loading, MDX compilation, structured data, site generation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"types"
|
|
22
22
|
],
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@docpensieve/shared": "0.
|
|
24
|
+
"@docpensieve/shared": "0.5.0-beta.1",
|
|
25
25
|
"@mdx-js/mdx": "^3.1.1",
|
|
26
26
|
"@shikijs/rehype": "^4.4.3",
|
|
27
27
|
"gray-matter": "^4.0.3",
|
package/src/authors.js
CHANGED
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
* @module @docpensieve/core/authors
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { ConfigError } from '@docpensieve/shared';
|
|
15
|
+
import { ConfigError, UI_STRINGS } from '@docpensieve/shared';
|
|
16
|
+
|
|
17
|
+
/** Locale of a date when the caller names none: the wording of the default language. */
|
|
18
|
+
const DEFAULT_DATE_LOCALE = UI_STRINGS.en.dateLocale;
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* @typedef {object} Author
|
|
@@ -44,10 +47,11 @@ const AUTHOR_FIELDS = new Set(['name', 'bio', 'avatar', 'url']);
|
|
|
44
47
|
* @param {unknown} value
|
|
45
48
|
* @param {string} field Name of the field, for the error message.
|
|
46
49
|
* @param {string} where Page the date comes from.
|
|
50
|
+
* @param {string} [locale] Locale the label is written in. Default: `en-GB`.
|
|
47
51
|
* @returns {{ iso: string, label: string } | null} `null` when absent.
|
|
48
52
|
* @throws {ConfigError} When the value is not a date.
|
|
49
53
|
*/
|
|
50
|
-
export function readDate(value, field, where) {
|
|
54
|
+
export function readDate(value, field, where, locale = DEFAULT_DATE_LOCALE) {
|
|
51
55
|
if (value === undefined || value === null || value === '') return null;
|
|
52
56
|
|
|
53
57
|
const date = value instanceof Date ? value : new Date(String(value));
|
|
@@ -59,11 +63,11 @@ export function readDate(value, field, where) {
|
|
|
59
63
|
|
|
60
64
|
return {
|
|
61
65
|
iso: date.toISOString().slice(0, 10),
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
label: new Intl.DateTimeFormat(
|
|
66
|
+
// The locale comes from the page, never from the machine that built it:
|
|
67
|
+
// a site is produced once and read everywhere. Day first, month spelled
|
|
68
|
+
// out — "16 September 2026" is read the same way everywhere, where 09/16
|
|
69
|
+
// and 16/09 are the same page read two ways.
|
|
70
|
+
label: new Intl.DateTimeFormat(locale, {
|
|
67
71
|
day: 'numeric',
|
|
68
72
|
month: 'long',
|
|
69
73
|
year: 'numeric',
|
|
@@ -159,14 +163,15 @@ export function resolvePageAuthors(value, table = new Map()) {
|
|
|
159
163
|
* @param {Record<string, any>} frontmatter
|
|
160
164
|
* @param {Map<string, Author>} [table]
|
|
161
165
|
* @param {string} [where] Page named in a date error.
|
|
166
|
+
* @param {string} [locale] Locale the dates are written in.
|
|
162
167
|
* @returns {Byline | null}
|
|
163
168
|
* @throws {ConfigError} When a date cannot be read.
|
|
164
169
|
*/
|
|
165
|
-
export function buildByline(frontmatter, table = new Map(), where = 'this page') {
|
|
170
|
+
export function buildByline(frontmatter, table = new Map(), where = 'this page', locale) {
|
|
166
171
|
const authors = resolvePageAuthors(frontmatter?.authors, table);
|
|
167
|
-
const created = readDate(frontmatter?.date, 'date', where);
|
|
172
|
+
const created = readDate(frontmatter?.date, 'date', where, locale);
|
|
168
173
|
// The same field the sitemap reads for lastmod: one date, one meaning.
|
|
169
|
-
const updated = readDate(frontmatter?.modified, 'modified', where);
|
|
174
|
+
const updated = readDate(frontmatter?.modified, 'modified', where, locale);
|
|
170
175
|
|
|
171
176
|
if (authors.length === 0 && !created && !updated) return null;
|
|
172
177
|
|
package/src/config.js
CHANGED
|
@@ -9,11 +9,13 @@ import path from 'node:path';
|
|
|
9
9
|
import { pathToFileURL } from 'node:url';
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
|
+
ADMONITION_TONES,
|
|
12
13
|
CONFIG_FILENAME,
|
|
13
14
|
CONFIG_FILENAMES,
|
|
14
15
|
ConfigError,
|
|
15
16
|
DEFAULT_OUT_DIR,
|
|
16
17
|
THEME_FRAMEWORKS,
|
|
18
|
+
UI_STRINGS,
|
|
17
19
|
} from '@docpensieve/shared';
|
|
18
20
|
|
|
19
21
|
/**
|
|
@@ -27,6 +29,9 @@ import {
|
|
|
27
29
|
* reference one. Its pages carry a notice and are not indexed.
|
|
28
30
|
* @property {string} [logo] Logo of this version, instead of the project's.
|
|
29
31
|
* @property {string} [favicon] Favicon of this version, instead of the project's.
|
|
32
|
+
* @property {Record<string, string>} [translations] Folder of each translation,
|
|
33
|
+
* by language code. The pages of the site language stay at the root of the
|
|
34
|
+
* version; a translation is served under its code.
|
|
30
35
|
*/
|
|
31
36
|
|
|
32
37
|
/**
|
|
@@ -44,8 +49,14 @@ import {
|
|
|
44
49
|
* `columns` opens a panel of links instead of leading anywhere itself.
|
|
45
50
|
* @property {boolean} [foldedSidebar] Categories of the menu fold, opened on
|
|
46
51
|
* the branch of the page being read.
|
|
52
|
+
* @property {Record<string, Record<string, string>>} [ui]
|
|
53
|
+
* @property {Record<string, { label: string, tone: string, icon?: string }>} [admonitions]
|
|
54
|
+
* Kinds of admonition the project adds to the ones shipped. `icon` names an
|
|
55
|
+
* SVG of the version folder, inlined in place of the tone's drawing.
|
|
47
56
|
* @property {boolean} globalComponents
|
|
48
57
|
* @property {boolean} scrollToTop Back-to-top button on every page.
|
|
58
|
+
* @property {boolean} [stickyHeader] Header held at the top of the screen.
|
|
59
|
+
* `false` lets it scroll away with the page.
|
|
49
60
|
* @property {{ enabled: boolean }} jsonld
|
|
50
61
|
* @property {string} [logo] Image beside the project name, in the header.
|
|
51
62
|
* @property {string} [favicon] Icon of the browser tab: `.ico`, `.png` or `.svg`.
|
|
@@ -86,8 +97,15 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
86
97
|
// The menu shows whole by default: a documentation of a few dozen pages
|
|
87
98
|
// reads better open than behind folds. Long ones turn this on.
|
|
88
99
|
foldedSidebar: false,
|
|
100
|
+
// Six kinds of admonition ship with the tool; a project names its own here
|
|
101
|
+
// rather than waiting for that list to grow.
|
|
102
|
+
ui: {},
|
|
103
|
+
admonitions: {},
|
|
89
104
|
globalComponents: true,
|
|
90
105
|
scrollToTop: true,
|
|
106
|
+
// The header stays in reach: search, versions and menu are in it. A site
|
|
107
|
+
// that would rather give the height back to the text turns this off.
|
|
108
|
+
stickyHeader: true,
|
|
91
109
|
jsonld: { enabled: true },
|
|
92
110
|
logo: '',
|
|
93
111
|
favicon: '',
|
|
@@ -179,9 +197,43 @@ export function normalizeConfig(userConfig) {
|
|
|
179
197
|
// be both. "../../elsewhere" wrote outside the output folder, "a/b" nested
|
|
180
198
|
// the version, "Été" produced an encoded URL.
|
|
181
199
|
const VERSION_SLUG = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
200
|
+
// A language code becomes a URL segment and the `lang` of the document. The
|
|
201
|
+
// standard is BCP 47, and Intl carries it: a regex of our own refused
|
|
202
|
+
// "zh-Hans-CN", which is valid, and accepted shapes that are not.
|
|
203
|
+
const isLanguageCode = (/** @type {string} */ value) => {
|
|
204
|
+
try {
|
|
205
|
+
Intl.getCanonicalLocales(value);
|
|
206
|
+
// Well formed is not the same as real: BCP 47 allows a language subtag
|
|
207
|
+
// of five to eight letters, so "francais" passes that check and would
|
|
208
|
+
// land in the markup as `lang="francais"` — a value no browser maps to
|
|
209
|
+
// a language, under a French address serving English wording. CLDR
|
|
210
|
+
// knows which tags name a language; the subtag alone is asked, so that
|
|
211
|
+
// a region, a script or a private extension does not get in the way.
|
|
212
|
+
const names = new Intl.DisplayNames(['en'], { type: 'language', fallback: 'none' });
|
|
213
|
+
return names.of(new Intl.Locale(value).language) !== undefined;
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// A translation belongs to a version, since a version is what has pages.
|
|
220
|
+
// Written at the root, it was read by nobody: the build succeeded, no
|
|
221
|
+
// language appeared, and nothing said why.
|
|
222
|
+
if (userConfig.translations !== undefined) {
|
|
223
|
+
throw new ConfigError('"translations" belongs to a version, not to the configuration root.', {
|
|
224
|
+
hint: "Move it into the version it translates: { slug: 'v1.0', folder: 'docs/v1.0', translations: { fr: 'docs/v1.0-fr' } }.",
|
|
225
|
+
});
|
|
226
|
+
}
|
|
182
227
|
|
|
183
228
|
const seen = new Set();
|
|
184
229
|
for (const version of config.versions) {
|
|
230
|
+
// The language of the site is declared once, at the root: a version
|
|
231
|
+
// carries translations, not a language of its own.
|
|
232
|
+
if (/** @type {Record<string, unknown>} */ (version)?.lang !== undefined) {
|
|
233
|
+
throw new ConfigError(`The version "${version.slug}" declares a language of its own.`, {
|
|
234
|
+
hint: 'The site has one language, set by "lang" at the root; a version names its translations in "translations".',
|
|
235
|
+
});
|
|
236
|
+
}
|
|
185
237
|
for (const field of /** @type {const} */ (['slug', 'name', 'folder'])) {
|
|
186
238
|
if (typeof version?.[field] !== 'string' || version[field].length === 0) {
|
|
187
239
|
throw new ConfigError(
|
|
@@ -207,6 +259,47 @@ export function normalizeConfig(userConfig) {
|
|
|
207
259
|
);
|
|
208
260
|
}
|
|
209
261
|
}
|
|
262
|
+
// A translation names a language and the folder holding it. The pages of
|
|
263
|
+
// the site language keep their address: only a translation takes a prefix,
|
|
264
|
+
// so nothing already published moves.
|
|
265
|
+
if (version.translations !== undefined) {
|
|
266
|
+
if (
|
|
267
|
+
version.translations === null ||
|
|
268
|
+
typeof version.translations !== 'object' ||
|
|
269
|
+
Array.isArray(version.translations)
|
|
270
|
+
) {
|
|
271
|
+
throw new ConfigError(`The translations of version "${version.slug}" must be an object.`, {
|
|
272
|
+
hint: "Write translations: { fr: 'docs/v1.0-fr' } — one folder per language.",
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
for (const [lang, folder] of Object.entries(version.translations)) {
|
|
276
|
+
if (!isLanguageCode(lang)) {
|
|
277
|
+
throw new ConfigError(
|
|
278
|
+
`"${lang}" does not name a language, in version "${version.slug}".`,
|
|
279
|
+
{
|
|
280
|
+
hint: 'Write the code, not the name: "fr" for French, "pt-BR", "zh-Hans". It becomes the lang of the document and a segment of the address.',
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
if (typeof folder !== 'string' || folder === '') {
|
|
285
|
+
throw new ConfigError(
|
|
286
|
+
`The translation "${lang}" of version "${version.slug}" has no folder.`,
|
|
287
|
+
{
|
|
288
|
+
hint: "Give the folder holding those pages: { ${lang}: 'docs/v1.0-${lang}' }.",
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
if (folder === version.folder) {
|
|
293
|
+
throw new ConfigError(
|
|
294
|
+
`The translation "${lang}" of version "${version.slug}" reads the same folder as the version.`,
|
|
295
|
+
{
|
|
296
|
+
hint: 'A translation is a folder of its own: the same pages would be published twice.',
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
210
303
|
if (seen.has(version.slug)) {
|
|
211
304
|
throw new ConfigError(`The version slug "${version.slug}" is declared twice.`);
|
|
212
305
|
}
|
|
@@ -274,6 +367,91 @@ export function normalizeConfig(userConfig) {
|
|
|
274
367
|
}
|
|
275
368
|
}
|
|
276
369
|
|
|
370
|
+
// Kinds of admonition the project adds. A kind is a label and a tone: the
|
|
371
|
+
// tone carries the colour, taken from the theme, so a new kind needs no
|
|
372
|
+
// stylesheet and follows whichever theme is active.
|
|
373
|
+
if (config.ui !== undefined) {
|
|
374
|
+
if (config.ui === null || typeof config.ui !== 'object' || Array.isArray(config.ui)) {
|
|
375
|
+
throw new ConfigError('ui must be an object of languages.', {
|
|
376
|
+
hint: "Write ui: { fr: { search: 'Chercher' } } — one entry per language.",
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
const known = Object.keys(UI_STRINGS.en);
|
|
380
|
+
for (const [lang, words] of Object.entries(config.ui)) {
|
|
381
|
+
if (!words || typeof words !== 'object' || Array.isArray(words)) {
|
|
382
|
+
throw new ConfigError(`The wording of "${lang}" must be an object.`, {
|
|
383
|
+
hint: "Write ui: { fr: { search: 'Chercher' } }.",
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
for (const [key, value] of Object.entries(words)) {
|
|
387
|
+
// A key nobody reads would leave the shipped wording in place without
|
|
388
|
+
// a word, and a typo is exactly what this field invites.
|
|
389
|
+
if (!known.includes(key)) {
|
|
390
|
+
throw new ConfigError(`Unknown wording key in "${lang}": "${key}".`, {
|
|
391
|
+
hint: `Keys: ${known.join(', ')}.`,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
// `pages` counts, so it is written by plural category rather than as
|
|
395
|
+
// one word: a language with four of them cannot be served by a pair.
|
|
396
|
+
if (key === 'pages') {
|
|
397
|
+
const forms = Object.values(value ?? {});
|
|
398
|
+
if (typeof value !== 'object' || Array.isArray(value) || forms.length === 0) {
|
|
399
|
+
throw new ConfigError(`The wording "pages" of "${lang}" must be a set of plurals.`, {
|
|
400
|
+
hint: "Write pages: { one: 'page', other: 'pages' } — the categories of the language, 'other' at least.",
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
if (forms.some((form) => typeof form !== 'string' || form === '')) {
|
|
404
|
+
throw new ConfigError(`A plural of "pages" in "${lang}" is not a text.`, {
|
|
405
|
+
hint: 'Every category gives the word that follows the number.',
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (typeof value !== 'string' || value === '') {
|
|
411
|
+
throw new ConfigError(`The wording "${key}" of "${lang}" must be a text.`, {
|
|
412
|
+
hint: 'An empty label would leave the element unnamed on screen.',
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (config.admonitions !== undefined) {
|
|
420
|
+
if (
|
|
421
|
+
config.admonitions === null ||
|
|
422
|
+
typeof config.admonitions !== 'object' ||
|
|
423
|
+
Array.isArray(config.admonitions)
|
|
424
|
+
) {
|
|
425
|
+
throw new ConfigError('admonitions must be an object of kinds.', {
|
|
426
|
+
hint: "Write admonitions: { review: { label: 'Review', tone: 'info' } }.",
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
for (const [name, kind] of Object.entries(config.admonitions)) {
|
|
430
|
+
if (!kind || typeof kind.label !== 'string' || kind.label.trim() === '') {
|
|
431
|
+
throw new ConfigError(`The admonition "${name}" has no label.`, {
|
|
432
|
+
hint: `Write "${name}": { label: '…', tone: '${ADMONITION_TONES[0]}' } — the label is what the reader sees.`,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
if (kind.icon !== undefined && (typeof kind.icon !== 'string' || kind.icon.trim() === '')) {
|
|
436
|
+
throw new ConfigError(`The icon of the admonition "${name}" must be a path.`, {
|
|
437
|
+
hint: "Give an SVG of the version folder — icon: '/icons/review.svg' — or leave the field out.",
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
if (!ADMONITION_TONES.includes(kind.tone)) {
|
|
441
|
+
throw new ConfigError(
|
|
442
|
+
`The admonition "${name}" has an unknown tone: "${String(kind.tone)}".`,
|
|
443
|
+
{ hint: `Tones: ${ADMONITION_TONES.join(', ')}. The tone is what colours the block.` },
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (config.stickyHeader !== undefined && typeof config.stickyHeader !== 'boolean') {
|
|
450
|
+
throw new ConfigError('stickyHeader must be true or false.', {
|
|
451
|
+
hint: 'true holds the header at the top of the screen; false lets it scroll away.',
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
277
455
|
// Links of the header: site navigation, not page content. A target starts
|
|
278
456
|
// from the root of a version, or names another site; a relative one would
|
|
279
457
|
// change meaning from page to page. A link may name the version it lives in,
|