@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 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 = 'The search index could not be loaded: every page is listed below.';
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} pages.`;
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
- ? `No page matches “${query}”.`
190
- : `${found.length} ${found.length === 1 ? 'page' : 'pages'} for “${query}”.`;
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.4.0-beta.2",
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.4.0-beta.2",
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
- // Fixed locale: the page is built once and read everywhere, so the label
63
- // must not depend on the machine that produced it. Day first, month
64
- // spelled out — "16 September 2026" is read the same way everywhere,
65
- // where 09/16 and 16/09 are the same page read two ways.
66
- label: new Intl.DateTimeFormat('en-GB', {
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,