hdoc-tools 0.61.0 → 0.62.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 -21
- package/README.md +89 -75
- package/editor/dist/assets/index-Blewr90z.css +1 -1
- package/editor/dist/assets/index-BtxvGZHW.js +111 -111
- package/editor/dist/assets/spell.worker-sryEKmOj.js +13 -13
- package/editor/dist/index.html +14 -14
- package/hdoc-build-db.js +275 -275
- package/hdoc-build-embeddings.js +202 -202
- package/hdoc-build-pdf.js +232 -232
- package/hdoc-bump.js +125 -125
- package/hdoc-create.js +110 -110
- package/hdoc-db.js +114 -114
- package/hdoc-help.js +60 -60
- package/hdoc-install-browser.js +145 -145
- package/hdoc-mermaid.js +204 -204
- package/hdoc-module.js +1102 -1102
- package/hdoc-validate-config.js +355 -355
- package/hdoc-validate-interbook.js +321 -321
- package/hdoc-validate.js +1231 -1231
- package/hdoc-ver.js +45 -45
- package/package.json +12 -1
- package/templates/doc-header-non-git.html +19 -19
- package/templates/doc-header.html +26 -26
- package/templates/init/.github/workflows/hdocbuild_onpull.yml +16 -16
- package/templates/init/.github/workflows/hdocbuild_onpush.yml +15 -15
- package/templates/init/LICENSE +21 -21
- package/templates/init/README.md +9 -9
- package/templates/init/_hdocbook/index.md +4 -4
- package/templates/init/gitignore +8 -8
- package/templates/init/resources/README.md +2 -2
- package/templates/pdf/css/custom-block.css +90 -90
- package/templates/pdf/css/fonts.css +221 -221
- package/templates/pdf/css/hdocs-pdf.css +495 -495
- package/templates/pdf/css/vars.css +404 -404
- package/templates/pdf/template-footer.html +19 -19
- package/templates/pdf/template-header.html +37 -37
- package/templates/pdf/template.html +20 -20
- package/templates/pdf-header-non-git.html +12 -12
- package/templates/pdf-header.html +16 -16
- package/ui/content/invalid-hdocbook-json.html +6 -6
- package/ui/content/invalid-hdocbook-json.md +7 -7
- package/ui/css/theme-default/styles/components/content.css +124 -124
- package/ui/css/theme-default/styles/components/sidebar.css +182 -182
- package/ui/css/theme-default/styles/htldoc.layouts.css +310 -310
- package/ui/index.html +419 -419
package/hdoc-validate.js
CHANGED
|
@@ -1,1231 +1,1231 @@
|
|
|
1
|
-
(() => {
|
|
2
|
-
const cheerio = require("cheerio");
|
|
3
|
-
const dns = require("node:dns");
|
|
4
|
-
const fs = require("node:fs");
|
|
5
|
-
const path = require("node:path");
|
|
6
|
-
const hdoc = require(path.join(__dirname, "hdoc-module.js"));
|
|
7
|
-
const interbook = require(path.join(__dirname, "hdoc-validate-interbook.js"));
|
|
8
|
-
const translator = require("american-british-english-translator");
|
|
9
|
-
|
|
10
|
-
const spellcheck_options = {
|
|
11
|
-
british: true,
|
|
12
|
-
spelling: true,
|
|
13
|
-
};
|
|
14
|
-
const regex_nav_paths = /[a-z0-9-\/]+[a-z0-9]+#{0,1}[a-z0-9-\/]+/;
|
|
15
|
-
|
|
16
|
-
const errors = {};
|
|
17
|
-
const messages = {};
|
|
18
|
-
const warnings = {};
|
|
19
|
-
const html_to_validate = [];
|
|
20
|
-
const md_to_validate = [];
|
|
21
|
-
const exclude_links = {};
|
|
22
|
-
const exclude_spellcheck = {};
|
|
23
|
-
let private_repo = false;
|
|
24
|
-
let redirects = {};
|
|
25
|
-
let skip_link_file = '';
|
|
26
|
-
let _on_int_net_cached = null; // null = not yet checked; cached after first DNS lookup
|
|
27
|
-
const exclude_h1_count = {};
|
|
28
|
-
const exclude_spellcheck_output = [];
|
|
29
|
-
let global_spellcheck = [];
|
|
30
|
-
|
|
31
|
-
const excludeLink = (url) => {
|
|
32
|
-
if (exclude_links[url]) return true;
|
|
33
|
-
for (let key in exclude_links) {
|
|
34
|
-
if (Object.hasOwn(exclude_links, key)) {
|
|
35
|
-
if (key.endsWith("*")) {
|
|
36
|
-
key = key.substring(0, key.length - 1);
|
|
37
|
-
if (url.startsWith(key)) return true;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
return false;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const loadSkipLinkValidation = (source_path) => {
|
|
45
|
-
skip_link_file = path.join(source_path, "validated-links.txt");
|
|
46
|
-
if (fs.existsSync(skip_link_file)) {
|
|
47
|
-
console.log(`Loading skip link validation file from: ${skip_link_file}`);
|
|
48
|
-
const skip_links = fs.readFileSync(skip_link_file, "utf8").split("\n");
|
|
49
|
-
for (let i = 0; i < skip_links.length; i++) {
|
|
50
|
-
if (skip_links[i].trim() !== "") {
|
|
51
|
-
exclude_links[skip_links[i].trim()] = true;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
} else {
|
|
55
|
-
//Create the file if it doesn't exist
|
|
56
|
-
console.log(`Creating skip link validation file: ${skip_link_file}`);
|
|
57
|
-
fs.writeFileSync(skip_link_file, "", "utf8");
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const spellcheckContent = async (sourceFile, excludes) => {
|
|
62
|
-
const spelling_errors = {};
|
|
63
|
-
const words = [];
|
|
64
|
-
const text = fs.readFileSync(sourceFile.path, "utf8");
|
|
65
|
-
const source_path = sourceFile.relativePath.replace(
|
|
66
|
-
`.${sourceFile.extension}`,
|
|
67
|
-
"",
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
const markdown_paths = getMDPathFromHtmlPath(sourceFile);
|
|
71
|
-
|
|
72
|
-
const translate_output = translator.translate(text, spellcheck_options);
|
|
73
|
-
if (Object.keys(translate_output).length) {
|
|
74
|
-
for (const key in translate_output) {
|
|
75
|
-
if (Object.hasOwn(translate_output, key)) {
|
|
76
|
-
// key is the line of text
|
|
77
|
-
let error_message = `British spelling:`;
|
|
78
|
-
for (let i = 0; i < translate_output[key].length; i++) {
|
|
79
|
-
for (const spelling in translate_output[key][i]) {
|
|
80
|
-
if (
|
|
81
|
-
Object.hasOwn(translate_output[key][i], spelling) &&
|
|
82
|
-
typeof translate_output[key][i][spelling].details === "string"
|
|
83
|
-
) {
|
|
84
|
-
const link_location = hdoc.find_string_in_string(text.split('\n')[key - 1], spelling);
|
|
85
|
-
if (link_location !== null)
|
|
86
|
-
error_message = `${markdown_paths.relativePath}:${key}:${link_location.column} - ${error_message}`;
|
|
87
|
-
else
|
|
88
|
-
error_message = `${markdown_paths.relativePath}:${key} - ${error_message}`;
|
|
89
|
-
if (!excludes[source_path] && !global_spellcheck.includes(spelling.toLowerCase())) {
|
|
90
|
-
errors[sourceFile.relativePath].push(
|
|
91
|
-
`${error_message} ${spelling} should be ${translate_output[key][i][spelling].details}`,
|
|
92
|
-
);
|
|
93
|
-
spelling_errors[spelling] = true;
|
|
94
|
-
} else if (
|
|
95
|
-
!excludes[source_path].includes(spelling.toLowerCase()) && !global_spellcheck.includes(spelling.toLowerCase())
|
|
96
|
-
) {
|
|
97
|
-
errors[sourceFile.relativePath].push(
|
|
98
|
-
`${error_message} ${spelling} should be ${translate_output[key][i][spelling].details}`,
|
|
99
|
-
);
|
|
100
|
-
spelling_errors[spelling] = true;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
if (Object.keys(spelling_errors).length) {
|
|
109
|
-
const exclude_output = {
|
|
110
|
-
document_path: sourceFile.relativePath.replace(
|
|
111
|
-
path.extname(sourceFile.relativePath),
|
|
112
|
-
"",
|
|
113
|
-
),
|
|
114
|
-
words: [],
|
|
115
|
-
};
|
|
116
|
-
for (const word in spelling_errors) {
|
|
117
|
-
if (Object.hasOwn(spelling_errors, word)) {
|
|
118
|
-
words.push(word);
|
|
119
|
-
exclude_output.words.push(word);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
exclude_spellcheck_output.push(exclude_output);
|
|
123
|
-
}
|
|
124
|
-
return words;
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
const checkInline = async (source_path, inline, excludes) => {
|
|
128
|
-
const inline_errors = [];
|
|
129
|
-
for (let i = 0; i < inline.length; i++) {
|
|
130
|
-
const title = inline[i].title;
|
|
131
|
-
const link = inline[i].link;
|
|
132
|
-
|
|
133
|
-
// Validate link segment spellings
|
|
134
|
-
const paths = link.split("/");
|
|
135
|
-
for (let i = 0; i < paths.length; i++) {
|
|
136
|
-
const path_words = paths[i].split("-");
|
|
137
|
-
for (let j = 0; j < path_words.length; j++) {
|
|
138
|
-
const translate_output = translator.translate(
|
|
139
|
-
path_words[j],
|
|
140
|
-
spellcheck_options,
|
|
141
|
-
);
|
|
142
|
-
if (Object.keys(translate_output).length) {
|
|
143
|
-
for (const spell_val in translate_output) {
|
|
144
|
-
if (Object.hasOwn(translate_output, spell_val)) {
|
|
145
|
-
for (const spelling in translate_output[spell_val][0]) {
|
|
146
|
-
if (Object.hasOwn(translate_output[spell_val][0], spelling)) {
|
|
147
|
-
if (!excludes[link]) {
|
|
148
|
-
inline_errors.push(
|
|
149
|
-
`Inline Link [${link}] contains a British English spelling: ${spelling} should be ${translate_output[spell_val][0][spelling].details}`,
|
|
150
|
-
);
|
|
151
|
-
} else if (
|
|
152
|
-
!excludes[link].includes(spelling.toLowerCase())
|
|
153
|
-
) {
|
|
154
|
-
inline_errors.push(
|
|
155
|
-
`Inline Link [${link}] contains a British English spelling: ${spelling} should be ${translate_output[spell_val][0][spelling].details}`,
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Validate display names
|
|
167
|
-
const translate_output = translator.translate(title, spellcheck_options);
|
|
168
|
-
if (Object.keys(translate_output).length) {
|
|
169
|
-
for (const spell_val in translate_output) {
|
|
170
|
-
if (Object.hasOwn(translate_output, spell_val)) {
|
|
171
|
-
for (let j = 0; j < translate_output[spell_val].length; j++) {
|
|
172
|
-
for (const spelling in translate_output[spell_val][j]) {
|
|
173
|
-
if (Object.hasOwn(translate_output[spell_val][j], spelling)) {
|
|
174
|
-
if (!excludes[link]) {
|
|
175
|
-
inline_errors.push(
|
|
176
|
-
`Inline title for link [${link}] contains a British English spelling: ${spelling} should be ${translate_output[spell_val][j][spelling].details}`,
|
|
177
|
-
);
|
|
178
|
-
} else if (!excludes[link].includes(spelling.toLowerCase())) {
|
|
179
|
-
inline_errors.push(
|
|
180
|
-
`Inline title for link [${link}] contains a British English spelling: ${spelling} should be ${translate_output[spell_val][j][spelling].details}`,
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Validate path exists - link should be a html file at this point as its after the content has been built
|
|
191
|
-
let file_exists = true;
|
|
192
|
-
let file_name = path.join(source_path, `${link}.html`);
|
|
193
|
-
if (!fs.existsSync(file_name)) {
|
|
194
|
-
file_name = path.join(source_path, `${link}.htm`);
|
|
195
|
-
if (!fs.existsSync(file_name)) {
|
|
196
|
-
file_name = path.join(source_path, link, "index.html");
|
|
197
|
-
if (!fs.existsSync(file_name)) {
|
|
198
|
-
file_name = path.join(source_path, link, "index.htm");
|
|
199
|
-
if (!fs.existsSync(file_name)) {
|
|
200
|
-
file_exists = false;
|
|
201
|
-
inline_errors.push(`Inline link [${link}] file does not exist.`);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
return inline_errors;
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
const checkNavigation = async (source_path, flat_nav, excludes, draft_links) => {
|
|
212
|
-
const nav_errors = [];
|
|
213
|
-
for (const key in flat_nav) {
|
|
214
|
-
if (Object.hasOwn(flat_nav, key)) {
|
|
215
|
-
// doc paths should only contain a-z - characters
|
|
216
|
-
const invalid_chars = key.replace(regex_nav_paths, "");
|
|
217
|
-
if (invalid_chars !== "") {
|
|
218
|
-
nav_errors.push(
|
|
219
|
-
`Navigation path [${key}] contains the following invalid characters: [${[...invalid_chars].join("] [")}]`,
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
const key_split = key.split("#");
|
|
223
|
-
const key_no_hash = key_split[0];
|
|
224
|
-
|
|
225
|
-
// See if there's a redirect in place
|
|
226
|
-
let redirected = false;
|
|
227
|
-
let redirect_errored = false;
|
|
228
|
-
const redir = checkRedirect(source_path, key_no_hash);
|
|
229
|
-
|
|
230
|
-
if (redir.exists && redir.error !== null) {
|
|
231
|
-
nav_errors.push(redir.error);
|
|
232
|
-
redirect_errored = true;
|
|
233
|
-
} else if (redir.exists && redir.error === null) {
|
|
234
|
-
redirected = true;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Validate path exists - key should be a html file at this point
|
|
238
|
-
let file_exists = true;
|
|
239
|
-
let file_name = path.join(source_path, `${key_no_hash}.html`);
|
|
240
|
-
if (!fs.existsSync(file_name)) {
|
|
241
|
-
file_name = path.join(source_path, `${key_no_hash}.htm`);
|
|
242
|
-
if (!fs.existsSync(file_name)) {
|
|
243
|
-
file_name = path.join(source_path, key_no_hash, "index.html");
|
|
244
|
-
if (!fs.existsSync(file_name)) {
|
|
245
|
-
file_name = path.join(source_path, key_no_hash, "index.htm");
|
|
246
|
-
if (!fs.existsSync(file_name)) {
|
|
247
|
-
file_exists = false;
|
|
248
|
-
if (!redirected && !redirect_errored && draft_links.indexOf(key_no_hash) === -1)
|
|
249
|
-
nav_errors.push(
|
|
250
|
-
`Navigation path [${key_no_hash}] file does not exist.`,
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
if (file_exists) {
|
|
258
|
-
// File exists - but is there a redirect? If so, we want to flag this as an error
|
|
259
|
-
if (redirected)
|
|
260
|
-
nav_errors.push(
|
|
261
|
-
`Navigation path [${key_no_hash}] is redirected, but path still exists.`,
|
|
262
|
-
);
|
|
263
|
-
|
|
264
|
-
// Check file path case match
|
|
265
|
-
const true_file = hdoc.true_case_path_sync(file_name)
|
|
266
|
-
.replace(source_path, "")
|
|
267
|
-
.replaceAll("\\", "/");
|
|
268
|
-
const relative_file = file_name
|
|
269
|
-
.replace(source_path, "")
|
|
270
|
-
.replaceAll("\\", "/");
|
|
271
|
-
if (true_file !== relative_file) {
|
|
272
|
-
nav_errors.push(
|
|
273
|
-
`Navigation path [${key}] for filename [${relative_file}] does not match filename case [${true_file}].`,
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Validate path spellings
|
|
279
|
-
const paths = key.split("/");
|
|
280
|
-
for (let i = 0; i < paths.length; i++) {
|
|
281
|
-
const path_words = paths[i].split("-");
|
|
282
|
-
for (let j = 0; j < path_words.length; j++) {
|
|
283
|
-
const translate_output = translator.translate(
|
|
284
|
-
path_words[j],
|
|
285
|
-
spellcheck_options,
|
|
286
|
-
);
|
|
287
|
-
if (Object.keys(translate_output).length) {
|
|
288
|
-
for (const spell_val in translate_output) {
|
|
289
|
-
if (Object.hasOwn(translate_output, spell_val)) {
|
|
290
|
-
for (const spelling in translate_output[spell_val][0]) {
|
|
291
|
-
if (
|
|
292
|
-
Object.hasOwn(translate_output[spell_val][0], spelling)
|
|
293
|
-
) {
|
|
294
|
-
if (!excludes[key]) {
|
|
295
|
-
nav_errors.push(
|
|
296
|
-
`Navigation path [${key}] key contains a British English spelling: ${spelling} should be ${translate_output[spell_val][0][spelling].details}`,
|
|
297
|
-
);
|
|
298
|
-
} else if (
|
|
299
|
-
!excludes[key].includes(spelling.toLowerCase())
|
|
300
|
-
) {
|
|
301
|
-
nav_errors.push(
|
|
302
|
-
`Navigation path [${key}] key contains a British English spelling: ${spelling} should be ${translate_output[spell_val][0][spelling].details}`,
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Validate display names/bookmarks
|
|
314
|
-
for (let i = 0; i < flat_nav[key].length; i++) {
|
|
315
|
-
if (flat_nav[key][i].link === key) {
|
|
316
|
-
const translate_output = translator.translate(
|
|
317
|
-
flat_nav[key][i].text,
|
|
318
|
-
spellcheck_options,
|
|
319
|
-
);
|
|
320
|
-
if (Object.keys(translate_output).length) {
|
|
321
|
-
for (const spell_val in translate_output) {
|
|
322
|
-
if (Object.hasOwn(translate_output, spell_val)) {
|
|
323
|
-
for (let j = 0; j < translate_output[spell_val].length; j++) {
|
|
324
|
-
for (const spelling in translate_output[spell_val][j]) {
|
|
325
|
-
if (
|
|
326
|
-
Object.hasOwn(translate_output[spell_val][j], spelling)
|
|
327
|
-
) {
|
|
328
|
-
if (!excludes[key]) {
|
|
329
|
-
nav_errors.push(
|
|
330
|
-
`Navigation path [${key}] display text contains a British English spelling: ${spelling} should be ${translate_output[spell_val][j][spelling].details}`,
|
|
331
|
-
);
|
|
332
|
-
} else if (
|
|
333
|
-
!excludes[key].includes(spelling.toLowerCase())
|
|
334
|
-
) {
|
|
335
|
-
nav_errors.push(
|
|
336
|
-
`Navigation path [${key}] display text contains a British English spelling: ${spelling} should be ${translate_output[spell_val][j][spelling].details}`,
|
|
337
|
-
);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
return nav_errors;
|
|
350
|
-
};
|
|
351
|
-
|
|
352
|
-
const checkRedirects = async (source_path) => {
|
|
353
|
-
const redir_errors = [];
|
|
354
|
-
for (const key in redirects) {
|
|
355
|
-
if (Object.hasOwn(redirects, key)) {
|
|
356
|
-
if (
|
|
357
|
-
redirects[key].code !== 301 &&
|
|
358
|
-
redirects[key].code !== 308 &&
|
|
359
|
-
redirects[key].code !== 410
|
|
360
|
-
)
|
|
361
|
-
redir_errors.push(`Invalid redirect code: ${redirects[key].code}`);
|
|
362
|
-
|
|
363
|
-
if (redirects[key].location && !redirects[key].skip_location_validation) {
|
|
364
|
-
const redir_locations = [
|
|
365
|
-
path.join(source_path, `${redirects[key].location}.md`),
|
|
366
|
-
path.join(source_path, redirects[key].location, "index.md"),
|
|
367
|
-
path.join(source_path, `${redirects[key].location}.html`),
|
|
368
|
-
path.join(source_path, `${redirects[key].location}.htm`),
|
|
369
|
-
path.join(source_path, redirects[key].location, "index.html"),
|
|
370
|
-
path.join(source_path, redirects[key].location, "index.htm"),
|
|
371
|
-
];
|
|
372
|
-
let redir_location_ok = false;
|
|
373
|
-
for (let i = 0; i < redir_locations.length; i++) {
|
|
374
|
-
if (fs.existsSync(redir_locations[i])) {
|
|
375
|
-
redir_location_ok = true;
|
|
376
|
-
break;
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
if (!redir_location_ok)
|
|
380
|
-
redir_errors.push(
|
|
381
|
-
`Redirect location does not exist: ${redirects[key].location}`,
|
|
382
|
-
);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
return redir_errors;
|
|
387
|
-
};
|
|
388
|
-
|
|
389
|
-
const checkRedirect = (source_path, nav_path) => {
|
|
390
|
-
const response = {
|
|
391
|
-
exists: false,
|
|
392
|
-
error: null,
|
|
393
|
-
};
|
|
394
|
-
if (redirects[nav_path]) {
|
|
395
|
-
response.exists = true;
|
|
396
|
-
if (redirects[nav_path].location) {
|
|
397
|
-
|
|
398
|
-
if (redirects[nav_path].skip_location_validation) return response;
|
|
399
|
-
|
|
400
|
-
// We have a redirect, check if it's a valid location
|
|
401
|
-
let file_path = path.join(
|
|
402
|
-
source_path,
|
|
403
|
-
`${redirects[nav_path].location}.html`,
|
|
404
|
-
);
|
|
405
|
-
if (!fs.existsSync(file_path)) {
|
|
406
|
-
file_path = path.join(
|
|
407
|
-
source_path,
|
|
408
|
-
`${redirects[nav_path].location}.htm`,
|
|
409
|
-
);
|
|
410
|
-
if (!fs.existsSync(file_path)) {
|
|
411
|
-
file_path = path.join(
|
|
412
|
-
source_path,
|
|
413
|
-
redirects[nav_path].location,
|
|
414
|
-
"index.html",
|
|
415
|
-
);
|
|
416
|
-
if (!fs.existsSync(file_path)) {
|
|
417
|
-
file_path = path.join(
|
|
418
|
-
source_path,
|
|
419
|
-
redirects[nav_path].location,
|
|
420
|
-
"index.htm",
|
|
421
|
-
);
|
|
422
|
-
if (!fs.existsSync(file_path)) {
|
|
423
|
-
response.error = `Redirect path for [${nav_path}] does not exist: ${redirects[nav_path].location}`;
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
return response;
|
|
431
|
-
};
|
|
432
|
-
|
|
433
|
-
const isHashAnchor = (html_file, hash_anchor, full_hash_anchor_link = "") => {
|
|
434
|
-
const markdown_paths = getMDPathFromHtmlPath(html_file);
|
|
435
|
-
const markdown_content = fs.readFileSync(markdown_paths.markdownPath, 'utf8');
|
|
436
|
-
|
|
437
|
-
try {
|
|
438
|
-
const file_content = fs.readFileSync(html_file.path, {
|
|
439
|
-
encoding: "utf-8",
|
|
440
|
-
});
|
|
441
|
-
const clean_hash_anchor = hash_anchor.startsWith("/")
|
|
442
|
-
? hash_anchor.substring(2, hash_anchor.length)
|
|
443
|
-
: hash_anchor.substring(1, hash_anchor.length);
|
|
444
|
-
if (
|
|
445
|
-
!file_content.includes(`<div id="hb-doc-anchor-${clean_hash_anchor}"`)
|
|
446
|
-
) {
|
|
447
|
-
const error_message = processErrorMessage(`Target hash anchor is not present in page content: ${full_hash_anchor_link !== "" ? full_hash_anchor_link : hash_anchor}`, markdown_paths.relativePath, markdown_content, full_hash_anchor_link);
|
|
448
|
-
errors[html_file.relativePath].push( error_message );
|
|
449
|
-
}
|
|
450
|
-
} catch (e) {
|
|
451
|
-
errors[html_file.relativePath].push(e);
|
|
452
|
-
}
|
|
453
|
-
};
|
|
454
|
-
|
|
455
|
-
const getMDPathFromHtmlPath = (htmlFile) => {
|
|
456
|
-
const returnPaths = {
|
|
457
|
-
markdownPath: htmlFile.path.replace(`.${htmlFile.extension}`, '.md'),
|
|
458
|
-
relativePath: htmlFile.relativePath.replace(`.${htmlFile.extension}`, '.md')
|
|
459
|
-
};
|
|
460
|
-
if (!fs.existsSync(returnPaths.markdownPath)) {
|
|
461
|
-
// No matching markdown
|
|
462
|
-
returnPaths.markdownPath = htmlFile.path;
|
|
463
|
-
}
|
|
464
|
-
return returnPaths;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// Headers that mimic a real Chrome browser request — sites doing bot detection
|
|
468
|
-
// check far more than just User-Agent (Accept, Sec-Fetch-*, client hints, etc.).
|
|
469
|
-
const _fetch_headers = {
|
|
470
|
-
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
471
|
-
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
|
472
|
-
'Accept-Language': 'en-US,en;q=0.9',
|
|
473
|
-
'Accept-Encoding': 'gzip, deflate, br',
|
|
474
|
-
'Cache-Control': 'no-cache',
|
|
475
|
-
'Pragma': 'no-cache',
|
|
476
|
-
'Sec-Fetch-Dest': 'document',
|
|
477
|
-
'Sec-Fetch-Mode': 'navigate',
|
|
478
|
-
'Sec-Fetch-Site': 'none',
|
|
479
|
-
'Sec-Fetch-User': '?1',
|
|
480
|
-
'Sec-Ch-Ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
|
|
481
|
-
'Sec-Ch-Ua-Mobile': '?0',
|
|
482
|
-
'Sec-Ch-Ua-Platform': '"Windows"',
|
|
483
|
-
'Upgrade-Insecure-Requests': '1',
|
|
484
|
-
};
|
|
485
|
-
|
|
486
|
-
// Checks a single external URL by sending a HEAD request, falling back to GET
|
|
487
|
-
// if the server returns 405 (Method Not Allowed) or 404 (some servers, e.g.
|
|
488
|
-
// marketplace.visualstudio.com, return 404 for HEAD even when the page exists).
|
|
489
|
-
// Retries up to 5 times on transient errors (5xx, 429, network failures).
|
|
490
|
-
// Returns the HTTP status code.
|
|
491
|
-
const fetchExternalLinkStatus = async (url) => {
|
|
492
|
-
const opts = { method: 'HEAD', headers: _fetch_headers, timeoutMs: 10000, redirect: 'follow' };
|
|
493
|
-
const resp = await hdoc.fetchWithRetry(url, opts);
|
|
494
|
-
if (resp.status === 404 || resp.status === 405) {
|
|
495
|
-
const getResp = await hdoc.fetchWithRetry(url, { ...opts, method: 'GET' });
|
|
496
|
-
return getResp.status;
|
|
497
|
-
}
|
|
498
|
-
return resp.status;
|
|
499
|
-
};
|
|
500
|
-
|
|
501
|
-
// Map an inter-book check result onto errors/warnings/messages and the
|
|
502
|
-
// validated-links cache. 'ok' and 'skip' outcomes are stable, so they are
|
|
503
|
-
// appended to validated-links.txt like any other passing link.
|
|
504
|
-
const handleInterbookResult = (result, link, htmlFile, markdown_paths, markdown_content) => {
|
|
505
|
-
if (result.level === "error") {
|
|
506
|
-
errors[htmlFile.relativePath].push(
|
|
507
|
-
processErrorMessage(result.message, markdown_paths.relativePath, markdown_content, link),
|
|
508
|
-
);
|
|
509
|
-
} else if (result.level === "warning") {
|
|
510
|
-
warnings[htmlFile.relativePath].push(
|
|
511
|
-
processErrorMessage(result.message, markdown_paths.relativePath, markdown_content, link),
|
|
512
|
-
);
|
|
513
|
-
} else {
|
|
514
|
-
messages[htmlFile.relativePath].push(result.message);
|
|
515
|
-
fs.appendFileSync(skip_link_file, `${link}\n`);
|
|
516
|
-
}
|
|
517
|
-
};
|
|
518
|
-
|
|
519
|
-
const checkLinks = async (source_path, htmlFile, links, hdocbook_config, hdocbook_project, global_links_checked, output_links) => {
|
|
520
|
-
const markdown_paths = getMDPathFromHtmlPath(htmlFile);
|
|
521
|
-
const markdown_content = fs.readFileSync(markdown_paths.markdownPath, 'utf8');
|
|
522
|
-
|
|
523
|
-
// Resolve the "are we on the internal network?" question once per process
|
|
524
|
-
// rather than once per internal.hornbill.com link.
|
|
525
|
-
const ensureIntNetCached = async () => {
|
|
526
|
-
if (_on_int_net_cached === null) {
|
|
527
|
-
try {
|
|
528
|
-
_on_int_net_cached = await checkHostExistsInDNS('docs-internal.hornbill.com');
|
|
529
|
-
} catch (_e) {
|
|
530
|
-
_on_int_net_cached = false;
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
return _on_int_net_cached;
|
|
534
|
-
};
|
|
535
|
-
|
|
536
|
-
// Collect external links that need an HTTP check so they can be run
|
|
537
|
-
// concurrently rather than one-at-a-time.
|
|
538
|
-
const externalChecks = [];
|
|
539
|
-
|
|
540
|
-
for (let i = 0; i < links.length; i++) {
|
|
541
|
-
if (output_links) console.log(` - ${links[i]}`);
|
|
542
|
-
if (exclude_links[links[i]]) continue;
|
|
543
|
-
if (global_links_checked.includes(links[i])) continue;
|
|
544
|
-
global_links_checked.push(links[i]);
|
|
545
|
-
|
|
546
|
-
const valid_url = hdoc.valid_url(links[i]);
|
|
547
|
-
if (!valid_url) {
|
|
548
|
-
// Could be a relative path, check
|
|
549
|
-
if (links[i].startsWith("#") || links[i].startsWith("/#")) {
|
|
550
|
-
//Flat Anchor - validate we have a same-file hit
|
|
551
|
-
isHashAnchor(htmlFile, links[i]);
|
|
552
|
-
} else if (links[i].startsWith("/") && !links[i].startsWith("/#")) {
|
|
553
|
-
let link_segments = links[i].split("/");
|
|
554
|
-
if (link_segments[0] === "") link_segments.shift();
|
|
555
|
-
const link_root = link_segments[0] === "_books" ? link_segments[1] : link_segments[0];
|
|
556
|
-
|
|
557
|
-
// Check for links with a _books path that have no specific file target
|
|
558
|
-
// We do need to exclude those with an extension though, for pages that link downloadable resources
|
|
559
|
-
if (link_segments[0] === "_books" && path.extname(links[i]) === '') {
|
|
560
|
-
const error_message = processErrorMessage(`Root relative page links should not include _books in the path: ${links[i]}`, markdown_paths.relativePath, markdown_content, links[i]);
|
|
561
|
-
errors[htmlFile.relativePath].push(error_message);
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// Links into other books: verified against GitHub when a token
|
|
565
|
-
// was supplied, otherwise skipped (can't validate locally).
|
|
566
|
-
if ((link_segments.length > 1 && link_root !== hdocbook_config.docId) || (link_segments.length === 1 && link_root !== hdocbook_config.docId && link_root !== "index")) {
|
|
567
|
-
if (interbook.enabled() && path.extname(links[i].split("#")[0]) === "") {
|
|
568
|
-
const link = links[i];
|
|
569
|
-
externalChecks.push(async () =>
|
|
570
|
-
handleInterbookResult(
|
|
571
|
-
await interbook.check_link(link),
|
|
572
|
-
link,
|
|
573
|
-
htmlFile,
|
|
574
|
-
markdown_paths,
|
|
575
|
-
markdown_content,
|
|
576
|
-
),
|
|
577
|
-
);
|
|
578
|
-
} else {
|
|
579
|
-
fs.appendFileSync(skip_link_file, `${links[i]}\n`);
|
|
580
|
-
}
|
|
581
|
-
continue;
|
|
582
|
-
}
|
|
583
|
-
isRelativePath(source_path, htmlFile, links[i]);
|
|
584
|
-
} else {
|
|
585
|
-
const error_message = processErrorMessage(`Root relative links should start with a forward-slash: ${links[i]}`, markdown_paths.relativePath, markdown_content, links[i]);
|
|
586
|
-
errors[htmlFile.relativePath].push(error_message);
|
|
587
|
-
}
|
|
588
|
-
} else {
|
|
589
|
-
messages[htmlFile.relativePath].push(
|
|
590
|
-
`Link is a properly formatted external URL: ${links[i]}`,
|
|
591
|
-
);
|
|
592
|
-
|
|
593
|
-
// Skip if it's the auto-generated edit url, as these could be part of a private repo which would return a 404
|
|
594
|
-
// publicSource must be truthy, not just defined - an empty string makes
|
|
595
|
-
// get_github_api_path return "" which has no edit_path
|
|
596
|
-
if (
|
|
597
|
-
hdocbook_config.publicSource &&
|
|
598
|
-
links[i] ===
|
|
599
|
-
hdoc
|
|
600
|
-
.get_github_api_path(
|
|
601
|
-
hdocbook_config.publicSource,
|
|
602
|
-
htmlFile.relativePath,
|
|
603
|
-
)
|
|
604
|
-
.edit_path.replace(path.extname(htmlFile.relativePath), ".md")
|
|
605
|
-
) {
|
|
606
|
-
fs.appendFileSync(skip_link_file, `${links[i]}\n`);
|
|
607
|
-
continue;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
if (valid_url.protocol === "mailto:") {
|
|
611
|
-
fs.appendFileSync(skip_link_file, `${links[i]}\n`);
|
|
612
|
-
continue;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// Skip if the link is excluded in the project config
|
|
616
|
-
if (excludeLink(links[i])) {
|
|
617
|
-
messages[htmlFile.relativePath].push(
|
|
618
|
-
`Skipping link validation for: ${links[i]}`,
|
|
619
|
-
);
|
|
620
|
-
continue;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
if (
|
|
624
|
-
(links[i].toLowerCase().includes("docs.hornbill.com") ||
|
|
625
|
-
links[i].toLowerCase().includes("docs-internal.hornbill.com")) &&
|
|
626
|
-
!markdown_paths.relativePath.includes('/_inline/')
|
|
627
|
-
) {
|
|
628
|
-
const error_message = processErrorMessage(`Hornbill Docs links should not be fully-qualified: ${links[i]}`, markdown_paths.relativePath, markdown_content, links[i]);
|
|
629
|
-
errors[htmlFile.relativePath].push(error_message);
|
|
630
|
-
continue;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
if (
|
|
634
|
-
links[i].toLowerCase().includes("docs-internal.hornbill.com") &&
|
|
635
|
-
markdown_paths.relativePath.includes('/_inline/') &&
|
|
636
|
-
!private_repo
|
|
637
|
-
) {
|
|
638
|
-
// Is the parent book in a public repo? If so, flag this as an error.
|
|
639
|
-
const error_message = processErrorMessage(`Hornbill docs-internal links should not be used in public book inline content: ${links[i]}`, markdown_paths.relativePath, markdown_content, links[i]);
|
|
640
|
-
errors[htmlFile.relativePath].push(error_message);
|
|
641
|
-
continue;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
// Fully-qualified Hornbill Docs links (only permitted in _inline
|
|
645
|
-
// content — anything else errored above): with a GitHub token,
|
|
646
|
-
// validate book/article/anchor instead of a plain HTTP check.
|
|
647
|
-
const link_host = valid_url.hostname ? valid_url.hostname.toLowerCase() : "";
|
|
648
|
-
if (
|
|
649
|
-
interbook.enabled() &&
|
|
650
|
-
(link_host === "docs.hornbill.com" || link_host === "docs-internal.hornbill.com") &&
|
|
651
|
-
path.extname(valid_url.pathname) === ""
|
|
652
|
-
) {
|
|
653
|
-
const link = links[i];
|
|
654
|
-
const book_link = valid_url.pathname + valid_url.hash;
|
|
655
|
-
externalChecks.push(async () =>
|
|
656
|
-
handleInterbookResult(
|
|
657
|
-
await interbook.check_link(book_link),
|
|
658
|
-
link,
|
|
659
|
-
htmlFile,
|
|
660
|
-
markdown_paths,
|
|
661
|
-
markdown_content,
|
|
662
|
-
),
|
|
663
|
-
);
|
|
664
|
-
continue;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
// Capture url in closure for the async check below
|
|
668
|
-
const url = links[i];
|
|
669
|
-
const isInternal = url.toLowerCase().includes("internal.hornbill.com");
|
|
670
|
-
|
|
671
|
-
externalChecks.push(async () => {
|
|
672
|
-
// For internal.hornbill.com links, check network reachability first (result cached)
|
|
673
|
-
if (isInternal) {
|
|
674
|
-
const on_int_net = await ensureIntNetCached();
|
|
675
|
-
if (!on_int_net) {
|
|
676
|
-
messages[htmlFile.relativePath].push(
|
|
677
|
-
`Outside of Hornbill network - skipping internal link validation for: ${url}`,
|
|
678
|
-
);
|
|
679
|
-
fs.appendFileSync(skip_link_file, `${url}\n`);
|
|
680
|
-
return;
|
|
681
|
-
}
|
|
682
|
-
messages[htmlFile.relativePath].push(
|
|
683
|
-
`Inside of Hornbill network - performing internal link validation for: ${url}`,
|
|
684
|
-
);
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
try {
|
|
688
|
-
const status = await fetchExternalLinkStatus(url);
|
|
689
|
-
if ((status < 200 || status > 299) && status !== 304) {
|
|
690
|
-
if (process.env.GITHUB_ACTIONS === 'true' && status === 403 && url.includes(".hornbill.com")) {
|
|
691
|
-
// Always returns 403 for Hornbill sites through GitHub Actions — not a real error
|
|
692
|
-
} else {
|
|
693
|
-
throw `Unexpected Status Returned: ${status}`;
|
|
694
|
-
}
|
|
695
|
-
} else {
|
|
696
|
-
fs.appendFileSync(skip_link_file, `${url}\n`);
|
|
697
|
-
}
|
|
698
|
-
} catch (e) {
|
|
699
|
-
let error_message;
|
|
700
|
-
if (e instanceof AggregateError) {
|
|
701
|
-
error_message = processErrorMessage(`Issue with external link [${url}]: ${e.message} - ${JSON.stringify(e.errors)}`, markdown_paths.relativePath, markdown_content, url);
|
|
702
|
-
} else {
|
|
703
|
-
error_message = processErrorMessage(`Issue with external link [${url}]: ${e}`, markdown_paths.relativePath, markdown_content, url);
|
|
704
|
-
}
|
|
705
|
-
if (hdocbook_project.validation.external_link_warnings || process.env.GITHUB_ACTIONS === 'true')
|
|
706
|
-
warnings[htmlFile.relativePath].push(error_message);
|
|
707
|
-
else
|
|
708
|
-
errors[htmlFile.relativePath].push(error_message);
|
|
709
|
-
}
|
|
710
|
-
});
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
// Run all external HTTP checks concurrently — fetch is lightweight enough
|
|
715
|
-
// that uncapped concurrency is fine for the link counts seen in practice.
|
|
716
|
-
await Promise.all(externalChecks.map(fn => fn()));
|
|
717
|
-
};
|
|
718
|
-
|
|
719
|
-
const checkHostExistsInDNS = async (hostname) => {
|
|
720
|
-
return new Promise((resolve, reject) => {
|
|
721
|
-
dns.lookup(hostname, { all:true }, (err, addresses) => {
|
|
722
|
-
if (err) {
|
|
723
|
-
reject(err);
|
|
724
|
-
} else {
|
|
725
|
-
resolve(addresses !== undefined);
|
|
726
|
-
}
|
|
727
|
-
});
|
|
728
|
-
});
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
const checkImages = async (source_path, htmlFile, links) => {
|
|
732
|
-
const markdown_paths = getMDPathFromHtmlPath(htmlFile);
|
|
733
|
-
const markdown_content = fs.readFileSync(markdown_paths.markdownPath, 'utf8');
|
|
734
|
-
for (let i = 0; i < links.length; i++) {
|
|
735
|
-
// Validate that image is a valid URL first
|
|
736
|
-
if (!hdoc.valid_url(links[i])) {
|
|
737
|
-
|
|
738
|
-
if (!links[i].startsWith("/") && !markdown_paths.relativePath.includes('/_inline/')) {
|
|
739
|
-
const error_message = processErrorMessage(`Root relative image links should start with a forward-slash: ${links[i]}`, markdown_paths.relativePath, markdown_content, links[i]);
|
|
740
|
-
errors[htmlFile.relativePath].push(error_message);
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
// Could be a relative path, check image exists
|
|
744
|
-
doesFileExist(source_path, htmlFile, links[i], markdown_paths.relativePath, markdown_content);
|
|
745
|
-
} else {
|
|
746
|
-
messages[htmlFile.relativePath].push(
|
|
747
|
-
`Image link is a properly formatted external URL: ${links[i]}`,
|
|
748
|
-
);
|
|
749
|
-
// Do a Get to the URL to see if it exists
|
|
750
|
-
try {
|
|
751
|
-
const img_response = await fetch(links[i]);
|
|
752
|
-
if (!img_response.ok) throw new Error(`HTTP ${img_response.status}`);
|
|
753
|
-
messages[htmlFile.relativePath].push(
|
|
754
|
-
`Image link is a valid external URL: ${links[i]}`,
|
|
755
|
-
);
|
|
756
|
-
} catch (e) {
|
|
757
|
-
// Handle errors
|
|
758
|
-
const error_message = processErrorMessage(`External image link error: ${links[i]} - ${e.message}`, markdown_paths.relativePath, markdown_content, links[i]);
|
|
759
|
-
errors[htmlFile.relativePath].push(error_message);
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
};
|
|
764
|
-
|
|
765
|
-
const checkTags = async (htmlFile) => {
|
|
766
|
-
const markdown_paths = getMDPathFromHtmlPath(htmlFile);
|
|
767
|
-
// Check if file is excluded from tag check
|
|
768
|
-
const file_no_ext = htmlFile.relativePath.replace(
|
|
769
|
-
path.extname(htmlFile.relativePath),
|
|
770
|
-
"",
|
|
771
|
-
);
|
|
772
|
-
if (exclude_h1_count[file_no_ext]) return;
|
|
773
|
-
|
|
774
|
-
// Check tags
|
|
775
|
-
const htmlBody = fs.readFileSync(htmlFile.path, "utf8");
|
|
776
|
-
const $ = cheerio.load(htmlBody);
|
|
777
|
-
|
|
778
|
-
const h1_tags = $("h1")
|
|
779
|
-
.map(function () {
|
|
780
|
-
return $(this);
|
|
781
|
-
})
|
|
782
|
-
.get();
|
|
783
|
-
if (h1_tags.length && h1_tags.length > 1) {
|
|
784
|
-
let error_msg = `${h1_tags.length} <h1> tags found in content: `;
|
|
785
|
-
for (let i = 0; i < h1_tags.length; i++) {
|
|
786
|
-
error_msg += h1_tags[i].text();
|
|
787
|
-
if (i < h1_tags.length - 1) error_msg += "; ";
|
|
788
|
-
}
|
|
789
|
-
errors[htmlFile.relativePath].push(`${markdown_paths.relativePath} - ${error_msg}`);
|
|
790
|
-
}
|
|
791
|
-
};
|
|
792
|
-
|
|
793
|
-
const dreeOptions = {
|
|
794
|
-
descendants: true,
|
|
795
|
-
excludeEmptyDirectories: true,
|
|
796
|
-
extensions: ["htm", "html", "md"],
|
|
797
|
-
hash: false,
|
|
798
|
-
normalize: true,
|
|
799
|
-
size: false,
|
|
800
|
-
sizeInBytes: false,
|
|
801
|
-
stat: false,
|
|
802
|
-
symbolicLinks: false,
|
|
803
|
-
};
|
|
804
|
-
|
|
805
|
-
// File scan callback for content type files
|
|
806
|
-
const fileContentCallback = (element) => {
|
|
807
|
-
if (element.extension.toLowerCase() === "md") {
|
|
808
|
-
md_to_validate.push(element);
|
|
809
|
-
} else {
|
|
810
|
-
html_to_validate.push(element);
|
|
811
|
-
}
|
|
812
|
-
};
|
|
813
|
-
|
|
814
|
-
const isRelativePath = (source_path, html_path, relative_path) => {
|
|
815
|
-
const markdown_paths = getMDPathFromHtmlPath(html_path);
|
|
816
|
-
const markdown_content = fs.readFileSync(markdown_paths.markdownPath, 'utf8');
|
|
817
|
-
|
|
818
|
-
const rel_path_ext = path.extname(relative_path);
|
|
819
|
-
const response = {
|
|
820
|
-
is_rel_path: false,
|
|
821
|
-
has_md_extension: rel_path_ext === ".md",
|
|
822
|
-
has_html_extension: rel_path_ext === ".htm" || rel_path_ext === ".html",
|
|
823
|
-
};
|
|
824
|
-
const supported_relpaths = [
|
|
825
|
-
`${path.sep}index.htm`,
|
|
826
|
-
`${path.sep}index.html`,
|
|
827
|
-
".htm",
|
|
828
|
-
".html",
|
|
829
|
-
".md",
|
|
830
|
-
];
|
|
831
|
-
|
|
832
|
-
// Remove explicit anchor links and _books prefix
|
|
833
|
-
const clean_relative_path = relative_path
|
|
834
|
-
.split("#")[0]
|
|
835
|
-
.replace("_books/", "");
|
|
836
|
-
|
|
837
|
-
let hash_anchor = null;
|
|
838
|
-
if (relative_path.split("#")[1])
|
|
839
|
-
hash_anchor = `#${relative_path.split("#")[1]}`;
|
|
840
|
-
|
|
841
|
-
// Make full file path
|
|
842
|
-
const file_path = path.join(source_path, clean_relative_path);
|
|
843
|
-
|
|
844
|
-
// Does path exist?
|
|
845
|
-
if (fs.existsSync(file_path)) {
|
|
846
|
-
response.is_rel_path = true;
|
|
847
|
-
} else {
|
|
848
|
-
// Path
|
|
849
|
-
for (let i = 0; i < supported_relpaths.length; i++) {
|
|
850
|
-
const html_file_path = `${file_path}${supported_relpaths[i]}`;
|
|
851
|
-
if (fs.existsSync(html_file_path)) {
|
|
852
|
-
response.is_rel_path = true;
|
|
853
|
-
|
|
854
|
-
// Check for hash anchor
|
|
855
|
-
if (hash_anchor !== null) {
|
|
856
|
-
isHashAnchor(
|
|
857
|
-
{ path: html_file_path, relativePath: html_path.relativePath, extension: html_path.extension },
|
|
858
|
-
hash_anchor,
|
|
859
|
-
relative_path,
|
|
860
|
-
);
|
|
861
|
-
}
|
|
862
|
-
break;
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
if (response.has_md_extension || response.has_html_extension) {
|
|
867
|
-
const error_message = processErrorMessage(`Relative link has ${rel_path_ext} extension, but should not: ${relative_path}`, markdown_paths.relativePath, markdown_content, relative_path);
|
|
868
|
-
errors[html_path.relativePath].push(error_message);
|
|
869
|
-
return;
|
|
870
|
-
}
|
|
871
|
-
if (response.is_rel_path) {
|
|
872
|
-
messages[html_path.relativePath].push(
|
|
873
|
-
`Relative path exists: ${clean_relative_path}`,
|
|
874
|
-
);
|
|
875
|
-
return;
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
// See if there's a redirect in place
|
|
879
|
-
const relpath =
|
|
880
|
-
clean_relative_path.indexOf("/") === 0
|
|
881
|
-
? clean_relative_path.substring(1)
|
|
882
|
-
: clean_relative_path;
|
|
883
|
-
const redir = checkRedirect(source_path, relpath);
|
|
884
|
-
if (redir.exists) {
|
|
885
|
-
if (redir.error !== null) {
|
|
886
|
-
const error_message = processErrorMessage(`${redir.error}: ${relative_path}`, markdown_paths.relativePath, markdown_content, clean_relative_path);
|
|
887
|
-
errors[html_path.relativePath].push(error_message);
|
|
888
|
-
}
|
|
889
|
-
} else {
|
|
890
|
-
const error_message = processErrorMessage(`Link path does not exist: ${relative_path}`, markdown_paths.relativePath, markdown_content, clean_relative_path);
|
|
891
|
-
errors[html_path.relativePath].push(error_message);
|
|
892
|
-
}
|
|
893
|
-
};
|
|
894
|
-
|
|
895
|
-
const processErrorMessage = (message, md_path, content, search) => {
|
|
896
|
-
// whole_link — a link that is a substring of a longer link elsewhere in
|
|
897
|
-
// the file must not be located at the longer link's position
|
|
898
|
-
const link_location = hdoc.find_string_in_string(content, search, true);
|
|
899
|
-
let error_message = message;
|
|
900
|
-
if (link_location !== null)
|
|
901
|
-
error_message = `${md_path}:${link_location.line}:${link_location.column} - ${error_message}`;
|
|
902
|
-
else
|
|
903
|
-
error_message = `${md_path} - ${error_message}`;
|
|
904
|
-
return error_message;
|
|
905
|
-
};
|
|
906
|
-
|
|
907
|
-
const doesFileExist = (source_path, html_path, relative_path, markdown_path, markdown_content) => {
|
|
908
|
-
// Remove explicit anchor links and _books prefix
|
|
909
|
-
const clean_relative_path = relative_path
|
|
910
|
-
.split("#")[0]
|
|
911
|
-
.replace("_books/", "");
|
|
912
|
-
const file_path = path.join(source_path, clean_relative_path);
|
|
913
|
-
if (
|
|
914
|
-
!fs.existsSync(file_path) &&
|
|
915
|
-
!fs.existsSync(`${file_path + path.sep}index.htm`) &&
|
|
916
|
-
!fs.existsSync(`${file_path}index.html`) &&
|
|
917
|
-
!fs.existsSync(`${file_path}.htm`) &&
|
|
918
|
-
!fs.existsSync(`${file_path}.html`)
|
|
919
|
-
) {
|
|
920
|
-
const error_message = processErrorMessage(`Book resource does not exist: ${clean_relative_path}`, markdown_path, markdown_content, relative_path);
|
|
921
|
-
errors[html_path.relativePath].push(error_message);
|
|
922
|
-
return false;
|
|
923
|
-
}
|
|
924
|
-
messages[html_path.relativePath].push(
|
|
925
|
-
`Book resource exists: ${clean_relative_path}`,
|
|
926
|
-
);
|
|
927
|
-
return true;
|
|
928
|
-
};
|
|
929
|
-
|
|
930
|
-
// Takes a dree element, returns an object with a pair of arrays
|
|
931
|
-
const getLinks = (file) => {
|
|
932
|
-
messages[file.relativePath].push("Parsing HTML file");
|
|
933
|
-
const htmlBody = fs.readFileSync(file.path, "utf8");
|
|
934
|
-
const links = {
|
|
935
|
-
href: [],
|
|
936
|
-
img: [],
|
|
937
|
-
};
|
|
938
|
-
const $ = cheerio.load(htmlBody);
|
|
939
|
-
const hrefs = $("a")
|
|
940
|
-
.map(function (i) {
|
|
941
|
-
return $(this).attr("href");
|
|
942
|
-
})
|
|
943
|
-
.get();
|
|
944
|
-
const srcs = $("img")
|
|
945
|
-
.map(function (i) {
|
|
946
|
-
if ($(this).attr("alt") === undefined || $(this).attr("alt").trim() === "") {
|
|
947
|
-
errors[file.relativePath].push(`Image tag with src [${$(this).attr("src")}] is missing alt attribute.`);
|
|
948
|
-
}
|
|
949
|
-
return $(this).attr("src");
|
|
950
|
-
})
|
|
951
|
-
.get();
|
|
952
|
-
links.href.push(...hrefs);
|
|
953
|
-
links.img.push(...srcs);
|
|
954
|
-
return links;
|
|
955
|
-
};
|
|
956
|
-
|
|
957
|
-
exports.run = async (
|
|
958
|
-
source_path,
|
|
959
|
-
doc_id,
|
|
960
|
-
verbose,
|
|
961
|
-
hdocbook_config,
|
|
962
|
-
hdocbook_project,
|
|
963
|
-
nav_items,
|
|
964
|
-
prod_families,
|
|
965
|
-
prods_supported,
|
|
966
|
-
gen_exclude,
|
|
967
|
-
gen_redirects,
|
|
968
|
-
draft_links,
|
|
969
|
-
is_private,
|
|
970
|
-
browser,
|
|
971
|
-
source_root_path,
|
|
972
|
-
output_links = true,
|
|
973
|
-
git_token = "",
|
|
974
|
-
) => {
|
|
975
|
-
console.log("Performing Validation and Building SEO Link List...");
|
|
976
|
-
redirects = gen_redirects;
|
|
977
|
-
private_repo = is_private;
|
|
978
|
-
interbook.init(git_token);
|
|
979
|
-
if (interbook.enabled())
|
|
980
|
-
console.log(
|
|
981
|
-
"GitHub token supplied - inter-book links will be validated against GitHub",
|
|
982
|
-
);
|
|
983
|
-
|
|
984
|
-
// Load the skip link validation file if it exists
|
|
985
|
-
loadSkipLinkValidation(source_root_path);
|
|
986
|
-
|
|
987
|
-
// Get a list of HTML files in source_path
|
|
988
|
-
hdoc.scan_dir(source_path, dreeOptions, fileContentCallback);
|
|
989
|
-
|
|
990
|
-
// Check product family
|
|
991
|
-
let valid_product = false;
|
|
992
|
-
const meta_errors = [];
|
|
993
|
-
for (let i = 0; i < prod_families.products.length; i++) {
|
|
994
|
-
if (prod_families.products[i].id === hdocbook_config.productFamily) {
|
|
995
|
-
valid_product = true;
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
if (!valid_product) {
|
|
999
|
-
let val_prod_error = `Incorrect productFamily: ${hdocbook_config.productFamily}. Supported values:`;
|
|
1000
|
-
for (let i = 0; i < prods_supported.length; i++) {
|
|
1001
|
-
val_prod_error += `\n - ${prods_supported[i]}`;
|
|
1002
|
-
}
|
|
1003
|
-
meta_errors.push(val_prod_error);
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
if (hdocbook_config.publicSource && hdocbook_config.publicSource !== "") {
|
|
1007
|
-
// Validate publicSource
|
|
1008
|
-
if (hdocbook_config.publicSource.toLowerCase() === "--publicsource--") {
|
|
1009
|
-
meta_errors.push(
|
|
1010
|
-
"Value for publicSource in book metadata is set to its default template value",
|
|
1011
|
-
);
|
|
1012
|
-
} else {
|
|
1013
|
-
// Check URL exists
|
|
1014
|
-
if (
|
|
1015
|
-
!hdocbook_config.publicSource.startsWith("https://github.com") &&
|
|
1016
|
-
!hdocbook_config.publicSource.startsWith("https://api.github.com")
|
|
1017
|
-
) {
|
|
1018
|
-
meta_errors.push(
|
|
1019
|
-
`Value for publicSource in book metadata is not a recognised GitHub URL: ${hdocbook_config.publicSource}`,
|
|
1020
|
-
);
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
if (
|
|
1026
|
-
!hdocbook_config.audience ||
|
|
1027
|
-
!Array.isArray(hdocbook_config.audience) ||
|
|
1028
|
-
hdocbook_config.audience.length === 0
|
|
1029
|
-
) {
|
|
1030
|
-
meta_errors.push(
|
|
1031
|
-
"Property audience of type array in book metadata is mandatory.",
|
|
1032
|
-
);
|
|
1033
|
-
}
|
|
1034
|
-
if (hdocbook_project.validation) {
|
|
1035
|
-
if (
|
|
1036
|
-
hdocbook_project.validation.exclude_links &&
|
|
1037
|
-
Array.isArray(hdocbook_project.validation.exclude_links)
|
|
1038
|
-
) {
|
|
1039
|
-
for (const excl_link of hdocbook_project.validation.exclude_links) {
|
|
1040
|
-
exclude_links[excl_link] = true;
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
if (
|
|
1044
|
-
hdocbook_project.validation.exclude_spellcheck &&
|
|
1045
|
-
Array.isArray(hdocbook_project.validation.exclude_spellcheck)
|
|
1046
|
-
) {
|
|
1047
|
-
for (const excl_sc of hdocbook_project.validation.exclude_spellcheck) {
|
|
1048
|
-
|
|
1049
|
-
if (exclude_spellcheck[excl_sc.document_path] !== undefined) {
|
|
1050
|
-
meta_errors.push(
|
|
1051
|
-
"Document path is duplicated in exclude_spellcheck validation array: " + excl_sc.document_path,
|
|
1052
|
-
);
|
|
1053
|
-
}
|
|
1054
|
-
exclude_spellcheck[excl_sc.document_path] = excl_sc.words;
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
if (
|
|
1058
|
-
hdocbook_project.validation.exclude_h1_count &&
|
|
1059
|
-
Array.isArray(hdocbook_project.validation.exclude_h1_count)
|
|
1060
|
-
) {
|
|
1061
|
-
for (const excl_h1 of hdocbook_project.validation.exclude_h1_count) {
|
|
1062
|
-
exclude_h1_count[excl_h1] = true;
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
if (hdocbook_project.validation && Array.isArray(hdocbook_project.validation.spellcheckDictionary)) global_spellcheck = hdocbook_project.validation.spellcheckDictionary.map((w) => String(w).toLowerCase());
|
|
1068
|
-
|
|
1069
|
-
// Check navigation spellings & paths exist
|
|
1070
|
-
const nav_errors = await checkNavigation(
|
|
1071
|
-
source_path,
|
|
1072
|
-
nav_items,
|
|
1073
|
-
exclude_spellcheck,
|
|
1074
|
-
draft_links,
|
|
1075
|
-
);
|
|
1076
|
-
if (nav_errors.length > 0) meta_errors.push(...nav_errors);
|
|
1077
|
-
|
|
1078
|
-
// Check inline content spellings & paths exist
|
|
1079
|
-
if (hdocbook_config.inline && hdocbook_config.inline.length > 0) {
|
|
1080
|
-
const inline_errors = await checkInline(
|
|
1081
|
-
source_path,
|
|
1082
|
-
hdocbook_config.inline,
|
|
1083
|
-
exclude_spellcheck,
|
|
1084
|
-
);
|
|
1085
|
-
if (inline_errors.length > 0) meta_errors.push(...inline_errors);
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
// Check redirects
|
|
1089
|
-
const redirect_errors = await checkRedirects(source_path);
|
|
1090
|
-
if (redirect_errors.length > 0) meta_errors.push(...redirect_errors);
|
|
1091
|
-
|
|
1092
|
-
if (meta_errors.length > 0) {
|
|
1093
|
-
console.log("\r\n-----------------------");
|
|
1094
|
-
console.log(" Validation Output ");
|
|
1095
|
-
console.log("-----------------------");
|
|
1096
|
-
for (let i = 0; i < meta_errors.length; i++) {
|
|
1097
|
-
console.error(`- ${meta_errors[i]}`);
|
|
1098
|
-
}
|
|
1099
|
-
console.error(`\r\n${meta_errors.length} Validation Errors Found`);
|
|
1100
|
-
return false;
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
const excl_output = [];
|
|
1104
|
-
|
|
1105
|
-
// Do spellchecking on markdown files
|
|
1106
|
-
const md_files_spellchecked = {};
|
|
1107
|
-
const mdPromiseArray = [];
|
|
1108
|
-
for (let i = 0; i < md_to_validate.length; i++) {
|
|
1109
|
-
errors[md_to_validate[i].relativePath] = [];
|
|
1110
|
-
messages[md_to_validate[i].relativePath] = [];
|
|
1111
|
-
warnings[md_to_validate[i].relativePath] = [];
|
|
1112
|
-
mdPromiseArray.push(md_to_validate[i]);
|
|
1113
|
-
}
|
|
1114
|
-
await Promise.all(
|
|
1115
|
-
mdPromiseArray.map(async (file) => {
|
|
1116
|
-
// Initiate maps for errors and verbose messages for markdown file
|
|
1117
|
-
const exclusions = await spellcheckContent(file, exclude_spellcheck);
|
|
1118
|
-
if (gen_exclude && exclusions.length > 0)
|
|
1119
|
-
excl_output.push({
|
|
1120
|
-
document_path: file.relativePath.replace(`.${file.extension}`, ""),
|
|
1121
|
-
words: exclusions,
|
|
1122
|
-
});
|
|
1123
|
-
md_files_spellchecked[
|
|
1124
|
-
file.relativePath.replace(`.${file.extension}`, "")
|
|
1125
|
-
] = true;
|
|
1126
|
-
}),
|
|
1127
|
-
);
|
|
1128
|
-
|
|
1129
|
-
// Perform rest of validation against HTML files
|
|
1130
|
-
const htmlPromiseArray = [];
|
|
1131
|
-
for (let i = 0; i < html_to_validate.length; i++) {
|
|
1132
|
-
errors[html_to_validate[i].relativePath] = [];
|
|
1133
|
-
messages[html_to_validate[i].relativePath] = [];
|
|
1134
|
-
warnings[html_to_validate[i].relativePath] = [];
|
|
1135
|
-
htmlPromiseArray.push(html_to_validate[i]);
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
const global_links_checked = [];
|
|
1140
|
-
|
|
1141
|
-
for (const key in html_to_validate) {
|
|
1142
|
-
const file = html_to_validate[key];
|
|
1143
|
-
// Check for British spellings in static HTML content
|
|
1144
|
-
if (
|
|
1145
|
-
!md_files_spellchecked[
|
|
1146
|
-
file.relativePath.replace(`.${file.extension}`, "")
|
|
1147
|
-
]
|
|
1148
|
-
) {
|
|
1149
|
-
const exclusions = await spellcheckContent(file, exclude_spellcheck);
|
|
1150
|
-
if (gen_exclude && exclusions.length > 0)
|
|
1151
|
-
excl_output.push({
|
|
1152
|
-
document_path: file.relativePath.replace(
|
|
1153
|
-
`.${file.extension}`,
|
|
1154
|
-
"",
|
|
1155
|
-
),
|
|
1156
|
-
words: exclusions,
|
|
1157
|
-
});
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
const links = getLinks(file);
|
|
1161
|
-
if (links.href.length === 0) {
|
|
1162
|
-
messages[file.relativePath].push("No links found in file");
|
|
1163
|
-
} else {
|
|
1164
|
-
console.log(`\r\nChecking ${links.href.length} Links in ${file.relativePath}`);
|
|
1165
|
-
await checkLinks(source_path, file, links.href, hdocbook_config, hdocbook_project, global_links_checked, output_links);
|
|
1166
|
-
}
|
|
1167
|
-
if (links.img.length === 0) {
|
|
1168
|
-
messages[file.relativePath].push("No images found in file");
|
|
1169
|
-
} else {
|
|
1170
|
-
await checkImages(source_path, file, links.img);
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
// Check for multiple H1 tags
|
|
1174
|
-
await checkTags(file);
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
if (gen_exclude) console.log(JSON.stringify(excl_output, null, 2));
|
|
1178
|
-
|
|
1179
|
-
if (verbose) {
|
|
1180
|
-
console.log("\r\n-------------");
|
|
1181
|
-
console.log(" Verbose ");
|
|
1182
|
-
console.log("-------------");
|
|
1183
|
-
for (const key in messages) {
|
|
1184
|
-
if (Object.hasOwn(messages, key) && messages[key].length > 0) {
|
|
1185
|
-
console.log(`\r\nMessage output for ${key}`);
|
|
1186
|
-
for (let i = 0; i < messages[key].length; i++) {
|
|
1187
|
-
console.log(` - ${messages[key][i]}`);
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
console.log("\r\n-----------------------");
|
|
1194
|
-
console.log(" Validation Output ");
|
|
1195
|
-
console.log("-----------------------");
|
|
1196
|
-
let warning_count = 0;
|
|
1197
|
-
for (const key in warnings) {
|
|
1198
|
-
if (Object.hasOwn(warnings, key) && warnings[key].length > 0) {
|
|
1199
|
-
for (let i = 0; i < warnings[key].length; i++) {
|
|
1200
|
-
console.info(`[WARNING] ${key} - ${warnings[key][i]}`);
|
|
1201
|
-
warning_count++;
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
|
-
}
|
|
1205
|
-
if (warning_count > 0) {
|
|
1206
|
-
console.info(`\r\n${warning_count} Validation Warnings Found (non-fatal)\r\n`);
|
|
1207
|
-
}
|
|
1208
|
-
if (Object.keys(errors).length > 0) {
|
|
1209
|
-
let error_count = 0;
|
|
1210
|
-
for (const key in errors) {
|
|
1211
|
-
if (Object.hasOwn(errors, key) && errors[key].length > 0) {
|
|
1212
|
-
for (let i = 0; i < errors[key].length; i++) {
|
|
1213
|
-
console.error(`${key} - ${errors[key][i]}`);
|
|
1214
|
-
error_count++;
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
if (error_count > 0) {
|
|
1219
|
-
console.error(`\r\n${error_count} Validation Errors Found`);
|
|
1220
|
-
if (verbose) {
|
|
1221
|
-
console.log("\n");
|
|
1222
|
-
console.error(JSON.stringify(exclude_spellcheck_output, null, 2));
|
|
1223
|
-
}
|
|
1224
|
-
return false;
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
console.log("\r\nNo Validation Errors Found!\n");
|
|
1229
|
-
return true;
|
|
1230
|
-
};
|
|
1231
|
-
})();
|
|
1
|
+
(() => {
|
|
2
|
+
const cheerio = require("cheerio");
|
|
3
|
+
const dns = require("node:dns");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const hdoc = require(path.join(__dirname, "hdoc-module.js"));
|
|
7
|
+
const interbook = require(path.join(__dirname, "hdoc-validate-interbook.js"));
|
|
8
|
+
const translator = require("american-british-english-translator");
|
|
9
|
+
|
|
10
|
+
const spellcheck_options = {
|
|
11
|
+
british: true,
|
|
12
|
+
spelling: true,
|
|
13
|
+
};
|
|
14
|
+
const regex_nav_paths = /[a-z0-9-\/]+[a-z0-9]+#{0,1}[a-z0-9-\/]+/;
|
|
15
|
+
|
|
16
|
+
const errors = {};
|
|
17
|
+
const messages = {};
|
|
18
|
+
const warnings = {};
|
|
19
|
+
const html_to_validate = [];
|
|
20
|
+
const md_to_validate = [];
|
|
21
|
+
const exclude_links = {};
|
|
22
|
+
const exclude_spellcheck = {};
|
|
23
|
+
let private_repo = false;
|
|
24
|
+
let redirects = {};
|
|
25
|
+
let skip_link_file = '';
|
|
26
|
+
let _on_int_net_cached = null; // null = not yet checked; cached after first DNS lookup
|
|
27
|
+
const exclude_h1_count = {};
|
|
28
|
+
const exclude_spellcheck_output = [];
|
|
29
|
+
let global_spellcheck = [];
|
|
30
|
+
|
|
31
|
+
const excludeLink = (url) => {
|
|
32
|
+
if (exclude_links[url]) return true;
|
|
33
|
+
for (let key in exclude_links) {
|
|
34
|
+
if (Object.hasOwn(exclude_links, key)) {
|
|
35
|
+
if (key.endsWith("*")) {
|
|
36
|
+
key = key.substring(0, key.length - 1);
|
|
37
|
+
if (url.startsWith(key)) return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const loadSkipLinkValidation = (source_path) => {
|
|
45
|
+
skip_link_file = path.join(source_path, "validated-links.txt");
|
|
46
|
+
if (fs.existsSync(skip_link_file)) {
|
|
47
|
+
console.log(`Loading skip link validation file from: ${skip_link_file}`);
|
|
48
|
+
const skip_links = fs.readFileSync(skip_link_file, "utf8").split("\n");
|
|
49
|
+
for (let i = 0; i < skip_links.length; i++) {
|
|
50
|
+
if (skip_links[i].trim() !== "") {
|
|
51
|
+
exclude_links[skip_links[i].trim()] = true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
//Create the file if it doesn't exist
|
|
56
|
+
console.log(`Creating skip link validation file: ${skip_link_file}`);
|
|
57
|
+
fs.writeFileSync(skip_link_file, "", "utf8");
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const spellcheckContent = async (sourceFile, excludes) => {
|
|
62
|
+
const spelling_errors = {};
|
|
63
|
+
const words = [];
|
|
64
|
+
const text = fs.readFileSync(sourceFile.path, "utf8");
|
|
65
|
+
const source_path = sourceFile.relativePath.replace(
|
|
66
|
+
`.${sourceFile.extension}`,
|
|
67
|
+
"",
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const markdown_paths = getMDPathFromHtmlPath(sourceFile);
|
|
71
|
+
|
|
72
|
+
const translate_output = translator.translate(text, spellcheck_options);
|
|
73
|
+
if (Object.keys(translate_output).length) {
|
|
74
|
+
for (const key in translate_output) {
|
|
75
|
+
if (Object.hasOwn(translate_output, key)) {
|
|
76
|
+
// key is the line of text
|
|
77
|
+
let error_message = `British spelling:`;
|
|
78
|
+
for (let i = 0; i < translate_output[key].length; i++) {
|
|
79
|
+
for (const spelling in translate_output[key][i]) {
|
|
80
|
+
if (
|
|
81
|
+
Object.hasOwn(translate_output[key][i], spelling) &&
|
|
82
|
+
typeof translate_output[key][i][spelling].details === "string"
|
|
83
|
+
) {
|
|
84
|
+
const link_location = hdoc.find_string_in_string(text.split('\n')[key - 1], spelling);
|
|
85
|
+
if (link_location !== null)
|
|
86
|
+
error_message = `${markdown_paths.relativePath}:${key}:${link_location.column} - ${error_message}`;
|
|
87
|
+
else
|
|
88
|
+
error_message = `${markdown_paths.relativePath}:${key} - ${error_message}`;
|
|
89
|
+
if (!excludes[source_path] && !global_spellcheck.includes(spelling.toLowerCase())) {
|
|
90
|
+
errors[sourceFile.relativePath].push(
|
|
91
|
+
`${error_message} ${spelling} should be ${translate_output[key][i][spelling].details}`,
|
|
92
|
+
);
|
|
93
|
+
spelling_errors[spelling] = true;
|
|
94
|
+
} else if (
|
|
95
|
+
!excludes[source_path].includes(spelling.toLowerCase()) && !global_spellcheck.includes(spelling.toLowerCase())
|
|
96
|
+
) {
|
|
97
|
+
errors[sourceFile.relativePath].push(
|
|
98
|
+
`${error_message} ${spelling} should be ${translate_output[key][i][spelling].details}`,
|
|
99
|
+
);
|
|
100
|
+
spelling_errors[spelling] = true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (Object.keys(spelling_errors).length) {
|
|
109
|
+
const exclude_output = {
|
|
110
|
+
document_path: sourceFile.relativePath.replace(
|
|
111
|
+
path.extname(sourceFile.relativePath),
|
|
112
|
+
"",
|
|
113
|
+
),
|
|
114
|
+
words: [],
|
|
115
|
+
};
|
|
116
|
+
for (const word in spelling_errors) {
|
|
117
|
+
if (Object.hasOwn(spelling_errors, word)) {
|
|
118
|
+
words.push(word);
|
|
119
|
+
exclude_output.words.push(word);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
exclude_spellcheck_output.push(exclude_output);
|
|
123
|
+
}
|
|
124
|
+
return words;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const checkInline = async (source_path, inline, excludes) => {
|
|
128
|
+
const inline_errors = [];
|
|
129
|
+
for (let i = 0; i < inline.length; i++) {
|
|
130
|
+
const title = inline[i].title;
|
|
131
|
+
const link = inline[i].link;
|
|
132
|
+
|
|
133
|
+
// Validate link segment spellings
|
|
134
|
+
const paths = link.split("/");
|
|
135
|
+
for (let i = 0; i < paths.length; i++) {
|
|
136
|
+
const path_words = paths[i].split("-");
|
|
137
|
+
for (let j = 0; j < path_words.length; j++) {
|
|
138
|
+
const translate_output = translator.translate(
|
|
139
|
+
path_words[j],
|
|
140
|
+
spellcheck_options,
|
|
141
|
+
);
|
|
142
|
+
if (Object.keys(translate_output).length) {
|
|
143
|
+
for (const spell_val in translate_output) {
|
|
144
|
+
if (Object.hasOwn(translate_output, spell_val)) {
|
|
145
|
+
for (const spelling in translate_output[spell_val][0]) {
|
|
146
|
+
if (Object.hasOwn(translate_output[spell_val][0], spelling)) {
|
|
147
|
+
if (!excludes[link]) {
|
|
148
|
+
inline_errors.push(
|
|
149
|
+
`Inline Link [${link}] contains a British English spelling: ${spelling} should be ${translate_output[spell_val][0][spelling].details}`,
|
|
150
|
+
);
|
|
151
|
+
} else if (
|
|
152
|
+
!excludes[link].includes(spelling.toLowerCase())
|
|
153
|
+
) {
|
|
154
|
+
inline_errors.push(
|
|
155
|
+
`Inline Link [${link}] contains a British English spelling: ${spelling} should be ${translate_output[spell_val][0][spelling].details}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Validate display names
|
|
167
|
+
const translate_output = translator.translate(title, spellcheck_options);
|
|
168
|
+
if (Object.keys(translate_output).length) {
|
|
169
|
+
for (const spell_val in translate_output) {
|
|
170
|
+
if (Object.hasOwn(translate_output, spell_val)) {
|
|
171
|
+
for (let j = 0; j < translate_output[spell_val].length; j++) {
|
|
172
|
+
for (const spelling in translate_output[spell_val][j]) {
|
|
173
|
+
if (Object.hasOwn(translate_output[spell_val][j], spelling)) {
|
|
174
|
+
if (!excludes[link]) {
|
|
175
|
+
inline_errors.push(
|
|
176
|
+
`Inline title for link [${link}] contains a British English spelling: ${spelling} should be ${translate_output[spell_val][j][spelling].details}`,
|
|
177
|
+
);
|
|
178
|
+
} else if (!excludes[link].includes(spelling.toLowerCase())) {
|
|
179
|
+
inline_errors.push(
|
|
180
|
+
`Inline title for link [${link}] contains a British English spelling: ${spelling} should be ${translate_output[spell_val][j][spelling].details}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Validate path exists - link should be a html file at this point as its after the content has been built
|
|
191
|
+
let file_exists = true;
|
|
192
|
+
let file_name = path.join(source_path, `${link}.html`);
|
|
193
|
+
if (!fs.existsSync(file_name)) {
|
|
194
|
+
file_name = path.join(source_path, `${link}.htm`);
|
|
195
|
+
if (!fs.existsSync(file_name)) {
|
|
196
|
+
file_name = path.join(source_path, link, "index.html");
|
|
197
|
+
if (!fs.existsSync(file_name)) {
|
|
198
|
+
file_name = path.join(source_path, link, "index.htm");
|
|
199
|
+
if (!fs.existsSync(file_name)) {
|
|
200
|
+
file_exists = false;
|
|
201
|
+
inline_errors.push(`Inline link [${link}] file does not exist.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return inline_errors;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const checkNavigation = async (source_path, flat_nav, excludes, draft_links) => {
|
|
212
|
+
const nav_errors = [];
|
|
213
|
+
for (const key in flat_nav) {
|
|
214
|
+
if (Object.hasOwn(flat_nav, key)) {
|
|
215
|
+
// doc paths should only contain a-z - characters
|
|
216
|
+
const invalid_chars = key.replace(regex_nav_paths, "");
|
|
217
|
+
if (invalid_chars !== "") {
|
|
218
|
+
nav_errors.push(
|
|
219
|
+
`Navigation path [${key}] contains the following invalid characters: [${[...invalid_chars].join("] [")}]`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const key_split = key.split("#");
|
|
223
|
+
const key_no_hash = key_split[0];
|
|
224
|
+
|
|
225
|
+
// See if there's a redirect in place
|
|
226
|
+
let redirected = false;
|
|
227
|
+
let redirect_errored = false;
|
|
228
|
+
const redir = checkRedirect(source_path, key_no_hash);
|
|
229
|
+
|
|
230
|
+
if (redir.exists && redir.error !== null) {
|
|
231
|
+
nav_errors.push(redir.error);
|
|
232
|
+
redirect_errored = true;
|
|
233
|
+
} else if (redir.exists && redir.error === null) {
|
|
234
|
+
redirected = true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Validate path exists - key should be a html file at this point
|
|
238
|
+
let file_exists = true;
|
|
239
|
+
let file_name = path.join(source_path, `${key_no_hash}.html`);
|
|
240
|
+
if (!fs.existsSync(file_name)) {
|
|
241
|
+
file_name = path.join(source_path, `${key_no_hash}.htm`);
|
|
242
|
+
if (!fs.existsSync(file_name)) {
|
|
243
|
+
file_name = path.join(source_path, key_no_hash, "index.html");
|
|
244
|
+
if (!fs.existsSync(file_name)) {
|
|
245
|
+
file_name = path.join(source_path, key_no_hash, "index.htm");
|
|
246
|
+
if (!fs.existsSync(file_name)) {
|
|
247
|
+
file_exists = false;
|
|
248
|
+
if (!redirected && !redirect_errored && draft_links.indexOf(key_no_hash) === -1)
|
|
249
|
+
nav_errors.push(
|
|
250
|
+
`Navigation path [${key_no_hash}] file does not exist.`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (file_exists) {
|
|
258
|
+
// File exists - but is there a redirect? If so, we want to flag this as an error
|
|
259
|
+
if (redirected)
|
|
260
|
+
nav_errors.push(
|
|
261
|
+
`Navigation path [${key_no_hash}] is redirected, but path still exists.`,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// Check file path case match
|
|
265
|
+
const true_file = hdoc.true_case_path_sync(file_name)
|
|
266
|
+
.replace(source_path, "")
|
|
267
|
+
.replaceAll("\\", "/");
|
|
268
|
+
const relative_file = file_name
|
|
269
|
+
.replace(source_path, "")
|
|
270
|
+
.replaceAll("\\", "/");
|
|
271
|
+
if (true_file !== relative_file) {
|
|
272
|
+
nav_errors.push(
|
|
273
|
+
`Navigation path [${key}] for filename [${relative_file}] does not match filename case [${true_file}].`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Validate path spellings
|
|
279
|
+
const paths = key.split("/");
|
|
280
|
+
for (let i = 0; i < paths.length; i++) {
|
|
281
|
+
const path_words = paths[i].split("-");
|
|
282
|
+
for (let j = 0; j < path_words.length; j++) {
|
|
283
|
+
const translate_output = translator.translate(
|
|
284
|
+
path_words[j],
|
|
285
|
+
spellcheck_options,
|
|
286
|
+
);
|
|
287
|
+
if (Object.keys(translate_output).length) {
|
|
288
|
+
for (const spell_val in translate_output) {
|
|
289
|
+
if (Object.hasOwn(translate_output, spell_val)) {
|
|
290
|
+
for (const spelling in translate_output[spell_val][0]) {
|
|
291
|
+
if (
|
|
292
|
+
Object.hasOwn(translate_output[spell_val][0], spelling)
|
|
293
|
+
) {
|
|
294
|
+
if (!excludes[key]) {
|
|
295
|
+
nav_errors.push(
|
|
296
|
+
`Navigation path [${key}] key contains a British English spelling: ${spelling} should be ${translate_output[spell_val][0][spelling].details}`,
|
|
297
|
+
);
|
|
298
|
+
} else if (
|
|
299
|
+
!excludes[key].includes(spelling.toLowerCase())
|
|
300
|
+
) {
|
|
301
|
+
nav_errors.push(
|
|
302
|
+
`Navigation path [${key}] key contains a British English spelling: ${spelling} should be ${translate_output[spell_val][0][spelling].details}`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Validate display names/bookmarks
|
|
314
|
+
for (let i = 0; i < flat_nav[key].length; i++) {
|
|
315
|
+
if (flat_nav[key][i].link === key) {
|
|
316
|
+
const translate_output = translator.translate(
|
|
317
|
+
flat_nav[key][i].text,
|
|
318
|
+
spellcheck_options,
|
|
319
|
+
);
|
|
320
|
+
if (Object.keys(translate_output).length) {
|
|
321
|
+
for (const spell_val in translate_output) {
|
|
322
|
+
if (Object.hasOwn(translate_output, spell_val)) {
|
|
323
|
+
for (let j = 0; j < translate_output[spell_val].length; j++) {
|
|
324
|
+
for (const spelling in translate_output[spell_val][j]) {
|
|
325
|
+
if (
|
|
326
|
+
Object.hasOwn(translate_output[spell_val][j], spelling)
|
|
327
|
+
) {
|
|
328
|
+
if (!excludes[key]) {
|
|
329
|
+
nav_errors.push(
|
|
330
|
+
`Navigation path [${key}] display text contains a British English spelling: ${spelling} should be ${translate_output[spell_val][j][spelling].details}`,
|
|
331
|
+
);
|
|
332
|
+
} else if (
|
|
333
|
+
!excludes[key].includes(spelling.toLowerCase())
|
|
334
|
+
) {
|
|
335
|
+
nav_errors.push(
|
|
336
|
+
`Navigation path [${key}] display text contains a British English spelling: ${spelling} should be ${translate_output[spell_val][j][spelling].details}`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return nav_errors;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const checkRedirects = async (source_path) => {
|
|
353
|
+
const redir_errors = [];
|
|
354
|
+
for (const key in redirects) {
|
|
355
|
+
if (Object.hasOwn(redirects, key)) {
|
|
356
|
+
if (
|
|
357
|
+
redirects[key].code !== 301 &&
|
|
358
|
+
redirects[key].code !== 308 &&
|
|
359
|
+
redirects[key].code !== 410
|
|
360
|
+
)
|
|
361
|
+
redir_errors.push(`Invalid redirect code: ${redirects[key].code}`);
|
|
362
|
+
|
|
363
|
+
if (redirects[key].location && !redirects[key].skip_location_validation) {
|
|
364
|
+
const redir_locations = [
|
|
365
|
+
path.join(source_path, `${redirects[key].location}.md`),
|
|
366
|
+
path.join(source_path, redirects[key].location, "index.md"),
|
|
367
|
+
path.join(source_path, `${redirects[key].location}.html`),
|
|
368
|
+
path.join(source_path, `${redirects[key].location}.htm`),
|
|
369
|
+
path.join(source_path, redirects[key].location, "index.html"),
|
|
370
|
+
path.join(source_path, redirects[key].location, "index.htm"),
|
|
371
|
+
];
|
|
372
|
+
let redir_location_ok = false;
|
|
373
|
+
for (let i = 0; i < redir_locations.length; i++) {
|
|
374
|
+
if (fs.existsSync(redir_locations[i])) {
|
|
375
|
+
redir_location_ok = true;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (!redir_location_ok)
|
|
380
|
+
redir_errors.push(
|
|
381
|
+
`Redirect location does not exist: ${redirects[key].location}`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return redir_errors;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const checkRedirect = (source_path, nav_path) => {
|
|
390
|
+
const response = {
|
|
391
|
+
exists: false,
|
|
392
|
+
error: null,
|
|
393
|
+
};
|
|
394
|
+
if (redirects[nav_path]) {
|
|
395
|
+
response.exists = true;
|
|
396
|
+
if (redirects[nav_path].location) {
|
|
397
|
+
|
|
398
|
+
if (redirects[nav_path].skip_location_validation) return response;
|
|
399
|
+
|
|
400
|
+
// We have a redirect, check if it's a valid location
|
|
401
|
+
let file_path = path.join(
|
|
402
|
+
source_path,
|
|
403
|
+
`${redirects[nav_path].location}.html`,
|
|
404
|
+
);
|
|
405
|
+
if (!fs.existsSync(file_path)) {
|
|
406
|
+
file_path = path.join(
|
|
407
|
+
source_path,
|
|
408
|
+
`${redirects[nav_path].location}.htm`,
|
|
409
|
+
);
|
|
410
|
+
if (!fs.existsSync(file_path)) {
|
|
411
|
+
file_path = path.join(
|
|
412
|
+
source_path,
|
|
413
|
+
redirects[nav_path].location,
|
|
414
|
+
"index.html",
|
|
415
|
+
);
|
|
416
|
+
if (!fs.existsSync(file_path)) {
|
|
417
|
+
file_path = path.join(
|
|
418
|
+
source_path,
|
|
419
|
+
redirects[nav_path].location,
|
|
420
|
+
"index.htm",
|
|
421
|
+
);
|
|
422
|
+
if (!fs.existsSync(file_path)) {
|
|
423
|
+
response.error = `Redirect path for [${nav_path}] does not exist: ${redirects[nav_path].location}`;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return response;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const isHashAnchor = (html_file, hash_anchor, full_hash_anchor_link = "") => {
|
|
434
|
+
const markdown_paths = getMDPathFromHtmlPath(html_file);
|
|
435
|
+
const markdown_content = fs.readFileSync(markdown_paths.markdownPath, 'utf8');
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
const file_content = fs.readFileSync(html_file.path, {
|
|
439
|
+
encoding: "utf-8",
|
|
440
|
+
});
|
|
441
|
+
const clean_hash_anchor = hash_anchor.startsWith("/")
|
|
442
|
+
? hash_anchor.substring(2, hash_anchor.length)
|
|
443
|
+
: hash_anchor.substring(1, hash_anchor.length);
|
|
444
|
+
if (
|
|
445
|
+
!file_content.includes(`<div id="hb-doc-anchor-${clean_hash_anchor}"`)
|
|
446
|
+
) {
|
|
447
|
+
const error_message = processErrorMessage(`Target hash anchor is not present in page content: ${full_hash_anchor_link !== "" ? full_hash_anchor_link : hash_anchor}`, markdown_paths.relativePath, markdown_content, full_hash_anchor_link);
|
|
448
|
+
errors[html_file.relativePath].push( error_message );
|
|
449
|
+
}
|
|
450
|
+
} catch (e) {
|
|
451
|
+
errors[html_file.relativePath].push(e);
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const getMDPathFromHtmlPath = (htmlFile) => {
|
|
456
|
+
const returnPaths = {
|
|
457
|
+
markdownPath: htmlFile.path.replace(`.${htmlFile.extension}`, '.md'),
|
|
458
|
+
relativePath: htmlFile.relativePath.replace(`.${htmlFile.extension}`, '.md')
|
|
459
|
+
};
|
|
460
|
+
if (!fs.existsSync(returnPaths.markdownPath)) {
|
|
461
|
+
// No matching markdown
|
|
462
|
+
returnPaths.markdownPath = htmlFile.path;
|
|
463
|
+
}
|
|
464
|
+
return returnPaths;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Headers that mimic a real Chrome browser request — sites doing bot detection
|
|
468
|
+
// check far more than just User-Agent (Accept, Sec-Fetch-*, client hints, etc.).
|
|
469
|
+
const _fetch_headers = {
|
|
470
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
471
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
|
472
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
473
|
+
'Accept-Encoding': 'gzip, deflate, br',
|
|
474
|
+
'Cache-Control': 'no-cache',
|
|
475
|
+
'Pragma': 'no-cache',
|
|
476
|
+
'Sec-Fetch-Dest': 'document',
|
|
477
|
+
'Sec-Fetch-Mode': 'navigate',
|
|
478
|
+
'Sec-Fetch-Site': 'none',
|
|
479
|
+
'Sec-Fetch-User': '?1',
|
|
480
|
+
'Sec-Ch-Ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
|
|
481
|
+
'Sec-Ch-Ua-Mobile': '?0',
|
|
482
|
+
'Sec-Ch-Ua-Platform': '"Windows"',
|
|
483
|
+
'Upgrade-Insecure-Requests': '1',
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
// Checks a single external URL by sending a HEAD request, falling back to GET
|
|
487
|
+
// if the server returns 405 (Method Not Allowed) or 404 (some servers, e.g.
|
|
488
|
+
// marketplace.visualstudio.com, return 404 for HEAD even when the page exists).
|
|
489
|
+
// Retries up to 5 times on transient errors (5xx, 429, network failures).
|
|
490
|
+
// Returns the HTTP status code.
|
|
491
|
+
const fetchExternalLinkStatus = async (url) => {
|
|
492
|
+
const opts = { method: 'HEAD', headers: _fetch_headers, timeoutMs: 10000, redirect: 'follow' };
|
|
493
|
+
const resp = await hdoc.fetchWithRetry(url, opts);
|
|
494
|
+
if (resp.status === 404 || resp.status === 405) {
|
|
495
|
+
const getResp = await hdoc.fetchWithRetry(url, { ...opts, method: 'GET' });
|
|
496
|
+
return getResp.status;
|
|
497
|
+
}
|
|
498
|
+
return resp.status;
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// Map an inter-book check result onto errors/warnings/messages and the
|
|
502
|
+
// validated-links cache. 'ok' and 'skip' outcomes are stable, so they are
|
|
503
|
+
// appended to validated-links.txt like any other passing link.
|
|
504
|
+
const handleInterbookResult = (result, link, htmlFile, markdown_paths, markdown_content) => {
|
|
505
|
+
if (result.level === "error") {
|
|
506
|
+
errors[htmlFile.relativePath].push(
|
|
507
|
+
processErrorMessage(result.message, markdown_paths.relativePath, markdown_content, link),
|
|
508
|
+
);
|
|
509
|
+
} else if (result.level === "warning") {
|
|
510
|
+
warnings[htmlFile.relativePath].push(
|
|
511
|
+
processErrorMessage(result.message, markdown_paths.relativePath, markdown_content, link),
|
|
512
|
+
);
|
|
513
|
+
} else {
|
|
514
|
+
messages[htmlFile.relativePath].push(result.message);
|
|
515
|
+
fs.appendFileSync(skip_link_file, `${link}\n`);
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const checkLinks = async (source_path, htmlFile, links, hdocbook_config, hdocbook_project, global_links_checked, output_links) => {
|
|
520
|
+
const markdown_paths = getMDPathFromHtmlPath(htmlFile);
|
|
521
|
+
const markdown_content = fs.readFileSync(markdown_paths.markdownPath, 'utf8');
|
|
522
|
+
|
|
523
|
+
// Resolve the "are we on the internal network?" question once per process
|
|
524
|
+
// rather than once per internal.hornbill.com link.
|
|
525
|
+
const ensureIntNetCached = async () => {
|
|
526
|
+
if (_on_int_net_cached === null) {
|
|
527
|
+
try {
|
|
528
|
+
_on_int_net_cached = await checkHostExistsInDNS('docs-internal.hornbill.com');
|
|
529
|
+
} catch (_e) {
|
|
530
|
+
_on_int_net_cached = false;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return _on_int_net_cached;
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
// Collect external links that need an HTTP check so they can be run
|
|
537
|
+
// concurrently rather than one-at-a-time.
|
|
538
|
+
const externalChecks = [];
|
|
539
|
+
|
|
540
|
+
for (let i = 0; i < links.length; i++) {
|
|
541
|
+
if (output_links) console.log(` - ${links[i]}`);
|
|
542
|
+
if (exclude_links[links[i]]) continue;
|
|
543
|
+
if (global_links_checked.includes(links[i])) continue;
|
|
544
|
+
global_links_checked.push(links[i]);
|
|
545
|
+
|
|
546
|
+
const valid_url = hdoc.valid_url(links[i]);
|
|
547
|
+
if (!valid_url) {
|
|
548
|
+
// Could be a relative path, check
|
|
549
|
+
if (links[i].startsWith("#") || links[i].startsWith("/#")) {
|
|
550
|
+
//Flat Anchor - validate we have a same-file hit
|
|
551
|
+
isHashAnchor(htmlFile, links[i]);
|
|
552
|
+
} else if (links[i].startsWith("/") && !links[i].startsWith("/#")) {
|
|
553
|
+
let link_segments = links[i].split("/");
|
|
554
|
+
if (link_segments[0] === "") link_segments.shift();
|
|
555
|
+
const link_root = link_segments[0] === "_books" ? link_segments[1] : link_segments[0];
|
|
556
|
+
|
|
557
|
+
// Check for links with a _books path that have no specific file target
|
|
558
|
+
// We do need to exclude those with an extension though, for pages that link downloadable resources
|
|
559
|
+
if (link_segments[0] === "_books" && path.extname(links[i]) === '') {
|
|
560
|
+
const error_message = processErrorMessage(`Root relative page links should not include _books in the path: ${links[i]}`, markdown_paths.relativePath, markdown_content, links[i]);
|
|
561
|
+
errors[htmlFile.relativePath].push(error_message);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Links into other books: verified against GitHub when a token
|
|
565
|
+
// was supplied, otherwise skipped (can't validate locally).
|
|
566
|
+
if ((link_segments.length > 1 && link_root !== hdocbook_config.docId) || (link_segments.length === 1 && link_root !== hdocbook_config.docId && link_root !== "index")) {
|
|
567
|
+
if (interbook.enabled() && path.extname(links[i].split("#")[0]) === "") {
|
|
568
|
+
const link = links[i];
|
|
569
|
+
externalChecks.push(async () =>
|
|
570
|
+
handleInterbookResult(
|
|
571
|
+
await interbook.check_link(link),
|
|
572
|
+
link,
|
|
573
|
+
htmlFile,
|
|
574
|
+
markdown_paths,
|
|
575
|
+
markdown_content,
|
|
576
|
+
),
|
|
577
|
+
);
|
|
578
|
+
} else {
|
|
579
|
+
fs.appendFileSync(skip_link_file, `${links[i]}\n`);
|
|
580
|
+
}
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
isRelativePath(source_path, htmlFile, links[i]);
|
|
584
|
+
} else {
|
|
585
|
+
const error_message = processErrorMessage(`Root relative links should start with a forward-slash: ${links[i]}`, markdown_paths.relativePath, markdown_content, links[i]);
|
|
586
|
+
errors[htmlFile.relativePath].push(error_message);
|
|
587
|
+
}
|
|
588
|
+
} else {
|
|
589
|
+
messages[htmlFile.relativePath].push(
|
|
590
|
+
`Link is a properly formatted external URL: ${links[i]}`,
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
// Skip if it's the auto-generated edit url, as these could be part of a private repo which would return a 404
|
|
594
|
+
// publicSource must be truthy, not just defined - an empty string makes
|
|
595
|
+
// get_github_api_path return "" which has no edit_path
|
|
596
|
+
if (
|
|
597
|
+
hdocbook_config.publicSource &&
|
|
598
|
+
links[i] ===
|
|
599
|
+
hdoc
|
|
600
|
+
.get_github_api_path(
|
|
601
|
+
hdocbook_config.publicSource,
|
|
602
|
+
htmlFile.relativePath,
|
|
603
|
+
)
|
|
604
|
+
.edit_path.replace(path.extname(htmlFile.relativePath), ".md")
|
|
605
|
+
) {
|
|
606
|
+
fs.appendFileSync(skip_link_file, `${links[i]}\n`);
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (valid_url.protocol === "mailto:") {
|
|
611
|
+
fs.appendFileSync(skip_link_file, `${links[i]}\n`);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Skip if the link is excluded in the project config
|
|
616
|
+
if (excludeLink(links[i])) {
|
|
617
|
+
messages[htmlFile.relativePath].push(
|
|
618
|
+
`Skipping link validation for: ${links[i]}`,
|
|
619
|
+
);
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (
|
|
624
|
+
(links[i].toLowerCase().includes("docs.hornbill.com") ||
|
|
625
|
+
links[i].toLowerCase().includes("docs-internal.hornbill.com")) &&
|
|
626
|
+
!markdown_paths.relativePath.includes('/_inline/')
|
|
627
|
+
) {
|
|
628
|
+
const error_message = processErrorMessage(`Hornbill Docs links should not be fully-qualified: ${links[i]}`, markdown_paths.relativePath, markdown_content, links[i]);
|
|
629
|
+
errors[htmlFile.relativePath].push(error_message);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (
|
|
634
|
+
links[i].toLowerCase().includes("docs-internal.hornbill.com") &&
|
|
635
|
+
markdown_paths.relativePath.includes('/_inline/') &&
|
|
636
|
+
!private_repo
|
|
637
|
+
) {
|
|
638
|
+
// Is the parent book in a public repo? If so, flag this as an error.
|
|
639
|
+
const error_message = processErrorMessage(`Hornbill docs-internal links should not be used in public book inline content: ${links[i]}`, markdown_paths.relativePath, markdown_content, links[i]);
|
|
640
|
+
errors[htmlFile.relativePath].push(error_message);
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Fully-qualified Hornbill Docs links (only permitted in _inline
|
|
645
|
+
// content — anything else errored above): with a GitHub token,
|
|
646
|
+
// validate book/article/anchor instead of a plain HTTP check.
|
|
647
|
+
const link_host = valid_url.hostname ? valid_url.hostname.toLowerCase() : "";
|
|
648
|
+
if (
|
|
649
|
+
interbook.enabled() &&
|
|
650
|
+
(link_host === "docs.hornbill.com" || link_host === "docs-internal.hornbill.com") &&
|
|
651
|
+
path.extname(valid_url.pathname) === ""
|
|
652
|
+
) {
|
|
653
|
+
const link = links[i];
|
|
654
|
+
const book_link = valid_url.pathname + valid_url.hash;
|
|
655
|
+
externalChecks.push(async () =>
|
|
656
|
+
handleInterbookResult(
|
|
657
|
+
await interbook.check_link(book_link),
|
|
658
|
+
link,
|
|
659
|
+
htmlFile,
|
|
660
|
+
markdown_paths,
|
|
661
|
+
markdown_content,
|
|
662
|
+
),
|
|
663
|
+
);
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Capture url in closure for the async check below
|
|
668
|
+
const url = links[i];
|
|
669
|
+
const isInternal = url.toLowerCase().includes("internal.hornbill.com");
|
|
670
|
+
|
|
671
|
+
externalChecks.push(async () => {
|
|
672
|
+
// For internal.hornbill.com links, check network reachability first (result cached)
|
|
673
|
+
if (isInternal) {
|
|
674
|
+
const on_int_net = await ensureIntNetCached();
|
|
675
|
+
if (!on_int_net) {
|
|
676
|
+
messages[htmlFile.relativePath].push(
|
|
677
|
+
`Outside of Hornbill network - skipping internal link validation for: ${url}`,
|
|
678
|
+
);
|
|
679
|
+
fs.appendFileSync(skip_link_file, `${url}\n`);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
messages[htmlFile.relativePath].push(
|
|
683
|
+
`Inside of Hornbill network - performing internal link validation for: ${url}`,
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
try {
|
|
688
|
+
const status = await fetchExternalLinkStatus(url);
|
|
689
|
+
if ((status < 200 || status > 299) && status !== 304) {
|
|
690
|
+
if (process.env.GITHUB_ACTIONS === 'true' && status === 403 && url.includes(".hornbill.com")) {
|
|
691
|
+
// Always returns 403 for Hornbill sites through GitHub Actions — not a real error
|
|
692
|
+
} else {
|
|
693
|
+
throw `Unexpected Status Returned: ${status}`;
|
|
694
|
+
}
|
|
695
|
+
} else {
|
|
696
|
+
fs.appendFileSync(skip_link_file, `${url}\n`);
|
|
697
|
+
}
|
|
698
|
+
} catch (e) {
|
|
699
|
+
let error_message;
|
|
700
|
+
if (e instanceof AggregateError) {
|
|
701
|
+
error_message = processErrorMessage(`Issue with external link [${url}]: ${e.message} - ${JSON.stringify(e.errors)}`, markdown_paths.relativePath, markdown_content, url);
|
|
702
|
+
} else {
|
|
703
|
+
error_message = processErrorMessage(`Issue with external link [${url}]: ${e}`, markdown_paths.relativePath, markdown_content, url);
|
|
704
|
+
}
|
|
705
|
+
if (hdocbook_project.validation.external_link_warnings || process.env.GITHUB_ACTIONS === 'true')
|
|
706
|
+
warnings[htmlFile.relativePath].push(error_message);
|
|
707
|
+
else
|
|
708
|
+
errors[htmlFile.relativePath].push(error_message);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Run all external HTTP checks concurrently — fetch is lightweight enough
|
|
715
|
+
// that uncapped concurrency is fine for the link counts seen in practice.
|
|
716
|
+
await Promise.all(externalChecks.map(fn => fn()));
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
const checkHostExistsInDNS = async (hostname) => {
|
|
720
|
+
return new Promise((resolve, reject) => {
|
|
721
|
+
dns.lookup(hostname, { all:true }, (err, addresses) => {
|
|
722
|
+
if (err) {
|
|
723
|
+
reject(err);
|
|
724
|
+
} else {
|
|
725
|
+
resolve(addresses !== undefined);
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const checkImages = async (source_path, htmlFile, links) => {
|
|
732
|
+
const markdown_paths = getMDPathFromHtmlPath(htmlFile);
|
|
733
|
+
const markdown_content = fs.readFileSync(markdown_paths.markdownPath, 'utf8');
|
|
734
|
+
for (let i = 0; i < links.length; i++) {
|
|
735
|
+
// Validate that image is a valid URL first
|
|
736
|
+
if (!hdoc.valid_url(links[i])) {
|
|
737
|
+
|
|
738
|
+
if (!links[i].startsWith("/") && !markdown_paths.relativePath.includes('/_inline/')) {
|
|
739
|
+
const error_message = processErrorMessage(`Root relative image links should start with a forward-slash: ${links[i]}`, markdown_paths.relativePath, markdown_content, links[i]);
|
|
740
|
+
errors[htmlFile.relativePath].push(error_message);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Could be a relative path, check image exists
|
|
744
|
+
doesFileExist(source_path, htmlFile, links[i], markdown_paths.relativePath, markdown_content);
|
|
745
|
+
} else {
|
|
746
|
+
messages[htmlFile.relativePath].push(
|
|
747
|
+
`Image link is a properly formatted external URL: ${links[i]}`,
|
|
748
|
+
);
|
|
749
|
+
// Do a Get to the URL to see if it exists
|
|
750
|
+
try {
|
|
751
|
+
const img_response = await fetch(links[i]);
|
|
752
|
+
if (!img_response.ok) throw new Error(`HTTP ${img_response.status}`);
|
|
753
|
+
messages[htmlFile.relativePath].push(
|
|
754
|
+
`Image link is a valid external URL: ${links[i]}`,
|
|
755
|
+
);
|
|
756
|
+
} catch (e) {
|
|
757
|
+
// Handle errors
|
|
758
|
+
const error_message = processErrorMessage(`External image link error: ${links[i]} - ${e.message}`, markdown_paths.relativePath, markdown_content, links[i]);
|
|
759
|
+
errors[htmlFile.relativePath].push(error_message);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
const checkTags = async (htmlFile) => {
|
|
766
|
+
const markdown_paths = getMDPathFromHtmlPath(htmlFile);
|
|
767
|
+
// Check if file is excluded from tag check
|
|
768
|
+
const file_no_ext = htmlFile.relativePath.replace(
|
|
769
|
+
path.extname(htmlFile.relativePath),
|
|
770
|
+
"",
|
|
771
|
+
);
|
|
772
|
+
if (exclude_h1_count[file_no_ext]) return;
|
|
773
|
+
|
|
774
|
+
// Check tags
|
|
775
|
+
const htmlBody = fs.readFileSync(htmlFile.path, "utf8");
|
|
776
|
+
const $ = cheerio.load(htmlBody);
|
|
777
|
+
|
|
778
|
+
const h1_tags = $("h1")
|
|
779
|
+
.map(function () {
|
|
780
|
+
return $(this);
|
|
781
|
+
})
|
|
782
|
+
.get();
|
|
783
|
+
if (h1_tags.length && h1_tags.length > 1) {
|
|
784
|
+
let error_msg = `${h1_tags.length} <h1> tags found in content: `;
|
|
785
|
+
for (let i = 0; i < h1_tags.length; i++) {
|
|
786
|
+
error_msg += h1_tags[i].text();
|
|
787
|
+
if (i < h1_tags.length - 1) error_msg += "; ";
|
|
788
|
+
}
|
|
789
|
+
errors[htmlFile.relativePath].push(`${markdown_paths.relativePath} - ${error_msg}`);
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
const dreeOptions = {
|
|
794
|
+
descendants: true,
|
|
795
|
+
excludeEmptyDirectories: true,
|
|
796
|
+
extensions: ["htm", "html", "md"],
|
|
797
|
+
hash: false,
|
|
798
|
+
normalize: true,
|
|
799
|
+
size: false,
|
|
800
|
+
sizeInBytes: false,
|
|
801
|
+
stat: false,
|
|
802
|
+
symbolicLinks: false,
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
// File scan callback for content type files
|
|
806
|
+
const fileContentCallback = (element) => {
|
|
807
|
+
if (element.extension.toLowerCase() === "md") {
|
|
808
|
+
md_to_validate.push(element);
|
|
809
|
+
} else {
|
|
810
|
+
html_to_validate.push(element);
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
const isRelativePath = (source_path, html_path, relative_path) => {
|
|
815
|
+
const markdown_paths = getMDPathFromHtmlPath(html_path);
|
|
816
|
+
const markdown_content = fs.readFileSync(markdown_paths.markdownPath, 'utf8');
|
|
817
|
+
|
|
818
|
+
const rel_path_ext = path.extname(relative_path);
|
|
819
|
+
const response = {
|
|
820
|
+
is_rel_path: false,
|
|
821
|
+
has_md_extension: rel_path_ext === ".md",
|
|
822
|
+
has_html_extension: rel_path_ext === ".htm" || rel_path_ext === ".html",
|
|
823
|
+
};
|
|
824
|
+
const supported_relpaths = [
|
|
825
|
+
`${path.sep}index.htm`,
|
|
826
|
+
`${path.sep}index.html`,
|
|
827
|
+
".htm",
|
|
828
|
+
".html",
|
|
829
|
+
".md",
|
|
830
|
+
];
|
|
831
|
+
|
|
832
|
+
// Remove explicit anchor links and _books prefix
|
|
833
|
+
const clean_relative_path = relative_path
|
|
834
|
+
.split("#")[0]
|
|
835
|
+
.replace("_books/", "");
|
|
836
|
+
|
|
837
|
+
let hash_anchor = null;
|
|
838
|
+
if (relative_path.split("#")[1])
|
|
839
|
+
hash_anchor = `#${relative_path.split("#")[1]}`;
|
|
840
|
+
|
|
841
|
+
// Make full file path
|
|
842
|
+
const file_path = path.join(source_path, clean_relative_path);
|
|
843
|
+
|
|
844
|
+
// Does path exist?
|
|
845
|
+
if (fs.existsSync(file_path)) {
|
|
846
|
+
response.is_rel_path = true;
|
|
847
|
+
} else {
|
|
848
|
+
// Path
|
|
849
|
+
for (let i = 0; i < supported_relpaths.length; i++) {
|
|
850
|
+
const html_file_path = `${file_path}${supported_relpaths[i]}`;
|
|
851
|
+
if (fs.existsSync(html_file_path)) {
|
|
852
|
+
response.is_rel_path = true;
|
|
853
|
+
|
|
854
|
+
// Check for hash anchor
|
|
855
|
+
if (hash_anchor !== null) {
|
|
856
|
+
isHashAnchor(
|
|
857
|
+
{ path: html_file_path, relativePath: html_path.relativePath, extension: html_path.extension },
|
|
858
|
+
hash_anchor,
|
|
859
|
+
relative_path,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
if (response.has_md_extension || response.has_html_extension) {
|
|
867
|
+
const error_message = processErrorMessage(`Relative link has ${rel_path_ext} extension, but should not: ${relative_path}`, markdown_paths.relativePath, markdown_content, relative_path);
|
|
868
|
+
errors[html_path.relativePath].push(error_message);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (response.is_rel_path) {
|
|
872
|
+
messages[html_path.relativePath].push(
|
|
873
|
+
`Relative path exists: ${clean_relative_path}`,
|
|
874
|
+
);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// See if there's a redirect in place
|
|
879
|
+
const relpath =
|
|
880
|
+
clean_relative_path.indexOf("/") === 0
|
|
881
|
+
? clean_relative_path.substring(1)
|
|
882
|
+
: clean_relative_path;
|
|
883
|
+
const redir = checkRedirect(source_path, relpath);
|
|
884
|
+
if (redir.exists) {
|
|
885
|
+
if (redir.error !== null) {
|
|
886
|
+
const error_message = processErrorMessage(`${redir.error}: ${relative_path}`, markdown_paths.relativePath, markdown_content, clean_relative_path);
|
|
887
|
+
errors[html_path.relativePath].push(error_message);
|
|
888
|
+
}
|
|
889
|
+
} else {
|
|
890
|
+
const error_message = processErrorMessage(`Link path does not exist: ${relative_path}`, markdown_paths.relativePath, markdown_content, clean_relative_path);
|
|
891
|
+
errors[html_path.relativePath].push(error_message);
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
const processErrorMessage = (message, md_path, content, search) => {
|
|
896
|
+
// whole_link — a link that is a substring of a longer link elsewhere in
|
|
897
|
+
// the file must not be located at the longer link's position
|
|
898
|
+
const link_location = hdoc.find_string_in_string(content, search, true);
|
|
899
|
+
let error_message = message;
|
|
900
|
+
if (link_location !== null)
|
|
901
|
+
error_message = `${md_path}:${link_location.line}:${link_location.column} - ${error_message}`;
|
|
902
|
+
else
|
|
903
|
+
error_message = `${md_path} - ${error_message}`;
|
|
904
|
+
return error_message;
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
const doesFileExist = (source_path, html_path, relative_path, markdown_path, markdown_content) => {
|
|
908
|
+
// Remove explicit anchor links and _books prefix
|
|
909
|
+
const clean_relative_path = relative_path
|
|
910
|
+
.split("#")[0]
|
|
911
|
+
.replace("_books/", "");
|
|
912
|
+
const file_path = path.join(source_path, clean_relative_path);
|
|
913
|
+
if (
|
|
914
|
+
!fs.existsSync(file_path) &&
|
|
915
|
+
!fs.existsSync(`${file_path + path.sep}index.htm`) &&
|
|
916
|
+
!fs.existsSync(`${file_path}index.html`) &&
|
|
917
|
+
!fs.existsSync(`${file_path}.htm`) &&
|
|
918
|
+
!fs.existsSync(`${file_path}.html`)
|
|
919
|
+
) {
|
|
920
|
+
const error_message = processErrorMessage(`Book resource does not exist: ${clean_relative_path}`, markdown_path, markdown_content, relative_path);
|
|
921
|
+
errors[html_path.relativePath].push(error_message);
|
|
922
|
+
return false;
|
|
923
|
+
}
|
|
924
|
+
messages[html_path.relativePath].push(
|
|
925
|
+
`Book resource exists: ${clean_relative_path}`,
|
|
926
|
+
);
|
|
927
|
+
return true;
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
// Takes a dree element, returns an object with a pair of arrays
|
|
931
|
+
const getLinks = (file) => {
|
|
932
|
+
messages[file.relativePath].push("Parsing HTML file");
|
|
933
|
+
const htmlBody = fs.readFileSync(file.path, "utf8");
|
|
934
|
+
const links = {
|
|
935
|
+
href: [],
|
|
936
|
+
img: [],
|
|
937
|
+
};
|
|
938
|
+
const $ = cheerio.load(htmlBody);
|
|
939
|
+
const hrefs = $("a")
|
|
940
|
+
.map(function (i) {
|
|
941
|
+
return $(this).attr("href");
|
|
942
|
+
})
|
|
943
|
+
.get();
|
|
944
|
+
const srcs = $("img")
|
|
945
|
+
.map(function (i) {
|
|
946
|
+
if ($(this).attr("alt") === undefined || $(this).attr("alt").trim() === "") {
|
|
947
|
+
errors[file.relativePath].push(`Image tag with src [${$(this).attr("src")}] is missing alt attribute.`);
|
|
948
|
+
}
|
|
949
|
+
return $(this).attr("src");
|
|
950
|
+
})
|
|
951
|
+
.get();
|
|
952
|
+
links.href.push(...hrefs);
|
|
953
|
+
links.img.push(...srcs);
|
|
954
|
+
return links;
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
exports.run = async (
|
|
958
|
+
source_path,
|
|
959
|
+
doc_id,
|
|
960
|
+
verbose,
|
|
961
|
+
hdocbook_config,
|
|
962
|
+
hdocbook_project,
|
|
963
|
+
nav_items,
|
|
964
|
+
prod_families,
|
|
965
|
+
prods_supported,
|
|
966
|
+
gen_exclude,
|
|
967
|
+
gen_redirects,
|
|
968
|
+
draft_links,
|
|
969
|
+
is_private,
|
|
970
|
+
browser,
|
|
971
|
+
source_root_path,
|
|
972
|
+
output_links = true,
|
|
973
|
+
git_token = "",
|
|
974
|
+
) => {
|
|
975
|
+
console.log("Performing Validation and Building SEO Link List...");
|
|
976
|
+
redirects = gen_redirects;
|
|
977
|
+
private_repo = is_private;
|
|
978
|
+
interbook.init(git_token);
|
|
979
|
+
if (interbook.enabled())
|
|
980
|
+
console.log(
|
|
981
|
+
"GitHub token supplied - inter-book links will be validated against GitHub",
|
|
982
|
+
);
|
|
983
|
+
|
|
984
|
+
// Load the skip link validation file if it exists
|
|
985
|
+
loadSkipLinkValidation(source_root_path);
|
|
986
|
+
|
|
987
|
+
// Get a list of HTML files in source_path
|
|
988
|
+
hdoc.scan_dir(source_path, dreeOptions, fileContentCallback);
|
|
989
|
+
|
|
990
|
+
// Check product family
|
|
991
|
+
let valid_product = false;
|
|
992
|
+
const meta_errors = [];
|
|
993
|
+
for (let i = 0; i < prod_families.products.length; i++) {
|
|
994
|
+
if (prod_families.products[i].id === hdocbook_config.productFamily) {
|
|
995
|
+
valid_product = true;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
if (!valid_product) {
|
|
999
|
+
let val_prod_error = `Incorrect productFamily: ${hdocbook_config.productFamily}. Supported values:`;
|
|
1000
|
+
for (let i = 0; i < prods_supported.length; i++) {
|
|
1001
|
+
val_prod_error += `\n - ${prods_supported[i]}`;
|
|
1002
|
+
}
|
|
1003
|
+
meta_errors.push(val_prod_error);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (hdocbook_config.publicSource && hdocbook_config.publicSource !== "") {
|
|
1007
|
+
// Validate publicSource
|
|
1008
|
+
if (hdocbook_config.publicSource.toLowerCase() === "--publicsource--") {
|
|
1009
|
+
meta_errors.push(
|
|
1010
|
+
"Value for publicSource in book metadata is set to its default template value",
|
|
1011
|
+
);
|
|
1012
|
+
} else {
|
|
1013
|
+
// Check URL exists
|
|
1014
|
+
if (
|
|
1015
|
+
!hdocbook_config.publicSource.startsWith("https://github.com") &&
|
|
1016
|
+
!hdocbook_config.publicSource.startsWith("https://api.github.com")
|
|
1017
|
+
) {
|
|
1018
|
+
meta_errors.push(
|
|
1019
|
+
`Value for publicSource in book metadata is not a recognised GitHub URL: ${hdocbook_config.publicSource}`,
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
if (
|
|
1026
|
+
!hdocbook_config.audience ||
|
|
1027
|
+
!Array.isArray(hdocbook_config.audience) ||
|
|
1028
|
+
hdocbook_config.audience.length === 0
|
|
1029
|
+
) {
|
|
1030
|
+
meta_errors.push(
|
|
1031
|
+
"Property audience of type array in book metadata is mandatory.",
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
if (hdocbook_project.validation) {
|
|
1035
|
+
if (
|
|
1036
|
+
hdocbook_project.validation.exclude_links &&
|
|
1037
|
+
Array.isArray(hdocbook_project.validation.exclude_links)
|
|
1038
|
+
) {
|
|
1039
|
+
for (const excl_link of hdocbook_project.validation.exclude_links) {
|
|
1040
|
+
exclude_links[excl_link] = true;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (
|
|
1044
|
+
hdocbook_project.validation.exclude_spellcheck &&
|
|
1045
|
+
Array.isArray(hdocbook_project.validation.exclude_spellcheck)
|
|
1046
|
+
) {
|
|
1047
|
+
for (const excl_sc of hdocbook_project.validation.exclude_spellcheck) {
|
|
1048
|
+
|
|
1049
|
+
if (exclude_spellcheck[excl_sc.document_path] !== undefined) {
|
|
1050
|
+
meta_errors.push(
|
|
1051
|
+
"Document path is duplicated in exclude_spellcheck validation array: " + excl_sc.document_path,
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
exclude_spellcheck[excl_sc.document_path] = excl_sc.words;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
if (
|
|
1058
|
+
hdocbook_project.validation.exclude_h1_count &&
|
|
1059
|
+
Array.isArray(hdocbook_project.validation.exclude_h1_count)
|
|
1060
|
+
) {
|
|
1061
|
+
for (const excl_h1 of hdocbook_project.validation.exclude_h1_count) {
|
|
1062
|
+
exclude_h1_count[excl_h1] = true;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (hdocbook_project.validation && Array.isArray(hdocbook_project.validation.spellcheckDictionary)) global_spellcheck = hdocbook_project.validation.spellcheckDictionary.map((w) => String(w).toLowerCase());
|
|
1068
|
+
|
|
1069
|
+
// Check navigation spellings & paths exist
|
|
1070
|
+
const nav_errors = await checkNavigation(
|
|
1071
|
+
source_path,
|
|
1072
|
+
nav_items,
|
|
1073
|
+
exclude_spellcheck,
|
|
1074
|
+
draft_links,
|
|
1075
|
+
);
|
|
1076
|
+
if (nav_errors.length > 0) meta_errors.push(...nav_errors);
|
|
1077
|
+
|
|
1078
|
+
// Check inline content spellings & paths exist
|
|
1079
|
+
if (hdocbook_config.inline && hdocbook_config.inline.length > 0) {
|
|
1080
|
+
const inline_errors = await checkInline(
|
|
1081
|
+
source_path,
|
|
1082
|
+
hdocbook_config.inline,
|
|
1083
|
+
exclude_spellcheck,
|
|
1084
|
+
);
|
|
1085
|
+
if (inline_errors.length > 0) meta_errors.push(...inline_errors);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Check redirects
|
|
1089
|
+
const redirect_errors = await checkRedirects(source_path);
|
|
1090
|
+
if (redirect_errors.length > 0) meta_errors.push(...redirect_errors);
|
|
1091
|
+
|
|
1092
|
+
if (meta_errors.length > 0) {
|
|
1093
|
+
console.log("\r\n-----------------------");
|
|
1094
|
+
console.log(" Validation Output ");
|
|
1095
|
+
console.log("-----------------------");
|
|
1096
|
+
for (let i = 0; i < meta_errors.length; i++) {
|
|
1097
|
+
console.error(`- ${meta_errors[i]}`);
|
|
1098
|
+
}
|
|
1099
|
+
console.error(`\r\n${meta_errors.length} Validation Errors Found`);
|
|
1100
|
+
return false;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const excl_output = [];
|
|
1104
|
+
|
|
1105
|
+
// Do spellchecking on markdown files
|
|
1106
|
+
const md_files_spellchecked = {};
|
|
1107
|
+
const mdPromiseArray = [];
|
|
1108
|
+
for (let i = 0; i < md_to_validate.length; i++) {
|
|
1109
|
+
errors[md_to_validate[i].relativePath] = [];
|
|
1110
|
+
messages[md_to_validate[i].relativePath] = [];
|
|
1111
|
+
warnings[md_to_validate[i].relativePath] = [];
|
|
1112
|
+
mdPromiseArray.push(md_to_validate[i]);
|
|
1113
|
+
}
|
|
1114
|
+
await Promise.all(
|
|
1115
|
+
mdPromiseArray.map(async (file) => {
|
|
1116
|
+
// Initiate maps for errors and verbose messages for markdown file
|
|
1117
|
+
const exclusions = await spellcheckContent(file, exclude_spellcheck);
|
|
1118
|
+
if (gen_exclude && exclusions.length > 0)
|
|
1119
|
+
excl_output.push({
|
|
1120
|
+
document_path: file.relativePath.replace(`.${file.extension}`, ""),
|
|
1121
|
+
words: exclusions,
|
|
1122
|
+
});
|
|
1123
|
+
md_files_spellchecked[
|
|
1124
|
+
file.relativePath.replace(`.${file.extension}`, "")
|
|
1125
|
+
] = true;
|
|
1126
|
+
}),
|
|
1127
|
+
);
|
|
1128
|
+
|
|
1129
|
+
// Perform rest of validation against HTML files
|
|
1130
|
+
const htmlPromiseArray = [];
|
|
1131
|
+
for (let i = 0; i < html_to_validate.length; i++) {
|
|
1132
|
+
errors[html_to_validate[i].relativePath] = [];
|
|
1133
|
+
messages[html_to_validate[i].relativePath] = [];
|
|
1134
|
+
warnings[html_to_validate[i].relativePath] = [];
|
|
1135
|
+
htmlPromiseArray.push(html_to_validate[i]);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
const global_links_checked = [];
|
|
1140
|
+
|
|
1141
|
+
for (const key in html_to_validate) {
|
|
1142
|
+
const file = html_to_validate[key];
|
|
1143
|
+
// Check for British spellings in static HTML content
|
|
1144
|
+
if (
|
|
1145
|
+
!md_files_spellchecked[
|
|
1146
|
+
file.relativePath.replace(`.${file.extension}`, "")
|
|
1147
|
+
]
|
|
1148
|
+
) {
|
|
1149
|
+
const exclusions = await spellcheckContent(file, exclude_spellcheck);
|
|
1150
|
+
if (gen_exclude && exclusions.length > 0)
|
|
1151
|
+
excl_output.push({
|
|
1152
|
+
document_path: file.relativePath.replace(
|
|
1153
|
+
`.${file.extension}`,
|
|
1154
|
+
"",
|
|
1155
|
+
),
|
|
1156
|
+
words: exclusions,
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const links = getLinks(file);
|
|
1161
|
+
if (links.href.length === 0) {
|
|
1162
|
+
messages[file.relativePath].push("No links found in file");
|
|
1163
|
+
} else {
|
|
1164
|
+
console.log(`\r\nChecking ${links.href.length} Links in ${file.relativePath}`);
|
|
1165
|
+
await checkLinks(source_path, file, links.href, hdocbook_config, hdocbook_project, global_links_checked, output_links);
|
|
1166
|
+
}
|
|
1167
|
+
if (links.img.length === 0) {
|
|
1168
|
+
messages[file.relativePath].push("No images found in file");
|
|
1169
|
+
} else {
|
|
1170
|
+
await checkImages(source_path, file, links.img);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// Check for multiple H1 tags
|
|
1174
|
+
await checkTags(file);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
if (gen_exclude) console.log(JSON.stringify(excl_output, null, 2));
|
|
1178
|
+
|
|
1179
|
+
if (verbose) {
|
|
1180
|
+
console.log("\r\n-------------");
|
|
1181
|
+
console.log(" Verbose ");
|
|
1182
|
+
console.log("-------------");
|
|
1183
|
+
for (const key in messages) {
|
|
1184
|
+
if (Object.hasOwn(messages, key) && messages[key].length > 0) {
|
|
1185
|
+
console.log(`\r\nMessage output for ${key}`);
|
|
1186
|
+
for (let i = 0; i < messages[key].length; i++) {
|
|
1187
|
+
console.log(` - ${messages[key][i]}`);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
console.log("\r\n-----------------------");
|
|
1194
|
+
console.log(" Validation Output ");
|
|
1195
|
+
console.log("-----------------------");
|
|
1196
|
+
let warning_count = 0;
|
|
1197
|
+
for (const key in warnings) {
|
|
1198
|
+
if (Object.hasOwn(warnings, key) && warnings[key].length > 0) {
|
|
1199
|
+
for (let i = 0; i < warnings[key].length; i++) {
|
|
1200
|
+
console.info(`[WARNING] ${key} - ${warnings[key][i]}`);
|
|
1201
|
+
warning_count++;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
if (warning_count > 0) {
|
|
1206
|
+
console.info(`\r\n${warning_count} Validation Warnings Found (non-fatal)\r\n`);
|
|
1207
|
+
}
|
|
1208
|
+
if (Object.keys(errors).length > 0) {
|
|
1209
|
+
let error_count = 0;
|
|
1210
|
+
for (const key in errors) {
|
|
1211
|
+
if (Object.hasOwn(errors, key) && errors[key].length > 0) {
|
|
1212
|
+
for (let i = 0; i < errors[key].length; i++) {
|
|
1213
|
+
console.error(`${key} - ${errors[key][i]}`);
|
|
1214
|
+
error_count++;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
if (error_count > 0) {
|
|
1219
|
+
console.error(`\r\n${error_count} Validation Errors Found`);
|
|
1220
|
+
if (verbose) {
|
|
1221
|
+
console.log("\n");
|
|
1222
|
+
console.error(JSON.stringify(exclude_spellcheck_output, null, 2));
|
|
1223
|
+
}
|
|
1224
|
+
return false;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
console.log("\r\nNo Validation Errors Found!\n");
|
|
1229
|
+
return true;
|
|
1230
|
+
};
|
|
1231
|
+
})();
|