hdoc-tools 0.62.4 → 0.63.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 (73) hide show
  1. package/editor/dist/assets/{index-BtxvGZHW.js → index-Dknn5g5A.js} +5 -4
  2. package/editor/dist/index.html +13 -13
  3. package/hdoc-build.js +5 -3
  4. package/hdoc-content-routes.js +136 -4
  5. package/hdoc-serve.js +26 -3
  6. package/hdoc-validate-interbook.js +8 -0
  7. package/hdoc-validate.js +92 -35
  8. package/package.json +1 -1
  9. package/ui/css/theme-default/styles/base.css +86 -21
  10. package/ui/css/theme-default/styles/components/api-doc.css +6 -3
  11. package/ui/css/theme-default/styles/components/content.css +140 -39
  12. package/ui/css/theme-default/styles/components/custom-block.css +1 -1
  13. package/ui/css/theme-default/styles/components/htl-doc.css +243 -64
  14. package/ui/css/theme-default/styles/components/htl-library.css +152 -0
  15. package/ui/css/theme-default/styles/components/htl-search.css +267 -0
  16. package/ui/css/theme-default/styles/components/sidebar.css +59 -16
  17. package/ui/css/theme-default/styles/fonts.css +17 -3
  18. package/ui/css/theme-default/styles/htldoc.layouts.css +756 -87
  19. package/ui/css/theme-default/styles/vars.css +40 -35
  20. package/ui/favicon.svg +64 -0
  21. package/ui/images/hornbill-logo-full-reversed.svg +92 -0
  22. package/ui/images/hornbill-logo-full.svg +92 -0
  23. package/ui/images/hug_library.jpg +0 -0
  24. package/ui/images/mcp-catalog.svg +9 -0
  25. package/ui/images/products/hornbill-square.svg +1 -0
  26. package/ui/index.html +1106 -342
  27. package/ui/js/bootstrap.js +89 -0
  28. package/ui/js/doc.hornbill.js +3156 -751
  29. package/ui/js/hb.vue.js +121 -0
  30. package/ui/js/highlightjs/highlight.pack.js +1513 -2
  31. package/ui/js/highlightjs/styles/vs2015-accessible.css +141 -0
  32. package/ui/js/highlightjs-badge.js +41 -58
  33. package/ui/js/mermaid.min.js +1447 -1295
  34. package/ui/js/webcomponents/hdocApprove.js +115 -0
  35. package/ui/js/highlightjs/styles/brown-paper.css +0 -64
  36. package/ui/js/highlightjs/styles/brown-papersq.png +0 -0
  37. package/ui/js/highlightjs/styles/codepen-embed.css +0 -60
  38. package/ui/js/highlightjs/styles/color-brewer.css +0 -71
  39. package/ui/js/highlightjs/styles/darcula.css +0 -77
  40. package/ui/js/highlightjs/styles/dark.css +0 -63
  41. package/ui/js/highlightjs/styles/darkula.css +0 -6
  42. package/ui/js/highlightjs/styles/default.css +0 -99
  43. package/ui/js/highlightjs/styles/dracula.css +0 -76
  44. package/ui/js/highlightjs/styles/far.css +0 -71
  45. package/ui/js/highlightjs/styles/foundation.css +0 -88
  46. package/ui/js/highlightjs/styles/github-gist.css +0 -71
  47. package/ui/js/highlightjs/styles/github-mm.css +0 -71
  48. package/ui/js/highlightjs/styles/github.css +0 -99
  49. package/ui/js/highlightjs/styles/googlecode.css +0 -89
  50. package/ui/js/highlightjs/styles/grayscale.css +0 -101
  51. package/ui/js/highlightjs/styles/idea.css +0 -97
  52. package/ui/js/highlightjs/styles/ir-black.css +0 -73
  53. package/ui/js/highlightjs/styles/kavadocs.css +0 -71
  54. package/ui/js/highlightjs/styles/kavadocsdark.css +0 -120
  55. package/ui/js/highlightjs/styles/kimbie.dark.css +0 -74
  56. package/ui/js/highlightjs/styles/kimbie.light.css +0 -74
  57. package/ui/js/highlightjs/styles/magula.css +0 -70
  58. package/ui/js/highlightjs/styles/mono-blue.css +0 -59
  59. package/ui/js/highlightjs/styles/monokai-sublime.css +0 -83
  60. package/ui/js/highlightjs/styles/monokai.css +0 -70
  61. package/ui/js/highlightjs/styles/obsidian.css +0 -88
  62. package/ui/js/highlightjs/styles/paraiso-dark.css +0 -72
  63. package/ui/js/highlightjs/styles/paraiso-light.css +0 -72
  64. package/ui/js/highlightjs/styles/railscasts.css +0 -106
  65. package/ui/js/highlightjs/styles/rainbow.css +0 -85
  66. package/ui/js/highlightjs/styles/solarized-dark.css +0 -84
  67. package/ui/js/highlightjs/styles/solarized-light.css +0 -84
  68. package/ui/js/highlightjs/styles/sunburst.css +0 -102
  69. package/ui/js/highlightjs/styles/twilight.css +0 -97
  70. package/ui/js/highlightjs/styles/vs.css +0 -68
  71. package/ui/js/highlightjs/styles/vs2015.css +0 -117
  72. package/ui/js/highlightjs/styles/xcode.css +0 -104
  73. package/ui/js/highlightjs/styles/zenburn.css +0 -80
@@ -63,8 +63,8 @@ Error generating stack: `+e.message+`
63
63
  }
64
64
  } catch (e) {}
65
65
  <\/script>
66
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
67
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
66
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.css" integrity="sha384-Bk5cbLkZQ5raZ0+H2/+VbfYx3WpvxvQK4zqXZr7sYODuaX7bKXoSOnipQxkaS8sv" crossorigin="anonymous">
67
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
68
68
  <link rel="stylesheet" href="/_ui/css/theme-default/styles/fonts.css">
69
69
  <link rel="stylesheet" href="/_ui/css/theme-default/styles/vars.css">
70
70
  <link rel="stylesheet" href="/_ui/css/theme-default/styles/base.css">
@@ -74,7 +74,7 @@ Error generating stack: `+e.message+`
74
74
  <link rel="stylesheet" href="/_ui/css/theme-default/styles/components/content.css">
75
75
  <link rel="stylesheet" href="/_ui/css/theme-default/styles/components/custom-block.css">
76
76
  <link rel="stylesheet" href="/_ui/css/theme-default/styles/components/api-doc.css">
77
- <link rel="stylesheet" href="/_ui/js/highlightjs/styles/vs2015.css">
77
+ <link rel="stylesheet" href="/_ui/js/highlightjs/styles/vs2015-accessible.css">
78
78
  <style>html,body{margin:0;}</style>
79
79
  </head>
80
80
  <body>
@@ -98,7 +98,8 @@ Error generating stack: `+e.message+`
98
98
  try {
99
99
  if (window.hljs) {
100
100
  var blocks = root.querySelectorAll('pre>code');
101
- for (var i = 0; i < blocks.length; i++) window.hljs.highlightBlock(blocks[i]);
101
+ // highlight.js v11 API (highlightBlock was removed in v11)
102
+ for (var i = 0; i < blocks.length; i++) window.hljs.highlightElement(blocks[i]);
102
103
  }
103
104
  if (window.highlightJsBadge) {
104
105
  window.highlightJsBadge({ contentSelector: '#hdoc-preview-root' });
@@ -1,14 +1,14 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>hdoc edit</title>
8
- <script type="module" crossorigin src="/assets/index-BtxvGZHW.js"></script>
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>hdoc edit</title>
8
+ <script type="module" crossorigin src="/assets/index-Dknn5g5A.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-Blewr90z.css">
10
- </head>
11
- <body>
12
- <div id="root"></div>
13
- </body>
14
- </html>
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
package/hdoc-build.js CHANGED
@@ -48,9 +48,11 @@
48
48
  "pdf-header-non-git.html",
49
49
  );
50
50
 
51
- // Mermaid theme SINGLE source shared with the client-side renderer
52
- // (ui/js/doc.hornbill.js fetches the same file). Emitted into each queued
53
- // diagram as YAML frontmatter; inline JSON is valid YAML, so no conversion.
51
+ // Mermaid theme for the PDF SVG bake. Emitted into each queued diagram as
52
+ // YAML frontmatter; inline JSON is valid YAML, so no conversion. NOTE: the
53
+ // viewer no longer fetches this file since the ui/ sync with the live
54
+ // htdocs viewer it embeds the same values as HB_MERMAID_CONFIG in
55
+ // ui/js/doc.hornbill.js. Keep the two in step when changing the theme.
54
56
  const mermaid_theme_path = path.resolve(
55
57
  __dirname,
56
58
  "ui",
@@ -21,6 +21,14 @@ const stream = require("node:stream");
21
21
  const hdoc = require(path.join(__dirname, "hdoc-module.js"));
22
22
  const mdfm = require("markdown-it-front-matter");
23
23
 
24
+ // Document header template baked around rendered markdown, so the local
25
+ // preview shows the same header (breadcrumbs/title/type/reading time) as
26
+ // published output. Package-static, so read once.
27
+ const doc_header_template = fs.readFileSync(
28
+ path.join(__dirname, "templates", "doc-header-non-git.html"),
29
+ "utf8",
30
+ );
31
+
24
32
  // Escape a Mermaid definition for safe embedding as the text content of a
25
33
  // <pre> element. The viewer's client-side Mermaid plugin reads the element's
26
34
  // textContent and renders it in the browser, so we only need HTML-text safety
@@ -192,18 +200,81 @@ exports.create_content_handler = (ctx) => {
192
200
  const tips = require(`${__dirname}/custom_modules/tips.js`);
193
201
  md.use(tips, { links: true });
194
202
 
195
- const html = md.render(md_txt.toString());
203
+ // Wrap h2/h3 sections in anchor-id <div>s exactly as the build pipeline
204
+ // does (hdoc.wrapAndExtract) — the theme CSS targets that structure, and
205
+ // it also yields the reading time for the document header.
206
+ const rendered = md.render(md_txt.toString());
207
+ const extracted = hdoc.wrapAndExtract(rendered, ["h1"]);
208
+ const html = extracted.html;
196
209
 
197
210
  const frontmatter = frontmatter_content.length
198
211
  ? hdoc.parse_yaml(frontmatter_content)
199
212
  : null;
200
- return { html, frontmatter };
213
+ return { html, frontmatter, read_time: extracted.readTimeMins };
214
+ }
215
+
216
+ // Wrap rendered markdown with the same document header the build pipeline
217
+ // bakes into published pages (templates/doc-header-non-git.html): breadcrumb
218
+ // trail, first-<h1> title, doc type and reading time. Without this the viewer
219
+ // shows the raw body with the <h1> inline, which is not what publishing
220
+ // produces. logical_path is the extensionless book-relative request path
221
+ // (e.g. "my-book/some/page") used for the breadcrumb lookup.
222
+ function wrap_with_document_header(html, frontmatter, logical_path, read_time) {
223
+ let body = html;
224
+
225
+ // Pull the first <h1> out of the body — the header owns the title, exactly
226
+ // as hdoc-build does for published output.
227
+ let title = "";
228
+ const h1_match = body.match(/<h1[^>]*>([\s\S]*?)<\/h1>/);
229
+ if (h1_match) {
230
+ title = h1_match[1].trim();
231
+ body = body.replace(h1_match[0], "");
232
+ } else if (frontmatter?.title) {
233
+ title = frontmatter.title;
234
+ }
235
+
236
+ const reading_time = frontmatter?.["reading-time"] ?? read_time ?? 0;
237
+
238
+ // Breadcrumbs from the live nav (config re-read per request), emitted with
239
+ // the same <li> shape hdoc-build writes. Last crumb (the page itself) is
240
+ // dropped, matching the build pipeline.
241
+ let bc_tags = "\n";
242
+ try {
243
+ const bc = hdoc.build_breadcrumbs(
244
+ get_book_config().navigation.items,
245
+ ).bc;
246
+ const crumbs = bc[logical_path] || bc[`/${logical_path}`];
247
+ if (crumbs) {
248
+ for (let i = 0; i < crumbs.length - 1; i++) {
249
+ if (crumbs[i].link) {
250
+ const bc_link = crumbs[i].link.startsWith("/")
251
+ ? crumbs[i].link
252
+ : `/${crumbs[i].link}`;
253
+ bc_tags += `\t\t\t\t<li class="mt-0 nav-bar-item"><a href="${bc_link}" class="ps-0 pe-0 text-decoration-none">${crumbs[i].text}</a></li>\n`;
254
+ } else {
255
+ bc_tags += `\t\t\t\t<li class="mt-0 nav-bar-item">${crumbs[i].text}</li>\n`;
256
+ }
257
+ }
258
+ }
259
+ } catch {
260
+ // Bad/missing navigation is validate's problem to report, not the
261
+ // preview's — render the page with an empty trail.
262
+ }
263
+ bc_tags += "\t\t\t";
264
+
265
+ const header = doc_header_template
266
+ .replaceAll("{{title}}", title)
267
+ .replaceAll("{{doc-type}}", frontmatter?.type || "Article")
268
+ .replaceAll("{{reading-time}}", String(reading_time))
269
+ .replaceAll("{{breadcrumbs}}", bc_tags);
270
+
271
+ return `${header}\n${body}`;
201
272
  }
202
273
 
203
274
  async function transform_markdown_and_send_html(req, res, file_path) {
204
275
  if (!fs.existsSync(file_path)) return false;
205
276
 
206
- const { html, frontmatter } = await render_markdown(
277
+ const { html, frontmatter, read_time } = await render_markdown(
207
278
  file_path,
208
279
  fs.readFileSync(file_path).toString(),
209
280
  );
@@ -215,8 +286,20 @@ exports.create_content_handler = (ctx) => {
215
286
  res.setHeader("X-frontmatter", base64);
216
287
  }
217
288
 
289
+ // Inline (_inline/) fragments are embedded elsewhere and get no header,
290
+ // same as the build pipeline.
291
+ const logical_path = req.url
292
+ .replace(/^\/_books\//, "")
293
+ .split("?")[0]
294
+ .split("#")[0]
295
+ .replace(/\.(html|htm|md)$/i, "")
296
+ .replace(/\/+$/, "");
297
+ const out = logical_path.includes("/_inline/")
298
+ ? html
299
+ : wrap_with_document_header(html, frontmatter, logical_path, read_time);
300
+
218
301
  res.setHeader("Content-Type", "text/html");
219
- res.send(html);
302
+ res.send(out);
220
303
  return true;
221
304
  }
222
305
 
@@ -409,11 +492,22 @@ exports.create_content_handler = (ctx) => {
409
492
 
410
493
  function handle_library_request(req, res) {
411
494
  const book_config = get_book_config();
495
+ // Mirror the fields the production library.json carries so the viewer's
496
+ // library home can group/describe the book. audience is an array in
497
+ // hdocbook.json but a plain string in the production library feed.
412
498
  const library = {
413
499
  books: [
414
500
  {
415
501
  docId: book_config.docId,
502
+ version: book_config.version,
503
+ audience: Array.isArray(book_config.audience)
504
+ ? book_config.audience[0]
505
+ : book_config.audience,
416
506
  title: book_config.title,
507
+ description: book_config.description || "",
508
+ productFamilyId: book_config.productFamily,
509
+ tags: book_config.tags || [],
510
+ publicSource: book_config.publicSource,
417
511
  nav_inline: nav_inline,
418
512
  },
419
513
  ],
@@ -422,9 +516,47 @@ exports.create_content_handler = (ctx) => {
422
516
  res.send(JSON.stringify(library, null, 3));
423
517
  }
424
518
 
519
+ // The production viewer needs a valid products.json (its init dereferences
520
+ // data.products). Fetch the real product family list once in the background
521
+ // (same helper the build pipeline uses) and serve it; if docs.hornbill.com
522
+ // is unreachable, serve an empty list — the library page then groups the
523
+ // book under "Other" instead of failing to initialise.
524
+ let products_cache = null;
525
+ const products_fetch = hdoc
526
+ .load_product_families()
527
+ .then((p) => {
528
+ if (p.success) products_cache = p.prod_families;
529
+ else
530
+ console.log(
531
+ "[WARNING] Could not fetch product families from docs.hornbill.com — serving an empty product list to the viewer",
532
+ );
533
+ })
534
+ .catch(() => {});
535
+
536
+ async function handle_products_request(req, res) {
537
+ await products_fetch;
538
+ res.setHeader("Content-Type", "application/json");
539
+ res.send(JSON.stringify(products_cache || { products: [] }));
540
+ }
541
+
542
+ // Endpoints the production viewer probes but that have no local backing
543
+ // service (docs sign-in session, MCP catalog, full-text search). A JSON 404
544
+ // makes the viewer's fetchJsonFile resolve null and degrade gracefully;
545
+ // without these the SPA catch-all would answer with index.html instead.
546
+ function handle_unavailable_api(req, res) {
547
+ res.status(404);
548
+ res.setHeader("Content-Type", "application/json");
549
+ res.send(
550
+ JSON.stringify({ error: "Not available in the hdoc local preview" }),
551
+ );
552
+ }
553
+
425
554
  function register(app) {
555
+ app.get("/_books/products.json", handle_products_request);
426
556
  app.get("/_books/library.json", handle_library_request);
427
557
  app.get("/_books/*splat", handle_books_request);
558
+ app.get("/_api/{*splat}", handle_unavailable_api);
559
+ app.get("/_search", handle_unavailable_api);
428
560
  }
429
561
 
430
562
  return {
package/hdoc-serve.js CHANGED
@@ -92,6 +92,12 @@
92
92
  });
93
93
  content.register(app);
94
94
 
95
+ // Local preview serves exactly one book — skip the viewer's library home
96
+ // and land straight in the book.
97
+ app.get("/", (req, res) => {
98
+ res.redirect(`/${docId}`);
99
+ });
100
+
95
101
  // Catch all
96
102
  app.get("/{*splat}", (req, res) => {
97
103
 
@@ -110,9 +116,26 @@
110
116
 
111
117
  // If the file exists, send it.
112
118
  if (fs.existsSync(ui_file_path)) {
113
- // Stream the large Mermaid bundle (skips per-request variable
114
- // expansion over ~4.5MB) and let send_file set a long cache header.
115
- if (path.basename(ui_file_path) === "mermaid.min.js") {
119
+ // Only text assets go through send_content_file (which reads the
120
+ // file as a string for variable expansion) binary assets (fonts,
121
+ // images) are corrupted by that path, so stream them verbatim.
122
+ // mermaid.min.js is also streamed (skips per-request variable
123
+ // expansion over ~4.5MB, and send_file sets its cache header).
124
+ const text_exts = new Set([
125
+ ".html",
126
+ ".htm",
127
+ ".css",
128
+ ".js",
129
+ ".json",
130
+ ".svg",
131
+ ".txt",
132
+ ".md",
133
+ ".map",
134
+ ]);
135
+ if (
136
+ path.basename(ui_file_path) === "mermaid.min.js" ||
137
+ !text_exts.has(path.extname(ui_file_path).toLowerCase())
138
+ ) {
116
139
  content.send_file(req, res, ui_file_path);
117
140
  return;
118
141
  }
@@ -290,6 +290,14 @@
290
290
  const doc_id = segments.shift();
291
291
  const article_path = segments.length > 0 ? segments.join("/") : "index";
292
292
 
293
+ // No book in the path (e.g. a bare "/") - nothing to resolve
294
+ if (!doc_id) {
295
+ return {
296
+ level: "skip",
297
+ message: `Inter-book link has no target book - link not verified: ${link}`,
298
+ };
299
+ }
300
+
293
301
  // Books not sourced from GitHub — resolved by docId suffix, no repo
294
302
  if (has_suffix(doc_id, UNVERIFIABLE_SUFFIXES)) {
295
303
  return {
package/hdoc-validate.js CHANGED
@@ -498,10 +498,22 @@
498
498
  return resp.status;
499
499
  };
500
500
 
501
- // Map an inter-book check result onto errors/warnings/messages and the
502
- // validated-links cache. 'ok' and 'skip' outcomes are stable, so they are
503
- // appended to validated-links.txt like any other passing link.
504
- const handleInterbookResult = (result, link, htmlFile, markdown_paths, markdown_content) => {
501
+ // Links already written to validated-links.txt this run. A link can appear
502
+ // on many pages, but only needs writing to the cache file once.
503
+ const skip_links_written = new Set();
504
+ const appendSkipLink = (link) => {
505
+ if (skip_links_written.has(link)) return;
506
+ skip_links_written.add(link);
507
+ fs.appendFileSync(skip_link_file, `${link}\n`);
508
+ };
509
+
510
+ // Map a network check result (inter-book or external URL) onto
511
+ // errors/warnings/messages and the validated-links cache. 'ok' and 'skip'
512
+ // outcomes are stable, so they are appended to validated-links.txt like any
513
+ // other passing link. Called once per PAGE carrying the link, so the
514
+ // message is positioned against the page being reported on.
515
+ const emitLinkResult = (result, link, htmlFile, markdown_paths, markdown_content) => {
516
+ if (!result) return;
505
517
  if (result.level === "error") {
506
518
  errors[htmlFile.relativePath].push(
507
519
  processErrorMessage(result.message, markdown_paths.relativePath, markdown_content, link),
@@ -512,10 +524,25 @@
512
524
  );
513
525
  } else {
514
526
  messages[htmlFile.relativePath].push(result.message);
515
- fs.appendFileSync(skip_link_file, `${link}\n`);
527
+ appendSkipLink(link);
516
528
  }
517
529
  };
518
530
 
531
+ // Run a network check for a link at most once per run, but hand the SAME
532
+ // result back to every page that carries the link. Deduplicating the fetch
533
+ // is a performance concern; deduplicating the reporting is not - a broken
534
+ // link on ten pages has to be flagged on all ten, or fixing the first page
535
+ // just surfaces the second on the next build.
536
+ // global_links_checked: Map link -> Promise<{ level, message } | null>
537
+ const checkLinkOnce = (global_links_checked, link, check) => {
538
+ let pending = global_links_checked.get(link);
539
+ if (!pending) {
540
+ pending = check();
541
+ global_links_checked.set(link, pending);
542
+ }
543
+ return pending;
544
+ };
545
+
519
546
  const checkLinks = async (source_path, htmlFile, links, hdocbook_config, hdocbook_project, global_links_checked, output_links) => {
520
547
  const markdown_paths = getMDPathFromHtmlPath(htmlFile);
521
548
  const markdown_content = fs.readFileSync(markdown_paths.markdownPath, 'utf8');
@@ -537,11 +564,15 @@
537
564
  // concurrently rather than one-at-a-time.
538
565
  const externalChecks = [];
539
566
 
567
+ // Same link twice on the same page is reported once. Across pages it is
568
+ // reported every time - see checkLinkOnce.
569
+ const page_links_checked = new Set();
570
+
540
571
  for (let i = 0; i < links.length; i++) {
541
572
  if (output_links) console.log(` - ${links[i]}`);
542
573
  if (exclude_links[links[i]]) continue;
543
- if (global_links_checked.includes(links[i])) continue;
544
- global_links_checked.push(links[i]);
574
+ if (page_links_checked.has(links[i])) continue;
575
+ page_links_checked.add(links[i]);
545
576
 
546
577
  const valid_url = hdoc.valid_url(links[i]);
547
578
  if (!valid_url) {
@@ -554,6 +585,13 @@
554
585
  if (link_segments[0] === "") link_segments.shift();
555
586
  const link_root = link_segments[0] === "_books" ? link_segments[1] : link_segments[0];
556
587
 
588
+ // A bare "/" is the docs site home page - no book, nothing to
589
+ // resolve locally or against GitHub.
590
+ if (link_root === undefined || link_root === "") {
591
+ appendSkipLink(links[i]);
592
+ continue;
593
+ }
594
+
557
595
  // Check for links with a _books path that have no specific file target
558
596
  // We do need to exclude those with an extension though, for pages that link downloadable resources
559
597
  if (link_segments[0] === "_books" && path.extname(links[i]) === '') {
@@ -567,8 +605,10 @@
567
605
  if (interbook.enabled() && path.extname(links[i].split("#")[0]) === "") {
568
606
  const link = links[i];
569
607
  externalChecks.push(async () =>
570
- handleInterbookResult(
571
- await interbook.check_link(link),
608
+ emitLinkResult(
609
+ await checkLinkOnce(global_links_checked, link, () =>
610
+ interbook.check_link(link),
611
+ ),
572
612
  link,
573
613
  htmlFile,
574
614
  markdown_paths,
@@ -576,7 +616,7 @@
576
616
  ),
577
617
  );
578
618
  } else {
579
- fs.appendFileSync(skip_link_file, `${links[i]}\n`);
619
+ appendSkipLink(links[i]);
580
620
  }
581
621
  continue;
582
622
  }
@@ -603,12 +643,12 @@
603
643
  )
604
644
  .edit_path.replace(path.extname(htmlFile.relativePath), ".md")
605
645
  ) {
606
- fs.appendFileSync(skip_link_file, `${links[i]}\n`);
646
+ appendSkipLink(links[i]);
607
647
  continue;
608
648
  }
609
649
 
610
650
  if (valid_url.protocol === "mailto:") {
611
- fs.appendFileSync(skip_link_file, `${links[i]}\n`);
651
+ appendSkipLink(links[i]);
612
652
  continue;
613
653
  }
614
654
 
@@ -653,8 +693,10 @@
653
693
  const link = links[i];
654
694
  const book_link = valid_url.pathname + valid_url.hash;
655
695
  externalChecks.push(async () =>
656
- handleInterbookResult(
657
- await interbook.check_link(book_link),
696
+ emitLinkResult(
697
+ await checkLinkOnce(global_links_checked, link, () =>
698
+ interbook.check_link(book_link),
699
+ ),
658
700
  link,
659
701
  htmlFile,
660
702
  markdown_paths,
@@ -668,20 +710,19 @@
668
710
  const url = links[i];
669
711
  const isInternal = url.toLowerCase().includes("internal.hornbill.com");
670
712
 
671
- externalChecks.push(async () => {
713
+ // Returns a page-independent { level, message } (or null for "say
714
+ // nothing") so the outcome can be cached per URL and replayed
715
+ // against every page that links to it.
716
+ const checkExternalUrl = async () => {
672
717
  // For internal.hornbill.com links, check network reachability first (result cached)
673
718
  if (isInternal) {
674
719
  const on_int_net = await ensureIntNetCached();
675
720
  if (!on_int_net) {
676
- messages[htmlFile.relativePath].push(
677
- `Outside of Hornbill network - skipping internal link validation for: ${url}`,
678
- );
679
- fs.appendFileSync(skip_link_file, `${url}\n`);
680
- return;
721
+ return {
722
+ level: "skip",
723
+ message: `Outside of Hornbill network - skipping internal link validation for: ${url}`,
724
+ };
681
725
  }
682
- messages[htmlFile.relativePath].push(
683
- `Inside of Hornbill network - performing internal link validation for: ${url}`,
684
- );
685
726
  }
686
727
 
687
728
  try {
@@ -689,25 +730,39 @@
689
730
  if ((status < 200 || status > 299) && status !== 304) {
690
731
  if (process.env.GITHUB_ACTIONS === 'true' && status === 403 && url.includes(".hornbill.com")) {
691
732
  // Always returns 403 for Hornbill sites through GitHub Actions — not a real error
692
- } else {
693
- throw `Unexpected Status Returned: ${status}`;
733
+ return null;
694
734
  }
695
- } else {
696
- fs.appendFileSync(skip_link_file, `${url}\n`);
735
+ throw `Unexpected Status Returned: ${status}`;
697
736
  }
737
+ return {
738
+ level: "ok",
739
+ message: `External link is valid: ${url}`,
740
+ };
698
741
  } catch (e) {
699
742
  let error_message;
700
743
  if (e instanceof AggregateError) {
701
- error_message = processErrorMessage(`Issue with external link [${url}]: ${e.message} - ${JSON.stringify(e.errors)}`, markdown_paths.relativePath, markdown_content, url);
744
+ error_message = `Issue with external link [${url}]: ${e.message} - ${JSON.stringify(e.errors)}`;
702
745
  } else {
703
- error_message = processErrorMessage(`Issue with external link [${url}]: ${e}`, markdown_paths.relativePath, markdown_content, url);
746
+ error_message = `Issue with external link [${url}]: ${e}`;
704
747
  }
705
- if (hdocbook_project.validation.external_link_warnings || process.env.GITHUB_ACTIONS === 'true')
706
- warnings[htmlFile.relativePath].push(error_message);
707
- else
708
- errors[htmlFile.relativePath].push(error_message);
748
+ const level =
749
+ hdocbook_project.validation.external_link_warnings ||
750
+ process.env.GITHUB_ACTIONS === 'true'
751
+ ? "warning"
752
+ : "error";
753
+ return { level, message: error_message };
709
754
  }
710
- });
755
+ };
756
+
757
+ externalChecks.push(async () =>
758
+ emitLinkResult(
759
+ await checkLinkOnce(global_links_checked, url, checkExternalUrl),
760
+ url,
761
+ htmlFile,
762
+ markdown_paths,
763
+ markdown_content,
764
+ ),
765
+ );
711
766
  }
712
767
  }
713
768
 
@@ -1136,7 +1191,9 @@
1136
1191
  }
1137
1192
 
1138
1193
 
1139
- const global_links_checked = [];
1194
+ // link -> Promise<{ level, message } | null> for network checks: fetched
1195
+ // once per run, but reported on every page that carries the link.
1196
+ const global_links_checked = new Map();
1140
1197
 
1141
1198
  for (const key in html_to_validate) {
1142
1199
  const file = html_to_validate[key];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hdoc-tools",
3
- "version": "0.62.4",
3
+ "version": "0.63.0",
4
4
  "description": "Hornbill HDocBook Development Support Tool",
5
5
  "main": "hdoc.js",
6
6
  "bin": {