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-module.js CHANGED
@@ -1,1079 +1,1102 @@
1
- (() => {
2
- const cheerio = require("cheerio");
3
- const crypto = require("node:crypto");
4
- const fs = require("node:fs");
5
- const os = require("node:os");
6
- const path = require("node:path");
7
-
8
- const includesCache = {};
9
-
10
- let retried = false;
11
-
12
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
13
-
14
- // GitHub enforces a "secondary" (burst) rate limit on top of the hourly one:
15
- // too many requests in a short window are rejected with 403/429 even when the
16
- // hourly budget is intact. The contributor pull fires one call per file, so a
17
- // fast run trips it. Historically `hdoc validate` got away with this only
18
- // because verbose console output slowed the loop down between calls; the
19
- // --quiet mode removed that incidental spacing and started hitting 403s.
20
- // Throttle GitHub API calls to a fixed minimum interval so behaviour no longer
21
- // depends on how chatty the console is.
22
- let _last_github_call_at = 0;
23
- const GITHUB_HOST = "api.github.com";
24
- const GITHUB_MIN_INTERVAL_MS = 800;
25
- // Never block the whole build waiting on a single rate-limited call.
26
- const GITHUB_MAX_BACKOFF_MS = 60000;
27
-
28
- // For a rate-limited response, work out how long to wait before retrying.
29
- // Returns null when the response is NOT a retryable rate limit (e.g. a genuine
30
- // 403 auth/forbidden), in which case the caller should give up immediately.
31
- const rate_limit_wait_ms = (response) => {
32
- if (response.status !== 403 && response.status !== 429) return null;
33
- const retry_after = response.headers.get("retry-after");
34
- if (retry_after !== null) {
35
- const secs = Number(retry_after);
36
- if (!Number.isNaN(secs)) return Math.max(0, secs * 1000);
37
- }
38
- const remaining = response.headers.get("x-ratelimit-remaining");
39
- const reset = response.headers.get("x-ratelimit-reset");
40
- if (remaining === "0" && reset !== null) {
41
- const reset_ms = Number(reset) * 1000 - Date.now();
42
- if (!Number.isNaN(reset_ms)) return Math.max(0, reset_ms);
43
- }
44
- // 403/429 with no rate-limit signal => auth/forbidden, not worth retrying.
45
- return null;
46
- };
47
-
48
- // Wraps the built-in fetch() with automatic retry for transient errors.
49
- // Retries up to maxRetries times when the server returns an HTTP error status
50
- // >= 400, except for 401 (Unauthorized) and non-rate-limit 403 (Forbidden)
51
- // which are auth failures that won't be resolved by retrying. GitHub
52
- // rate-limit responses (403/429 carrying Retry-After or X-RateLimit-Reset) ARE
53
- // retried after honouring the indicated wait. Network errors (where fetch
54
- // itself throws) are also retried. Sets the module-level `retried` flag so
55
- // callers can detect and log a success-after-retry message.
56
- //
57
- // Pass `timeoutMs` inside options to apply a per-attempt timeout. A fresh
58
- // AbortSignal is created for every attempt so that a timed-out first attempt
59
- // does not leave an already-aborted signal in place for the retries.
60
- const fetchWithRetry = async (url, options = {}, maxRetries = 5) => {
61
- const { timeoutMs, ...fetchOptions } = options;
62
- const is_github = typeof url === "string" && url.includes(GITHUB_HOST);
63
- let retryCount = 0;
64
- while (true) {
65
- // Space out GitHub API calls so a fast (quiet) run does not burst past
66
- // the secondary rate limit. No effect on non-GitHub requests.
67
- if (is_github) {
68
- const since = Date.now() - _last_github_call_at;
69
- if (since < GITHUB_MIN_INTERVAL_MS) {
70
- await sleep(GITHUB_MIN_INTERVAL_MS - since);
71
- }
72
- _last_github_call_at = Date.now();
73
- }
74
- // Create a fresh signal for each attempt; reusing an already-aborted
75
- // signal would cause every subsequent retry to abort immediately.
76
- const attemptOptions = timeoutMs
77
- ? { ...fetchOptions, signal: AbortSignal.timeout(timeoutMs) }
78
- : fetchOptions;
79
- let response;
80
- try {
81
- response = await fetch(url, attemptOptions);
82
- } catch (err) {
83
- // Network-level error (DNS failure, connection refused, timeout, etc.)
84
- retryCount++;
85
- if (retryCount > maxRetries) throw err;
86
- retried = true;
87
- continue;
88
- }
89
- if (response.ok || response.status === 401) {
90
- return response;
91
- }
92
- // 403/429: retry only when it is a rate limit we can wait out.
93
- if (response.status === 403 || response.status === 429) {
94
- const wait_ms = rate_limit_wait_ms(response);
95
- if (wait_ms === null) return response; // auth/forbidden — give up
96
- retryCount++;
97
- if (retryCount > maxRetries) return response;
98
- retried = true;
99
- const backoff_ms = Math.min(wait_ms, GITHUB_MAX_BACKOFF_MS);
100
- console.log(
101
- `GitHub rate limit hit (HTTP ${response.status}) — waiting ${Math.round(backoff_ms / 1000)}s before retry ${retryCount}/${maxRetries}. Provide --git-token <PAT> to raise the limit.`,
102
- );
103
- await sleep(backoff_ms);
104
- continue;
105
- }
106
- retryCount++;
107
- if (retryCount > maxRetries) return response;
108
- retried = true;
109
- }
110
- };
111
-
112
- exports.fetchWithRetry = fetchWithRetry;
113
-
114
- exports.content_type_for_ext = (ext) => {
115
- switch (ext) {
116
- case ".z":
117
- return "application/x-compress";
118
- case ".tgz":
119
- return "application/x-compressed";
120
- case ".gz":
121
- return "application/x-gzip";
122
- case ".zip":
123
- return "application/x-zip-compressed";
124
- case ".xml":
125
- return "application/xml";
126
- case ".bmp":
127
- return "image/bmp";
128
- case ".gif":
129
- return "image/gif";
130
- case ".jpg":
131
- return "image/jpeg";
132
- case ".png":
133
- return "image/png";
134
- case ".tiff":
135
- return "image/tiff";
136
- case ".ico":
137
- return "image/x-icon";
138
- case ".svg":
139
- return "image/svg+xml";
140
- case ".css":
141
- return "text/css";
142
- case ".htm":
143
- case ".html":
144
- return "text/html";
145
- case ".txt":
146
- return "text/plain";
147
- case ".md":
148
- return "text/plain";
149
- case ".json":
150
- return "application/json";
151
- case ".js":
152
- return "application/javascript";
153
- default:
154
- return "application/octet-stream";
155
- }
156
- };
157
-
158
- exports.valid_url = (url) => {
159
- const stringIsAValidUrl = (s) => {
160
- try {
161
- const url_obj = new URL(s);
162
- return url_obj;
163
- } catch (err) {
164
- return false;
165
- }
166
- };
167
- return stringIsAValidUrl(url);
168
- };
169
-
170
- exports.expand_variables = (text, docId = "") => {
171
- let clean_text = text;
172
- if (docId !== "") {
173
- clean_text = clean_text.replaceAll("{{DOC_ID}}", docId);
174
- }
175
- clean_text = clean_text.replaceAll("{{BUILD_NUMBER}}", "0");
176
-
177
- let build_date = new Date().toISOString();
178
- build_date = build_date.replace("T", " ");
179
- build_date = build_date.substring(0, 19);
180
- clean_text = clean_text.replaceAll("{{BUILD_DATE}}", build_date);
181
- return clean_text;
182
- };
183
-
184
- exports.process_includes = async (file_path, body, source_path) => {
185
- const response = {
186
- body: body,
187
- found: 0,
188
- success: 0,
189
- failed: 0,
190
- included: [],
191
- errors: [],
192
- };
193
-
194
- // Search body for INCLUDEs
195
- const regexp = /\[\[INCLUDE .*]]/g;
196
- const body_array = [...response.body.matchAll(regexp)];
197
-
198
- for (let i = 0; i < body_array.length; i++) {
199
- response.found++;
200
-
201
- // Extract include data from array
202
- const include_value = body_array[i][0];
203
-
204
- let link;
205
- try {
206
- link = include_value.split(" ")[1];
207
- link = link.substring(0, link.length - 2);
208
- } catch (e) {
209
- response.failed++;
210
- response.errors.push(
211
- `Error parsing INCLUDE [${include_value}] from [${file_path}]: ${err}`,
212
- );
213
- continue;
214
- }
215
-
216
- if (
217
- (link.startsWith("http://") || link.startsWith("https://")) &&
218
- includesCache[link] !== undefined
219
- ) {
220
- console.log(`Serving From Cache: ${link}`);
221
- response.body = response.body.replace(
222
- include_value,
223
- includesCache[link],
224
- );
225
- response.success++;
226
- continue;
227
- }
228
-
229
- // Validate link in INCLUDE
230
- let file_content;
231
- if (link.startsWith("http://") || link.startsWith("https://")) {
232
- // Remote content to include
233
- try {
234
- new URL(link);
235
- } catch (e) {
236
- response.failed++;
237
- response.errors.push(
238
- `Error validating INCLUDE link [${link}] from [${file_path}]: ${e}`,
239
- );
240
- continue;
241
- }
242
-
243
- try {
244
- const file_response = await fetchWithRetry(link);
245
- if (retried) {
246
- retried = false;
247
- console.log("API call retry success!");
248
- }
249
- if (file_response.status === 200) {
250
- file_content = await file_response.text();
251
- } else {
252
- throw `Unexpected Status ${file_response.status}`;
253
- }
254
- } catch (e) {
255
- response.failed++;
256
- response.errors.push(
257
- `Error getting INCLUDE link content [${link}] from [${file_path}]: ${e}`,
258
- );
259
- continue;
260
- }
261
- console.log(`Included From Remote Source: ${link}`);
262
- } else {
263
- // Local content to include
264
- try {
265
- file_content = fs.readFileSync(path.join(source_path, link), "utf8");
266
- } catch (e) {
267
- response.failed++;
268
- response.errors.push(
269
- `Error getting INCLUDE file [${link}] from [${file_path}]: ${e}`,
270
- );
271
- continue;
272
- }
273
- console.log(`Included From Local Source: ${link}`);
274
- }
275
- response.success++;
276
- includesCache[link] = file_content;
277
- response.body = response.body.replace(include_value, file_content);
278
- }
279
- return response;
280
- };
281
-
282
- // Takes html, returns the first heading detected in the order provided in h_to_search
283
- // Looks for h1 tags first, then hX, hY, hZ in order
284
- exports.getFirstHTMLHeading = (html_body, h_to_search = ["h1"]) => {
285
- const $ = cheerio.load(html_body);
286
- for (const tag of h_to_search) {
287
- const el = $(tag).first();
288
- if (el.length > 0) return el;
289
- }
290
- return false;
291
- };
292
-
293
- const makeAnchorIdFriendly = (str) => {
294
- return `hb-doc-anchor-${str // Add prefix
295
- .toLowerCase() // Convert to lowercase
296
- .trim() // Trim leading and trailing spaces
297
- .replace(/[^a-z0-9\s-]/g, "") // Remove all non-alphanumeric characters except spaces and hyphens
298
- .replace(/\s+/g, "-") // Replace spaces with hyphens
299
- .replace(/-+/g, "-")}`; // Replace multiple hyphens with a single hyphen
300
- };
301
-
302
- // Processes HTML, wraps h2 and h3 tags and their content in divs with an id matching that of the h text
303
- exports.wrapHContent = (htmlContent) => {
304
- const $ = cheerio.load(htmlContent, { decodeEntities: false });
305
- let result = '';
306
- let inH2 = false;
307
- let inH3 = false;
308
-
309
- $('body').contents().each(function() {
310
- const tagName = this.type === 'tag' ? this.name?.toLowerCase() : null;
311
-
312
- if (tagName === 'h2') {
313
- // Close open h3 (nested inside h2), then close h2
314
- if (inH3) { result += '</div>'; inH3 = false; }
315
- if (inH2) { result += '</div>'; inH2 = false; }
316
- const anchorId = makeAnchorIdFriendly($(this).text().trim());
317
- result += `<div id="${anchorId}">${$.html(this)}`;
318
- inH2 = true;
319
- } else if (tagName === 'h3') {
320
- // Close previous h3 (it stays nested inside any open h2)
321
- if (inH3) { result += '</div>'; inH3 = false; }
322
- const anchorId = makeAnchorIdFriendly($(this).text().trim());
323
- result += `<div id="${anchorId}">${$.html(this)}`;
324
- inH3 = true;
325
- } else {
326
- result += $.html(this);
327
- }
328
- });
329
-
330
- // Flush remaining open divs — h3 is nested inside h2 so close inner first
331
- if (inH3) result += '</div>';
332
- if (inH2) result += '</div>';
333
-
334
- return `<html><head></head><body>${result}</body></html>`;
335
- };
336
-
337
- // Combined single-pass version of wrapHContent + getFirstHTMLHeading + get_html_read_time.
338
- // Iterates body contents once to wrap h2/h3 divs AND extract the first matching heading text,
339
- // first paragraph text, and reading-time estimate — avoiding 3 extra cheerio.load() calls.
340
- exports.wrapAndExtract = (htmlContent, h_tags_to_search = ["h1"]) => {
341
- const $ = cheerio.load(htmlContent, { decodeEntities: false });
342
- let result = '';
343
- let inH2 = false;
344
- let inH3 = false;
345
- let firstHeadingText = null;
346
- let firstParagraphText = null;
347
-
348
- $('body').contents().each(function() {
349
- const tagName = this.type === 'tag' ? this.name?.toLowerCase() : null;
350
- const text = tagName ? $(this).text().trim() : null;
351
-
352
- if (firstHeadingText === null && tagName && h_tags_to_search.includes(tagName)) {
353
- firstHeadingText = text;
354
- }
355
- if (firstParagraphText === null && tagName === 'p') {
356
- firstParagraphText = text;
357
- }
358
-
359
- if (tagName === 'h2') {
360
- if (inH3) { result += '</div>'; inH3 = false; }
361
- if (inH2) { result += '</div>'; inH2 = false; }
362
- result += `<div id="${makeAnchorIdFriendly(text)}">${$.html(this)}`;
363
- inH2 = true;
364
- } else if (tagName === 'h3') {
365
- if (inH3) { result += '</div>'; inH3 = false; }
366
- result += `<div id="${makeAnchorIdFriendly(text)}">${$.html(this)}`;
367
- inH3 = true;
368
- } else {
369
- result += $.html(this);
370
- }
371
- });
372
-
373
- if (inH3) result += '</div>';
374
- if (inH2) result += '</div>';
375
-
376
- // Word count re-uses the already-parsed DOM — no extra cheerio.load()
377
- const bodyText = $("body").text();
378
- const wordCount = bodyText.trim().split(/\s+/).filter(Boolean).length;
379
- const readTimeMins = wordCount === 0 ? 0 : (Math.round(wordCount / 200) || 1);
380
-
381
- return {
382
- html: `<html><head></head><body>${result}</body></html>`,
383
- firstHeadingText,
384
- firstParagraphText,
385
- readTimeMins,
386
- };
387
- };
388
-
389
- exports.getIDDivs = (html_body) => {
390
- const $ = cheerio.load(html_body, {
391
- decodeEntities: false,
392
- });
393
-
394
- const divs = [];
395
-
396
- $("div").each(function (i, element) {
397
- if ($(this).attr("id")?.startsWith("hb-doc-anchor-")) {
398
- divs.push({
399
- id: $(this).attr("id"),
400
- html: $(this).html(),
401
- text: $(this).text(),
402
- });
403
- }
404
- });
405
- return divs;
406
- };
407
-
408
- exports.getHTMLFrontmatterHeader = (html_body) => {
409
- const response = {
410
- fm_header: "",
411
- fm_properties: {},
412
- };
413
- const $ = cheerio.load(html_body, {
414
- decodeEntities: false,
415
- });
416
- if (
417
- $._root?.children &&
418
- Array.isArray($._root.children) &&
419
- $._root.children.length > 0
420
- ) {
421
- for (const child of $._root.children) {
422
- if (
423
- child.type === "comment" &&
424
- child.data &&
425
- child.data.startsWith("[[FRONTMATTER")
426
- ) {
427
- // We have a Frontmatter header - return each property in an array
428
- const fm_properties = child.data.split(/\r?\n/);
429
- for (let i = 0; i < fm_properties.length; i++) {
430
- if (fm_properties[i].includes(":")) {
431
- const property_details = fm_properties[i].split(/:(.*)/s);
432
- if (property_details.length > 1) {
433
- let prop_val = property_details[1].trim();
434
- if (/^".*"$/.test(prop_val)) {
435
- prop_val = prop_val.substring(1, prop_val.length - 1);
436
- }
437
- if (property_details[0].trim().toLowerCase() === "title") {
438
- // Decode HTML entities in the title value: handles named entities
439
- // (&amp; &lt; &gt; &quot; &#39; &apos;), decimal numeric refs (&#123;),
440
- // and hex numeric refs (&#x7B;).
441
- prop_val = prop_val.replace(/&amp;|&lt;|&gt;|&quot;|&#39;|&apos;|&#(\d+);|&#x([0-9a-fA-F]+);/g,
442
- (m, dec, hex) => dec ? String.fromCharCode(+dec) : hex ? String.fromCharCode(parseInt(hex, 16)) : ({ '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&apos;': "'" })[m]);
443
- }
444
- response.fm_properties[
445
- property_details[0].trim().toLowerCase()
446
- ] = prop_val;
447
- }
448
- }
449
- }
450
-
451
- // And return the header as a whole so it can be easily replaced
452
- response.fm_header = child.data;
453
- }
454
- }
455
- }
456
-
457
- return response;
458
- };
459
-
460
- exports.truncate_string = (str, n, useWordBoundary) => {
461
- if (str.length <= n) {
462
- return str;
463
- }
464
- const subString = str.slice(0, n - 1);
465
- return `${
466
- useWordBoundary
467
- ? subString.slice(0, subString.lastIndexOf(" "))
468
- : subString
469
- }…`;
470
- };
471
-
472
- exports.html_to_text = (html, { baseElement } = {}) => {
473
- const $ = cheerio.load(html, { decodeEntities: false });
474
- if (baseElement) {
475
- return $(baseElement).map((_i, el) => $(el).text()).get().join("\n");
476
- }
477
- return $("body").text();
478
- };
479
-
480
- exports.get_html_read_time = (html) => {
481
- // Get word count
482
- const text = exports.html_to_text(html);
483
- const word_count = text.trim().split(/\s+/).filter(Boolean).length;
484
- if (word_count === 0) return 0;
485
-
486
- // Calculate the read time - divide the word count by 200
487
- let read_time = Math.round(word_count / 200);
488
- if (read_time === 0) read_time = 1;
489
- return read_time;
490
- };
491
-
492
- exports.get_github_api_path = (repo, relative_path) => {
493
- if (repo) {
494
- const clean_repo = repo.endsWith("/") ? repo.slice(0, -1) : repo;
495
- const github_paths = {};
496
- github_paths.api_path = clean_repo.replace(
497
- "https://github.com/",
498
- "https://api.github.com/repos/",
499
- );
500
- github_paths.api_path += `/commits?path=${encodeURIComponent(
501
- `/${relative_path.replace("\\\\", "/").replace("\\", "/")}`,
502
- )}`;
503
- github_paths.edit_path = `${repo}/blob/main/${relative_path.replace("\\\\", "/").replace("\\", "/")}`;
504
- return github_paths;
505
- }
506
- return "";
507
- };
508
-
509
- const get_github_contributors_path = (repo) => {
510
- const clean_repo = repo.endsWith("/") ? repo.slice(0, -1) : repo;
511
- const github_paths = {};
512
- github_paths.api_path = clean_repo.replace(
513
- "https://github.com/",
514
- "https://api.github.com/repos/",
515
- );
516
- github_paths.api_path += "/contributors";
517
- return github_paths;
518
- };
519
-
520
- exports.get_github_repo_details = async (
521
- github_url,
522
- github_api_token,
523
- ) => {
524
- const response = {
525
- success: false,
526
- error: "",
527
- data: {},
528
- private: false
529
- };
530
- const request_options = {
531
- headers: {
532
- "User-Agent": "HornbillDocsBuild",
533
- "Cache-Control": "no-cache",
534
- Host: "api.github.com",
535
- Accept: "application/json",
536
- },
537
- timeout: 5000,
538
- };
539
- if (github_api_token !== "") {
540
- request_options.headers.authorization = `Bearer ${github_api_token}`;
541
- }
542
-
543
- let github_response;
544
- let github_data;
545
- try {
546
- github_response = await fetchWithRetry(github_url, {
547
- headers: request_options.headers,
548
- timeoutMs: 5000,
549
- });
550
- if (retried) {
551
- retried = false;
552
- console.log("API call retry success!");
553
- }
554
- github_data = await github_response.json();
555
- } catch (err) {
556
- // Network-level failure (fetchWithRetry re-throws after exhausting retries)
557
- response.error = `Unexpected response from GitHub for [${github_url}:\n${JSON.stringify(err)}]`;
558
- return response;
559
- }
560
- // fetch does not throw on HTTP errors — return early for unexpected status codes
561
- if (github_response.status !== 200 && github_response.status !== 401 && github_response.status !== 403) {
562
- response.error = new Error(`HTTP ${github_response.status}`);
563
- return response;
564
- }
565
- if (github_response.status === 200) {
566
- response.success = true;
567
- response.data = github_data;
568
- response.private = github_data.private;
569
- } else {
570
- // Is it a 404 or 403?
571
- response.error = `${github_response.status} : ${github_data.message}`;
572
- }
573
- return response;
574
- };
575
-
576
- exports.get_github_contributors = async (
577
- github_url,
578
- github_api_token,
579
- repo,
580
- ) => {
581
- const response = {
582
- success: false,
583
- error: "",
584
- contributors: [],
585
- contributor_count: 0,
586
- last_commit_date: "",
587
- };
588
- const contributors = {};
589
-
590
- const request_options = {
591
- headers: {
592
- "User-Agent": "HornbillDocsBuild",
593
- "Cache-Control": "no-cache",
594
- Host: "api.github.com",
595
- Accept: "application/json",
596
- },
597
- timeout: 5000,
598
- };
599
- if (github_api_token !== "") {
600
- request_options.headers.authorization = `Bearer ${github_api_token}`;
601
- }
602
- let github_response;
603
- let github_data;
604
- try {
605
- github_response = await fetchWithRetry(github_url, {
606
- headers: request_options.headers,
607
- timeoutMs: 5000,
608
- });
609
- if (retried) {
610
- retried = false;
611
- console.log("API call retry success!");
612
- }
613
- github_data = await github_response.json();
614
- } catch (err) {
615
- // Network-level failure (fetchWithRetry re-throws after exhausting retries)
616
- response.error = `Unexpected response from GitHub for [${github_url}:\n${JSON.stringify(err)}]`;
617
- return response;
618
- }
619
- // fetch does not throw on HTTP errors — return early for unexpected status codes
620
- if (github_response.status !== 200 && github_response.status !== 401 && github_response.status !== 403) {
621
- response.error = new Error(`HTTP ${github_response.status}`);
622
- return response;
623
- }
624
- if (github_response.status === 200) {
625
- response.success = true;
626
- const commits = github_data;
627
- for (const commit of commits) {
628
- if (
629
- commit.committer?.type &&
630
- commit.committer.type.toLowerCase() === "user" &&
631
- commit.committer.login.toLowerCase() !== "web-flow"
632
- ) {
633
- if (!contributors[commit.committer.id]) {
634
- response.contributor_count++;
635
- contributors[commit.committer.id] = {
636
- login: commit.committer.login,
637
- avatar_url: commit.committer.avatar_url,
638
- html_url: commit.committer.html_url,
639
- name: commit.commit.committer.name,
640
- };
641
- }
642
- if (response.last_commit_date !== "") {
643
- const new_commit_date = new Date(commit.commit.committer.date);
644
- const exist_commit_date = new Date(response.last_commit_date);
645
- if (new_commit_date > exist_commit_date)
646
- response.last_commit_date = commit.commit.committer.date;
647
- } else {
648
- response.last_commit_date = commit.commit.committer.date;
649
- }
650
- } else if (commit.author?.id) {
651
- if (!contributors[commit.author.id]) {
652
- response.contributor_count++;
653
- contributors[commit.author.id] = {
654
- login: commit.author.login,
655
- avatar_url: commit.author.avatar_url,
656
- html_url: commit.author.html_url,
657
- name: commit.commit.author.name,
658
- };
659
- }
660
- if (response.last_commit_date !== "") {
661
- const new_commit_date = new Date(commit.commit.author.date);
662
- const exist_commit_date = new Date(response.last_commit_date);
663
- if (new_commit_date > exist_commit_date)
664
- response.last_commit_date = commit.commit.author.date;
665
- } else {
666
- response.last_commit_date = commit.commit.author.date;
667
- }
668
- }
669
- }
670
- for (const key in contributors) {
671
- if (Object.hasOwn(contributors, key)) {
672
- response.contributors.push(contributors[key]);
673
- }
674
- }
675
- } else if (github_response.status === 403) {
676
- // Private repo, fine-grained permissions don't yet support getting commits without content, get list from meta permissions
677
- const contrib_url = get_github_contributors_path(repo).api_path;
678
- try {
679
- github_response = await fetchWithRetry(contrib_url, {
680
- headers: request_options.headers,
681
- timeoutMs: 5000,
682
- });
683
- if (retried) {
684
- retried = false;
685
- console.log("API call retry success!");
686
- }
687
- github_data = await github_response.json();
688
- } catch (err) {
689
- // Network-level failure (fetchWithRetry re-throws after exhausting retries)
690
- response.error = `Unexpected response from GitHub for [${contrib_url}:\n${JSON.stringify(err)}]`;
691
- return response;
692
- }
693
- // fetch does not throw on HTTP errors — return early if fallback request failed
694
- if (github_response.status !== 200) {
695
- response.error = new Error(`HTTP ${github_response.status}`);
696
- return response;
697
- }
698
- if (github_response.status === 200) {
699
- response.success = true;
700
- const commits = github_data;
701
- for (const commit of commits) {
702
- if (
703
- commit.type &&
704
- commit.type.toLowerCase() === "user" &&
705
- commit.login.toLowerCase() !== "web-flow"
706
- ) {
707
- if (!contributors[commit.id]) {
708
- response.contributor_count++;
709
- contributors[commit.id] = {
710
- login: commit.login,
711
- avatar_url: commit.avatar_url,
712
- html_url: commit.html_url,
713
- name: commit.name ? commit.name : commit.login,
714
- };
715
- }
716
- if (
717
- response.last_commit_date !== "" &&
718
- response.last_commit_date !== "No Commit Date Available"
719
- ) {
720
- const new_commit_date = new Date(commit.date);
721
- const exist_commit_date = new Date(response.last_commit_date);
722
- if (new_commit_date > exist_commit_date)
723
- response.last_commit_date = commit.date;
724
- } else {
725
- response.last_commit_date = commit.date
726
- ? commit.date
727
- : "No Commit Date Available";
728
- }
729
- }
730
- }
731
- for (const key in contributors) {
732
- if (Object.hasOwn(contributors, key)) {
733
- response.contributors.push(contributors[key]);
734
- }
735
- }
736
- }
737
- } else {
738
- response.error = `Unexpected Status: ${github_response.status}.`;
739
- }
740
- return response;
741
- };
742
-
743
- exports.strip_drafts = (nav_items) => {
744
- const return_nav = nav_items;
745
- recurse_nav(return_nav);
746
- return return_nav;
747
- };
748
-
749
- const recurse_nav = (nav_items) => {
750
- for (const key in nav_items) {
751
- if (nav_items[key].draft) {
752
- nav_items.splice(key, 1);
753
- recurse_nav(nav_items);
754
- } else if (nav_items[key].items) {
755
- recurse_nav(nav_items[key].items);
756
- }
757
- }
758
- };
759
-
760
- exports.build_breadcrumbs = (nav_items) => {
761
- const response = {
762
- bc: {},
763
- errors: [],
764
- };
765
- const buildBreadcrumb = (items, parentLinks) => {
766
- // Process parent links
767
- let parentlink = true;
768
- if (parentLinks.length > 0) {
769
- if (parentLinks[0].link === undefined || parentLinks[0].link === "" || parentLinks[0].draft )
770
- parentlink = false;
771
-
772
- for (let i = 1; i < 10; i++) {
773
- if (
774
- parentLinks[i] &&
775
- parentLinks[i].link === undefined &&
776
- items.length > 0 &&
777
- items[0].link
778
- ) {
779
- parentLinks[i].link = items[0].link;
780
- }
781
- }
782
- }
783
-
784
- // Loop through items, build breadcrumb
785
- for (let i = 0; i < items.length; i++) {
786
- if (!items[i].text) {
787
- response.errors.push(
788
- `The following Nav Item is missing its text property: ${JSON.stringify(
789
- items[i],
790
- )}`,
791
- );
792
- }
793
-
794
- if (!items[i].link && !items[i].items) {
795
- response.errors.push(
796
- `The following Nav Item has no link or items property: ${JSON.stringify(
797
- items[i],
798
- )}`,
799
- );
800
- }
801
- const item = items[i];
802
- if (!parentlink && item.link) {
803
- parentLinks[0].link = item.link;
804
- parentlink = true;
805
- }
806
- const { text, link, items: subItems } = item;
807
- const breadcrumb = [...parentLinks, { text, link }];
808
-
809
- if (link) {
810
- response.bc[link] = breadcrumb;
811
- }
812
-
813
- if (subItems) {
814
- buildBreadcrumb(subItems, breadcrumb);
815
- }
816
- }
817
- };
818
-
819
- buildBreadcrumb(nav_items, []);
820
- return response;
821
- };
822
-
823
- exports.get_draft_links = (items, parent_is_draft = false) => {
824
- let draft_links = [];
825
-
826
- for (const item of items) {
827
- // Check if this item is draft or if any parent was draft
828
- const is_draft = parent_is_draft || item.draft;
829
-
830
- // If the current item has a link and is draft (or parent is draft), add the link
831
- if (is_draft && item.link) {
832
- draft_links.push(item.link);
833
- }
834
-
835
- // If the current item has nested items, recursively check them
836
- if (item.items) {
837
- draft_links = draft_links.concat(this.get_draft_links(item.items, is_draft));
838
- }
839
- }
840
- return draft_links;
841
- };
842
-
843
- exports.load_product_families = async () => {
844
- const response = {
845
- success: false,
846
- prod_families: {},
847
- prods_supported: [],
848
- errors: "",
849
- };
850
- const prod_families_url = "https://docs.hornbill.com/_books/products.json";
851
- for (let i = 1; i < 4; i++) {
852
- try {
853
- const prods = await fetch(prod_families_url, {
854
- signal: AbortSignal.timeout(5000),
855
- });
856
- if (prods.status === 200) {
857
- response.prod_families = await prods.json();
858
- response.prods_supported = [];
859
- for (let i = 0; i < response.prod_families.products.length; i++) {
860
- response.prods_supported.push(
861
- response.prod_families.products[i].id,
862
- );
863
- }
864
- response.success = true;
865
- break;
866
- }
867
- throw `Unexpected status - ${prods.status} ${prods.statusText}`;
868
- } catch (e) {
869
- if (response.errors === "")
870
- response.errors = `Request to ${prod_families_url} failed:`;
871
- response.errors += `\nAttempt ${i} - Error returning product families: ${e}`;
872
- // Wait 2 seconds and try again
873
- await new Promise((r) => setTimeout(r, 2000));
874
- }
875
- }
876
- return response;
877
- };
878
-
879
- // Recursively walks a directory tree and invokes fileCallback(element) for each file.
880
- // Mirrors the dree.scan API so existing options objects and callbacks work unchanged.
881
- // Supported options:
882
- // extensions - array of extensions to include (e.g. ["md","html"]); omit for all files
883
- // hash - compute MD5 hash of each file's content and set element.hash
884
- // normalize - convert backslashes to forward slashes in all paths
885
- // sorted - sort directory entries alphabetically before recursing
886
- // sizeInBytes / size - include file size as element.sizeInBytes
887
- // stat - include the fs.Stats object as element.stat
888
- // depth - maximum recursion depth (default: unlimited)
889
- // excludeEmptyDirectories - skip directories that contain no entries
890
- // symbolicLinks - set false to skip symbolic links (default: include them)
891
- // Each element passed to the callback has: name, path (absolute), relativePath, and any
892
- // optional fields enabled above.
893
- exports.scan_dir = (dirPath, opts = {}, fileCallback) => {
894
- const extensions = opts.extensions ? new Set(opts.extensions.map((e) => e.toLowerCase())) : null;
895
- const maxDepth = opts.depth !== undefined ? opts.depth : Infinity;
896
-
897
- const walk = (currentPath, depth) => {
898
- if (depth > maxDepth) return;
899
- let entries;
900
- try {
901
- entries = fs.readdirSync(currentPath, { withFileTypes: true });
902
- } catch (_) { return; }
903
-
904
- if (opts.sorted) entries = entries.slice().sort((a, b) => a.name.localeCompare(b.name));
905
-
906
- for (const entry of entries) {
907
- if (opts.symbolicLinks === false && entry.isSymbolicLink()) continue;
908
- const fullPath = path.join(currentPath, entry.name);
909
-
910
- if (entry.isDirectory()) {
911
- if (opts.excludeEmptyDirectories) {
912
- try { if (fs.readdirSync(fullPath).length === 0) continue; } catch (_) { continue; }
913
- }
914
- walk(fullPath, depth + 1);
915
- } else if (entry.isFile()) {
916
- const ext = path.extname(entry.name).slice(1).toLowerCase();
917
- if (extensions && !extensions.has(ext)) continue;
918
-
919
- const absPath = opts.normalize ? fullPath.replaceAll("\\", "/") : fullPath;
920
- const relPath = opts.normalize
921
- ? path.relative(dirPath, fullPath).replaceAll("\\", "/")
922
- : path.relative(dirPath, fullPath);
923
-
924
- const element = { name: entry.name, path: absPath, relativePath: relPath, extension: ext };
925
-
926
- if (opts.sizeInBytes || opts.size || opts.stat) {
927
- const stat = fs.statSync(fullPath);
928
- if (opts.sizeInBytes || opts.size) element.sizeInBytes = stat.size;
929
- if (opts.stat) element.stat = stat;
930
- }
931
-
932
- if (opts.hash) {
933
- element.hash = crypto.createHash("md5").update(fs.readFileSync(fullPath)).digest("hex");
934
- }
935
-
936
- fileCallback(element);
937
- }
938
- }
939
- };
940
-
941
- walk(dirPath, 1);
942
- };
943
-
944
- // Resolves the true on-disk casing of a file path by walking each path segment
945
- // and doing a case-insensitive match against the actual directory listing.
946
- // This is important on case-sensitive filesystems (Linux) to catch casing mismatches
947
- // that would silently pass on macOS/Windows but break in CI or production.
948
- // On Windows, the drive letter is normalised to uppercase and backslash delimiters are used.
949
- exports.true_case_path_sync = (filePath) => {
950
- const isWin = process.platform === "win32";
951
- const delim = isWin ? "\\" : "/";
952
- filePath = path.normalize(filePath);
953
- const segments = filePath.split(delim).filter((s) => s !== "");
954
- let base = path.isAbsolute(filePath) ? (isWin ? segments.shift().toUpperCase() : "") : process.cwd();
955
- return segments.reduce((realPath, seg) => {
956
- const entries = fs.readdirSync(realPath + delim);
957
- // Escape any regex special chars in the segment name before building the pattern
958
- const re = new RegExp(`^${seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i");
959
- const match = entries.find((e) => re.test(e));
960
- if (!match) throw new Error(`true_case_path_sync: no match for "${seg}" in "${realPath}"`);
961
- return realPath + delim + match;
962
- }, base);
963
- };
964
-
965
- // Creates an empty temporary file with a random name and optional file extension suffix
966
- // (e.g. { postfix: ".mmd" }). Returns an object with:
967
- // name - the absolute path to the temp file
968
- // removeCallback - call this to delete the file when done; errors are silently ignored
969
- // so it is safe to call even if the file was already cleaned up.
970
- exports.tmp_file_sync = (opts = {}) => {
971
- const name = path.join(os.tmpdir(), `hdoc-${crypto.randomBytes(8).toString("hex")}${opts.postfix || ""}`);
972
- fs.closeSync(fs.openSync(name, "w"));
973
- return { name, removeCallback: () => { try { fs.unlinkSync(name); } catch (_) {} } };
974
- };
975
-
976
- // Parses a YAML string into a plain JS object. Designed for markdown frontmatter,
977
- // which is always simple: scalar key/value pairs, block arrays (- item), and inline
978
- // arrays ([a, b, c]). Does not support anchors, multi-line strings, or nested objects.
979
- // parseVal handles type coercion: booleans, null, integers, floats, quoted strings,
980
- // inline arrays, and plain strings (returned as-is).
981
- exports.parse_yaml = (str) => {
982
- const parseVal = (v) => {
983
- if (v === "true") return true;
984
- if (v === "false") return false;
985
- if (v === "null" || v === "~") return null;
986
- if (/^-?\d+$/.test(v)) return parseInt(v, 10);
987
- if (/^-?\d*\.\d+$/.test(v)) return parseFloat(v);
988
- if (/^['"].*['"]$/.test(v)) return v.slice(1, -1); // strip surrounding quotes
989
- if (v.startsWith("[") && v.endsWith("]")) return v.slice(1, -1).split(",").map(i => parseVal(i.trim()));
990
- return v;
991
- };
992
- const result = {};
993
- let currentKey = null;
994
- for (const line of str.split("\n")) {
995
- if (!line.trim() || /^\s*#/.test(line)) continue;
996
- // Block array item (indented dash): append to the current key's array
997
- const arrMatch = line.match(/^\s+-\s+(.+)$/);
998
- if (arrMatch && currentKey) {
999
- if (!Array.isArray(result[currentKey])) result[currentKey] = [];
1000
- result[currentKey].push(parseVal(arrMatch[1].trim()));
1001
- continue;
1002
- }
1003
- // key: value pair — value may be empty (e.g. start of a block array)
1004
- const kvMatch = line.match(/^([^:]+):\s*(.*)$/);
1005
- if (kvMatch) {
1006
- currentKey = kvMatch[1].trim();
1007
- result[currentKey] = kvMatch[2].trim() ? parseVal(kvMatch[2].trim()) : null;
1008
- }
1009
- }
1010
- return result;
1011
- };
1012
-
1013
- // Pretty-prints an XML string with configurable indentation and line endings.
1014
- // Tokenises the input using a regex that preserves CDATA sections, comments, and
1015
- // processing instructions as atomic units so their content is never re-indented.
1016
- // Options:
1017
- // indentation - string to use per indent level (default: two spaces)
1018
- // lineSeparator - line ending to use (default: "\n")
1019
- // collapseContent - when true (default), elements that contain only a single text
1020
- // node are kept on one line: <tag>text</tag> rather than split
1021
- // across three lines. Uses one-token lookahead to detect this.
1022
- exports.xml_format = (xml, opts = {}) => {
1023
- const ind = opts.indentation || " ";
1024
- const sep = opts.lineSeparator || "\n";
1025
- const collapse = opts.collapseContent !== false;
1026
- // Match CDATA, comments, PIs, tags, and text nodes as individual tokens
1027
- const tokens = xml.trim().match(/(<!\[CDATA\[[\s\S]*?\]\]>|<!--[\s\S]*?-->|<[^>]+>|[^<]+)/g) || [];
1028
- let depth = 0;
1029
- let out = "";
1030
- for (let i = 0; i < tokens.length; i++) {
1031
- const t = tokens[i].trim();
1032
- if (!t) continue;
1033
- const isClose = t.startsWith("</");
1034
- const isSelf = t.startsWith("<") && t.endsWith("/>") && !t.startsWith("<?");
1035
- const isSpecial = t.startsWith("<?") || t.startsWith("<!--") || t.startsWith("<![");
1036
- const isOpen = t.startsWith("<") && !isClose && !isSelf && !isSpecial;
1037
- if (isClose) {
1038
- depth = Math.max(0, depth - 1);
1039
- out += ind.repeat(depth) + t + sep;
1040
- } else if (isSelf || isSpecial) {
1041
- out += ind.repeat(depth) + t + sep;
1042
- } else if (isOpen) {
1043
- // Collapse: if the very next token is text and the one after is the closing tag,
1044
- // emit all three on one line and skip those two tokens.
1045
- if (collapse && i + 2 < tokens.length) {
1046
- const nextTxt = tokens[i + 1] ? tokens[i + 1].trim() : "";
1047
- const nextClose = tokens[i + 2] ? tokens[i + 2].trim() : "";
1048
- if (nextTxt && !nextTxt.startsWith("<") && nextClose.startsWith("</")) {
1049
- out += ind.repeat(depth) + t + nextTxt + nextClose + sep;
1050
- i += 2;
1051
- continue;
1052
- }
1053
- }
1054
- out += ind.repeat(depth) + t + sep;
1055
- depth++;
1056
- } else {
1057
- // Plain text node
1058
- out += ind.repeat(depth) + t + sep;
1059
- }
1060
- }
1061
- return out.trimEnd();
1062
- };
1063
-
1064
- exports.find_string_in_string = (fileContent, searchString) => {
1065
- const lines = fileContent.split('\n');
1066
-
1067
- for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {
1068
- const columnNumber = lines[lineNumber].indexOf(searchString);
1069
-
1070
- if (columnNumber !== -1) {
1071
- // Return 1-based line and column numbers
1072
- return { line: lineNumber + 1, column: columnNumber + 1 };
1073
- }
1074
- }
1075
-
1076
- // If not found, return null
1077
- return null;
1078
- }
1079
- })();
1
+ (() => {
2
+ const cheerio = require("cheerio");
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const os = require("node:os");
6
+ const path = require("node:path");
7
+
8
+ const includesCache = {};
9
+
10
+ let retried = false;
11
+
12
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
13
+
14
+ // GitHub enforces a "secondary" (burst) rate limit on top of the hourly one:
15
+ // too many requests in a short window are rejected with 403/429 even when the
16
+ // hourly budget is intact. The contributor pull fires one call per file, so a
17
+ // fast run trips it. Historically `hdoc validate` got away with this only
18
+ // because verbose console output slowed the loop down between calls; the
19
+ // --quiet mode removed that incidental spacing and started hitting 403s.
20
+ // Throttle GitHub API calls to a fixed minimum interval so behaviour no longer
21
+ // depends on how chatty the console is.
22
+ let _last_github_call_at = 0;
23
+ const GITHUB_HOST = "api.github.com";
24
+ const GITHUB_MIN_INTERVAL_MS = 800;
25
+ // Never block the whole build waiting on a single rate-limited call.
26
+ const GITHUB_MAX_BACKOFF_MS = 60000;
27
+
28
+ // For a rate-limited response, work out how long to wait before retrying.
29
+ // Returns null when the response is NOT a retryable rate limit (e.g. a genuine
30
+ // 403 auth/forbidden), in which case the caller should give up immediately.
31
+ const rate_limit_wait_ms = (response) => {
32
+ if (response.status !== 403 && response.status !== 429) return null;
33
+ const retry_after = response.headers.get("retry-after");
34
+ if (retry_after !== null) {
35
+ const secs = Number(retry_after);
36
+ if (!Number.isNaN(secs)) return Math.max(0, secs * 1000);
37
+ }
38
+ const remaining = response.headers.get("x-ratelimit-remaining");
39
+ const reset = response.headers.get("x-ratelimit-reset");
40
+ if (remaining === "0" && reset !== null) {
41
+ const reset_ms = Number(reset) * 1000 - Date.now();
42
+ if (!Number.isNaN(reset_ms)) return Math.max(0, reset_ms);
43
+ }
44
+ // 403/429 with no rate-limit signal => auth/forbidden, not worth retrying.
45
+ return null;
46
+ };
47
+
48
+ // Wraps the built-in fetch() with automatic retry for transient errors.
49
+ // Retries up to maxRetries times when the server returns an HTTP error status
50
+ // >= 400, except for 401 (Unauthorized) and non-rate-limit 403 (Forbidden)
51
+ // which are auth failures that won't be resolved by retrying. GitHub
52
+ // rate-limit responses (403/429 carrying Retry-After or X-RateLimit-Reset) ARE
53
+ // retried after honouring the indicated wait. Network errors (where fetch
54
+ // itself throws) are also retried. Sets the module-level `retried` flag so
55
+ // callers can detect and log a success-after-retry message.
56
+ //
57
+ // Pass `timeoutMs` inside options to apply a per-attempt timeout. A fresh
58
+ // AbortSignal is created for every attempt so that a timed-out first attempt
59
+ // does not leave an already-aborted signal in place for the retries.
60
+ const fetchWithRetry = async (url, options = {}, maxRetries = 5) => {
61
+ const { timeoutMs, ...fetchOptions } = options;
62
+ const is_github = typeof url === "string" && url.includes(GITHUB_HOST);
63
+ let retryCount = 0;
64
+ while (true) {
65
+ // Space out GitHub API calls so a fast (quiet) run does not burst past
66
+ // the secondary rate limit. No effect on non-GitHub requests.
67
+ if (is_github) {
68
+ const since = Date.now() - _last_github_call_at;
69
+ if (since < GITHUB_MIN_INTERVAL_MS) {
70
+ await sleep(GITHUB_MIN_INTERVAL_MS - since);
71
+ }
72
+ _last_github_call_at = Date.now();
73
+ }
74
+ // Create a fresh signal for each attempt; reusing an already-aborted
75
+ // signal would cause every subsequent retry to abort immediately.
76
+ const attemptOptions = timeoutMs
77
+ ? { ...fetchOptions, signal: AbortSignal.timeout(timeoutMs) }
78
+ : fetchOptions;
79
+ let response;
80
+ try {
81
+ response = await fetch(url, attemptOptions);
82
+ } catch (err) {
83
+ // Network-level error (DNS failure, connection refused, timeout, etc.)
84
+ retryCount++;
85
+ if (retryCount > maxRetries) throw err;
86
+ retried = true;
87
+ continue;
88
+ }
89
+ if (response.ok || response.status === 401) {
90
+ return response;
91
+ }
92
+ // 403/429: retry only when it is a rate limit we can wait out.
93
+ if (response.status === 403 || response.status === 429) {
94
+ const wait_ms = rate_limit_wait_ms(response);
95
+ if (wait_ms === null) return response; // auth/forbidden — give up
96
+ retryCount++;
97
+ if (retryCount > maxRetries) return response;
98
+ retried = true;
99
+ const backoff_ms = Math.min(wait_ms, GITHUB_MAX_BACKOFF_MS);
100
+ console.log(
101
+ `GitHub rate limit hit (HTTP ${response.status}) — waiting ${Math.round(backoff_ms / 1000)}s before retry ${retryCount}/${maxRetries}. Provide --git-token <PAT> to raise the limit.`,
102
+ );
103
+ await sleep(backoff_ms);
104
+ continue;
105
+ }
106
+ retryCount++;
107
+ if (retryCount > maxRetries) return response;
108
+ retried = true;
109
+ }
110
+ };
111
+
112
+ exports.fetchWithRetry = fetchWithRetry;
113
+
114
+ exports.content_type_for_ext = (ext) => {
115
+ switch (ext) {
116
+ case ".z":
117
+ return "application/x-compress";
118
+ case ".tgz":
119
+ return "application/x-compressed";
120
+ case ".gz":
121
+ return "application/x-gzip";
122
+ case ".zip":
123
+ return "application/x-zip-compressed";
124
+ case ".xml":
125
+ return "application/xml";
126
+ case ".bmp":
127
+ return "image/bmp";
128
+ case ".gif":
129
+ return "image/gif";
130
+ case ".jpg":
131
+ return "image/jpeg";
132
+ case ".png":
133
+ return "image/png";
134
+ case ".tiff":
135
+ return "image/tiff";
136
+ case ".ico":
137
+ return "image/x-icon";
138
+ case ".svg":
139
+ return "image/svg+xml";
140
+ case ".css":
141
+ return "text/css";
142
+ case ".htm":
143
+ case ".html":
144
+ return "text/html";
145
+ case ".txt":
146
+ return "text/plain";
147
+ case ".md":
148
+ return "text/plain";
149
+ case ".json":
150
+ return "application/json";
151
+ case ".js":
152
+ return "application/javascript";
153
+ default:
154
+ return "application/octet-stream";
155
+ }
156
+ };
157
+
158
+ exports.valid_url = (url) => {
159
+ const stringIsAValidUrl = (s) => {
160
+ try {
161
+ const url_obj = new URL(s);
162
+ return url_obj;
163
+ } catch (err) {
164
+ return false;
165
+ }
166
+ };
167
+ return stringIsAValidUrl(url);
168
+ };
169
+
170
+ exports.expand_variables = (text, docId = "") => {
171
+ let clean_text = text;
172
+ if (docId !== "") {
173
+ clean_text = clean_text.replaceAll("{{DOC_ID}}", docId);
174
+ }
175
+ clean_text = clean_text.replaceAll("{{BUILD_NUMBER}}", "0");
176
+
177
+ let build_date = new Date().toISOString();
178
+ build_date = build_date.replace("T", " ");
179
+ build_date = build_date.substring(0, 19);
180
+ clean_text = clean_text.replaceAll("{{BUILD_DATE}}", build_date);
181
+ return clean_text;
182
+ };
183
+
184
+ exports.process_includes = async (file_path, body, source_path) => {
185
+ const response = {
186
+ body: body,
187
+ found: 0,
188
+ success: 0,
189
+ failed: 0,
190
+ included: [],
191
+ errors: [],
192
+ };
193
+
194
+ // Search body for INCLUDEs
195
+ const regexp = /\[\[INCLUDE .*]]/g;
196
+ const body_array = [...response.body.matchAll(regexp)];
197
+
198
+ for (let i = 0; i < body_array.length; i++) {
199
+ response.found++;
200
+
201
+ // Extract include data from array
202
+ const include_value = body_array[i][0];
203
+
204
+ let link;
205
+ try {
206
+ link = include_value.split(" ")[1];
207
+ link = link.substring(0, link.length - 2);
208
+ } catch (e) {
209
+ response.failed++;
210
+ response.errors.push(
211
+ `Error parsing INCLUDE [${include_value}] from [${file_path}]: ${err}`,
212
+ );
213
+ continue;
214
+ }
215
+
216
+ if (
217
+ (link.startsWith("http://") || link.startsWith("https://")) &&
218
+ includesCache[link] !== undefined
219
+ ) {
220
+ console.log(`Serving From Cache: ${link}`);
221
+ response.body = response.body.replace(
222
+ include_value,
223
+ includesCache[link],
224
+ );
225
+ response.success++;
226
+ continue;
227
+ }
228
+
229
+ // Validate link in INCLUDE
230
+ let file_content;
231
+ if (link.startsWith("http://") || link.startsWith("https://")) {
232
+ // Remote content to include
233
+ try {
234
+ new URL(link);
235
+ } catch (e) {
236
+ response.failed++;
237
+ response.errors.push(
238
+ `Error validating INCLUDE link [${link}] from [${file_path}]: ${e}`,
239
+ );
240
+ continue;
241
+ }
242
+
243
+ try {
244
+ const file_response = await fetchWithRetry(link);
245
+ if (retried) {
246
+ retried = false;
247
+ console.log("API call retry success!");
248
+ }
249
+ if (file_response.status === 200) {
250
+ file_content = await file_response.text();
251
+ } else {
252
+ throw `Unexpected Status ${file_response.status}`;
253
+ }
254
+ } catch (e) {
255
+ response.failed++;
256
+ response.errors.push(
257
+ `Error getting INCLUDE link content [${link}] from [${file_path}]: ${e}`,
258
+ );
259
+ continue;
260
+ }
261
+ console.log(`Included From Remote Source: ${link}`);
262
+ } else {
263
+ // Local content to include
264
+ try {
265
+ file_content = fs.readFileSync(path.join(source_path, link), "utf8");
266
+ } catch (e) {
267
+ response.failed++;
268
+ response.errors.push(
269
+ `Error getting INCLUDE file [${link}] from [${file_path}]: ${e}`,
270
+ );
271
+ continue;
272
+ }
273
+ console.log(`Included From Local Source: ${link}`);
274
+ }
275
+ response.success++;
276
+ includesCache[link] = file_content;
277
+ response.body = response.body.replace(include_value, file_content);
278
+ }
279
+ return response;
280
+ };
281
+
282
+ // Takes html, returns the first heading detected in the order provided in h_to_search
283
+ // Looks for h1 tags first, then hX, hY, hZ in order
284
+ exports.getFirstHTMLHeading = (html_body, h_to_search = ["h1"]) => {
285
+ const $ = cheerio.load(html_body);
286
+ for (const tag of h_to_search) {
287
+ const el = $(tag).first();
288
+ if (el.length > 0) return el;
289
+ }
290
+ return false;
291
+ };
292
+
293
+ // Exported for inter-book anchor validation — the anchor id derivation MUST
294
+ // stay identical between build-time wrapping and cross-book link checks.
295
+ exports.makeAnchorIdFriendly = (str) => makeAnchorIdFriendly(str);
296
+
297
+ const makeAnchorIdFriendly = (str) => {
298
+ return `hb-doc-anchor-${str // Add prefix
299
+ .toLowerCase() // Convert to lowercase
300
+ .trim() // Trim leading and trailing spaces
301
+ .replace(/[^a-z0-9\s-]/g, "") // Remove all non-alphanumeric characters except spaces and hyphens
302
+ .replace(/\s+/g, "-") // Replace spaces with hyphens
303
+ .replace(/-+/g, "-")}`; // Replace multiple hyphens with a single hyphen
304
+ };
305
+
306
+ // Processes HTML, wraps h2 and h3 tags and their content in divs with an id matching that of the h text
307
+ exports.wrapHContent = (htmlContent) => {
308
+ const $ = cheerio.load(htmlContent, { decodeEntities: false });
309
+ let result = '';
310
+ let inH2 = false;
311
+ let inH3 = false;
312
+
313
+ $('body').contents().each(function() {
314
+ const tagName = this.type === 'tag' ? this.name?.toLowerCase() : null;
315
+
316
+ if (tagName === 'h2') {
317
+ // Close open h3 (nested inside h2), then close h2
318
+ if (inH3) { result += '</div>'; inH3 = false; }
319
+ if (inH2) { result += '</div>'; inH2 = false; }
320
+ const anchorId = makeAnchorIdFriendly($(this).text().trim());
321
+ result += `<div id="${anchorId}">${$.html(this)}`;
322
+ inH2 = true;
323
+ } else if (tagName === 'h3') {
324
+ // Close previous h3 (it stays nested inside any open h2)
325
+ if (inH3) { result += '</div>'; inH3 = false; }
326
+ const anchorId = makeAnchorIdFriendly($(this).text().trim());
327
+ result += `<div id="${anchorId}">${$.html(this)}`;
328
+ inH3 = true;
329
+ } else {
330
+ result += $.html(this);
331
+ }
332
+ });
333
+
334
+ // Flush remaining open divs — h3 is nested inside h2 so close inner first
335
+ if (inH3) result += '</div>';
336
+ if (inH2) result += '</div>';
337
+
338
+ return `<html><head></head><body>${result}</body></html>`;
339
+ };
340
+
341
+ // Combined single-pass version of wrapHContent + getFirstHTMLHeading + get_html_read_time.
342
+ // Iterates body contents once to wrap h2/h3 divs AND extract the first matching heading text,
343
+ // first paragraph text, and reading-time estimate — avoiding 3 extra cheerio.load() calls.
344
+ exports.wrapAndExtract = (htmlContent, h_tags_to_search = ["h1"]) => {
345
+ const $ = cheerio.load(htmlContent, { decodeEntities: false });
346
+ let result = '';
347
+ let inH2 = false;
348
+ let inH3 = false;
349
+ let firstHeadingText = null;
350
+ let firstParagraphText = null;
351
+
352
+ $('body').contents().each(function() {
353
+ const tagName = this.type === 'tag' ? this.name?.toLowerCase() : null;
354
+ const text = tagName ? $(this).text().trim() : null;
355
+
356
+ if (firstHeadingText === null && tagName && h_tags_to_search.includes(tagName)) {
357
+ firstHeadingText = text;
358
+ }
359
+ if (firstParagraphText === null && tagName === 'p') {
360
+ firstParagraphText = text;
361
+ }
362
+
363
+ if (tagName === 'h2') {
364
+ if (inH3) { result += '</div>'; inH3 = false; }
365
+ if (inH2) { result += '</div>'; inH2 = false; }
366
+ result += `<div id="${makeAnchorIdFriendly(text)}">${$.html(this)}`;
367
+ inH2 = true;
368
+ } else if (tagName === 'h3') {
369
+ if (inH3) { result += '</div>'; inH3 = false; }
370
+ result += `<div id="${makeAnchorIdFriendly(text)}">${$.html(this)}`;
371
+ inH3 = true;
372
+ } else {
373
+ result += $.html(this);
374
+ }
375
+ });
376
+
377
+ if (inH3) result += '</div>';
378
+ if (inH2) result += '</div>';
379
+
380
+ // Word count re-uses the already-parsed DOM — no extra cheerio.load()
381
+ const bodyText = $("body").text();
382
+ const wordCount = bodyText.trim().split(/\s+/).filter(Boolean).length;
383
+ const readTimeMins = wordCount === 0 ? 0 : (Math.round(wordCount / 200) || 1);
384
+
385
+ return {
386
+ html: `<html><head></head><body>${result}</body></html>`,
387
+ firstHeadingText,
388
+ firstParagraphText,
389
+ readTimeMins,
390
+ };
391
+ };
392
+
393
+ exports.getIDDivs = (html_body) => {
394
+ const $ = cheerio.load(html_body, {
395
+ decodeEntities: false,
396
+ });
397
+
398
+ const divs = [];
399
+
400
+ $("div").each(function (i, element) {
401
+ if ($(this).attr("id")?.startsWith("hb-doc-anchor-")) {
402
+ divs.push({
403
+ id: $(this).attr("id"),
404
+ html: $(this).html(),
405
+ text: $(this).text(),
406
+ });
407
+ }
408
+ });
409
+ return divs;
410
+ };
411
+
412
+ exports.getHTMLFrontmatterHeader = (html_body) => {
413
+ const response = {
414
+ fm_header: "",
415
+ fm_properties: {},
416
+ };
417
+ const $ = cheerio.load(html_body, {
418
+ decodeEntities: false,
419
+ });
420
+ if (
421
+ $._root?.children &&
422
+ Array.isArray($._root.children) &&
423
+ $._root.children.length > 0
424
+ ) {
425
+ for (const child of $._root.children) {
426
+ if (
427
+ child.type === "comment" &&
428
+ child.data &&
429
+ child.data.startsWith("[[FRONTMATTER")
430
+ ) {
431
+ // We have a Frontmatter header - return each property in an array
432
+ const fm_properties = child.data.split(/\r?\n/);
433
+ for (let i = 0; i < fm_properties.length; i++) {
434
+ if (fm_properties[i].includes(":")) {
435
+ const property_details = fm_properties[i].split(/:(.*)/s);
436
+ if (property_details.length > 1) {
437
+ let prop_val = property_details[1].trim();
438
+ if (/^".*"$/.test(prop_val)) {
439
+ prop_val = prop_val.substring(1, prop_val.length - 1);
440
+ }
441
+ if (property_details[0].trim().toLowerCase() === "title") {
442
+ // Decode HTML entities in the title value: handles named entities
443
+ // (&amp; &lt; &gt; &quot; &#39; &apos;), decimal numeric refs (&#123;),
444
+ // and hex numeric refs (&#x7B;).
445
+ prop_val = prop_val.replace(/&amp;|&lt;|&gt;|&quot;|&#39;|&apos;|&#(\d+);|&#x([0-9a-fA-F]+);/g,
446
+ (m, dec, hex) => dec ? String.fromCharCode(+dec) : hex ? String.fromCharCode(parseInt(hex, 16)) : ({ '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&apos;': "'" })[m]);
447
+ }
448
+ response.fm_properties[
449
+ property_details[0].trim().toLowerCase()
450
+ ] = prop_val;
451
+ }
452
+ }
453
+ }
454
+
455
+ // And return the header as a whole so it can be easily replaced
456
+ response.fm_header = child.data;
457
+ }
458
+ }
459
+ }
460
+
461
+ return response;
462
+ };
463
+
464
+ exports.truncate_string = (str, n, useWordBoundary) => {
465
+ if (str.length <= n) {
466
+ return str;
467
+ }
468
+ const subString = str.slice(0, n - 1);
469
+ return `${
470
+ useWordBoundary
471
+ ? subString.slice(0, subString.lastIndexOf(" "))
472
+ : subString
473
+ }…`;
474
+ };
475
+
476
+ exports.html_to_text = (html, { baseElement } = {}) => {
477
+ const $ = cheerio.load(html, { decodeEntities: false });
478
+ if (baseElement) {
479
+ return $(baseElement).map((_i, el) => $(el).text()).get().join("\n");
480
+ }
481
+ return $("body").text();
482
+ };
483
+
484
+ exports.get_html_read_time = (html) => {
485
+ // Get word count
486
+ const text = exports.html_to_text(html);
487
+ const word_count = text.trim().split(/\s+/).filter(Boolean).length;
488
+ if (word_count === 0) return 0;
489
+
490
+ // Calculate the read time - divide the word count by 200
491
+ let read_time = Math.round(word_count / 200);
492
+ if (read_time === 0) read_time = 1;
493
+ return read_time;
494
+ };
495
+
496
+ exports.get_github_api_path = (repo, relative_path) => {
497
+ if (repo) {
498
+ const clean_repo = repo.endsWith("/") ? repo.slice(0, -1) : repo;
499
+ const github_paths = {};
500
+ github_paths.api_path = clean_repo.replace(
501
+ "https://github.com/",
502
+ "https://api.github.com/repos/",
503
+ );
504
+ github_paths.api_path += `/commits?path=${encodeURIComponent(
505
+ `/${relative_path.replace("\\\\", "/").replace("\\", "/")}`,
506
+ )}`;
507
+ github_paths.edit_path = `${repo}/blob/main/${relative_path.replace("\\\\", "/").replace("\\", "/")}`;
508
+ return github_paths;
509
+ }
510
+ return "";
511
+ };
512
+
513
+ const get_github_contributors_path = (repo) => {
514
+ const clean_repo = repo.endsWith("/") ? repo.slice(0, -1) : repo;
515
+ const github_paths = {};
516
+ github_paths.api_path = clean_repo.replace(
517
+ "https://github.com/",
518
+ "https://api.github.com/repos/",
519
+ );
520
+ github_paths.api_path += "/contributors";
521
+ return github_paths;
522
+ };
523
+
524
+ exports.get_github_repo_details = async (
525
+ github_url,
526
+ github_api_token,
527
+ ) => {
528
+ const response = {
529
+ success: false,
530
+ error: "",
531
+ data: {},
532
+ private: false
533
+ };
534
+ const request_options = {
535
+ headers: {
536
+ "User-Agent": "HornbillDocsBuild",
537
+ "Cache-Control": "no-cache",
538
+ Host: "api.github.com",
539
+ Accept: "application/json",
540
+ },
541
+ timeout: 5000,
542
+ };
543
+ if (github_api_token !== "") {
544
+ request_options.headers.authorization = `Bearer ${github_api_token}`;
545
+ }
546
+
547
+ let github_response;
548
+ let github_data;
549
+ try {
550
+ github_response = await fetchWithRetry(github_url, {
551
+ headers: request_options.headers,
552
+ timeoutMs: 5000,
553
+ });
554
+ if (retried) {
555
+ retried = false;
556
+ console.log("API call retry success!");
557
+ }
558
+ github_data = await github_response.json();
559
+ } catch (err) {
560
+ // Network-level failure (fetchWithRetry re-throws after exhausting retries)
561
+ response.error = `Unexpected response from GitHub for [${github_url}:\n${JSON.stringify(err)}]`;
562
+ return response;
563
+ }
564
+ // fetch does not throw on HTTP errors — return early for unexpected status codes
565
+ if (github_response.status !== 200 && github_response.status !== 401 && github_response.status !== 403) {
566
+ response.error = new Error(`HTTP ${github_response.status}`);
567
+ return response;
568
+ }
569
+ if (github_response.status === 200) {
570
+ response.success = true;
571
+ response.data = github_data;
572
+ response.private = github_data.private;
573
+ } else {
574
+ // Is it a 404 or 403?
575
+ response.error = `${github_response.status} : ${github_data.message}`;
576
+ }
577
+ return response;
578
+ };
579
+
580
+ exports.get_github_contributors = async (
581
+ github_url,
582
+ github_api_token,
583
+ repo,
584
+ ) => {
585
+ const response = {
586
+ success: false,
587
+ error: "",
588
+ contributors: [],
589
+ contributor_count: 0,
590
+ last_commit_date: "",
591
+ };
592
+ const contributors = {};
593
+
594
+ const request_options = {
595
+ headers: {
596
+ "User-Agent": "HornbillDocsBuild",
597
+ "Cache-Control": "no-cache",
598
+ Host: "api.github.com",
599
+ Accept: "application/json",
600
+ },
601
+ timeout: 5000,
602
+ };
603
+ if (github_api_token !== "") {
604
+ request_options.headers.authorization = `Bearer ${github_api_token}`;
605
+ }
606
+ let github_response;
607
+ let github_data;
608
+ try {
609
+ github_response = await fetchWithRetry(github_url, {
610
+ headers: request_options.headers,
611
+ timeoutMs: 5000,
612
+ });
613
+ if (retried) {
614
+ retried = false;
615
+ console.log("API call retry success!");
616
+ }
617
+ github_data = await github_response.json();
618
+ } catch (err) {
619
+ // Network-level failure (fetchWithRetry re-throws after exhausting retries)
620
+ response.error = `Unexpected response from GitHub for [${github_url}:\n${JSON.stringify(err)}]`;
621
+ return response;
622
+ }
623
+ // fetch does not throw on HTTP errors — return early for unexpected status codes
624
+ if (github_response.status !== 200 && github_response.status !== 401 && github_response.status !== 403) {
625
+ response.error = new Error(`HTTP ${github_response.status}`);
626
+ return response;
627
+ }
628
+ if (github_response.status === 200) {
629
+ response.success = true;
630
+ const commits = github_data;
631
+ for (const commit of commits) {
632
+ if (
633
+ commit.committer?.type &&
634
+ commit.committer.type.toLowerCase() === "user" &&
635
+ commit.committer.login.toLowerCase() !== "web-flow"
636
+ ) {
637
+ if (!contributors[commit.committer.id]) {
638
+ response.contributor_count++;
639
+ contributors[commit.committer.id] = {
640
+ login: commit.committer.login,
641
+ avatar_url: commit.committer.avatar_url,
642
+ html_url: commit.committer.html_url,
643
+ name: commit.commit.committer.name,
644
+ };
645
+ }
646
+ if (response.last_commit_date !== "") {
647
+ const new_commit_date = new Date(commit.commit.committer.date);
648
+ const exist_commit_date = new Date(response.last_commit_date);
649
+ if (new_commit_date > exist_commit_date)
650
+ response.last_commit_date = commit.commit.committer.date;
651
+ } else {
652
+ response.last_commit_date = commit.commit.committer.date;
653
+ }
654
+ } else if (commit.author?.id) {
655
+ if (!contributors[commit.author.id]) {
656
+ response.contributor_count++;
657
+ contributors[commit.author.id] = {
658
+ login: commit.author.login,
659
+ avatar_url: commit.author.avatar_url,
660
+ html_url: commit.author.html_url,
661
+ name: commit.commit.author.name,
662
+ };
663
+ }
664
+ if (response.last_commit_date !== "") {
665
+ const new_commit_date = new Date(commit.commit.author.date);
666
+ const exist_commit_date = new Date(response.last_commit_date);
667
+ if (new_commit_date > exist_commit_date)
668
+ response.last_commit_date = commit.commit.author.date;
669
+ } else {
670
+ response.last_commit_date = commit.commit.author.date;
671
+ }
672
+ }
673
+ }
674
+ for (const key in contributors) {
675
+ if (Object.hasOwn(contributors, key)) {
676
+ response.contributors.push(contributors[key]);
677
+ }
678
+ }
679
+ } else if (github_response.status === 403) {
680
+ // Private repo, fine-grained permissions don't yet support getting commits without content, get list from meta permissions
681
+ const contrib_url = get_github_contributors_path(repo).api_path;
682
+ try {
683
+ github_response = await fetchWithRetry(contrib_url, {
684
+ headers: request_options.headers,
685
+ timeoutMs: 5000,
686
+ });
687
+ if (retried) {
688
+ retried = false;
689
+ console.log("API call retry success!");
690
+ }
691
+ github_data = await github_response.json();
692
+ } catch (err) {
693
+ // Network-level failure (fetchWithRetry re-throws after exhausting retries)
694
+ response.error = `Unexpected response from GitHub for [${contrib_url}:\n${JSON.stringify(err)}]`;
695
+ return response;
696
+ }
697
+ // fetch does not throw on HTTP errors — return early if fallback request failed
698
+ if (github_response.status !== 200) {
699
+ response.error = new Error(`HTTP ${github_response.status}`);
700
+ return response;
701
+ }
702
+ if (github_response.status === 200) {
703
+ response.success = true;
704
+ const commits = github_data;
705
+ for (const commit of commits) {
706
+ if (
707
+ commit.type &&
708
+ commit.type.toLowerCase() === "user" &&
709
+ commit.login.toLowerCase() !== "web-flow"
710
+ ) {
711
+ if (!contributors[commit.id]) {
712
+ response.contributor_count++;
713
+ contributors[commit.id] = {
714
+ login: commit.login,
715
+ avatar_url: commit.avatar_url,
716
+ html_url: commit.html_url,
717
+ name: commit.name ? commit.name : commit.login,
718
+ };
719
+ }
720
+ if (
721
+ response.last_commit_date !== "" &&
722
+ response.last_commit_date !== "No Commit Date Available"
723
+ ) {
724
+ const new_commit_date = new Date(commit.date);
725
+ const exist_commit_date = new Date(response.last_commit_date);
726
+ if (new_commit_date > exist_commit_date)
727
+ response.last_commit_date = commit.date;
728
+ } else {
729
+ response.last_commit_date = commit.date
730
+ ? commit.date
731
+ : "No Commit Date Available";
732
+ }
733
+ }
734
+ }
735
+ for (const key in contributors) {
736
+ if (Object.hasOwn(contributors, key)) {
737
+ response.contributors.push(contributors[key]);
738
+ }
739
+ }
740
+ }
741
+ } else {
742
+ response.error = `Unexpected Status: ${github_response.status}.`;
743
+ }
744
+ return response;
745
+ };
746
+
747
+ exports.strip_drafts = (nav_items) => {
748
+ const return_nav = nav_items;
749
+ recurse_nav(return_nav);
750
+ return return_nav;
751
+ };
752
+
753
+ const recurse_nav = (nav_items) => {
754
+ for (const key in nav_items) {
755
+ if (nav_items[key].draft) {
756
+ nav_items.splice(key, 1);
757
+ recurse_nav(nav_items);
758
+ } else if (nav_items[key].items) {
759
+ recurse_nav(nav_items[key].items);
760
+ }
761
+ }
762
+ };
763
+
764
+ exports.build_breadcrumbs = (nav_items) => {
765
+ const response = {
766
+ bc: {},
767
+ errors: [],
768
+ };
769
+ const buildBreadcrumb = (items, parentLinks) => {
770
+ // Process parent links
771
+ let parentlink = true;
772
+ if (parentLinks.length > 0) {
773
+ if (parentLinks[0].link === undefined || parentLinks[0].link === "" || parentLinks[0].draft )
774
+ parentlink = false;
775
+
776
+ for (let i = 1; i < 10; i++) {
777
+ if (
778
+ parentLinks[i] &&
779
+ parentLinks[i].link === undefined &&
780
+ items.length > 0 &&
781
+ items[0].link
782
+ ) {
783
+ parentLinks[i].link = items[0].link;
784
+ }
785
+ }
786
+ }
787
+
788
+ // Loop through items, build breadcrumb
789
+ for (let i = 0; i < items.length; i++) {
790
+ if (!items[i].text) {
791
+ response.errors.push(
792
+ `The following Nav Item is missing its text property: ${JSON.stringify(
793
+ items[i],
794
+ )}`,
795
+ );
796
+ }
797
+
798
+ if (!items[i].link && !items[i].items) {
799
+ response.errors.push(
800
+ `The following Nav Item has no link or items property: ${JSON.stringify(
801
+ items[i],
802
+ )}`,
803
+ );
804
+ }
805
+ const item = items[i];
806
+ if (!parentlink && item.link) {
807
+ parentLinks[0].link = item.link;
808
+ parentlink = true;
809
+ }
810
+ const { text, link, items: subItems } = item;
811
+ const breadcrumb = [...parentLinks, { text, link }];
812
+
813
+ if (link) {
814
+ response.bc[link] = breadcrumb;
815
+ }
816
+
817
+ if (subItems) {
818
+ buildBreadcrumb(subItems, breadcrumb);
819
+ }
820
+ }
821
+ };
822
+
823
+ buildBreadcrumb(nav_items, []);
824
+ return response;
825
+ };
826
+
827
+ exports.get_draft_links = (items, parent_is_draft = false) => {
828
+ let draft_links = [];
829
+
830
+ for (const item of items) {
831
+ // Check if this item is draft or if any parent was draft
832
+ const is_draft = parent_is_draft || item.draft;
833
+
834
+ // If the current item has a link and is draft (or parent is draft), add the link
835
+ if (is_draft && item.link) {
836
+ draft_links.push(item.link);
837
+ }
838
+
839
+ // If the current item has nested items, recursively check them
840
+ if (item.items) {
841
+ draft_links = draft_links.concat(this.get_draft_links(item.items, is_draft));
842
+ }
843
+ }
844
+ return draft_links;
845
+ };
846
+
847
+ exports.load_product_families = async () => {
848
+ const response = {
849
+ success: false,
850
+ prod_families: {},
851
+ prods_supported: [],
852
+ errors: "",
853
+ };
854
+ const prod_families_url = "https://docs.hornbill.com/_books/products.json";
855
+ for (let i = 1; i < 4; i++) {
856
+ try {
857
+ const prods = await fetch(prod_families_url, {
858
+ signal: AbortSignal.timeout(5000),
859
+ });
860
+ if (prods.status === 200) {
861
+ response.prod_families = await prods.json();
862
+ response.prods_supported = [];
863
+ for (let i = 0; i < response.prod_families.products.length; i++) {
864
+ response.prods_supported.push(
865
+ response.prod_families.products[i].id,
866
+ );
867
+ }
868
+ response.success = true;
869
+ break;
870
+ }
871
+ throw `Unexpected status - ${prods.status} ${prods.statusText}`;
872
+ } catch (e) {
873
+ if (response.errors === "")
874
+ response.errors = `Request to ${prod_families_url} failed:`;
875
+ response.errors += `\nAttempt ${i} - Error returning product families: ${e}`;
876
+ // Wait 2 seconds and try again
877
+ await new Promise((r) => setTimeout(r, 2000));
878
+ }
879
+ }
880
+ return response;
881
+ };
882
+
883
+ // Recursively walks a directory tree and invokes fileCallback(element) for each file.
884
+ // Mirrors the dree.scan API so existing options objects and callbacks work unchanged.
885
+ // Supported options:
886
+ // extensions - array of extensions to include (e.g. ["md","html"]); omit for all files
887
+ // hash - compute MD5 hash of each file's content and set element.hash
888
+ // normalize - convert backslashes to forward slashes in all paths
889
+ // sorted - sort directory entries alphabetically before recursing
890
+ // sizeInBytes / size - include file size as element.sizeInBytes
891
+ // stat - include the fs.Stats object as element.stat
892
+ // depth - maximum recursion depth (default: unlimited)
893
+ // excludeEmptyDirectories - skip directories that contain no entries
894
+ // symbolicLinks - set false to skip symbolic links (default: include them)
895
+ // Each element passed to the callback has: name, path (absolute), relativePath, and any
896
+ // optional fields enabled above.
897
+ exports.scan_dir = (dirPath, opts = {}, fileCallback) => {
898
+ const extensions = opts.extensions ? new Set(opts.extensions.map((e) => e.toLowerCase())) : null;
899
+ const maxDepth = opts.depth !== undefined ? opts.depth : Infinity;
900
+
901
+ const walk = (currentPath, depth) => {
902
+ if (depth > maxDepth) return;
903
+ let entries;
904
+ try {
905
+ entries = fs.readdirSync(currentPath, { withFileTypes: true });
906
+ } catch (_) { return; }
907
+
908
+ if (opts.sorted) entries = entries.slice().sort((a, b) => a.name.localeCompare(b.name));
909
+
910
+ for (const entry of entries) {
911
+ if (opts.symbolicLinks === false && entry.isSymbolicLink()) continue;
912
+ const fullPath = path.join(currentPath, entry.name);
913
+
914
+ if (entry.isDirectory()) {
915
+ if (opts.excludeEmptyDirectories) {
916
+ try { if (fs.readdirSync(fullPath).length === 0) continue; } catch (_) { continue; }
917
+ }
918
+ walk(fullPath, depth + 1);
919
+ } else if (entry.isFile()) {
920
+ const ext = path.extname(entry.name).slice(1).toLowerCase();
921
+ if (extensions && !extensions.has(ext)) continue;
922
+
923
+ const absPath = opts.normalize ? fullPath.replaceAll("\\", "/") : fullPath;
924
+ const relPath = opts.normalize
925
+ ? path.relative(dirPath, fullPath).replaceAll("\\", "/")
926
+ : path.relative(dirPath, fullPath);
927
+
928
+ const element = { name: entry.name, path: absPath, relativePath: relPath, extension: ext };
929
+
930
+ if (opts.sizeInBytes || opts.size || opts.stat) {
931
+ const stat = fs.statSync(fullPath);
932
+ if (opts.sizeInBytes || opts.size) element.sizeInBytes = stat.size;
933
+ if (opts.stat) element.stat = stat;
934
+ }
935
+
936
+ if (opts.hash) {
937
+ element.hash = crypto.createHash("md5").update(fs.readFileSync(fullPath)).digest("hex");
938
+ }
939
+
940
+ fileCallback(element);
941
+ }
942
+ }
943
+ };
944
+
945
+ walk(dirPath, 1);
946
+ };
947
+
948
+ // Resolves the true on-disk casing of a file path by walking each path segment
949
+ // and doing a case-insensitive match against the actual directory listing.
950
+ // This is important on case-sensitive filesystems (Linux) to catch casing mismatches
951
+ // that would silently pass on macOS/Windows but break in CI or production.
952
+ // On Windows, the drive letter is normalised to uppercase and backslash delimiters are used.
953
+ exports.true_case_path_sync = (filePath) => {
954
+ const isWin = process.platform === "win32";
955
+ const delim = isWin ? "\\" : "/";
956
+ filePath = path.normalize(filePath);
957
+ const segments = filePath.split(delim).filter((s) => s !== "");
958
+ let base = path.isAbsolute(filePath) ? (isWin ? segments.shift().toUpperCase() : "") : process.cwd();
959
+ return segments.reduce((realPath, seg) => {
960
+ const entries = fs.readdirSync(realPath + delim);
961
+ // Escape any regex special chars in the segment name before building the pattern
962
+ const re = new RegExp(`^${seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i");
963
+ const match = entries.find((e) => re.test(e));
964
+ if (!match) throw new Error(`true_case_path_sync: no match for "${seg}" in "${realPath}"`);
965
+ return realPath + delim + match;
966
+ }, base);
967
+ };
968
+
969
+ // Creates an empty temporary file with a random name and optional file extension suffix
970
+ // (e.g. { postfix: ".mmd" }). Returns an object with:
971
+ // name - the absolute path to the temp file
972
+ // removeCallback - call this to delete the file when done; errors are silently ignored
973
+ // so it is safe to call even if the file was already cleaned up.
974
+ exports.tmp_file_sync = (opts = {}) => {
975
+ const name = path.join(os.tmpdir(), `hdoc-${crypto.randomBytes(8).toString("hex")}${opts.postfix || ""}`);
976
+ fs.closeSync(fs.openSync(name, "w"));
977
+ return { name, removeCallback: () => { try { fs.unlinkSync(name); } catch (_) {} } };
978
+ };
979
+
980
+ // Parses a YAML string into a plain JS object. Designed for markdown frontmatter,
981
+ // which is always simple: scalar key/value pairs, block arrays (- item), and inline
982
+ // arrays ([a, b, c]). Does not support anchors, multi-line strings, or nested objects.
983
+ // parseVal handles type coercion: booleans, null, integers, floats, quoted strings,
984
+ // inline arrays, and plain strings (returned as-is).
985
+ exports.parse_yaml = (str) => {
986
+ const parseVal = (v) => {
987
+ if (v === "true") return true;
988
+ if (v === "false") return false;
989
+ if (v === "null" || v === "~") return null;
990
+ if (/^-?\d+$/.test(v)) return parseInt(v, 10);
991
+ if (/^-?\d*\.\d+$/.test(v)) return parseFloat(v);
992
+ if (/^['"].*['"]$/.test(v)) return v.slice(1, -1); // strip surrounding quotes
993
+ if (v.startsWith("[") && v.endsWith("]")) return v.slice(1, -1).split(",").map(i => parseVal(i.trim()));
994
+ return v;
995
+ };
996
+ const result = {};
997
+ let currentKey = null;
998
+ for (const line of str.split("\n")) {
999
+ if (!line.trim() || /^\s*#/.test(line)) continue;
1000
+ // Block array item (indented dash): append to the current key's array
1001
+ const arrMatch = line.match(/^\s+-\s+(.+)$/);
1002
+ if (arrMatch && currentKey) {
1003
+ if (!Array.isArray(result[currentKey])) result[currentKey] = [];
1004
+ result[currentKey].push(parseVal(arrMatch[1].trim()));
1005
+ continue;
1006
+ }
1007
+ // key: value pair — value may be empty (e.g. start of a block array)
1008
+ const kvMatch = line.match(/^([^:]+):\s*(.*)$/);
1009
+ if (kvMatch) {
1010
+ currentKey = kvMatch[1].trim();
1011
+ result[currentKey] = kvMatch[2].trim() ? parseVal(kvMatch[2].trim()) : null;
1012
+ }
1013
+ }
1014
+ return result;
1015
+ };
1016
+
1017
+ // Pretty-prints an XML string with configurable indentation and line endings.
1018
+ // Tokenises the input using a regex that preserves CDATA sections, comments, and
1019
+ // processing instructions as atomic units so their content is never re-indented.
1020
+ // Options:
1021
+ // indentation - string to use per indent level (default: two spaces)
1022
+ // lineSeparator - line ending to use (default: "\n")
1023
+ // collapseContent - when true (default), elements that contain only a single text
1024
+ // node are kept on one line: <tag>text</tag> rather than split
1025
+ // across three lines. Uses one-token lookahead to detect this.
1026
+ exports.xml_format = (xml, opts = {}) => {
1027
+ const ind = opts.indentation || " ";
1028
+ const sep = opts.lineSeparator || "\n";
1029
+ const collapse = opts.collapseContent !== false;
1030
+ // Match CDATA, comments, PIs, tags, and text nodes as individual tokens
1031
+ const tokens = xml.trim().match(/(<!\[CDATA\[[\s\S]*?\]\]>|<!--[\s\S]*?-->|<[^>]+>|[^<]+)/g) || [];
1032
+ let depth = 0;
1033
+ let out = "";
1034
+ for (let i = 0; i < tokens.length; i++) {
1035
+ const t = tokens[i].trim();
1036
+ if (!t) continue;
1037
+ const isClose = t.startsWith("</");
1038
+ const isSelf = t.startsWith("<") && t.endsWith("/>") && !t.startsWith("<?");
1039
+ const isSpecial = t.startsWith("<?") || t.startsWith("<!--") || t.startsWith("<![");
1040
+ const isOpen = t.startsWith("<") && !isClose && !isSelf && !isSpecial;
1041
+ if (isClose) {
1042
+ depth = Math.max(0, depth - 1);
1043
+ out += ind.repeat(depth) + t + sep;
1044
+ } else if (isSelf || isSpecial) {
1045
+ out += ind.repeat(depth) + t + sep;
1046
+ } else if (isOpen) {
1047
+ // Collapse: if the very next token is text and the one after is the closing tag,
1048
+ // emit all three on one line and skip those two tokens.
1049
+ if (collapse && i + 2 < tokens.length) {
1050
+ const nextTxt = tokens[i + 1] ? tokens[i + 1].trim() : "";
1051
+ const nextClose = tokens[i + 2] ? tokens[i + 2].trim() : "";
1052
+ if (nextTxt && !nextTxt.startsWith("<") && nextClose.startsWith("</")) {
1053
+ out += ind.repeat(depth) + t + nextTxt + nextClose + sep;
1054
+ i += 2;
1055
+ continue;
1056
+ }
1057
+ }
1058
+ out += ind.repeat(depth) + t + sep;
1059
+ depth++;
1060
+ } else {
1061
+ // Plain text node
1062
+ out += ind.repeat(depth) + t + sep;
1063
+ }
1064
+ }
1065
+ return out.trimEnd();
1066
+ };
1067
+
1068
+ // Locates searchString in fileContent, returning 1-based {line, column}.
1069
+ // With whole_link=true, occurrences that are substrings of a longer
1070
+ // URL/path are skipped (e.g. searching for /book/page#anchor must not match
1071
+ // inside /book/page#anchor-more on an earlier line) — the match must not be
1072
+ // immediately preceded or followed by a link-continuation character.
1073
+ exports.find_string_in_string = (fileContent, searchString, whole_link = false) => {
1074
+ const lines = fileContent.split('\n');
1075
+ const link_char = /[a-zA-Z0-9\-_/#?&=.%~]/;
1076
+ let loose_match = null; // first substring hit — fallback when no exact-boundary match exists (e.g. bare URL followed by punctuation)
1077
+
1078
+ for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {
1079
+ let from = 0;
1080
+ while (true) {
1081
+ const columnNumber = lines[lineNumber].indexOf(searchString, from);
1082
+ if (columnNumber === -1) break;
1083
+ if (whole_link) {
1084
+ const prev = lines[lineNumber][columnNumber - 1];
1085
+ const next = lines[lineNumber][columnNumber + searchString.length];
1086
+ if ((prev !== undefined && link_char.test(prev)) ||
1087
+ (next !== undefined && link_char.test(next))) {
1088
+ if (loose_match === null)
1089
+ loose_match = { line: lineNumber + 1, column: columnNumber + 1 };
1090
+ from = columnNumber + 1;
1091
+ continue;
1092
+ }
1093
+ }
1094
+ // Return 1-based line and column numbers
1095
+ return { line: lineNumber + 1, column: columnNumber + 1 };
1096
+ }
1097
+ }
1098
+
1099
+ // No exact-boundary match — fall back to the first substring hit, or null
1100
+ return loose_match;
1101
+ }
1102
+ })();