@docpensieve/core 0.1.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/LICENSE +21 -0
- package/README.md +38 -0
- package/package.json +58 -0
- package/src/compiler.js +415 -0
- package/src/config.js +265 -0
- package/src/generator.js +517 -0
- package/src/index.js +35 -0
- package/src/loader.js +260 -0
- package/src/sidebar.js +105 -0
- package/src/structured-data.js +334 -0
- package/templates/layout.hbs +94 -0
- package/templates/nav-items.hbs +14 -0
- package/templates/toc-items.hbs +10 -0
- package/types/compiler.d.ts +108 -0
- package/types/config.d.ts +149 -0
- package/types/generator.d.ts +82 -0
- package/types/index.d.ts +34 -0
- package/types/loader.d.ts +66 -0
- package/types/sidebar.d.ts +60 -0
- package/types/structured-data.d.ts +51 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loading and normalisation of `docpensieve.config.js`.
|
|
3
|
+
* @module @docpensieve/core/config
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
CONFIG_FILENAME,
|
|
12
|
+
ConfigError,
|
|
13
|
+
DEFAULT_OUT_DIR,
|
|
14
|
+
NotImplementedError,
|
|
15
|
+
THEME_FRAMEWORKS,
|
|
16
|
+
} from '@docpensieve/shared';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} Version
|
|
20
|
+
* @property {string} slug URL and branch identifier (`'v1.0'`).
|
|
21
|
+
* @property {string} name Label shown in the version switcher (`'1.0'`).
|
|
22
|
+
* @property {string} folder Source folder, relative to the root.
|
|
23
|
+
* @property {boolean} [current] Version served by default. At most one.
|
|
24
|
+
* @property {boolean} [archived] Version kept but no longer maintained.
|
|
25
|
+
* @property {boolean} [prerelease] Version in preparation, not yet the
|
|
26
|
+
* reference one. Its pages carry a notice and are not indexed.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} DocPensieveConfig
|
|
31
|
+
* @property {string} projectName Name shown in the header and in the JSON-LD.
|
|
32
|
+
* @property {string} siteUrl Public URL, empty when unknown.
|
|
33
|
+
* @property {string} baseUrl Deployment prefix, slashes included.
|
|
34
|
+
* @property {string} outDir Output folder, relative to the root.
|
|
35
|
+
* @property {Version[]} versions At least one.
|
|
36
|
+
* @property {{ framework: string, darkMode?: string, tokens?: Record<string, string>, css?: string, source?: string }} theme
|
|
37
|
+
* @property {string} sidebar `'auto'`, or the path of a description.
|
|
38
|
+
* @property {boolean} globalComponents
|
|
39
|
+
* @property {boolean} scrollToTop Back-to-top button on every page.
|
|
40
|
+
* @property {{ enabled: boolean }} jsonld
|
|
41
|
+
* @property {string} [rootDir] Project root, set by `loadConfig`.
|
|
42
|
+
* @property {string} [lang] Document language, `'en'` by default.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Values applied when the user config leaves them out.
|
|
47
|
+
*
|
|
48
|
+
* The type is spelled out: without it, TypeScript would infer
|
|
49
|
+
* `versions: never[]` from the empty array and refuse every read of its
|
|
50
|
+
* elements elsewhere in the file.
|
|
51
|
+
*
|
|
52
|
+
* @type {Readonly<DocPensieveConfig>}
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
55
|
+
projectName: 'Documentation',
|
|
56
|
+
siteUrl: '',
|
|
57
|
+
baseUrl: '/',
|
|
58
|
+
outDir: DEFAULT_OUT_DIR,
|
|
59
|
+
versions: [],
|
|
60
|
+
theme: { framework: 'tailwind', darkMode: 'class' },
|
|
61
|
+
sidebar: 'auto',
|
|
62
|
+
globalComponents: true,
|
|
63
|
+
scrollToTop: true,
|
|
64
|
+
jsonld: { enabled: true },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Identity over the config, used only for autocompletion and type checking
|
|
69
|
+
* in the editor.
|
|
70
|
+
*
|
|
71
|
+
* @template T
|
|
72
|
+
* @param {T} config
|
|
73
|
+
* @returns {T}
|
|
74
|
+
*/
|
|
75
|
+
export function defineConfig(config) {
|
|
76
|
+
return config;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Merges the user config with the defaults and validates it.
|
|
81
|
+
*
|
|
82
|
+
* @param {Record<string, unknown>} userConfig
|
|
83
|
+
* @returns {DocPensieveConfig} Normalised config.
|
|
84
|
+
* @throws {ConfigError} When the config is structurally invalid.
|
|
85
|
+
*/
|
|
86
|
+
export function normalizeConfig(userConfig) {
|
|
87
|
+
if (userConfig === null || typeof userConfig !== 'object') {
|
|
88
|
+
throw new ConfigError('The configuration must be an object.', {
|
|
89
|
+
hint: `Export a default object from ${CONFIG_FILENAME}.`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const config = {
|
|
94
|
+
...DEFAULT_CONFIG,
|
|
95
|
+
...userConfig,
|
|
96
|
+
// Copied one by one: normalisation completes the versions (the first one
|
|
97
|
+
// becomes current when none is marked), and used to do so in the user's
|
|
98
|
+
// own array — to the point of crashing on a frozen configuration.
|
|
99
|
+
// The shape is checked right below: an array, and every entry with its
|
|
100
|
+
// three fields.
|
|
101
|
+
versions: /** @type {Version[]} */ (
|
|
102
|
+
Array.isArray(userConfig.versions)
|
|
103
|
+
? userConfig.versions.map((version) =>
|
|
104
|
+
version && typeof version === 'object' ? { ...version } : version,
|
|
105
|
+
)
|
|
106
|
+
: userConfig.versions
|
|
107
|
+
),
|
|
108
|
+
theme: { ...DEFAULT_CONFIG.theme, ...(userConfig.theme ?? {}) },
|
|
109
|
+
jsonld: { ...DEFAULT_CONFIG.jsonld, ...(userConfig.jsonld ?? {}) },
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
if (!Array.isArray(config.versions) || config.versions.length === 0) {
|
|
113
|
+
throw new ConfigError('The configuration must declare at least one version.', {
|
|
114
|
+
hint: "Add for example versions: [{ slug: 'v1.0', name: '1.0', folder: 'docs/v1.0', current: true }].",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The slug becomes an output folder and a URL segment: it must be able to
|
|
119
|
+
// be both. "../../elsewhere" wrote outside the output folder, "a/b" nested
|
|
120
|
+
// the version, "Été" produced an encoded URL.
|
|
121
|
+
const VERSION_SLUG = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
122
|
+
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
for (const version of config.versions) {
|
|
125
|
+
for (const field of /** @type {const} */ (['slug', 'name', 'folder'])) {
|
|
126
|
+
if (typeof version?.[field] !== 'string' || version[field].length === 0) {
|
|
127
|
+
throw new ConfigError(
|
|
128
|
+
`Every version must define "${field}" (offending version: ${JSON.stringify(version)}).`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!VERSION_SLUG.test(version.slug)) {
|
|
133
|
+
throw new ConfigError(`Invalid version slug: "${version.slug}".`, {
|
|
134
|
+
hint: 'Letters, digits, dot, dash and underscore, starting with a letter or a digit — "v1.0", "next".',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (seen.has(version.slug)) {
|
|
138
|
+
throw new ConfigError(`The version slug "${version.slug}" is declared twice.`);
|
|
139
|
+
}
|
|
140
|
+
seen.add(version.slug);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// A version cannot be both the one served by default and the one being
|
|
144
|
+
// prepared: the notice on the latter would contradict the role of the
|
|
145
|
+
// former, and readers would no longer know where they are.
|
|
146
|
+
const conflicting = config.versions.filter((version) => version.current && version.prerelease);
|
|
147
|
+
if (conflicting.length > 0) {
|
|
148
|
+
throw new ConfigError(
|
|
149
|
+
`A version cannot be both "current" and "prerelease": ${conflicting
|
|
150
|
+
.map((version) => version.slug)
|
|
151
|
+
.join(', ')}.`,
|
|
152
|
+
{ hint: 'Remove "prerelease" from the version served by default.' },
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const currents = config.versions.filter((version) => version.current);
|
|
157
|
+
if (currents.length > 1) {
|
|
158
|
+
throw new ConfigError(
|
|
159
|
+
`Only one version can be "current" (found: ${currents.map((v) => v.slug).join(', ')}).`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (currents.length === 0) {
|
|
163
|
+
// With no explicit choice, the first declared version is the reference.
|
|
164
|
+
config.versions[0].current = true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The sidebar is derived from the file tree, and nothing else is written
|
|
168
|
+
// yet. Accepting a path without reading it would suggest it is used.
|
|
169
|
+
if (config.sidebar !== 'auto') {
|
|
170
|
+
throw new NotImplementedError(
|
|
171
|
+
`A sidebar described by a file ("${config.sidebar}")`,
|
|
172
|
+
'4.2 Explicit sidebar',
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// siteUrl feeds everything that must be absolute: canonical, JSON-LD.
|
|
177
|
+
// Invalid, it went through here and blew up further on as a raw TypeError,
|
|
178
|
+
// stack included; with an exotic scheme, it built a nonsensical prefix.
|
|
179
|
+
if (config.siteUrl) {
|
|
180
|
+
/** @type {URL} */
|
|
181
|
+
let address;
|
|
182
|
+
try {
|
|
183
|
+
address = new URL(config.siteUrl);
|
|
184
|
+
} catch (cause) {
|
|
185
|
+
throw new ConfigError(`siteUrl is not a URL: "${config.siteUrl}".`, {
|
|
186
|
+
cause,
|
|
187
|
+
hint: 'Give the full address, scheme included: "https://example.com/docs".',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (address.protocol !== 'https:' && address.protocol !== 'http:') {
|
|
191
|
+
throw new ConfigError(`siteUrl must be a web address: "${config.siteUrl}".`, {
|
|
192
|
+
hint: 'Only http and https make sense for a published site.',
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
// Without an explicit baseUrl, the sub-path of siteUrl is the reference.
|
|
196
|
+
// Otherwise a siteUrl such as "https://example.com/docs" would silently
|
|
197
|
+
// produce links and canonicals stripped of "/docs".
|
|
198
|
+
if (userConfig.baseUrl === undefined) config.baseUrl = address.pathname;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// A baseUrl is a URL prefix: it needs both slashes, otherwise concatenation
|
|
202
|
+
// yields paths like "/docsversions/v1.0/".
|
|
203
|
+
config.baseUrl = `/${String(config.baseUrl ?? '/').replace(/^\/+|\/+$/g, '')}/`.replace(
|
|
204
|
+
/^\/\/$/,
|
|
205
|
+
'/',
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
if (!THEME_FRAMEWORKS.includes(config.theme.framework)) {
|
|
209
|
+
throw new ConfigError(`Unknown theme framework: "${config.theme.framework}".`, {
|
|
210
|
+
hint: `Accepted values: ${THEME_FRAMEWORKS.join(', ')}.`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return config;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Loads `docpensieve.config.js` from a project folder.
|
|
219
|
+
*
|
|
220
|
+
* @param {string} [cwd] Project root. Default: `process.cwd()`.
|
|
221
|
+
* @returns {Promise<DocPensieveConfig>} Normalised config.
|
|
222
|
+
* @throws {ConfigError} When the file is missing or exports no object.
|
|
223
|
+
*/
|
|
224
|
+
export async function loadConfig(cwd = process.cwd()) {
|
|
225
|
+
const configPath = path.resolve(cwd, CONFIG_FILENAME);
|
|
226
|
+
|
|
227
|
+
if (!existsSync(configPath)) {
|
|
228
|
+
throw new ConfigError(`No ${CONFIG_FILENAME} found in ${cwd}.`, {
|
|
229
|
+
hint: `Create a ${CONFIG_FILENAME} at the project root.`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let module;
|
|
234
|
+
try {
|
|
235
|
+
// pathToFileURL: on Windows, a raw path is not a valid specifier.
|
|
236
|
+
module = await import(pathToFileURL(configPath).href);
|
|
237
|
+
} catch (cause) {
|
|
238
|
+
throw new ConfigError(`Could not load ${CONFIG_FILENAME}.`, { cause });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const normalized = normalizeConfig(module.default);
|
|
242
|
+
normalized.rootDir = cwd;
|
|
243
|
+
return normalized;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Finds a declared version by its slug.
|
|
248
|
+
*
|
|
249
|
+
* @param {DocPensieveConfig} config Normalised config.
|
|
250
|
+
* @param {string} [slug] Slug to look for. Omitted: the "current" version.
|
|
251
|
+
* @returns {Version} The requested version.
|
|
252
|
+
* @throws {ConfigError} When the slug does not exist.
|
|
253
|
+
*/
|
|
254
|
+
export function resolveVersion(config, slug) {
|
|
255
|
+
const version = slug
|
|
256
|
+
? config.versions.find((candidate) => candidate.slug === slug)
|
|
257
|
+
: config.versions.find((candidate) => candidate.current);
|
|
258
|
+
|
|
259
|
+
if (!version) {
|
|
260
|
+
throw new ConfigError(`Unknown version: "${slug}".`, {
|
|
261
|
+
hint: `Declared versions: ${config.versions.map((v) => v.slug).join(', ')}.`,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return version;
|
|
265
|
+
}
|