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
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
|
+
}
|