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
|
@@ -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
|
+
})();
|