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