hdoc-tools 0.19.7 → 0.20.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/hdoc-module.js CHANGED
@@ -1,723 +1,708 @@
1
- (function () {
2
- "use strict";
3
-
4
- const axios = require("axios"),
5
- axiosRetry = require("axios-retry").default,
6
- cheerio = require("cheerio"),
7
- fs = require("fs"),
8
- https = require("https"),
9
- htmlentities = require("html-entities"),
10
- html2text = require("html-to-text"),
11
- { JSDOM } = require("jsdom"),
12
- path = require("path"),
13
- wordsCount = require("words-count").default;
14
-
15
- let includesCache = {},
16
- agent = new https.Agent({
17
- rejectUnauthorized: false,
18
- }),
19
- retried = false;
20
-
21
- axiosRetry(axios, {
22
- retries: 5,
23
- shouldResetTimeout: true,
24
- retryCondition: (error) => {
25
- return !error.response.status;
26
- },
27
- onRetry: (retryCount, error, requestConfig) => {
28
- retried = true;
29
- console.info(
30
- `\n[WARNING] API call failed - ${error.message}\nEndpoint: ${requestConfig.url}\nRetrying: ${retryCount}`
31
- );
32
- },
33
- });
34
-
35
- exports.content_type_for_ext = function (ext) {
36
- switch (ext) {
37
- case ".z":
38
- return "application/x-compress";
39
- case ".tgz":
40
- return "application/x-compressed";
41
- case ".gz":
42
- return "application/x-gzip";
43
- case ".zip":
44
- return "application/x-zip-compressed";
45
- case ".xml":
46
- return "application/xml";
47
- case ".bmp":
48
- return "image/bmp";
49
- case ".gif":
50
- return "image/gif";
51
- case ".jpg":
52
- return "image/jpeg";
53
- case ".png":
54
- return "image/png";
55
- case ".tiff":
56
- return "image/tiff";
57
- case ".ico":
58
- return "image/x-icon";
59
- case ".png":
60
- return "image/png";
61
- case ".svg":
62
- return "image/svg+xml";
63
- case ".css":
64
- return "text/css";
65
- case ".htm":
66
- case ".html":
67
- return "text/html";
68
- case ".txt":
69
- return "text/plain";
70
- case ".md":
71
- return "text/plain";
72
- case ".json":
73
- return "application/json";
74
- case ".js":
75
- return "application/javascript";
76
- default:
77
- return "application/octet-stream";
78
- }
79
- };
80
-
81
- exports.valid_url = function (url) {
82
- const stringIsAValidUrl = (s) => {
83
- try {
84
- const url_obj = new URL(s);
85
- return url_obj;
86
- } catch (err) {
87
- return false;
88
- }
89
- };
90
- return stringIsAValidUrl(url);
91
- };
92
-
93
- exports.expand_variables = function (text, docId = "") {
94
- if (docId !== "") {
95
- text = text.replaceAll("{{DOC_ID}}", docId);
96
- }
97
- text = text.replaceAll("{{BUILD_NUMBER}}", "0");
98
-
99
- let build_date = new Date().toISOString();
100
- build_date = build_date.replace("T", " ");
101
- build_date = build_date.substring(0, 19);
102
- text = text.replaceAll("{{BUILD_DATE}}", build_date);
103
- return text;
104
- };
105
-
106
- exports.process_includes = async function (file_path, body, source_path) {
107
- let response = {
108
- body: "",
109
- found: 0,
110
- success: 0,
111
- failed: 0,
112
- included: [],
113
- errors: [],
114
- };
115
-
116
- // Search body for INCLUDEs
117
- const regexp = /\[\[INCLUDE .*]]/g;
118
- const body_array = [...body.matchAll(regexp)];
119
-
120
- for (let i = 0; i < body_array.length; i++) {
121
- response.found++;
122
-
123
- // Extract include data from array
124
- const include_value = body_array[i][0];
125
-
126
- let link;
127
- try {
128
- link = include_value.split(" ")[1];
129
- link = link.substring(0, link.length - 2);
130
- } catch (e) {
131
- response.failed++;
132
- response.errors.push(
133
- `Error parsing INCLUDE [${include_value}] from [${file_path}]: ${err}`
134
- );
135
- continue;
136
- }
137
-
138
- if (
139
- (link.startsWith("http://") || link.startsWith("https://")) &&
140
- includesCache[link] !== undefined
141
- ) {
142
- console.log(`Serving From Cache: ${link}`);
143
- body = body.replace(include_value, includesCache[link]);
144
- response.success++;
145
- continue;
146
- }
147
-
148
- // Validate link in INCLUDE
149
- let file_content;
150
- if (link.startsWith("http://") || link.startsWith("https://")) {
151
- // Remote content to include
152
- try {
153
- new URL(link);
154
- } catch (e) {
155
- response.failed++;
156
- response.errors.push(
157
- `Error validating INCLUDE link [${link}] from [${file_path}]: ${e}`
158
- );
159
- continue;
160
- }
161
-
162
- try {
163
- const file_response = await axios.get(link);
164
- if (retried) {
165
- retried = false;
166
- console.log(`API call retry success!`);
167
- }
168
- if (file_response.status === 200) {
169
- file_content = file_response.data;
170
- } else {
171
- throw `Unexpected Status ${file_response.status}`;
172
- }
173
- } catch (e) {
174
- response.failed++;
175
- response.errors.push(
176
- `Error getting INCLUDE link content [${link}] from [${file_path}]: ${e}`
177
- );
178
- continue;
179
- }
180
- console.log(`Included From Remote Source: ${link}`);
181
- } else {
182
- // Local content to include
183
- try {
184
- file_content = fs.readFileSync(path.join(source_path, link), "utf8");
185
- } catch (e) {
186
- response.failed++;
187
- response.errors.push(
188
- `Error getting INCLUDE file [${link}] from [${file_path}]: ${e}`
189
- );
190
- continue;
191
- }
192
- console.log(`Included From Local Source: ${link}`);
193
- }
194
- response.success++;
195
- includesCache[link] = file_content;
196
- body = body.replace(include_value, file_content);
197
- }
198
- response.body = body;
199
- return response;
200
- };
201
-
202
- // Takes html, returns the first heading detected in the order provided in h_to_search
203
- // Looks for h1 tags first, then hX, hY, hZ in order
204
- exports.getFirstHTMLHeading = function (html_body, h_to_search = ["h1"]) {
205
- const $ = cheerio.load(html_body);
206
- for (let i = 0; i < h_to_search.length; i++) {
207
- let heading = $(h_to_search[i])
208
- .map(function (i) {
209
- return $(this);
210
- })
211
- .get();
212
- if (heading.length > 0) {
213
- return heading[0];
214
- }
215
- }
216
- return false;
217
- };
218
-
219
- const makeAnchorIdFriendly = function (str) {
220
- return (
221
- "hb-doc-anchor-" +
222
- str // Add prefix
223
- .toLowerCase() // Convert to lowercase
224
- .trim() // Trim leading and trailing spaces
225
- .replace(/[^a-z0-9\s-]/g, "") // Remove all non-alphanumeric characters except spaces and hyphens
226
- .replace(/\s+/g, "-") // Replace spaces with hyphens
227
- .replace(/-+/g, "-")
228
- ); // Replace multiple hyphens with a single hyphen
229
- };
230
-
231
- // Processes HTML, wraps h2 and h3 tags and their content in divs with an id matching that of the h text
232
- exports.wrapHContent = function (htmlContent) {
233
- const dom = new JSDOM(htmlContent);
234
- const document = dom.window.document;
235
-
236
- let nodes = Array.from(document.body.childNodes); // Convert NodeList to Array for easier manipulation
237
- let newContent = document.createDocumentFragment(); // Create a document fragment to hold the new structure
238
-
239
- let currentH2Div = null;
240
- let currentH3Div = null;
241
-
242
- nodes.forEach((node) => {
243
- if (node.nodeType === dom.window.Node.ELEMENT_NODE) {
244
- if (node.tagName.toLowerCase() === "h2") {
245
- // When an <h2> is found, close the current <h2> div (if any) and start a new one
246
- if (currentH2Div) {
247
- if (currentH3Div) {
248
- currentH2Div.appendChild(currentH3Div);
249
- currentH3Div = null;
250
- }
251
- newContent.appendChild(currentH2Div);
252
- }
253
- currentH2Div = document.createElement("div");
254
- currentH2Div.id = makeAnchorIdFriendly(node.textContent.trim());
255
- currentH2Div.appendChild(node);
256
- } else if (node.tagName.toLowerCase() === "h3") {
257
- // When an <h3> is found, close the current <h3> div (if any) and start a new one
258
- if (currentH3Div) {
259
- if (currentH2Div) {
260
- currentH2Div.appendChild(currentH3Div);
261
- } else {
262
- newContent.appendChild(currentH3Div);
263
- }
264
- }
265
- currentH3Div = document.createElement("div");
266
- currentH3Div.id = makeAnchorIdFriendly(node.textContent.trim());
267
- currentH3Div.appendChild(node);
268
- } else {
269
- if (currentH3Div) {
270
- currentH3Div.appendChild(node);
271
- } else if (currentH2Div) {
272
- currentH2Div.appendChild(node);
273
- } else {
274
- newContent.appendChild(node);
275
- }
276
- }
277
- } else {
278
- if (currentH3Div) {
279
- currentH3Div.appendChild(node);
280
- } else if (currentH2Div) {
281
- currentH2Div.appendChild(node);
282
- } else {
283
- newContent.appendChild(node);
284
- }
285
- }
286
- });
287
-
288
- // Append the last <h3> div if any
289
- if (currentH3Div) {
290
- if (currentH2Div) {
291
- currentH2Div.appendChild(currentH3Div);
292
- } else {
293
- newContent.appendChild(currentH3Div);
294
- }
295
- }
296
-
297
- // Append the last <h2> div if any
298
- if (currentH2Div) {
299
- newContent.appendChild(currentH2Div);
300
- }
301
-
302
- // Replace the old body content with the new content
303
- document.body.innerHTML = "";
304
- document.body.appendChild(newContent);
305
-
306
- // Serialize the document back to HTML and save it to a new file (for example: 'output.html')
307
- const outputHtml = dom.serialize();
308
- return outputHtml;
309
- };
310
-
311
- exports.getIDDivs = function (html_body) {
312
- const $ = cheerio.load(html_body, {
313
- decodeEntities: false,
314
- });
315
-
316
- const divs = [];
317
-
318
- $("div").each(function (i, element) {
319
- if (
320
- $(this).attr("id") &&
321
- $(this).attr("id").startsWith("hb-doc-anchor-")
322
- ) {
323
- divs.push({
324
- id: $(this).attr("id"),
325
- html: $(this).html(),
326
- text: $(this).text(),
327
- });
328
- }
329
- });
330
- return divs;
331
- };
332
-
333
- exports.getHTMLFrontmatterHeader = function (html_body) {
334
- let response = {
335
- fm_header: "",
336
- fm_properties: {},
337
- };
338
- const $ = cheerio.load(html_body, {
339
- decodeEntities: false,
340
- });
341
- if (
342
- $._root &&
343
- $._root.children &&
344
- $._root.children instanceof Array &&
345
- $._root.children.length > 0
346
- ) {
347
- $._root.children.forEach(function (child) {
348
- if (
349
- child.type === "comment" &&
350
- child.data &&
351
- child.data.startsWith("[[FRONTMATTER")
352
- ) {
353
- // We have a Frontmatter header - return each property in an array
354
- const fm_properties = child.data.split(/\r?\n/);
355
- for (let i = 0; i < fm_properties.length; i++) {
356
- if (fm_properties[i].includes(":")) {
357
- const property_details = fm_properties[i].split(/:(.*)/s);
358
- if (property_details.length > 1) {
359
- let prop_val = property_details[1].trim();
360
- if (/^".*"$/.test(prop_val)) {
361
- prop_val = prop_val.substring(1, prop_val.length - 1);
362
- }
363
- if (property_details[0].trim().toLowerCase() === "title") {
364
- prop_val = htmlentities.decode(prop_val);
365
- }
366
- response.fm_properties[
367
- property_details[0].trim().toLowerCase()
368
- ] = prop_val;
369
- }
370
- }
371
- }
372
-
373
- // And return the header as a whole so it can be easily replaced
374
- response.fm_header = child.data;
375
- }
376
- });
377
- }
378
-
379
- return response;
380
- };
381
-
382
- exports.truncate_string = function (str, n, useWordBoundary) {
383
- if (str.length <= n) {
384
- return str;
385
- }
386
- const subString = str.slice(0, n - 1);
387
- return (
388
- (useWordBoundary
389
- ? subString.slice(0, subString.lastIndexOf(" "))
390
- : subString) + "…"
391
- );
392
- };
393
-
394
- exports.get_html_read_time = function (html) {
395
- // Get word count
396
- const text = html2text.convert(html, {
397
- wordwrap: null,
398
- });
399
- const word_count = wordsCount(text);
400
- if (word_count === 0) return 0;
401
-
402
- // Calculate the read time - divide the word count by 200
403
- let read_time = Math.round(word_count / 200);
404
- if (read_time === 0) read_time = 1;
405
- return read_time;
406
- };
407
-
408
- exports.get_github_api_path = function (repo, relative_path) {
409
- if (repo) {
410
- repo = repo.endsWith("/") ? repo.slice(0, -1) : repo;
411
- let github_paths = {};
412
- github_paths.api_path = repo.replace(
413
- "https://github.com/",
414
- "https://api.github.com/repos/"
415
- );
416
- github_paths.api_path +=
417
- "/commits?path=" +
418
- encodeURIComponent(
419
- "/" + relative_path.replace("\\\\", "/").replace("\\", "/")
420
- );
421
- github_paths.edit_path =
422
- repo +
423
- "/blob/main/" +
424
- relative_path.replace("\\\\", "/").replace("\\", "/");
425
- return github_paths;
426
- }
427
- return "";
428
- };
429
-
430
- const get_github_contributors_path = function (repo) {
431
- repo = repo.endsWith("/") ? repo.slice(0, -1) : repo;
432
- let github_paths = {};
433
- github_paths.api_path = repo.replace(
434
- "https://github.com/",
435
- "https://api.github.com/repos/"
436
- );
437
- github_paths.api_path += "/contributors";
438
- return github_paths;
439
- };
440
-
441
- exports.get_github_contributors = async function (
442
- github_url,
443
- github_api_token,
444
- repo
445
- ) {
446
- let response = {
447
- success: false,
448
- error: "",
449
- contributors: [],
450
- contributor_count: 0,
451
- last_commit_date: "",
452
- };
453
- let contributors = {};
454
-
455
- let request_options = {
456
- headers: {
457
- "User-Agent": "HornbillDocsBuild",
458
- "Cache-Control": "no-cache",
459
- Host: "api.github.com",
460
- Accept: "application/json",
461
- },
462
- timeout: 5000,
463
- };
464
- if (github_api_token !== "") {
465
- request_options.headers.authorization = `Bearer ${github_api_token}`;
466
- }
467
- let github_response;
468
- try {
469
- github_response = await axios.get(github_url, request_options);
470
- if (retried) {
471
- retried = false;
472
- console.log(`API call retry success!`);
473
- }
474
- } catch (err) {
475
- if (err.response) {
476
- if (err.response.status !== 403 && err.response.status !== 401) {
477
- response.error = err;
478
- return response;
479
- } else {
480
- github_response = err.response;
481
- }
482
- } else {
483
- response.error = `Unexpected response from GitHub for [${github_url}:\n${JSON.stringify(
484
- err
485
- )}]`;
486
- }
487
- }
488
- if (github_response.status === 200) {
489
- response.success = true;
490
- let commits = github_response.data;
491
- commits.forEach(function (commit) {
492
- if (
493
- commit.committer &&
494
- commit.committer.type &&
495
- commit.committer.type.toLowerCase() === "user" &&
496
- commit.committer.login.toLowerCase() !== "web-flow"
497
- ) {
498
- if (!contributors[commit.committer.id]) {
499
- response.contributor_count++;
500
- contributors[commit.committer.id] = {
501
- login: commit.committer.login,
502
- avatar_url: commit.committer.avatar_url,
503
- html_url: commit.committer.html_url,
504
- name: commit.commit.committer.name,
505
- };
506
- }
507
- if (response.last_commit_date !== "") {
508
- const new_commit_date = new Date(commit.commit.committer.date);
509
- const exist_commit_date = new Date(response.last_commit_date);
510
- if (new_commit_date > exist_commit_date)
511
- response.last_commit_date = commit.commit.committer.date;
512
- } else {
513
- response.last_commit_date = commit.commit.committer.date;
514
- }
515
- } else if (commit.author && commit.author.id) {
516
- if (!contributors[commit.author.id]) {
517
- response.contributor_count++;
518
- contributors[commit.author.id] = {
519
- login: commit.author.login,
520
- avatar_url: commit.author.avatar_url,
521
- html_url: commit.author.html_url,
522
- name: commit.commit.author.name,
523
- };
524
- }
525
- if (response.last_commit_date !== "") {
526
- const new_commit_date = new Date(commit.commit.author.date);
527
- const exist_commit_date = new Date(response.last_commit_date);
528
- if (new_commit_date > exist_commit_date)
529
- response.last_commit_date = commit.commit.author.date;
530
- } else {
531
- response.last_commit_date = commit.commit.author.date;
532
- }
533
- }
534
- });
535
- for (const key in contributors) {
536
- if (contributors.hasOwnProperty(key)) {
537
- response.contributors.push(contributors[key]);
538
- }
539
- }
540
- } else if (github_response.status === 403) {
541
- // Private repo, fine-grained permissions don't yet support getting commits without content, get list from meta permissions
542
- const contrib_url = get_github_contributors_path(repo).api_path;
543
- try {
544
- github_response = await axios.get(contrib_url, request_options);
545
- if (retried) {
546
- retried = false;
547
- console.log(`API call retry success!`);
548
- }
549
- } catch (err) {
550
- if (err.response && err.response.status) {
551
- if (err.response.status !== 200) {
552
- response.error = err;
553
- return response;
554
- }
555
- } else {
556
- response.error = `Unexpected response from GitHub for [${contrib_url}:\n${JSON.stringify(
557
- err
558
- )}]`;
559
- }
560
- }
561
- if (github_response.status === 200) {
562
- response.success = true;
563
- let commits = github_response.data;
564
- commits.forEach(function (commit) {
565
- if (
566
- commit.type &&
567
- commit.type.toLowerCase() === "user" &&
568
- commit.login.toLowerCase() !== "web-flow"
569
- ) {
570
- if (!contributors[commit.id]) {
571
- response.contributor_count++;
572
- contributors[commit.id] = {
573
- login: commit.login,
574
- avatar_url: commit.avatar_url,
575
- html_url: commit.html_url,
576
- name: commit.name ? commit.name : commit.login,
577
- };
578
- }
579
- if (
580
- response.last_commit_date !== "" &&
581
- response.last_commit_date !== "No Commit Date Available"
582
- ) {
583
- const new_commit_date = new Date(commit.date);
584
- const exist_commit_date = new Date(response.last_commit_date);
585
- if (new_commit_date > exist_commit_date)
586
- response.last_commit_date = commit.date;
587
- } else {
588
- response.last_commit_date = commit.date
589
- ? commit.date
590
- : "No Commit Date Available";
591
- }
592
- }
593
- });
594
- for (const key in contributors) {
595
- if (contributors.hasOwnProperty(key)) {
596
- response.contributors.push(contributors[key]);
597
- }
598
- }
599
- }
600
- } else {
601
- response.error = `Unexpected Status: ${github_response.status}.`;
602
- }
603
- return response;
604
- };
605
-
606
- exports.strip_drafts = function (nav_items) {
607
- let return_nav = nav_items;
608
- recurse_nav(return_nav);
609
- return return_nav;
610
- };
611
-
612
- const recurse_nav = function (nav_items) {
613
- for (const key in nav_items) {
614
- if (nav_items[key].draft) {
615
- nav_items.splice(key, 1);
616
- recurse_nav(nav_items);
617
- } else if (nav_items[key].items) {
618
- recurse_nav(nav_items[key].items);
619
- }
620
- }
621
- };
622
-
623
- exports.build_breadcrumbs = function (nav_items) {
624
- let response = {
625
- bc: {},
626
- errors: [],
627
- };
628
- const buildBreadcrumb = (items, parentLinks) => {
629
- // Process parent links
630
- let parentlink = true;
631
- if (parentLinks.length > 0) {
632
- if (parentLinks[0].link === undefined || parentLinks[0].link === "")
633
- parentlink = false;
634
-
635
- for (let i = 1; i < 10; i++) {
636
- if (
637
- parentLinks[i] &&
638
- parentLinks[i].link === undefined &&
639
- items.length > 0 &&
640
- items[0].link
641
- ) {
642
- parentLinks[i].link = items[0].link;
643
- }
644
- }
645
- }
646
-
647
- // Loop through items, build breadcrumb
648
- for (let i = 0; i < items.length; i++) {
649
- if (!items[i].text) {
650
- response.errors.push(
651
- `The following Nav Item is missing its text property: ${JSON.stringify(
652
- items[i]
653
- )}`
654
- );
655
- }
656
-
657
- if (!items[i].link && !items[i].items) {
658
- response.errors.push(
659
- `The following Nav Item has no link or items property: ${JSON.stringify(
660
- items[i]
661
- )}`
662
- );
663
- }
664
- const item = items[i];
665
- if (!parentlink && item.link) {
666
- parentLinks[0].link = item.link;
667
- parentlink = true;
668
- }
669
- const { text, link, items: subItems } = item;
670
- const breadcrumb = [...parentLinks, { text, link }];
671
-
672
- if (link) {
673
- response.bc[link] = breadcrumb;
674
- }
675
-
676
- if (subItems) {
677
- buildBreadcrumb(subItems, breadcrumb);
678
- }
679
- }
680
- };
681
-
682
- buildBreadcrumb(nav_items, []);
683
- return response;
684
- };
685
-
686
- exports.load_product_families = async function () {
687
- let response = {
688
- success: false,
689
- prod_families: {},
690
- prods_supported: [],
691
- errors: "",
692
- };
693
- const prod_families_url = "https://docs.hornbill.com/_books/products.json";
694
- for (let i = 1; i < 4; i++) {
695
- try {
696
- const prods = await axios.get(prod_families_url, {
697
- httpsAgent: agent,
698
- timeout: 5000,
699
- });
700
- if (prods.status === 200) {
701
- response.prod_families = prods.data;
702
- response.prods_supported = [];
703
- for (let i = 0; i < response.prod_families.products.length; i++) {
704
- response.prods_supported.push(
705
- response.prod_families.products[i].id
706
- );
707
- }
708
- response.success = true;
709
- break;
710
- } else {
711
- throw `Unexpected status - ${prods.status} ${prods.statusText}`;
712
- }
713
- } catch (e) {
714
- if (response.errors === "")
715
- response.errors = `Request to ${prod_families_url} failed:`;
716
- response.errors += `\nAttempt ${i} - Error returning product families: ${e}`;
717
- // Wait 2 seconds and try again
718
- await new Promise((r) => setTimeout(r, 2000));
719
- }
720
- }
721
- return response;
722
- };
723
- })();
1
+ (() => {
2
+ const axios = require("axios");
3
+ const axiosRetry = require("axios-retry").default;
4
+ const cheerio = require("cheerio");
5
+ const fs = require("node:fs");
6
+ const https = require("node:https");
7
+ const htmlentities = require("html-entities");
8
+ const html2text = require("html-to-text");
9
+ const { JSDOM } = require("jsdom");
10
+ const path = require("node:path");
11
+ const wordsCount = require("words-count").default;
12
+
13
+ const includesCache = {};
14
+ const agent = new https.Agent({
15
+ rejectUnauthorized: false,
16
+ });
17
+
18
+ let retried = false;
19
+
20
+ axiosRetry(axios, {
21
+ retries: 5,
22
+ shouldResetTimeout: true,
23
+ retryCondition: (error) => {
24
+ return !error.response.status;
25
+ },
26
+ onRetry: (retryCount, error, requestConfig) => {
27
+ retried = true;
28
+ console.info(
29
+ `\n[WARNING] API call failed - ${error.message}\nEndpoint: ${requestConfig.url}\nRetrying: ${retryCount}`,
30
+ );
31
+ },
32
+ });
33
+
34
+ exports.content_type_for_ext = (ext) => {
35
+ switch (ext) {
36
+ case ".z":
37
+ return "application/x-compress";
38
+ case ".tgz":
39
+ return "application/x-compressed";
40
+ case ".gz":
41
+ return "application/x-gzip";
42
+ case ".zip":
43
+ return "application/x-zip-compressed";
44
+ case ".xml":
45
+ return "application/xml";
46
+ case ".bmp":
47
+ return "image/bmp";
48
+ case ".gif":
49
+ return "image/gif";
50
+ case ".jpg":
51
+ return "image/jpeg";
52
+ case ".png":
53
+ return "image/png";
54
+ case ".tiff":
55
+ return "image/tiff";
56
+ case ".ico":
57
+ return "image/x-icon";
58
+ case ".svg":
59
+ return "image/svg+xml";
60
+ case ".css":
61
+ return "text/css";
62
+ case ".htm":
63
+ case ".html":
64
+ return "text/html";
65
+ case ".txt":
66
+ return "text/plain";
67
+ case ".md":
68
+ return "text/plain";
69
+ case ".json":
70
+ return "application/json";
71
+ case ".js":
72
+ return "application/javascript";
73
+ default:
74
+ return "application/octet-stream";
75
+ }
76
+ };
77
+
78
+ exports.valid_url = (url) => {
79
+ const stringIsAValidUrl = (s) => {
80
+ try {
81
+ const url_obj = new URL(s);
82
+ return url_obj;
83
+ } catch (err) {
84
+ return false;
85
+ }
86
+ };
87
+ return stringIsAValidUrl(url);
88
+ };
89
+
90
+ exports.expand_variables = (text, docId = "") => {
91
+ let clean_text = text;
92
+ if (docId !== "") {
93
+ clean_text = clean_text.replaceAll("{{DOC_ID}}", docId);
94
+ }
95
+ clean_text = clean_text.replaceAll("{{BUILD_NUMBER}}", "0");
96
+
97
+ let build_date = new Date().toISOString();
98
+ build_date = build_date.replace("T", " ");
99
+ build_date = build_date.substring(0, 19);
100
+ clean_text = clean_text.replaceAll("{{BUILD_DATE}}", build_date);
101
+ return clean_text;
102
+ };
103
+
104
+ exports.process_includes = async (file_path, body, source_path) => {
105
+ const response = {
106
+ body: body,
107
+ found: 0,
108
+ success: 0,
109
+ failed: 0,
110
+ included: [],
111
+ errors: [],
112
+ };
113
+
114
+ // Search body for INCLUDEs
115
+ const regexp = /\[\[INCLUDE .*]]/g;
116
+ const body_array = [...response.body.matchAll(regexp)];
117
+
118
+ for (let i = 0; i < body_array.length; i++) {
119
+ response.found++;
120
+
121
+ // Extract include data from array
122
+ const include_value = body_array[i][0];
123
+
124
+ let link;
125
+ try {
126
+ link = include_value.split(" ")[1];
127
+ link = link.substring(0, link.length - 2);
128
+ } catch (e) {
129
+ response.failed++;
130
+ response.errors.push(
131
+ `Error parsing INCLUDE [${include_value}] from [${file_path}]: ${err}`,
132
+ );
133
+ continue;
134
+ }
135
+
136
+ if (
137
+ (link.startsWith("http://") || link.startsWith("https://")) &&
138
+ includesCache[link] !== undefined
139
+ ) {
140
+ console.log(`Serving From Cache: ${link}`);
141
+ response.body = response.body.replace(
142
+ include_value,
143
+ includesCache[link],
144
+ );
145
+ response.success++;
146
+ continue;
147
+ }
148
+
149
+ // Validate link in INCLUDE
150
+ let file_content;
151
+ if (link.startsWith("http://") || link.startsWith("https://")) {
152
+ // Remote content to include
153
+ try {
154
+ new URL(link);
155
+ } catch (e) {
156
+ response.failed++;
157
+ response.errors.push(
158
+ `Error validating INCLUDE link [${link}] from [${file_path}]: ${e}`,
159
+ );
160
+ continue;
161
+ }
162
+
163
+ try {
164
+ const file_response = await axios.get(link);
165
+ if (retried) {
166
+ retried = false;
167
+ console.log("API call retry success!");
168
+ }
169
+ if (file_response.status === 200) {
170
+ file_content = file_response.data;
171
+ } else {
172
+ throw `Unexpected Status ${file_response.status}`;
173
+ }
174
+ } catch (e) {
175
+ response.failed++;
176
+ response.errors.push(
177
+ `Error getting INCLUDE link content [${link}] from [${file_path}]: ${e}`,
178
+ );
179
+ continue;
180
+ }
181
+ console.log(`Included From Remote Source: ${link}`);
182
+ } else {
183
+ // Local content to include
184
+ try {
185
+ file_content = fs.readFileSync(path.join(source_path, link), "utf8");
186
+ } catch (e) {
187
+ response.failed++;
188
+ response.errors.push(
189
+ `Error getting INCLUDE file [${link}] from [${file_path}]: ${e}`,
190
+ );
191
+ continue;
192
+ }
193
+ console.log(`Included From Local Source: ${link}`);
194
+ }
195
+ response.success++;
196
+ includesCache[link] = file_content;
197
+ response.body = response.body.replace(include_value, file_content);
198
+ }
199
+ return response;
200
+ };
201
+
202
+ // Takes html, returns the first heading detected in the order provided in h_to_search
203
+ // Looks for h1 tags first, then hX, hY, hZ in order
204
+ exports.getFirstHTMLHeading = (html_body, h_to_search = ["h1"]) => {
205
+ const $ = cheerio.load(html_body);
206
+ for (let i = 0; i < h_to_search.length; i++) {
207
+ const heading = $(h_to_search[i])
208
+ .map(function (i) {
209
+ return $(this);
210
+ })
211
+ .get();
212
+ if (heading.length > 0) {
213
+ return heading[0];
214
+ }
215
+ }
216
+ return false;
217
+ };
218
+
219
+ const makeAnchorIdFriendly = (str) => {
220
+ return `hb-doc-anchor-${str // Add prefix
221
+ .toLowerCase() // Convert to lowercase
222
+ .trim() // Trim leading and trailing spaces
223
+ .replace(/[^a-z0-9\s-]/g, "") // Remove all non-alphanumeric characters except spaces and hyphens
224
+ .replace(/\s+/g, "-") // Replace spaces with hyphens
225
+ .replace(/-+/g, "-")}`; // Replace multiple hyphens with a single hyphen
226
+ };
227
+
228
+ // Processes HTML, wraps h2 and h3 tags and their content in divs with an id matching that of the h text
229
+ exports.wrapHContent = (htmlContent) => {
230
+ const dom = new JSDOM(htmlContent);
231
+ const document = dom.window.document;
232
+
233
+ const nodes = Array.from(document.body.childNodes); // Convert NodeList to Array for easier manipulation
234
+ const newContent = document.createDocumentFragment(); // Create a document fragment to hold the new structure
235
+
236
+ let currentH2Div = null;
237
+ let currentH3Div = null;
238
+
239
+ for (const node of nodes) {
240
+ if (node.nodeType === dom.window.Node.ELEMENT_NODE) {
241
+ if (node.tagName.toLowerCase() === "h2") {
242
+ // When an <h2> is found, close the current <h2> div (if any) and start a new one
243
+ if (currentH2Div) {
244
+ if (currentH3Div) {
245
+ currentH2Div.appendChild(currentH3Div);
246
+ currentH3Div = null;
247
+ }
248
+ newContent.appendChild(currentH2Div);
249
+ }
250
+ currentH2Div = document.createElement("div");
251
+ currentH2Div.id = makeAnchorIdFriendly(node.textContent.trim());
252
+ currentH2Div.appendChild(node);
253
+ } else if (node.tagName.toLowerCase() === "h3") {
254
+ // When an <h3> is found, close the current <h3> div (if any) and start a new one
255
+ if (currentH3Div) {
256
+ if (currentH2Div) {
257
+ currentH2Div.appendChild(currentH3Div);
258
+ } else {
259
+ newContent.appendChild(currentH3Div);
260
+ }
261
+ }
262
+ currentH3Div = document.createElement("div");
263
+ currentH3Div.id = makeAnchorIdFriendly(node.textContent.trim());
264
+ currentH3Div.appendChild(node);
265
+ } else {
266
+ if (currentH3Div) {
267
+ currentH3Div.appendChild(node);
268
+ } else if (currentH2Div) {
269
+ currentH2Div.appendChild(node);
270
+ } else {
271
+ newContent.appendChild(node);
272
+ }
273
+ }
274
+ } else {
275
+ if (currentH3Div) {
276
+ currentH3Div.appendChild(node);
277
+ } else if (currentH2Div) {
278
+ currentH2Div.appendChild(node);
279
+ } else {
280
+ newContent.appendChild(node);
281
+ }
282
+ }
283
+ }
284
+
285
+ // Append the last <h3> div if any
286
+ if (currentH3Div) {
287
+ if (currentH2Div) {
288
+ currentH2Div.appendChild(currentH3Div);
289
+ } else {
290
+ newContent.appendChild(currentH3Div);
291
+ }
292
+ }
293
+
294
+ // Append the last <h2> div if any
295
+ if (currentH2Div) {
296
+ newContent.appendChild(currentH2Div);
297
+ }
298
+
299
+ // Replace the old body content with the new content
300
+ document.body.innerHTML = "";
301
+ document.body.appendChild(newContent);
302
+
303
+ // Serialize the document back to HTML and save it to a new file (for example: 'output.html')
304
+ const outputHtml = dom.serialize();
305
+ return outputHtml;
306
+ };
307
+
308
+ exports.getIDDivs = (html_body) => {
309
+ const $ = cheerio.load(html_body, {
310
+ decodeEntities: false,
311
+ });
312
+
313
+ const divs = [];
314
+
315
+ $("div").each(function (i, element) {
316
+ if ($(this).attr("id")?.startsWith("hb-doc-anchor-")) {
317
+ divs.push({
318
+ id: $(this).attr("id"),
319
+ html: $(this).html(),
320
+ text: $(this).text(),
321
+ });
322
+ }
323
+ });
324
+ return divs;
325
+ };
326
+
327
+ exports.getHTMLFrontmatterHeader = (html_body) => {
328
+ const response = {
329
+ fm_header: "",
330
+ fm_properties: {},
331
+ };
332
+ const $ = cheerio.load(html_body, {
333
+ decodeEntities: false,
334
+ });
335
+ if (
336
+ $._root?.children &&
337
+ Array.isArray($._root.children) &&
338
+ $._root.children.length > 0
339
+ ) {
340
+ for (const child of $._root.children) {
341
+ if (
342
+ child.type === "comment" &&
343
+ child.data &&
344
+ child.data.startsWith("[[FRONTMATTER")
345
+ ) {
346
+ // We have a Frontmatter header - return each property in an array
347
+ const fm_properties = child.data.split(/\r?\n/);
348
+ for (let i = 0; i < fm_properties.length; i++) {
349
+ if (fm_properties[i].includes(":")) {
350
+ const property_details = fm_properties[i].split(/:(.*)/s);
351
+ if (property_details.length > 1) {
352
+ let prop_val = property_details[1].trim();
353
+ if (/^".*"$/.test(prop_val)) {
354
+ prop_val = prop_val.substring(1, prop_val.length - 1);
355
+ }
356
+ if (property_details[0].trim().toLowerCase() === "title") {
357
+ prop_val = htmlentities.decode(prop_val);
358
+ }
359
+ response.fm_properties[
360
+ property_details[0].trim().toLowerCase()
361
+ ] = prop_val;
362
+ }
363
+ }
364
+ }
365
+
366
+ // And return the header as a whole so it can be easily replaced
367
+ response.fm_header = child.data;
368
+ }
369
+ }
370
+ }
371
+
372
+ return response;
373
+ };
374
+
375
+ exports.truncate_string = (str, n, useWordBoundary) => {
376
+ if (str.length <= n) {
377
+ return str;
378
+ }
379
+ const subString = str.slice(0, n - 1);
380
+ return `${
381
+ useWordBoundary
382
+ ? subString.slice(0, subString.lastIndexOf(" "))
383
+ : subString
384
+ }…`;
385
+ };
386
+
387
+ exports.get_html_read_time = (html) => {
388
+ // Get word count
389
+ const text = html2text.convert(html, {
390
+ wordwrap: null,
391
+ });
392
+ const word_count = wordsCount(text);
393
+ if (word_count === 0) return 0;
394
+
395
+ // Calculate the read time - divide the word count by 200
396
+ let read_time = Math.round(word_count / 200);
397
+ if (read_time === 0) read_time = 1;
398
+ return read_time;
399
+ };
400
+
401
+ exports.get_github_api_path = (repo, relative_path) => {
402
+ if (repo) {
403
+ const clean_repo = repo.endsWith("/") ? repo.slice(0, -1) : repo;
404
+ const github_paths = {};
405
+ github_paths.api_path = clean_repo.replace(
406
+ "https://github.com/",
407
+ "https://api.github.com/repos/",
408
+ );
409
+ github_paths.api_path += `/commits?path=${encodeURIComponent(
410
+ `/${relative_path.replace("\\\\", "/").replace("\\", "/")}`,
411
+ )}`;
412
+ github_paths.edit_path = `${repo}/blob/main/${relative_path.replace("\\\\", "/").replace("\\", "/")}`;
413
+ return github_paths;
414
+ }
415
+ return "";
416
+ };
417
+
418
+ const get_github_contributors_path = (repo) => {
419
+ const clean_repo = repo.endsWith("/") ? repo.slice(0, -1) : repo;
420
+ const github_paths = {};
421
+ github_paths.api_path = clean_repo.replace(
422
+ "https://github.com/",
423
+ "https://api.github.com/repos/",
424
+ );
425
+ github_paths.api_path += "/contributors";
426
+ return github_paths;
427
+ };
428
+
429
+ exports.get_github_contributors = async (
430
+ github_url,
431
+ github_api_token,
432
+ repo,
433
+ ) => {
434
+ const response = {
435
+ success: false,
436
+ error: "",
437
+ contributors: [],
438
+ contributor_count: 0,
439
+ last_commit_date: "",
440
+ };
441
+ const contributors = {};
442
+
443
+ const request_options = {
444
+ headers: {
445
+ "User-Agent": "HornbillDocsBuild",
446
+ "Cache-Control": "no-cache",
447
+ Host: "api.github.com",
448
+ Accept: "application/json",
449
+ },
450
+ timeout: 5000,
451
+ };
452
+ if (github_api_token !== "") {
453
+ request_options.headers.authorization = `Bearer ${github_api_token}`;
454
+ }
455
+ let github_response;
456
+ try {
457
+ github_response = await axios.get(github_url, request_options);
458
+ if (retried) {
459
+ retried = false;
460
+ console.log("API call retry success!");
461
+ }
462
+ } catch (err) {
463
+ if (err.response) {
464
+ if (err.response.status !== 403 && err.response.status !== 401) {
465
+ response.error = err;
466
+ return response;
467
+ }
468
+ github_response = err.response;
469
+ } else {
470
+ response.error = `Unexpected response from GitHub for [${github_url}:\n${JSON.stringify(
471
+ err,
472
+ )}]`;
473
+ }
474
+ }
475
+ if (github_response.status === 200) {
476
+ response.success = true;
477
+ const commits = github_response.data;
478
+ for (const commit of commits) {
479
+ if (
480
+ commit.committer?.type &&
481
+ commit.committer.type.toLowerCase() === "user" &&
482
+ commit.committer.login.toLowerCase() !== "web-flow"
483
+ ) {
484
+ if (!contributors[commit.committer.id]) {
485
+ response.contributor_count++;
486
+ contributors[commit.committer.id] = {
487
+ login: commit.committer.login,
488
+ avatar_url: commit.committer.avatar_url,
489
+ html_url: commit.committer.html_url,
490
+ name: commit.commit.committer.name,
491
+ };
492
+ }
493
+ if (response.last_commit_date !== "") {
494
+ const new_commit_date = new Date(commit.commit.committer.date);
495
+ const exist_commit_date = new Date(response.last_commit_date);
496
+ if (new_commit_date > exist_commit_date)
497
+ response.last_commit_date = commit.commit.committer.date;
498
+ } else {
499
+ response.last_commit_date = commit.commit.committer.date;
500
+ }
501
+ } else if (commit.author?.id) {
502
+ if (!contributors[commit.author.id]) {
503
+ response.contributor_count++;
504
+ contributors[commit.author.id] = {
505
+ login: commit.author.login,
506
+ avatar_url: commit.author.avatar_url,
507
+ html_url: commit.author.html_url,
508
+ name: commit.commit.author.name,
509
+ };
510
+ }
511
+ if (response.last_commit_date !== "") {
512
+ const new_commit_date = new Date(commit.commit.author.date);
513
+ const exist_commit_date = new Date(response.last_commit_date);
514
+ if (new_commit_date > exist_commit_date)
515
+ response.last_commit_date = commit.commit.author.date;
516
+ } else {
517
+ response.last_commit_date = commit.commit.author.date;
518
+ }
519
+ }
520
+ }
521
+ for (const key in contributors) {
522
+ if (Object.hasOwn(contributors, key)) {
523
+ response.contributors.push(contributors[key]);
524
+ }
525
+ }
526
+ } else if (github_response.status === 403) {
527
+ // Private repo, fine-grained permissions don't yet support getting commits without content, get list from meta permissions
528
+ const contrib_url = get_github_contributors_path(repo).api_path;
529
+ try {
530
+ github_response = await axios.get(contrib_url, request_options);
531
+ if (retried) {
532
+ retried = false;
533
+ console.log("API call retry success!");
534
+ }
535
+ } catch (err) {
536
+ if (err.response?.status) {
537
+ if (err.response.status !== 200) {
538
+ response.error = err;
539
+ return response;
540
+ }
541
+ } else {
542
+ response.error = `Unexpected response from GitHub for [${contrib_url}:\n${JSON.stringify(
543
+ err,
544
+ )}]`;
545
+ }
546
+ }
547
+ if (github_response.status === 200) {
548
+ response.success = true;
549
+ const commits = github_response.data;
550
+ for (const commit of commits) {
551
+ if (
552
+ commit.type &&
553
+ commit.type.toLowerCase() === "user" &&
554
+ commit.login.toLowerCase() !== "web-flow"
555
+ ) {
556
+ if (!contributors[commit.id]) {
557
+ response.contributor_count++;
558
+ contributors[commit.id] = {
559
+ login: commit.login,
560
+ avatar_url: commit.avatar_url,
561
+ html_url: commit.html_url,
562
+ name: commit.name ? commit.name : commit.login,
563
+ };
564
+ }
565
+ if (
566
+ response.last_commit_date !== "" &&
567
+ response.last_commit_date !== "No Commit Date Available"
568
+ ) {
569
+ const new_commit_date = new Date(commit.date);
570
+ const exist_commit_date = new Date(response.last_commit_date);
571
+ if (new_commit_date > exist_commit_date)
572
+ response.last_commit_date = commit.date;
573
+ } else {
574
+ response.last_commit_date = commit.date
575
+ ? commit.date
576
+ : "No Commit Date Available";
577
+ }
578
+ }
579
+ }
580
+ for (const key in contributors) {
581
+ if (Object.hasOwn(contributors, key)) {
582
+ response.contributors.push(contributors[key]);
583
+ }
584
+ }
585
+ }
586
+ } else {
587
+ response.error = `Unexpected Status: ${github_response.status}.`;
588
+ }
589
+ return response;
590
+ };
591
+
592
+ exports.strip_drafts = (nav_items) => {
593
+ const return_nav = nav_items;
594
+ recurse_nav(return_nav);
595
+ return return_nav;
596
+ };
597
+
598
+ const recurse_nav = (nav_items) => {
599
+ for (const key in nav_items) {
600
+ if (nav_items[key].draft) {
601
+ nav_items.splice(key, 1);
602
+ recurse_nav(nav_items);
603
+ } else if (nav_items[key].items) {
604
+ recurse_nav(nav_items[key].items);
605
+ }
606
+ }
607
+ };
608
+
609
+ exports.build_breadcrumbs = (nav_items) => {
610
+ const response = {
611
+ bc: {},
612
+ errors: [],
613
+ };
614
+ const buildBreadcrumb = (items, parentLinks) => {
615
+ // Process parent links
616
+ let parentlink = true;
617
+ if (parentLinks.length > 0) {
618
+ if (parentLinks[0].link === undefined || parentLinks[0].link === "")
619
+ parentlink = false;
620
+
621
+ for (let i = 1; i < 10; i++) {
622
+ if (
623
+ parentLinks[i] &&
624
+ parentLinks[i].link === undefined &&
625
+ items.length > 0 &&
626
+ items[0].link
627
+ ) {
628
+ parentLinks[i].link = items[0].link;
629
+ }
630
+ }
631
+ }
632
+
633
+ // Loop through items, build breadcrumb
634
+ for (let i = 0; i < items.length; i++) {
635
+ if (!items[i].text) {
636
+ response.errors.push(
637
+ `The following Nav Item is missing its text property: ${JSON.stringify(
638
+ items[i],
639
+ )}`,
640
+ );
641
+ }
642
+
643
+ if (!items[i].link && !items[i].items) {
644
+ response.errors.push(
645
+ `The following Nav Item has no link or items property: ${JSON.stringify(
646
+ items[i],
647
+ )}`,
648
+ );
649
+ }
650
+ const item = items[i];
651
+ if (!parentlink && item.link) {
652
+ parentLinks[0].link = item.link;
653
+ parentlink = true;
654
+ }
655
+ const { text, link, items: subItems } = item;
656
+ const breadcrumb = [...parentLinks, { text, link }];
657
+
658
+ if (link) {
659
+ response.bc[link] = breadcrumb;
660
+ }
661
+
662
+ if (subItems) {
663
+ buildBreadcrumb(subItems, breadcrumb);
664
+ }
665
+ }
666
+ };
667
+
668
+ buildBreadcrumb(nav_items, []);
669
+ return response;
670
+ };
671
+
672
+ exports.load_product_families = async () => {
673
+ const response = {
674
+ success: false,
675
+ prod_families: {},
676
+ prods_supported: [],
677
+ errors: "",
678
+ };
679
+ const prod_families_url = "https://docs.hornbill.com/_books/products.json";
680
+ for (let i = 1; i < 4; i++) {
681
+ try {
682
+ const prods = await axios.get(prod_families_url, {
683
+ httpsAgent: agent,
684
+ timeout: 5000,
685
+ });
686
+ if (prods.status === 200) {
687
+ response.prod_families = prods.data;
688
+ response.prods_supported = [];
689
+ for (let i = 0; i < response.prod_families.products.length; i++) {
690
+ response.prods_supported.push(
691
+ response.prod_families.products[i].id,
692
+ );
693
+ }
694
+ response.success = true;
695
+ break;
696
+ }
697
+ throw `Unexpected status - ${prods.status} ${prods.statusText}`;
698
+ } catch (e) {
699
+ if (response.errors === "")
700
+ response.errors = `Request to ${prod_families_url} failed:`;
701
+ response.errors += `\nAttempt ${i} - Error returning product families: ${e}`;
702
+ // Wait 2 seconds and try again
703
+ await new Promise((r) => setTimeout(r, 2000));
704
+ }
705
+ }
706
+ return response;
707
+ };
708
+ })();