hdoc-tools 0.60.1 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +89 -75
  3. package/hdoc-build-db.js +275 -275
  4. package/hdoc-build-embeddings.js +202 -202
  5. package/hdoc-build-pdf.js +232 -232
  6. package/hdoc-build.js +14 -6
  7. package/hdoc-bump.js +4 -2
  8. package/hdoc-content-routes.js +143 -83
  9. package/hdoc-create.js +110 -108
  10. package/hdoc-db.js +114 -114
  11. package/hdoc-help.js +60 -60
  12. package/hdoc-init.js +103 -68
  13. package/hdoc-install-browser.js +145 -145
  14. package/hdoc-mermaid.js +204 -204
  15. package/hdoc-module.js +1102 -1079
  16. package/hdoc-serve.js +13 -7
  17. package/hdoc-stats.js +9 -9
  18. package/hdoc-validate-config.js +355 -329
  19. package/hdoc-validate-interbook.js +321 -0
  20. package/hdoc-validate.js +1231 -1158
  21. package/hdoc-ver.js +4 -2
  22. package/hdoc.js +12 -11
  23. package/npm-shrinkwrap.json +2 -2
  24. package/package.json +13 -2
  25. package/schemas/hdocbook-project.schema.json +20 -0
  26. package/schemas/hdocbook.schema.json +6 -2
  27. package/templates/doc-header-non-git.html +19 -19
  28. package/templates/doc-header.html +26 -26
  29. package/templates/init/.github/workflows/hdocbuild_onpull.yml +16 -16
  30. package/templates/init/.github/workflows/hdocbuild_onpush.yml +15 -15
  31. package/templates/init/LICENSE +21 -21
  32. package/templates/init/README.md +9 -9
  33. package/templates/init/_hdocbook/index.md +4 -4
  34. package/templates/init/gitignore +8 -8
  35. package/templates/init/resources/README.md +2 -2
  36. package/templates/pdf/css/custom-block.css +90 -90
  37. package/templates/pdf/css/fonts.css +221 -221
  38. package/templates/pdf/css/hdocs-pdf.css +495 -495
  39. package/templates/pdf/css/vars.css +404 -404
  40. package/templates/pdf/template-footer.html +19 -19
  41. package/templates/pdf/template-header.html +37 -37
  42. package/templates/pdf/template.html +20 -20
  43. package/templates/pdf-header-non-git.html +12 -12
  44. package/templates/pdf-header.html +16 -16
  45. package/ui/content/invalid-hdocbook-json.html +6 -6
  46. package/ui/content/invalid-hdocbook-json.md +7 -7
  47. package/ui/css/theme-default/styles/components/content.css +124 -124
  48. package/ui/css/theme-default/styles/components/sidebar.css +182 -182
  49. package/ui/css/theme-default/styles/htldoc.layouts.css +310 -310
  50. package/ui/index.html +419 -419
  51. package/ui/js/doc.hornbill.js +31 -44
  52. package/ui/js/mermaid-theme.json +27 -0
  53. package/hdoc-build-onyx.js +0 -134
  54. package/templates/mermaid-theme.yaml +0 -28
  55. package/templates/pdf/fonts/inter-cyrillic copy.woff2 +0 -0
@@ -0,0 +1,321 @@
1
+ // Inter-book link validation.
2
+ //
3
+ // When a GitHub token is supplied to build/validate, root-relative links that
4
+ // point at OTHER Hornbill Docs books (e.g. /esp-config/some/article#anchor) —
5
+ // and fully-qualified docs.hornbill.com links where those are permitted
6
+ // (_inline content) — are validated instead of being skipped:
7
+ //
8
+ // 1. The target book must exist — either listed in the published library
9
+ // (https://docs.hornbill.com/_books/library.json) or as a repo under
10
+ // github.com/Hornbill-Docs/<docId>.
11
+ // 2. The target article must exist in the book's GitHub repo (default
12
+ // branch): <docId>/<path>.md, <path>/index.md, <path>.html or <path>.htm.
13
+ // A path matched by the target book's redirects[] also passes.
14
+ // 3. If the source link carries a #hash-anchor, a matching heading anchor
15
+ // must exist in the target article. Anchor ids are derived from h2/h3
16
+ // headings with the same hdoc.makeAnchorIdFriendly slug used at build.
17
+ //
18
+ // All lookups are cached as promises (per book, per article, per repo config)
19
+ // so concurrent link checks for the same target collapse into one API call.
20
+ // GitHub API calls go through hdoc.fetchWithRetry which throttles to
21
+ // 1 request/800ms and honours rate-limit headers.
22
+ //
23
+ // Result levels:
24
+ // ok - link verified (caller appends to validated-links.txt)
25
+ // skip - could not verify by design (book has no public source) — logged
26
+ // warning - could not verify due to access/infra (401/403, library down)
27
+ // error - target book/article/anchor definitively missing
28
+ (() => {
29
+ const cheerio = require("cheerio");
30
+ const path = require("node:path");
31
+ const hdoc = require(path.join(__dirname, "hdoc-module.js"));
32
+
33
+ const LIBRARY_URL = "https://docs.hornbill.com/_books/library.json";
34
+ const GITHUB_ORG_FALLBACK = "https://github.com/Hornbill-Docs";
35
+
36
+ let git_token = "";
37
+ let library_promise = null;
38
+ const book_cache = {}; // docId -> promise of book resolution
39
+ const article_cache = {}; // docId|path -> promise of article fetch
40
+ const redirects_cache = {}; // repo url -> promise of redirect url set
41
+
42
+ const gh_headers = () => {
43
+ const headers = {
44
+ "User-Agent": "HornbillDocsBuild",
45
+ "Cache-Control": "no-cache",
46
+ Accept: "application/vnd.github.raw+json",
47
+ };
48
+ if (git_token !== "") headers.authorization = `Bearer ${git_token}`;
49
+ return headers;
50
+ };
51
+
52
+ // github.com/<owner>/<repo> -> api.github.com/repos/<owner>/<repo>
53
+ const repo_api_base = (repo_url) => {
54
+ const clean = repo_url.endsWith("/") ? repo_url.slice(0, -1) : repo_url;
55
+ return clean.replace(
56
+ "https://github.com/",
57
+ "https://api.github.com/repos/",
58
+ );
59
+ };
60
+
61
+ const get_library = () => {
62
+ if (library_promise === null) {
63
+ library_promise = (async () => {
64
+ const books = {};
65
+ const resp = await hdoc.fetchWithRetry(LIBRARY_URL, {
66
+ headers: { "User-Agent": "HornbillDocsBuild" },
67
+ timeoutMs: 10000,
68
+ });
69
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
70
+ const data = await resp.json();
71
+ for (const book of data.books || []) {
72
+ books[book.docId] = book;
73
+ }
74
+ return books;
75
+ })().catch((e) => {
76
+ // Cache the failure — one warning per run, not one per link
77
+ return { __error: `${e}` };
78
+ });
79
+ }
80
+ return library_promise;
81
+ };
82
+
83
+ // Resolve a docId to { exists, repo|null, unverifiable|undefined }
84
+ const resolve_book = (doc_id) => {
85
+ if (!book_cache[doc_id]) {
86
+ book_cache[doc_id] = (async () => {
87
+ const library = await get_library();
88
+ if (!library.__error && library[doc_id]) {
89
+ return {
90
+ exists: true,
91
+ repo: library[doc_id].publicSource || null,
92
+ };
93
+ }
94
+ // Not in the public library (internal book?) or library down —
95
+ // fall back to the conventional repo location.
96
+ const fallback_repo = `${GITHUB_ORG_FALLBACK}/${doc_id}`;
97
+ const details = await hdoc.get_github_repo_details(
98
+ repo_api_base(fallback_repo),
99
+ git_token,
100
+ );
101
+ if (details.success) return { exists: true, repo: fallback_repo };
102
+ // get_github_repo_details reports 404 as "Error: HTTP 404" (early
103
+ // return) or "404 : Not Found" (json branch) depending on path
104
+ const err = details.error instanceof Error ? details.error.message : `${details.error}`;
105
+ if (err.includes("404")) {
106
+ if (library.__error) {
107
+ // Library unreachable AND no fallback repo — can't be sure
108
+ return {
109
+ exists: false,
110
+ repo: null,
111
+ unverifiable: `book library unreachable (${library.__error}) and no repo at ${fallback_repo}`,
112
+ };
113
+ }
114
+ return { exists: false, repo: null };
115
+ }
116
+ // 401/403/network — repo may exist but we can't see it
117
+ return {
118
+ exists: false,
119
+ repo: null,
120
+ unverifiable: `GitHub lookup failed for ${fallback_repo}: ${err}`,
121
+ };
122
+ })();
123
+ }
124
+ return book_cache[doc_id];
125
+ };
126
+
127
+ // Fetch a repo file's raw content via the contents API.
128
+ // Returns { status, content } — content null unless status 200.
129
+ const fetch_repo_file = async (repo, file_path) => {
130
+ const encoded = file_path.split("/").map(encodeURIComponent).join("/");
131
+ const resp = await hdoc.fetchWithRetry(
132
+ `${repo_api_base(repo)}/contents/${encoded}`,
133
+ { headers: gh_headers(), timeoutMs: 10000 },
134
+ 2,
135
+ );
136
+ return {
137
+ status: resp.status,
138
+ content: resp.ok ? await resp.text() : null,
139
+ };
140
+ };
141
+
142
+ // The redirect urls declared in the target book's hdocbook-project.json —
143
+ // a link to a redirected path is valid (the published site serves 301/308).
144
+ const get_redirect_urls = (repo) => {
145
+ if (!redirects_cache[repo]) {
146
+ redirects_cache[repo] = (async () => {
147
+ const urls = {};
148
+ try {
149
+ const file = await fetch_repo_file(repo, "hdocbook-project.json");
150
+ if (file.status === 200) {
151
+ const project = JSON.parse(file.content);
152
+ for (const redirect of project.redirects || []) {
153
+ if (redirect.url) urls[redirect.url] = true;
154
+ }
155
+ }
156
+ } catch {
157
+ // Malformed/missing project file in target repo — no redirects
158
+ }
159
+ return urls;
160
+ })();
161
+ }
162
+ return redirects_cache[repo];
163
+ };
164
+
165
+ // Derive the set of hb-doc-anchor-* ids the build would generate for a
166
+ // target article. Markdown: h2/h3 ATX headings outside code fences, with
167
+ // [text](url) collapsed to text before slugging (matching rendered text).
168
+ // HTML: real h2/h3 elements via cheerio.
169
+ const extract_anchor_ids = (content, is_html) => {
170
+ const anchors = {};
171
+ if (is_html) {
172
+ const $ = cheerio.load(content);
173
+ $("h2, h3").each(function () {
174
+ anchors[hdoc.makeAnchorIdFriendly($(this).text().trim())] = true;
175
+ });
176
+ return anchors;
177
+ }
178
+ let in_fence = false;
179
+ for (const raw_line of content.split("\n")) {
180
+ const line = raw_line.trimEnd();
181
+ if (/^\s*(```|~~~)/.test(line)) {
182
+ in_fence = !in_fence;
183
+ continue;
184
+ }
185
+ if (in_fence) continue;
186
+ const match = line.match(/^(##|###)\s+(.*)$/);
187
+ if (!match) continue;
188
+ const text = match[2]
189
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // md links -> link text
190
+ .replace(/[`*_]/g, "") // inline emphasis/code markers
191
+ .trim();
192
+ anchors[hdoc.makeAnchorIdFriendly(text)] = true;
193
+ }
194
+ return anchors;
195
+ };
196
+
197
+ // Locate an article inside a book repo, trying the same resolution order
198
+ // the published site uses. Returns
199
+ // { found, redirected, unverifiable, anchors } — anchors null when the
200
+ // article was matched via redirect (content not fetched).
201
+ const resolve_article = (repo, doc_id, article_path) => {
202
+ const cache_key = `${doc_id}|${article_path}`;
203
+ if (!article_cache[cache_key]) {
204
+ article_cache[cache_key] = (async () => {
205
+ const base = `${doc_id}/${article_path}`;
206
+ const candidates = [
207
+ { file: `${base}.md`, html: false },
208
+ { file: `${base}/index.md`, html: false },
209
+ { file: `${base}.html`, html: true },
210
+ { file: `${base}.htm`, html: true },
211
+ ];
212
+ // Book-root link (/other-book) → the book's index page
213
+ if (article_path === "index") candidates.splice(1, 1);
214
+ for (const candidate of candidates) {
215
+ const file = await fetch_repo_file(repo, candidate.file);
216
+ if (file.status === 200) {
217
+ return {
218
+ found: true,
219
+ anchors: extract_anchor_ids(file.content, candidate.html),
220
+ };
221
+ }
222
+ if (file.status !== 404) {
223
+ return {
224
+ found: false,
225
+ unverifiable: `GitHub returned HTTP ${file.status} for ${candidate.file}`,
226
+ };
227
+ }
228
+ }
229
+ const redirect_urls = await get_redirect_urls(repo);
230
+ if (redirect_urls[`/${base}`]) {
231
+ return { found: true, redirected: true, anchors: null };
232
+ }
233
+ return { found: false };
234
+ })();
235
+ }
236
+ return article_cache[cache_key];
237
+ };
238
+
239
+ exports.init = (token) => {
240
+ git_token = token || "";
241
+ };
242
+
243
+ exports.enabled = () => git_token !== "";
244
+
245
+ // link: root-relative inter-book link, e.g. /esp-config/path/article#anchor
246
+ // Returns { level: 'ok'|'skip'|'warning'|'error', message }
247
+ exports.check_link = async (link) => {
248
+ const [link_path, hash_anchor] = link.split("#");
249
+ const segments = link_path.split("/").filter((s) => s !== "");
250
+ if (segments[0] === "_books") segments.shift();
251
+ const doc_id = segments.shift();
252
+ const article_path = segments.length > 0 ? segments.join("/") : "index";
253
+
254
+ let book;
255
+ try {
256
+ book = await resolve_book(doc_id);
257
+ } catch (e) {
258
+ return {
259
+ level: "warning",
260
+ message: `Unable to verify inter-book link [${link}]: ${e}`,
261
+ };
262
+ }
263
+ if (book.unverifiable) {
264
+ return {
265
+ level: "warning",
266
+ message: `Unable to verify inter-book link [${link}]: ${book.unverifiable}`,
267
+ };
268
+ }
269
+ if (!book.exists) {
270
+ return {
271
+ level: "error",
272
+ message: `Inter-book link target book does not exist: ${doc_id} [${link}]`,
273
+ };
274
+ }
275
+ if (!book.repo) {
276
+ // Generated books (API references) have no public source repo
277
+ return {
278
+ level: "skip",
279
+ message: `Inter-book link target book [${doc_id}] exists but has no source repo - article not verified: ${link}`,
280
+ };
281
+ }
282
+
283
+ let article;
284
+ try {
285
+ article = await resolve_article(book.repo, doc_id, article_path);
286
+ } catch (e) {
287
+ return {
288
+ level: "warning",
289
+ message: `Unable to verify inter-book link [${link}]: ${e}`,
290
+ };
291
+ }
292
+ if (article.unverifiable) {
293
+ return {
294
+ level: "warning",
295
+ message: `Unable to verify inter-book link [${link}]: ${article.unverifiable}`,
296
+ };
297
+ }
298
+ if (!article.found) {
299
+ return {
300
+ level: "error",
301
+ message: `Inter-book link target article does not exist in book [${doc_id}]: ${link}`,
302
+ };
303
+ }
304
+
305
+ if (hash_anchor) {
306
+ if (article.anchors === null) {
307
+ return {
308
+ level: "skip",
309
+ message: `Inter-book link resolves via redirect - hash anchor not verified: ${link}`,
310
+ };
311
+ }
312
+ if (!article.anchors[`hb-doc-anchor-${hash_anchor}`]) {
313
+ return {
314
+ level: "error",
315
+ message: `Inter-book link hash anchor not present in target article: ${link}`,
316
+ };
317
+ }
318
+ }
319
+ return { level: "ok", message: `Inter-book link verified: ${link}` };
320
+ };
321
+ })();