@docpensieve/core 0.1.3 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docpensieve/core",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "DocPensieve engine: loading, MDX compilation, structured data, site generation",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,7 +20,7 @@
20
20
  "types"
21
21
  ],
22
22
  "dependencies": {
23
- "@docpensieve/shared": "0.1.3",
23
+ "@docpensieve/shared": "0.1.5",
24
24
  "@mdx-js/mdx": "^3.1.1",
25
25
  "@shikijs/rehype": "^4.4.3",
26
26
  "gray-matter": "^4.0.3",
package/src/config.js CHANGED
@@ -40,6 +40,9 @@ import {
40
40
  * @property {boolean} globalComponents
41
41
  * @property {boolean} scrollToTop Back-to-top button on every page.
42
42
  * @property {{ enabled: boolean }} jsonld
43
+ * @property {string} [logo] Image beside the project name, in the header.
44
+ * @property {string} [favicon] Icon of the browser tab: `.ico`, `.png` or `.svg`.
45
+ * @property {string} [socialImage] Preview of a shared page. Needs `siteUrl`.
43
46
  * @property {string} [rootDir] Project root, set by `loadConfig`.
44
47
  * @property {string} [configFile] Path of the configuration file, set by `loadConfig`.
45
48
  * @property {string} [lang] Document language, `'en'` by default.
@@ -65,8 +68,31 @@ export const DEFAULT_CONFIG = Object.freeze({
65
68
  globalComponents: true,
66
69
  scrollToTop: true,
67
70
  jsonld: { enabled: true },
71
+ logo: '',
72
+ favicon: '',
73
+ socialImage: '',
68
74
  });
69
75
 
76
+ /**
77
+ * Extensions accepted for each project image, and what to do otherwise.
78
+ *
79
+ * @type {Record<'logo' | 'favicon' | 'socialImage', { extensions: string[], hint: string }>}
80
+ */
81
+ const IMAGE_KINDS = {
82
+ logo: {
83
+ extensions: ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif'],
84
+ hint: 'Give an image the browser displays: SVG, PNG, JPEG, WebP, GIF or AVIF.',
85
+ },
86
+ favicon: {
87
+ extensions: ['.ico', '.png', '.svg'],
88
+ hint: 'Browser tabs show .ico, .png and .svg icons.',
89
+ },
90
+ socialImage: {
91
+ extensions: ['.png', '.jpg', '.jpeg', '.webp', '.gif'],
92
+ hint: 'Social networks read neither SVG nor AVIF: export a PNG or a JPEG, 1200 × 630 pixels.',
93
+ },
94
+ };
95
+
70
96
  /**
71
97
  * Identity over the config, used only for autocompletion and type checking
72
98
  * in the editor.
@@ -208,6 +234,29 @@ export function normalizeConfig(userConfig) {
208
234
  '/',
209
235
  );
210
236
 
237
+ // The project's images: paths from the root, checked here for their kind.
238
+ // Whether they exist is checked when the build copies them.
239
+ for (const field of /** @type {const} */ (['logo', 'favicon', 'socialImage'])) {
240
+ const value = config[field];
241
+ if (value === undefined || value === '') continue;
242
+ const { extensions, hint } = IMAGE_KINDS[field];
243
+ if (typeof value !== 'string') {
244
+ throw new ConfigError(`${field} must be the path of an image, from the project root.`, {
245
+ hint,
246
+ });
247
+ }
248
+ if (!extensions.includes(path.extname(value).toLowerCase())) {
249
+ throw new ConfigError(`${field} must be a ${extensions.join(', ')} file: "${value}".`, {
250
+ hint,
251
+ });
252
+ }
253
+ }
254
+ if (config.socialImage && !config.siteUrl) {
255
+ throw new ConfigError('socialImage needs siteUrl.', {
256
+ hint: 'Social networks only read an absolute address: set siteUrl, the public address of the site.',
257
+ });
258
+ }
259
+
211
260
  if (!THEME_FRAMEWORKS.includes(config.theme.framework)) {
212
261
  throw new ConfigError(`Unknown theme framework: "${config.theme.framework}".`, {
213
262
  hint: `Accepted values: ${THEME_FRAMEWORKS.join(', ')}.`,
package/src/generator.js CHANGED
@@ -31,6 +31,22 @@ const TEMPLATE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..
31
31
  /** Path of the stylesheet written into every version. */
32
32
  const STYLESHEET = 'assets/docpensieve.css';
33
33
 
34
+ /**
35
+ * Where each project image is written in a version, before its extension.
36
+ * @type {Record<'logo' | 'favicon' | 'socialImage', string>}
37
+ */
38
+ const IMAGE_FILES = {
39
+ logo: 'assets/logo',
40
+ favicon: 'assets/favicon',
41
+ socialImage: 'assets/social-image',
42
+ };
43
+
44
+ /**
45
+ * Type announced to the browser, by favicon extension.
46
+ * @type {Record<string, string>}
47
+ */
48
+ const FAVICON_TYPES = { '.ico': 'image/x-icon', '.png': 'image/png', '.svg': 'image/svg+xml' };
49
+
34
50
  /** Collects the values of the `class` attributes of an HTML document. */
35
51
  const CLASS_ATTRIBUTE = /class="([^"]*)"/g;
36
52
 
@@ -191,6 +207,10 @@ export class SiteGenerator {
191
207
  [path.join(target, ...STYLESHEET.split('/')), 'the theme stylesheet'],
192
208
  ]);
193
209
 
210
+ // The project's images go into every version: each one stands on its
211
+ // own, down to the orphan branch it is published on.
212
+ const images = await this.#copyImages(target, versionBase, written);
213
+
194
214
  for (const doc of docs) {
195
215
  const url = pageUrl(doc);
196
216
 
@@ -215,6 +235,7 @@ export class SiteGenerator {
215
235
  breadcrumbTitles,
216
236
  basePath: versionBase,
217
237
  dirUrl,
238
+ logo: images.logo,
218
239
  }).toScriptTag();
219
240
 
220
241
  // A home page has neither menu nor table of contents: those are reading
@@ -232,6 +253,19 @@ export class SiteGenerator {
232
253
  homeUrl: versionBase,
233
254
  currentUrl: url,
234
255
  cssHref: joinUrl(versionBase, path.dirname(STYLESHEET)) + path.basename(STYLESHEET),
256
+ logoUrl: images.logo ?? '',
257
+ favicon: images.favicon
258
+ ? {
259
+ href: images.favicon,
260
+ type: FAVICON_TYPES[path.extname(images.favicon).toLowerCase()],
261
+ }
262
+ : null,
263
+ // Social networks only read an absolute address: normalisation
264
+ // refuses a preview image without siteUrl.
265
+ socialImage:
266
+ images.socialImage && this.config.siteUrl
267
+ ? new URL(images.socialImage, this.config.siteUrl).href
268
+ : '',
235
269
  cls: classes,
236
270
  versions: this.#versionLinks(version.slug),
237
271
  // A switcher offering a single choice is not a switcher.
@@ -270,6 +304,52 @@ export class SiteGenerator {
270
304
  return { pages: docs.length, outDir: target };
271
305
  }
272
306
 
307
+ /**
308
+ * Copies the project's images into a version's assets.
309
+ *
310
+ * @param {string} target Output folder of the version.
311
+ * @param {string} versionBase URL of the version.
312
+ * @param {Map<string, string>} written Files already written, for collisions.
313
+ * @returns {Promise<Partial<Record<keyof typeof IMAGE_FILES, string>>>} URL
314
+ * of each declared image.
315
+ * @throws {GeneratorError} When a declared image does not exist.
316
+ */
317
+ async #copyImages(target, versionBase, written) {
318
+ const rootDir = this.config.rootDir ?? process.cwd();
319
+ /** @type {Partial<Record<keyof typeof IMAGE_FILES, string>>} */
320
+ const urls = {};
321
+
322
+ for (const field of /** @type {(keyof typeof IMAGE_FILES)[]} */ (Object.keys(IMAGE_FILES))) {
323
+ const declared = this.config[field];
324
+ if (!declared) continue;
325
+
326
+ const file = `${IMAGE_FILES[field]}${path.extname(declared).toLowerCase()}`;
327
+ const destination = path.join(target, ...file.split('/'));
328
+ written.set(destination, `the ${field} image`);
329
+
330
+ try {
331
+ await mkdir(path.dirname(destination), { recursive: true });
332
+ await copyFile(path.resolve(rootDir, declared), destination);
333
+ } catch (cause) {
334
+ const missing = /** @type {NodeJS.ErrnoException} */ (cause).code === 'ENOENT';
335
+ throw new GeneratorError(
336
+ missing
337
+ ? `The ${field} image does not exist: "${declared}".`
338
+ : `Could not copy the ${field} image "${declared}".`,
339
+ {
340
+ cause,
341
+ hint: missing
342
+ ? `The path starts from the project root, ${rootDir}.`
343
+ : 'Check the permissions on the file and on the output folder.',
344
+ },
345
+ );
346
+ }
347
+ urls[field] = joinUrl(versionBase, path.posix.dirname(file)) + path.posix.basename(file);
348
+ }
349
+
350
+ return urls;
351
+ }
352
+
273
353
  /**
274
354
  * Generates every declared version, plus `versions.json` and a root that
275
355
  * redirects to the current version.
@@ -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,6 +13,22 @@
13
13
  {{#if canonical}}
14
14
  <link rel="canonical" href="{{canonical}}" />
15
15
  {{/if}}
16
+ {{#if favicon}}
17
+ <link rel="icon" href="{{favicon.href}}" type="{{favicon.type}}" />
18
+ {{/if}}
19
+ {{!-- What a social network shows of a shared page. Only with a preview
20
+ image: without one, the title and description alone are read anyway. --}}
21
+ {{#if socialImage}}
22
+ <meta property="og:type" content="website" />
23
+ <meta property="og:site_name" content="{{projectName}}" />
24
+ <meta property="og:title" content="{{title}}" />
25
+ {{#if description}}
26
+ <meta property="og:description" content="{{description}}" />
27
+ {{/if}}
28
+ <meta property="og:url" content="{{canonical}}" />
29
+ <meta property="og:image" content="{{socialImage}}" />
30
+ <meta name="twitter:card" content="summary_large_image" />
31
+ {{/if}}
16
32
  {{#each preloads}}
17
33
  <link rel="preload" as="{{as}}" href="{{href}}" />
18
34
  {{/each}}
@@ -24,7 +40,8 @@
24
40
 
25
41
  {{!-- Target of the back-to-top link: bring the focus back, not only the view. --}}
26
42
  <header class="{{{cls.header}}}" id="top" tabindex="-1">
27
- <a class="{{{cls.brand}}}" href="{{homeUrl}}">{{projectName}}</a>
43
+ {{!-- The logo's alt stays empty: the name that follows already says it. --}}
44
+ <a class="{{{cls.brand}}}" href="{{homeUrl}}">{{#if logoUrl}}<img class="{{{cls.brandLogo}}}" src="{{logoUrl}}" alt="" />{{/if}}{{projectName}}</a>
28
45
  {{#if showVersions}}
29
46
  <details class="{{{cls.versions}}}">
30
47
  {{!-- The accessible name contains the visible label: when dictated by
package/types/config.d.ts CHANGED
@@ -70,6 +70,18 @@ export type DocPensieveConfig = {
70
70
  jsonld: {
71
71
  enabled: boolean;
72
72
  };
73
+ /**
74
+ * Image beside the project name, in the header.
75
+ */
76
+ logo?: string;
77
+ /**
78
+ * Icon of the browser tab: `.ico`, `.png` or `.svg`.
79
+ */
80
+ favicon?: string;
81
+ /**
82
+ * Preview of a shared page. Needs `siteUrl`.
83
+ */
84
+ socialImage?: string;
73
85
  /**
74
86
  * Project root, set by `loadConfig`.
75
87
  */
@@ -105,6 +117,9 @@ export type DocPensieveConfig = {
105
117
  * @property {boolean} globalComponents
106
118
  * @property {boolean} scrollToTop Back-to-top button on every page.
107
119
  * @property {{ enabled: boolean }} jsonld
120
+ * @property {string} [logo] Image beside the project name, in the header.
121
+ * @property {string} [favicon] Icon of the browser tab: `.ico`, `.png` or `.svg`.
122
+ * @property {string} [socialImage] Preview of a shared page. Needs `siteUrl`.
108
123
  * @property {string} [rootDir] Project root, set by `loadConfig`.
109
124
  * @property {string} [configFile] Path of the configuration file, set by `loadConfig`.
110
125
  * @property {string} [lang] Document language, `'en'` by default.
@@ -13,15 +13,17 @@ export declare class StructuredDataBuilder {
13
13
  breadcrumbTitles?: Record<string, string>;
14
14
  basePath?: string;
15
15
  dirUrl?: string;
16
+ logo?: string;
16
17
  };
17
18
  breadcrumbTitles: Record<string, string>;
18
19
  basePath: string;
19
20
  dirUrl: string | undefined;
21
+ logo: string | undefined;
20
22
  /**
21
23
  * @param {Record<string, any>} frontmatter Page frontmatter.
22
24
  * @param {string} url Page URL on the site (`'/guide/install/'`).
23
25
  * @param {Record<string, any>} config Normalised project config.
24
- * @param {{ breadcrumbTitles?: Record<string, string>, basePath?: string, dirUrl?: string }} [options]
26
+ * @param {{ breadcrumbTitles?: Record<string, string>, basePath?: string, dirUrl?: string, logo?: string }} [options]
25
27
  * `breadcrumbTitles` maps a folder slug to its real title, so that the
26
28
  * breadcrumb shows “Café Guide” rather than “Cafe guide”. `basePath` is
27
29
  * the site root from which crumbs are counted: the generator sets
@@ -33,6 +35,7 @@ export declare class StructuredDataBuilder {
33
35
  breadcrumbTitles?: Record<string, string>;
34
36
  basePath?: string;
35
37
  dirUrl?: string;
38
+ logo?: string;
36
39
  });
37
40
  /**
38
41
  * Builds the page's schema.org graph.