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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Valentin Chevoleau
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # docpensieve
2
+
3
+ > Command-line interface
4
+
5
+ Part of [DocPensieve](https://github.com/Juniors017/docpensieve), a static documentation site generator:
6
+ Markdown and MDX in, static HTML out, one version per orphan branch, JSON-LD
7
+ structured data from the frontmatter.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install docpensieve
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ npx docpensieve init # sets up a project and picks the theme
19
+ npx docpensieve dev # builds, serves, watches and reloads
20
+ npx docpensieve build [ver] # builds every version, or a single one
21
+ npx docpensieve check # reads the produced site back: links, markup
22
+ npx docpensieve serve # serves the output folder
23
+ ```
24
+
25
+ `init` asks for the project name, its URL and the CSS framework. In a script
26
+ or in CI, `--yes --theme tailwind` skips the dialogue.
27
+
28
+ The development server reloads the browser after every rebuild, through a
29
+ script injected **at serving time**: the output of `build` stays free of
30
+ JavaScript.
31
+
32
+ ## Documentation
33
+
34
+ See the [repository](https://github.com/Juniors017/docpensieve#readme).
35
+
36
+ ## License
37
+
38
+ MIT
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Entry point of the DocPensieve CLI.
4
+ *
5
+ * This file only holds argument parsing and error presentation. All the logic
6
+ * lives in `src/commands/`.
7
+ */
8
+
9
+ import { createRequire } from 'node:module';
10
+
11
+ import { DocPensieveError, NotImplementedError } from '@docpensieve/shared';
12
+ import { Command } from 'commander';
13
+ import chalk from 'chalk';
14
+
15
+ import { build, check, dev, init, serve } from '../src/index.js';
16
+
17
+ const { version } = createRequire(import.meta.url)('../package.json');
18
+
19
+ const program = new Command();
20
+
21
+ program
22
+ .name('docpensieve')
23
+ .description('Static documentation site generator')
24
+ .version(version, '-v, --version');
25
+
26
+ program
27
+ .command('init')
28
+ .description('Sets up a documentation project')
29
+ .argument('[dir]', 'target folder', '.')
30
+ .option('-n, --name <name>', 'project name')
31
+ .option('-t, --theme <framework>', 'tailwind | custom')
32
+ .option('-u, --site-url <url>', 'public URL of the site')
33
+ .option('--version-name <version>', 'first version, e.g. 1.0')
34
+ .option('-y, --yes', 'accept the defaults without a dialogue')
35
+ .option('-f, --force', 'overwrite an existing configuration')
36
+ .action(async (dir, options) => {
37
+ await init(dir, { ...options, version: options.versionName });
38
+ });
39
+
40
+ program
41
+ .command('build')
42
+ .description('Generates the site (one version, or all of them when omitted)')
43
+ .argument('[version]', 'version slug, e.g. v1.0')
44
+ .option('-o, --out <dir>', 'output folder (default: the one in the config)')
45
+ .action(async (versionSlug, options) => {
46
+ await build(versionSlug, options);
47
+ });
48
+
49
+ program
50
+ .command('check')
51
+ .description('Reads the produced site back and reports dead links and invalid markup')
52
+ .option('-d, --dir <dir>', 'folder to check')
53
+ .action(async (options) => {
54
+ await check(options);
55
+ });
56
+
57
+ program
58
+ .command('dev')
59
+ .description('Development server with reload')
60
+ .option('-p, --port <number>', 'listening port', Number, 3000)
61
+ .action(async (options) => {
62
+ await dev(options);
63
+ });
64
+
65
+ program
66
+ .command('serve')
67
+ .description('Serves the output folder statically')
68
+ .option('-p, --port <number>', 'listening port', Number, 4000)
69
+ .option('-d, --dir <dir>', 'folder to serve')
70
+ .action(async (options) => {
71
+ await serve(options);
72
+ });
73
+
74
+ try {
75
+ await program.parseAsync(process.argv);
76
+ } catch (error) {
77
+ if (error instanceof NotImplementedError) {
78
+ // A known roadmap milestone: not a crash, so stay sober.
79
+ console.error(`${chalk.yellow('Not available yet')} ${error.message}`);
80
+ if (error.hint) console.error(chalk.dim(error.hint));
81
+ process.exitCode = 2;
82
+ } else if (error instanceof DocPensieveError) {
83
+ console.error(`${chalk.red('Error')} ${error.message}`);
84
+ if (error.hint) console.error(chalk.dim(error.hint));
85
+ process.exitCode = 1;
86
+ } else {
87
+ // Unexpected error: the stack is the useful information.
88
+ console.error(chalk.red('Unexpected error:'));
89
+ console.error(error);
90
+ process.exitCode = 1;
91
+ }
92
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "docpensieve",
3
+ "version": "0.1.0",
4
+ "description": "DocPensieve command-line interface (init, build, check, dev, serve)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=22.0.0"
9
+ },
10
+ "main": "./src/index.js",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./types/index.d.ts",
14
+ "default": "./src/index.js"
15
+ }
16
+ },
17
+ "bin": {
18
+ "docpensieve": "bin/docpensieve.js"
19
+ },
20
+ "files": [
21
+ "bin",
22
+ "src",
23
+ "types"
24
+ ],
25
+ "dependencies": {
26
+ "@docpensieve/components": "^0.1.0",
27
+ "@docpensieve/core": "^0.1.0",
28
+ "@docpensieve/shared": "^0.1.0",
29
+ "@docpensieve/theme": "^0.1.0",
30
+ "chalk": "^6.0.0",
31
+ "chokidar": "^5.0.0",
32
+ "commander": "^15.0.0"
33
+ },
34
+ "keywords": [
35
+ "docpensieve",
36
+ "cli",
37
+ "documentation",
38
+ "static-site-generator",
39
+ "mdx",
40
+ "ssg"
41
+ ],
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/Juniors017/docpensieve.git",
45
+ "directory": "packages/cli"
46
+ },
47
+ "homepage": "https://github.com/Juniors017/docpensieve#readme",
48
+ "bugs": {
49
+ "url": "https://github.com/Juniors017/docpensieve/issues"
50
+ },
51
+ "author": "Valentin Chevoleau (Juniors017)",
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "types": "./types/index.d.ts",
56
+ "scripts": {
57
+ "prepack": "tsc -b tsconfig.build.json"
58
+ }
59
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `docpensieve build` command.
3
+ *
4
+ * @module docpensieve/commands/build
5
+ */
6
+
7
+ import path from 'node:path';
8
+
9
+ import { componentsCss, createRegistry, setSiteContext } from '@docpensieve/components';
10
+ import { SiteGenerator, loadConfig, resolveVersion } from '@docpensieve/core';
11
+
12
+ import { createTheme } from '../theme.js';
13
+
14
+ /**
15
+ * @param {string | undefined} versionSlug Version to generate, or all of them when omitted.
16
+ * @param {{ out?: string, cwd?: string }} [options]
17
+ * @returns {Promise<void>}
18
+ */
19
+ export async function build(versionSlug, options = {}) {
20
+ const cwd = options.cwd ?? process.cwd();
21
+ const config = await loadConfig(cwd);
22
+ const outDir = path.resolve(cwd, options.out ?? config.outDir);
23
+
24
+ const generator = new SiteGenerator(config, {
25
+ // `globalComponents: false` removes the shipped components: a project that
26
+ // defines its own thus avoids a name collision. The option was declared
27
+ // and documented, but nobody read it.
28
+ components: config.globalComponents === false ? {} : createRegistry(),
29
+ onPage: setSiteContext,
30
+ theme: createTheme(config, await componentsCss()),
31
+ });
32
+
33
+ if (versionSlug) {
34
+ const version = resolveVersion(config, versionSlug);
35
+ console.log(`Generating version ${version.name} (${version.slug})…`);
36
+ const result = await generator.buildVersion(
37
+ version.slug,
38
+ path.join(outDir, 'versions', version.slug),
39
+ );
40
+ console.log(`${result.pages} page(s) written to ${result.outDir}`);
41
+ return;
42
+ }
43
+
44
+ console.log(`Generating ${config.versions.length} version(s)…`);
45
+ const result = await generator.buildAll();
46
+ console.log(`${result.pages} page(s) across ${result.versions} version(s) in ${result.outDir}`);
47
+ }
@@ -0,0 +1,383 @@
1
+ /**
2
+ * `docpensieve check` command — reads the produced site back.
3
+ *
4
+ * A successful build says nothing of a dead link or of invalid markup:
5
+ * nothing in the chain looks at them, and they only show when opening the
6
+ * pages one by one. This command does that reading.
7
+ *
8
+ * @module docpensieve/commands/check
9
+ */
10
+
11
+ import { existsSync } from 'node:fs';
12
+ import { readFile, readdir, stat } from 'node:fs/promises';
13
+ import path from 'node:path';
14
+
15
+ import { loadConfig } from '@docpensieve/core';
16
+ import { DocPensieveError } from '@docpensieve/shared';
17
+ import chalk from 'chalk';
18
+
19
+ /** Targets outside our remit: another domain, an anchor, a special protocol. */
20
+ const EXTERNAL = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
21
+
22
+ /**
23
+ * Attributes likely to carry an internal target.
24
+ *
25
+ * `content` is one of them for header metadata; the values that are not URLs
26
+ * do not start with a slash and are set aside.
27
+ *
28
+ * The lookbehind sets compound names aside: without it, `data-src` ends with
29
+ * `src` and would be taken for a target, although the browser will never
30
+ * fetch it — a dead link reported for nothing.
31
+ */
32
+ const ATTRIBUTES = /(?<![\w-])(?:href|src|content)="([^"]*)"/g;
33
+
34
+ /** Opening or closing tag, with its name. */
35
+ const TAG = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?(\/?)>/g;
36
+
37
+ /** Contents whose inside is not markup. */
38
+ const RAW = /<(script|style|textarea)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
39
+
40
+ /** Elements without content: they open nothing that must be closed. */
41
+ const VOID = new Set([
42
+ 'area',
43
+ 'base',
44
+ 'br',
45
+ 'col',
46
+ 'embed',
47
+ 'hr',
48
+ 'img',
49
+ 'input',
50
+ 'link',
51
+ 'meta',
52
+ 'param',
53
+ 'source',
54
+ 'track',
55
+ 'wbr',
56
+ ]);
57
+
58
+ /**
59
+ * Elements that only accept text content.
60
+ *
61
+ * A paragraph inside one of them is invalid: the browser takes it out of its
62
+ * wrapper, and the intended layout disappears.
63
+ */
64
+ const INLINE = new Set([
65
+ 'abbr',
66
+ 'b',
67
+ 'button',
68
+ 'cite',
69
+ 'code',
70
+ 'em',
71
+ 'i',
72
+ 'kbd',
73
+ 'label',
74
+ 'mark',
75
+ 'q',
76
+ 's',
77
+ 'small',
78
+ 'span',
79
+ 'strong',
80
+ 'sub',
81
+ 'sup',
82
+ 'u',
83
+ ]);
84
+
85
+ /**
86
+ * Block elements: a paragraph can contain none of them.
87
+ *
88
+ * The browser closes the paragraph by itself when it meets one: a heading
89
+ * written in a paragraph escapes it, and the text that followed ends up bare,
90
+ * outside any paragraph.
91
+ */
92
+ const BLOCK = new Set([
93
+ 'address',
94
+ 'article',
95
+ 'aside',
96
+ 'blockquote',
97
+ 'details',
98
+ 'div',
99
+ 'dl',
100
+ 'fieldset',
101
+ 'figcaption',
102
+ 'figure',
103
+ 'footer',
104
+ 'form',
105
+ 'h1',
106
+ 'h2',
107
+ 'h3',
108
+ 'h4',
109
+ 'h5',
110
+ 'h6',
111
+ 'header',
112
+ 'hr',
113
+ 'main',
114
+ 'nav',
115
+ 'ol',
116
+ 'pre',
117
+ 'section',
118
+ 'table',
119
+ 'ul',
120
+ ]);
121
+
122
+ /**
123
+ * @typedef {object} Fault
124
+ * @property {string} page Page path, relative to the checked root.
125
+ * @property {string} subject What is at stake — a target, a tag.
126
+ * @property {string} reason What is wrong.
127
+ */
128
+
129
+ /**
130
+ * @typedef {object} Page
131
+ * @property {string} relative Path relative to the checked root.
132
+ * @property {string} html File contents.
133
+ */
134
+
135
+ /**
136
+ * Recursively lists the HTML pages of a folder.
137
+ *
138
+ * @param {string} dir
139
+ * @returns {Promise<string[]>}
140
+ */
141
+ async function htmlFilesIn(dir) {
142
+ /** @type {string[]} */
143
+ const found = [];
144
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
145
+ const file = path.join(dir, entry.name);
146
+ if (entry.isDirectory()) found.push(...(await htmlFilesIn(file)));
147
+ else if (entry.name.endsWith('.html')) found.push(file);
148
+ }
149
+ return found;
150
+ }
151
+
152
+ /**
153
+ * Loads the pages of a produced site.
154
+ *
155
+ * @param {string} root
156
+ * @returns {Promise<Page[]>}
157
+ */
158
+ async function readPages(root) {
159
+ const files = await htmlFilesIn(root);
160
+ return Promise.all(
161
+ files.map(async (file) => ({
162
+ relative: path.relative(root, file).split(path.sep).join('/'),
163
+ html: await readFile(file, 'utf8'),
164
+ })),
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Tells whether a target matches a produced file.
170
+ *
171
+ * `/guide/` means `guide/index.html`; `/guide` can mean either depending on
172
+ * the host, so both forms are accepted.
173
+ *
174
+ * @param {string} root Checked folder.
175
+ * @param {string} urlPath URL path, deployment prefix already removed.
176
+ * @returns {Promise<boolean>}
177
+ */
178
+ async function existsInOutput(root, urlPath) {
179
+ const relative = decodeURIComponent(urlPath).replace(/^\/+/, '');
180
+ const candidates = urlPath.endsWith('/')
181
+ ? [path.join(relative, 'index.html')]
182
+ : [relative, `${relative}.html`, path.join(relative, 'index.html')];
183
+
184
+ for (const candidate of candidates) {
185
+ try {
186
+ const info = await stat(path.join(root, candidate));
187
+ if (info.isFile()) return true;
188
+ } catch {
189
+ // Missing candidate: try the next form.
190
+ }
191
+ }
192
+ return false;
193
+ }
194
+
195
+ /**
196
+ * Looks for internal links that lead nowhere.
197
+ *
198
+ * @param {string} root
199
+ * @param {string} baseUrl
200
+ * @param {Page[]} pages
201
+ * @returns {Promise<Fault[]>}
202
+ */
203
+ async function deadLinks(root, baseUrl, pages) {
204
+ /** @type {Fault[]} */
205
+ const faults = [];
206
+ const prefix = baseUrl === '/' ? '' : baseUrl.replace(/\/$/, '');
207
+
208
+ for (const { relative, html } of pages) {
209
+ // The same target often comes back within a page — the menu, the
210
+ // breadcrumb: reporting it once is enough.
211
+ const seen = new Set();
212
+
213
+ for (const [, raw] of html.matchAll(ATTRIBUTES)) {
214
+ if (raw === '' || EXTERNAL.test(raw) || !raw.startsWith('/')) continue;
215
+ if (seen.has(raw)) continue;
216
+ seen.add(raw);
217
+
218
+ const target = raw.split('#')[0].split('?')[0];
219
+ if (target === '') continue;
220
+
221
+ if (prefix && !target.startsWith(`${prefix}/`) && target !== prefix) {
222
+ faults.push({
223
+ page: relative,
224
+ subject: raw,
225
+ reason: `ignores the deployment prefix "${baseUrl}"`,
226
+ });
227
+ continue;
228
+ }
229
+
230
+ if (!(await existsInOutput(root, target.slice(prefix.length)))) {
231
+ faults.push({ page: relative, subject: raw, reason: 'leads to no file' });
232
+ }
233
+ }
234
+ }
235
+
236
+ return faults;
237
+ }
238
+
239
+ /**
240
+ * Looks for paragraphs placed where they cannot fit.
241
+ *
242
+ * The content of a JSX tag left alone on its line becomes a paragraph. A
243
+ * `<p className="…">` used as a wrapper therefore produces two nested
244
+ * paragraphs: the browser closes the first one by itself, the wrapper
245
+ * disappears, and the intended layout with it. Nothing reports it.
246
+ *
247
+ * The formatter makes the trap sneaky — it breaks a long string of classes
248
+ * over several lines, which leaves the text alone on its line after the fact.
249
+ *
250
+ * The analysis holds on a stack because the output **always** closes its
251
+ * paragraphs: nothing here is produced by hand, so a `<p>` opened while
252
+ * another one is open is indeed a nesting, never an implicit close.
253
+ *
254
+ * @param {Page[]} pages
255
+ * @returns {Fault[]}
256
+ */
257
+ function invalidMarkup(pages) {
258
+ /** @type {Fault[]} */
259
+ const faults = [];
260
+
261
+ for (const { relative, html } of pages) {
262
+ // The inside of a script or a style is not markup.
263
+ const markup = html.replace(RAW, '');
264
+
265
+ /** @type {string[]} */
266
+ const stack = [];
267
+ let reported = false;
268
+
269
+ for (const [, closing, name, selfClosing] of markup.matchAll(TAG)) {
270
+ const tag = name.toLowerCase();
271
+
272
+ if (closing) {
273
+ const position = stack.lastIndexOf(tag);
274
+ if (position !== -1) stack.length = position;
275
+ continue;
276
+ }
277
+
278
+ if (tag !== 'p' && BLOCK.has(tag) && !reported && stack.includes('p')) {
279
+ faults.push({
280
+ page: relative,
281
+ subject: `<${tag}>`,
282
+ reason: `block element "${tag}" inside a paragraph`,
283
+ });
284
+ reported = true;
285
+ }
286
+
287
+ if (tag === 'p' && !reported) {
288
+ if (stack.includes('p')) {
289
+ faults.push({
290
+ page: relative,
291
+ subject: '<p>',
292
+ reason: 'paragraph nested in a paragraph',
293
+ });
294
+ reported = true;
295
+ } else {
296
+ const wrapper = stack.findLast((open) => INLINE.has(open));
297
+ if (wrapper !== undefined) {
298
+ faults.push({
299
+ page: relative,
300
+ subject: '<p>',
301
+ reason: `paragraph inside a "${wrapper}", which only accepts text`,
302
+ });
303
+ reported = true;
304
+ }
305
+ }
306
+ }
307
+
308
+ if (!VOID.has(tag) && selfClosing !== '/') stack.push(tag);
309
+ }
310
+ }
311
+
312
+ return faults;
313
+ }
314
+
315
+ /**
316
+ * Checks the internal links of a generated site.
317
+ *
318
+ * Exported apart from the command: it reads no configuration and addresses no
319
+ * one, which makes it usable elsewhere and testable on its own.
320
+ *
321
+ * @param {string} root Folder of the produced site.
322
+ * @param {string} [baseUrl] Deployment prefix, slashes included.
323
+ * @returns {Promise<{ pages: number, faults: Fault[] }>}
324
+ */
325
+ export async function verifyLinks(root, baseUrl = '/') {
326
+ const pages = await readPages(root);
327
+ return { pages: pages.length, faults: await deadLinks(root, baseUrl, pages) };
328
+ }
329
+
330
+ /**
331
+ * Checks the markup of a generated site.
332
+ *
333
+ * @param {string} root Folder of the produced site.
334
+ * @returns {Promise<{ pages: number, faults: Fault[] }>}
335
+ */
336
+ export async function verifyMarkup(root) {
337
+ const pages = await readPages(root);
338
+ return { pages: pages.length, faults: invalidMarkup(pages) };
339
+ }
340
+
341
+ /**
342
+ * Reads the produced site back and reports what is wrong.
343
+ *
344
+ * @param {{ dir?: string, cwd?: string }} [options]
345
+ * @returns {Promise<{ root: string, pages: number, faults: Fault[] }>}
346
+ * @throws {DocPensieveError} When the folder does not exist, or when something
347
+ * is left to fix — the exit code is then that of an expected error, which is
348
+ * enough to fail a continuous integration run.
349
+ */
350
+ export async function check(options = {}) {
351
+ const cwd = options.cwd ?? process.cwd();
352
+ const config = await loadConfig(cwd);
353
+ const root = path.resolve(cwd, options.dir ?? config.outDir);
354
+
355
+ if (!existsSync(root)) {
356
+ throw new DocPensieveError(`Nothing to check: ${root} does not exist.`, {
357
+ hint: 'Run "docpensieve build" before "check".',
358
+ });
359
+ }
360
+
361
+ // The pages are only read once for both checks.
362
+ const pages = await readPages(root);
363
+ const faults = [...(await deadLinks(root, config.baseUrl, pages)), ...invalidMarkup(pages)];
364
+
365
+ if (faults.length === 0) {
366
+ console.log(`${pages.length} page(s) read in ${root}`);
367
+ console.log(chalk.green('No dead link, no invalid markup.'));
368
+ return { root, pages: pages.length, faults };
369
+ }
370
+
371
+ // The details come out before the error: the final message only carries a
372
+ // count, and the list says what to fix.
373
+ console.error(`${pages.length} page(s) read in ${root}\n`);
374
+ for (const { page, subject, reason } of faults) {
375
+ console.error(` ${chalk.bold(page)}`);
376
+ console.error(` ${subject}`);
377
+ console.error(` ${chalk.dim(`→ ${reason}`)}\n`);
378
+ }
379
+
380
+ throw new DocPensieveError(`${faults.length} issue(s) to fix.`, {
381
+ hint: 'An absolute target starts from the version root. A paragraph cannot contain another one.',
382
+ });
383
+ }