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.
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/bin/docpensieve.js +92 -0
- package/package.json +59 -0
- package/src/commands/build.js +47 -0
- package/src/commands/check.js +383 -0
- package/src/commands/dev.js +132 -0
- package/src/commands/init.js +298 -0
- package/src/commands/serve.js +44 -0
- package/src/index.js +15 -0
- package/src/server.js +207 -0
- package/src/theme.js +60 -0
- package/types/commands/build.d.ts +14 -0
- package/types/commands/check.d.ts +74 -0
- package/types/commands/dev.d.ts +25 -0
- package/types/commands/init.d.ts +27 -0
- package/types/commands/serve.d.ts +19 -0
- package/types/index.d.ts +14 -0
- package/types/server.d.ts +47 -0
- package/types/theme.d.ts +22 -0
|
@@ -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
|
+
}
|