@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 +2 -2
- package/src/config.js +49 -0
- package/src/generator.js +80 -0
- package/src/structured-data.js +4 -1
- package/templates/layout.hbs +18 -1
- package/types/config.d.ts +15 -0
- package/types/structured-data.d.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@docpensieve/core",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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.
|
package/src/structured-data.js
CHANGED
|
@@ -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
|
|
package/templates/layout.hbs
CHANGED
|
@@ -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
|
-
|
|
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.
|