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.
- package/LICENSE +21 -21
- package/README.md +89 -75
- package/hdoc-build-db.js +275 -275
- package/hdoc-build-embeddings.js +202 -202
- package/hdoc-build-pdf.js +232 -232
- package/hdoc-build.js +14 -6
- package/hdoc-bump.js +4 -2
- package/hdoc-content-routes.js +143 -83
- package/hdoc-create.js +110 -108
- package/hdoc-db.js +114 -114
- package/hdoc-help.js +60 -60
- package/hdoc-init.js +103 -68
- package/hdoc-install-browser.js +145 -145
- package/hdoc-mermaid.js +204 -204
- package/hdoc-module.js +1102 -1079
- package/hdoc-serve.js +13 -7
- package/hdoc-stats.js +9 -9
- package/hdoc-validate-config.js +355 -329
- package/hdoc-validate-interbook.js +321 -0
- package/hdoc-validate.js +1231 -1158
- package/hdoc-ver.js +4 -2
- package/hdoc.js +12 -11
- package/npm-shrinkwrap.json +2 -2
- package/package.json +13 -2
- package/schemas/hdocbook-project.schema.json +20 -0
- package/schemas/hdocbook.schema.json +6 -2
- package/templates/doc-header-non-git.html +19 -19
- package/templates/doc-header.html +26 -26
- package/templates/init/.github/workflows/hdocbuild_onpull.yml +16 -16
- package/templates/init/.github/workflows/hdocbuild_onpush.yml +15 -15
- package/templates/init/LICENSE +21 -21
- package/templates/init/README.md +9 -9
- package/templates/init/_hdocbook/index.md +4 -4
- package/templates/init/gitignore +8 -8
- package/templates/init/resources/README.md +2 -2
- package/templates/pdf/css/custom-block.css +90 -90
- package/templates/pdf/css/fonts.css +221 -221
- package/templates/pdf/css/hdocs-pdf.css +495 -495
- package/templates/pdf/css/vars.css +404 -404
- package/templates/pdf/template-footer.html +19 -19
- package/templates/pdf/template-header.html +37 -37
- package/templates/pdf/template.html +20 -20
- package/templates/pdf-header-non-git.html +12 -12
- package/templates/pdf-header.html +16 -16
- package/ui/content/invalid-hdocbook-json.html +6 -6
- package/ui/content/invalid-hdocbook-json.md +7 -7
- package/ui/css/theme-default/styles/components/content.css +124 -124
- package/ui/css/theme-default/styles/components/sidebar.css +182 -182
- package/ui/css/theme-default/styles/htldoc.layouts.css +310 -310
- package/ui/index.html +419 -419
- package/ui/js/doc.hornbill.js +31 -44
- package/ui/js/mermaid-theme.json +27 -0
- package/hdoc-build-onyx.js +0 -134
- package/templates/mermaid-theme.yaml +0 -28
- 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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
let
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
if (tagName === '
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
if (
|
|
366
|
-
result += `<div id="${makeAnchorIdFriendly(text)}">${$.html(this)}`;
|
|
367
|
-
|
|
368
|
-
} else {
|
|
369
|
-
result +=
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
if (
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
github_paths.api_path
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
github_paths.api_path
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
response
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
if (github_response.status
|
|
566
|
-
response.
|
|
567
|
-
response
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
response.
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
response
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
if (github_response.status
|
|
625
|
-
response.
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
response.
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
response
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
if (github_response.status
|
|
699
|
-
response.
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
parentLinks[i].link
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
if (
|
|
814
|
-
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
if
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
//
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
const
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
const
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
element.
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
//
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
//
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
//
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
if (
|
|
988
|
-
if (
|
|
989
|
-
if (v
|
|
990
|
-
return v;
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
const
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
out += ind.repeat(depth) + t + sep;
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
//
|
|
1077
|
-
|
|
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
|
+
// (& < > " ' '), decimal numeric refs ({),
|
|
444
|
+
// and hex numeric refs ({).
|
|
445
|
+
prop_val = prop_val.replace(/&|<|>|"|'|'|&#(\d+);|&#x([0-9a-fA-F]+);/g,
|
|
446
|
+
(m, dec, hex) => dec ? String.fromCharCode(+dec) : hex ? String.fromCharCode(parseInt(hex, 16)) : ({ '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'" })[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
|
+
})();
|