docpensieve 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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `docpensieve dev` command — build, watch and reload.
3
+ *
4
+ * @module docpensieve/commands/dev
5
+ */
6
+
7
+ import path from 'node:path';
8
+
9
+ import { componentsCss, createRegistry, setSiteContext } from '@docpensieve/components';
10
+ import { SiteGenerator, loadConfig } from '@docpensieve/core';
11
+ import { CONFIG_FILENAME, DocPensieveError } from '@docpensieve/shared';
12
+ import chokidar from 'chokidar';
13
+
14
+ import { RELOAD_PATH, createStaticServer, listen } from '../server.js';
15
+ import { createTheme } from '../theme.js';
16
+
17
+ /** Default port of `dev`, distinct from that of `serve`. */
18
+ const DEFAULT_PORT = 3000;
19
+
20
+ /** Delay for grouping file events, in milliseconds. */
21
+ const DEBOUNCE = 120;
22
+
23
+ /**
24
+ * Reload script injected on the fly, never written to disk.
25
+ *
26
+ * The injection happens when serving the page, not when generating it: the
27
+ * output of `build` stays free of any JavaScript, as the project requires.
28
+ */
29
+ const RELOAD_SCRIPT =
30
+ `<script>new EventSource(${JSON.stringify(RELOAD_PATH)})` +
31
+ `.addEventListener("message",()=>location.reload())</script>`;
32
+
33
+ /**
34
+ * @param {{ port?: number, cwd?: string }} [options]
35
+ * @returns {Promise<{
36
+ * server: import('node:http').Server,
37
+ * watcher: import('chokidar').FSWatcher,
38
+ * port: number,
39
+ * url: string,
40
+ * close: () => Promise<void>,
41
+ * }>}
42
+ */
43
+ export async function dev(options = {}) {
44
+ const cwd = options.cwd ?? process.cwd();
45
+ const config = await loadConfig(cwd);
46
+ const outDir = path.resolve(cwd, config.outDir);
47
+
48
+ const rebuild = async () => {
49
+ const started = Date.now();
50
+ // The configuration is read again every time: changing it must show
51
+ // without restarting the command.
52
+ const current = await loadConfig(cwd);
53
+ const generator = new SiteGenerator(current, {
54
+ // `globalComponents: false` removes the shipped components: a project
55
+ // that defines its own thus avoids a name collision. The option was
56
+ // declared and documented, but nobody read it.
57
+ components: current.globalComponents === false ? {} : createRegistry(),
58
+ onPage: setSiteContext,
59
+ theme: createTheme(current, await componentsCss()),
60
+ });
61
+ const result = await generator.buildAll();
62
+ console.log(`${result.pages} page(s) in ${Date.now() - started} ms`);
63
+ };
64
+
65
+ await rebuild();
66
+
67
+ /** @type {() => void} */
68
+ let notify = () => {};
69
+ const server = createStaticServer({
70
+ root: outDir,
71
+ basePath: config.baseUrl,
72
+ inject: RELOAD_SCRIPT,
73
+ onReload: (send) => {
74
+ notify = send;
75
+ },
76
+ });
77
+
78
+ const port = await listen(server, options.port ?? DEFAULT_PORT);
79
+ const url = `http://localhost:${port}${config.baseUrl}`;
80
+ console.log(`served at ${url}`);
81
+
82
+ const watched = [
83
+ path.resolve(cwd, CONFIG_FILENAME),
84
+ ...config.versions.map((version) => path.resolve(cwd, version.folder)),
85
+ ];
86
+ const watcher = chokidar.watch(watched, { ignoreInitial: true });
87
+
88
+ // Until chokidar has finished its inventory, `ignoreInitial` swallows the
89
+ // events: a change made in the second after startup went unnoticed. Control
90
+ // is only handed back once watching is active.
91
+ await new Promise((resolve) => watcher.once('ready', () => resolve(undefined)));
92
+
93
+ /** @type {NodeJS.Timeout | undefined} */
94
+ let pending;
95
+ watcher.on('all', (_event, changed) => {
96
+ // An editor emits several events per save: group them.
97
+ clearTimeout(pending);
98
+ pending = setTimeout(async () => {
99
+ console.log(`\n${path.relative(cwd, changed)} changed`);
100
+ try {
101
+ await rebuild();
102
+ notify();
103
+ } catch (error) {
104
+ // A content error must not kill the watch: print it and wait for the
105
+ // fix.
106
+ if (error instanceof DocPensieveError) {
107
+ console.error(error.message);
108
+ if (error.hint) console.error(error.hint);
109
+ } else {
110
+ console.error(error);
111
+ }
112
+ }
113
+ }, DEBOUNCE);
114
+ });
115
+
116
+ console.log('watching — Ctrl+C to stop');
117
+
118
+ return {
119
+ server,
120
+ watcher,
121
+ port,
122
+ url,
123
+ close: async () => {
124
+ clearTimeout(pending);
125
+ await watcher.close();
126
+ // Without this, close() waits for keep-alive connections to expire —
127
+ // the reload stream keeps one open permanently.
128
+ server.closeAllConnections();
129
+ await new Promise((resolve) => server.close(resolve));
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * `docpensieve init` command — sets up a documentation project.
3
+ *
4
+ * @module docpensieve/commands/init
5
+ */
6
+
7
+ import { existsSync } from 'node:fs';
8
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { createInterface } from 'node:readline/promises';
11
+
12
+ import { CONFIG_FILENAME, DocPensieveError, THEME_FRAMEWORKS } from '@docpensieve/shared';
13
+
14
+ /**
15
+ * Description shown next to each framework.
16
+ * @type {Record<string, string>}
17
+ */
18
+ const FRAMEWORK_LABELS = {
19
+ tailwind: 'Tailwind CSS — ships with the tool, nothing to install',
20
+ custom: 'custom theme, light stylesheet, no utilities',
21
+ };
22
+
23
+ /** Answers used when there is no dialogue. */
24
+ const DEFAULTS = { name: 'My documentation', siteUrl: '', theme: 'tailwind', version: '1.0' };
25
+
26
+ /**
27
+ * Asks a question, with a default value shown between brackets.
28
+ *
29
+ * @param {import('node:readline/promises').Interface} rl
30
+ * @param {string} question
31
+ * @param {string} fallback
32
+ * @returns {Promise<string>}
33
+ */
34
+ async function ask(rl, question, fallback) {
35
+ const suffix = fallback ? ` [${fallback}]` : '';
36
+ const answer = (await rl.question(`${question}${suffix}: `)).trim();
37
+ return answer || fallback;
38
+ }
39
+
40
+ /**
41
+ * Asks for the CSS framework among those the configuration accepts.
42
+ *
43
+ * @param {import('node:readline/promises').Interface} rl
44
+ * @returns {Promise<string>}
45
+ */
46
+ async function askFramework(rl) {
47
+ console.log('\nCSS framework:');
48
+ THEME_FRAMEWORKS.forEach((framework, index) => {
49
+ console.log(` ${index + 1}. ${framework} — ${FRAMEWORK_LABELS[framework] ?? ''}`);
50
+ });
51
+
52
+ for (;;) {
53
+ const answer = (await rl.question(`Your choice [1-${THEME_FRAMEWORKS.length}]: `)).trim();
54
+ if (answer === '') return THEME_FRAMEWORKS[0];
55
+
56
+ // Accept the number as well as the name: typing “tailwind” is more natural
57
+ // than counting lines.
58
+ const byName = THEME_FRAMEWORKS.find((framework) => framework === answer.toLowerCase());
59
+ if (byName) return byName;
60
+
61
+ const index = Number(answer);
62
+ if (Number.isInteger(index) && index >= 1 && index <= THEME_FRAMEWORKS.length) {
63
+ return THEME_FRAMEWORKS[index - 1];
64
+ }
65
+ console.log(`Answer not understood. Expected: a number, or ${THEME_FRAMEWORKS.join(', ')}.`);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Gathers the answers, through a dialogue or from the options.
71
+ *
72
+ * @param {{ name?: string, theme?: string, siteUrl?: string, version?: string, yes?: boolean }} options
73
+ * @returns {Promise<{ name: string, theme: string, siteUrl: string, version: string }>}
74
+ */
75
+ async function collect(options) {
76
+ const fromOptions = {
77
+ name: options.name ?? DEFAULTS.name,
78
+ siteUrl: options.siteUrl ?? DEFAULTS.siteUrl,
79
+ theme: options.theme ?? DEFAULTS.theme,
80
+ version: options.version ?? DEFAULTS.version,
81
+ };
82
+
83
+ // Without a terminal — script, CI, pipe — the dialogue would never complete:
84
+ // stick to the options and the defaults.
85
+ if (options.yes || !process.stdin.isTTY) return fromOptions;
86
+
87
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
88
+ try {
89
+ const name = await ask(rl, 'Project name', fromOptions.name);
90
+ const siteUrl = await ask(rl, 'Public URL of the site (optional)', fromOptions.siteUrl);
91
+ const version = await ask(rl, 'First version', fromOptions.version);
92
+ const theme = options.theme ?? (await askFramework(rl));
93
+ return { name, siteUrl, version, theme };
94
+ } finally {
95
+ rl.close();
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Quotes a string in the project's style.
101
+ *
102
+ * `JSON.stringify` would produce double quotes, against the grain of the rest
103
+ * of the generated file — the one the user opens first.
104
+ *
105
+ * @param {string} value
106
+ * @returns {string}
107
+ */
108
+ const quote = (value) => `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
109
+
110
+ /**
111
+ * @param {{ name: string, theme: string, siteUrl: string, version: string }} answers
112
+ * @returns {string} Contents of `docpensieve.config.js`.
113
+ */
114
+ function renderConfig({ name, theme, siteUrl, version }) {
115
+ const slug = versionSlug(version);
116
+ // A type annotation rather than an `import`: `defineConfig` transforms
117
+ // nothing, it is only there for autocompletion. Actually importing it would
118
+ // make the configuration unreadable in a folder where the package is not
119
+ // installed — that is, at the first `build` after an `npx`. A JSDoc
120
+ // `import()` type disappears at run time.
121
+ const lines = [
122
+ "/** @type {import('@docpensieve/core').DocPensieveConfig} */",
123
+ 'export default {',
124
+ ` projectName: ${quote(name)},`,
125
+ ];
126
+
127
+ if (siteUrl) {
128
+ lines.push(
129
+ ` siteUrl: ${quote(siteUrl)},`,
130
+ ' // baseUrl is derived from the path of siteUrl. Set it to force it.',
131
+ );
132
+ }
133
+
134
+ lines.push(
135
+ '',
136
+ ' // One entry per version. The compiled output then goes to an orphan',
137
+ ' // branch with the same slug.',
138
+ ' versions: [',
139
+ ` {`,
140
+ ` slug: ${quote(slug)},`,
141
+ ` name: ${quote(version)},`,
142
+ ` folder: ${quote(`docs/${slug}`)},`,
143
+ ` current: true,`,
144
+ ` },`,
145
+ ' ],',
146
+ '',
147
+ " outDir: 'dist',",
148
+ '',
149
+ ' theme: {',
150
+ ` framework: ${quote(theme)},`,
151
+ " darkMode: 'class',",
152
+ " // Override the palette: tokens: { '--dp-accent': '#008060' },",
153
+ ' },',
154
+ '',
155
+ " // 'auto': the sidebar follows the file tree and the 01-, 02- prefixes.",
156
+ " sidebar: 'auto',",
157
+ '',
158
+ ' globalComponents: true,',
159
+ ' jsonld: { enabled: true },',
160
+ '};',
161
+ '',
162
+ );
163
+
164
+ return lines.join('\n');
165
+ }
166
+
167
+ /**
168
+ * Slug of a version, as it names both the folder and the branch.
169
+ *
170
+ * A “v” is added when the user left it out: “1.0” and “v1.0” must give the
171
+ * same project.
172
+ *
173
+ * @param {string} version
174
+ * @returns {string}
175
+ */
176
+ function versionSlug(version) {
177
+ const trimmed = String(version).trim();
178
+ return /^v/i.test(trimmed) ? trimmed : `v${trimmed}`;
179
+ }
180
+
181
+ /**
182
+ * @param {string} name Project name, for the title of the home page.
183
+ * @returns {string}
184
+ */
185
+ const renderIndex = (name) => `---
186
+ title: Introduction
187
+ description: Documentation of ${name}.
188
+ date: ${new Date().toISOString().slice(0, 10)}
189
+
190
+ jsonld:
191
+ type: TechArticle
192
+ breadcrumbs: true
193
+ ---
194
+
195
+ # ${name}
196
+
197
+ Welcome to the documentation.
198
+
199
+ ## Getting started
200
+
201
+ Pages live in \`docs/\`. The \`01-\` prefix of a file orders the menu
202
+ without appearing in the URL.
203
+
204
+ See the [installation guide](/guide/installation/).
205
+ `;
206
+
207
+ /** @returns {string} Sample page, showing ordering and highlighting. */
208
+ const renderGuide = () => `---
209
+ title: Installation
210
+ description: Install and run the project.
211
+
212
+ jsonld:
213
+ type: TechArticle
214
+ ---
215
+
216
+ # Installation
217
+
218
+ ## Requirements
219
+
220
+ Node.js 22 or later.
221
+
222
+ ## Run
223
+
224
+ \`\`\`bash
225
+ npm install
226
+ npm run dev
227
+ \`\`\`
228
+ `;
229
+
230
+ /**
231
+ * Sets up a documentation project.
232
+ *
233
+ * @param {string} [dir] Target folder, created if needed.
234
+ * @param {{
235
+ * name?: string, theme?: string, siteUrl?: string, version?: string,
236
+ * yes?: boolean, force?: boolean,
237
+ * }} [options]
238
+ * @returns {Promise<{ dir: string, theme: string }>}
239
+ * @throws {DocPensieveError} Unknown framework, or project already initialised.
240
+ */
241
+ export async function init(dir = '.', options = {}) {
242
+ const target = path.resolve(dir);
243
+ const configPath = path.join(target, CONFIG_FILENAME);
244
+
245
+ if (existsSync(configPath) && !options.force) {
246
+ throw new DocPensieveError(`${CONFIG_FILENAME} already exists in ${target}.`, {
247
+ hint: 'Use --force to overwrite it, or pick another folder.',
248
+ });
249
+ }
250
+
251
+ // Before the dialogue: answering three questions only to be told afterwards
252
+ // that the theme does not exist would be annoying. After it, the check would
253
+ // be pointless — the dialogue only offers valid values.
254
+ if (options.theme && !THEME_FRAMEWORKS.includes(options.theme)) {
255
+ throw new DocPensieveError(`Unknown framework: "${options.theme}".`, {
256
+ hint: `Accepted values: ${THEME_FRAMEWORKS.join(', ')}.`,
257
+ });
258
+ }
259
+
260
+ const answers = await collect(options);
261
+
262
+ const slug = versionSlug(answers.version);
263
+ const docsDir = path.join(target, 'docs', slug);
264
+
265
+ await mkdir(path.join(docsDir, 'guide'), { recursive: true });
266
+ await writeFile(configPath, renderConfig(answers), 'utf8');
267
+ await writeFile(path.join(docsDir, 'index.md'), renderIndex(answers.name), 'utf8');
268
+ await writeFile(path.join(docsDir, 'guide', '01-installation.md'), renderGuide(), 'utf8');
269
+ await ignoreOutput(target);
270
+
271
+ console.log(`\nProject initialised in ${target}`);
272
+ console.log(` ${CONFIG_FILENAME}`);
273
+ console.log(` docs/${slug}/index.md`);
274
+ console.log(` docs/${slug}/guide/01-installation.md`);
275
+ console.log(`\nTheme: ${answers.theme} — ${FRAMEWORK_LABELS[answers.theme]}`);
276
+ console.log('\nNext: npx docpensieve dev');
277
+
278
+ return { dir: target, theme: answers.theme };
279
+ }
280
+
281
+ /**
282
+ * Adds the output folder to .gitignore, without overwriting what is there.
283
+ *
284
+ * @param {string} target
285
+ */
286
+ async function ignoreOutput(target) {
287
+ const file = path.join(target, '.gitignore');
288
+ let current = '';
289
+ try {
290
+ current = await readFile(file, 'utf8');
291
+ } catch {
292
+ // No .gitignore: create it.
293
+ }
294
+
295
+ if (/^dist\/?$/m.test(current)) return;
296
+ const separator = current && !current.endsWith('\n') ? '\n' : '';
297
+ await writeFile(file, `${current}${separator}dist/\nnode_modules/\n`, 'utf8');
298
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `docpensieve serve` command — serves the output folder statically.
3
+ *
4
+ * @module docpensieve/commands/serve
5
+ */
6
+
7
+ import { existsSync } from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ import { loadConfig } from '@docpensieve/core';
11
+ import { DocPensieveError } from '@docpensieve/shared';
12
+
13
+ import { createStaticServer, listen } from '../server.js';
14
+
15
+ /** Default port of `serve`, distinct from that of `dev`. */
16
+ const DEFAULT_PORT = 4000;
17
+
18
+ /**
19
+ * @param {{ port?: number, dir?: string, cwd?: string }} [options]
20
+ * @returns {Promise<{ server: import('node:http').Server, port: number, url: string }>}
21
+ * @throws {DocPensieveError} When the folder to serve does not exist.
22
+ */
23
+ export async function serve(options = {}) {
24
+ const cwd = options.cwd ?? process.cwd();
25
+ const config = await loadConfig(cwd);
26
+ const root = path.resolve(cwd, options.dir ?? config.outDir);
27
+
28
+ if (!existsSync(root)) {
29
+ throw new DocPensieveError(`Nothing to serve: ${root} does not exist.`, {
30
+ hint: 'Run "docpensieve build" before "serve".',
31
+ });
32
+ }
33
+
34
+ // The site is mounted under the configuration's baseUrl: otherwise the links
35
+ // of the generated pages would not resolve locally.
36
+ const server = createStaticServer({ root, basePath: config.baseUrl });
37
+ const port = await listen(server, options.port ?? DEFAULT_PORT);
38
+ const url = `http://localhost:${port}${config.baseUrl}`;
39
+
40
+ console.log(`${root}`);
41
+ console.log(`served at ${url}`);
42
+
43
+ return { server, port, url };
44
+ }
package/src/index.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * docpensieve — implementations of the commands.
3
+ *
4
+ * The binary (`bin/docpensieve.js`) only parses arguments: the logic lives
5
+ * here, to stay testable without spawning a subprocess.
6
+ *
7
+ * @module docpensieve
8
+ */
9
+
10
+ export { build } from './commands/build.js';
11
+ export { check, verifyLinks, verifyMarkup } from './commands/check.js';
12
+ export { dev } from './commands/dev.js';
13
+ export { init } from './commands/init.js';
14
+ export { serve } from './commands/serve.js';
15
+ export { createStaticServer, listen, resolveRequestPath } from './server.js';
package/src/server.js ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Static HTTP server shared by the `serve` and `dev` commands.
3
+ *
4
+ * @module docpensieve/server
5
+ */
6
+
7
+ import { createReadStream } from 'node:fs';
8
+ import { stat } from 'node:fs/promises';
9
+ import { createServer } from 'node:http';
10
+ import path from 'node:path';
11
+
12
+ import { DocPensieveError } from '@docpensieve/shared';
13
+
14
+ /**
15
+ * MIME types of the files a documentation site produces.
16
+ * @type {Record<string, string>}
17
+ */
18
+ const MIME_TYPES = {
19
+ '.html': 'text/html; charset=utf-8',
20
+ '.css': 'text/css; charset=utf-8',
21
+ '.js': 'text/javascript; charset=utf-8',
22
+ '.json': 'application/json; charset=utf-8',
23
+ '.svg': 'image/svg+xml',
24
+ '.png': 'image/png',
25
+ '.jpg': 'image/jpeg',
26
+ '.jpeg': 'image/jpeg',
27
+ '.gif': 'image/gif',
28
+ '.webp': 'image/webp',
29
+ '.avif': 'image/avif',
30
+ '.ico': 'image/x-icon',
31
+ '.woff': 'font/woff',
32
+ '.woff2': 'font/woff2',
33
+ '.ttf': 'font/ttf',
34
+ '.pdf': 'application/pdf',
35
+ '.txt': 'text/plain; charset=utf-8',
36
+ '.md': 'text/plain; charset=utf-8',
37
+ '.xml': 'application/xml; charset=utf-8',
38
+ };
39
+
40
+ /** Path reserved for the reload stream of the development server. */
41
+ export const RELOAD_PATH = '/__docpensieve/reload';
42
+
43
+ /**
44
+ * Resolves a request URL into a file path, without leaving the root.
45
+ *
46
+ * @param {string} pathname Request path, `basePath` already removed.
47
+ * @param {string} root Served folder.
48
+ * @returns {string | null} Absolute path, or `null` when the target escapes `root`.
49
+ */
50
+ export function resolveRequestPath(pathname, root) {
51
+ let decoded;
52
+ try {
53
+ decoded = decodeURIComponent(pathname);
54
+ } catch {
55
+ // Invalid escape sequence: the request designates nothing.
56
+ return null;
57
+ }
58
+
59
+ // A folder is served by its index page, like on any static host. A path
60
+ // without an extension is treated the same way.
61
+ const relative = decoded.replace(/^\/+/, '');
62
+ const candidate =
63
+ decoded.endsWith('/') || path.extname(relative) === ''
64
+ ? path.join(relative, 'index.html')
65
+ : relative;
66
+
67
+ const resolved = path.resolve(root, candidate);
68
+
69
+ // path.resolve absorbs the "..": check afterwards that the target is indeed
70
+ // under the root, otherwise a "/../../etc/passwd" request would get out.
71
+ const relativeToRoot = path.relative(root, resolved);
72
+ if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) return null;
73
+
74
+ return resolved;
75
+ }
76
+
77
+ /**
78
+ * Creates a static file server.
79
+ *
80
+ * @param {{
81
+ * root: string,
82
+ * basePath?: string,
83
+ * inject?: string | null,
84
+ * onReload?: (send: () => void) => void,
85
+ * }} options
86
+ * `basePath` is the prefix under which the site is mounted: it must reflect
87
+ * the configuration's `baseUrl`, otherwise the links of the pages do not
88
+ * resolve locally. `inject` is an HTML fragment inserted before `</body>` —
89
+ * the development server uses it for its reload script, which leaves the
90
+ * generated output intact.
91
+ * @returns {import('node:http').Server}
92
+ */
93
+ export function createStaticServer({ root, basePath = '/', inject = null, onReload }) {
94
+ /** @type {Set<import('node:http').ServerResponse>} */
95
+ const listeners = new Set();
96
+
97
+ if (onReload) {
98
+ onReload(() => {
99
+ for (const client of listeners) client.write('data: reload\n\n');
100
+ });
101
+ }
102
+
103
+ return createServer(async (request, response) => {
104
+ // `request.url` is optional in Node's signature: a malformed request must
105
+ // not make `new URL` throw.
106
+ const url = new URL(request.url ?? '/', 'http://localhost');
107
+
108
+ if (onReload && url.pathname === RELOAD_PATH) {
109
+ response.writeHead(200, {
110
+ 'content-type': 'text/event-stream',
111
+ 'cache-control': 'no-cache',
112
+ connection: 'keep-alive',
113
+ });
114
+ response.write('\n');
115
+ listeners.add(response);
116
+ request.on('close', () => listeners.delete(response));
117
+ return;
118
+ }
119
+
120
+ // The site is mounted under basePath: outside that prefix, redirect there
121
+ // rather than answer 404 on the root, which is the URL one types first.
122
+ if (basePath !== '/' && !url.pathname.startsWith(basePath)) {
123
+ response.writeHead(302, { location: basePath });
124
+ response.end();
125
+ return;
126
+ }
127
+
128
+ const pathname = basePath === '/' ? url.pathname : url.pathname.slice(basePath.length - 1);
129
+ const filepath = resolveRequestPath(pathname, root);
130
+
131
+ if (!filepath) {
132
+ response.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
133
+ response.end('403 — path outside the served folder.');
134
+ return;
135
+ }
136
+
137
+ let stats;
138
+ try {
139
+ stats = await stat(filepath);
140
+ } catch {
141
+ response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
142
+ response.end(`404 — ${url.pathname}`);
143
+ return;
144
+ }
145
+
146
+ const type = MIME_TYPES[path.extname(filepath).toLowerCase()] ?? 'application/octet-stream';
147
+ const headers = { 'content-type': type, 'cache-control': 'no-cache' };
148
+
149
+ if (inject && type.startsWith('text/html')) {
150
+ const { readFile } = await import('node:fs/promises');
151
+ const html = await readFile(filepath, 'utf8');
152
+ const body = html.includes('</body>')
153
+ ? html.replace('</body>', `${inject}</body>`)
154
+ : html + inject;
155
+ response.writeHead(200, { ...headers, 'content-length': Buffer.byteLength(body) });
156
+ response.end(body);
157
+ return;
158
+ }
159
+
160
+ response.writeHead(200, { ...headers, 'content-length': stats.size });
161
+ createReadStream(filepath).pipe(response);
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Starts listening, looking for a free port if needed.
167
+ *
168
+ * @param {import('node:http').Server} server
169
+ * @param {number} port Desired port.
170
+ * @param {number} [attempts] Number of ports tried from `port` on.
171
+ * @returns {Promise<number>} The port actually used.
172
+ * @throws {DocPensieveError} When no port is free in the range.
173
+ */
174
+ export function listen(server, port, attempts = 10) {
175
+ return new Promise((resolve, reject) => {
176
+ let current = port;
177
+
178
+ /** @param {NodeJS.ErrnoException} error */
179
+ const onError = (error) => {
180
+ // A busy port is the common case when restarting a dev server: trying
181
+ // the next one beats forcing --port.
182
+ if (error.code === 'EADDRINUSE' && current < port + attempts - 1) {
183
+ current += 1;
184
+ server.listen(current);
185
+ return;
186
+ }
187
+ server.off('error', onError);
188
+ reject(
189
+ new DocPensieveError(`Could not listen on port ${port}.`, {
190
+ cause: error,
191
+ hint: `No free port between ${port} and ${port + attempts - 1}. Use --port.`,
192
+ }),
193
+ );
194
+ };
195
+
196
+ server.on('error', onError);
197
+ server.once('listening', () => {
198
+ server.off('error', onError);
199
+ // The effective port, not the requested one: with 0, the system assigns
200
+ // a free one and the caller needs to know which. `address()` returns
201
+ // `null` or a string for a pipe, a case that does not concern us.
202
+ const address = server.address();
203
+ resolve(typeof address === 'object' && address !== null ? address.port : current);
204
+ });
205
+ server.listen(current);
206
+ });
207
+ }