hdoc-tools 0.63.0 → 0.65.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/hdoc-content-routes.js +15 -0
- package/hdoc-edit.js +3 -1
- package/hdoc-help.js +1 -1
- package/hdoc-serve-validate.js +516 -0
- package/hdoc-serve.js +811 -0
- package/hdoc-toc.js +7 -2
- package/package.json +2 -1
- package/ui/index.html +5 -2
- package/ui/js/bootstrap.js +3 -1
- package/ui/js/doc.hornbill.js +15 -1
- package/ui/js/hdoc-edit-book.js +2129 -0
- package/ui/js/hdoc-edit-cm.js +29 -0
- package/ui/js/hdoc-edit-inline.js +1649 -0
- package/ui/js/hdoc-edit-spell.js +17 -0
package/hdoc-content-routes.js
CHANGED
|
@@ -271,6 +271,20 @@ exports.create_content_handler = (ctx) => {
|
|
|
271
271
|
return `${header}\n${body}`;
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
// Render markdown source to the final page fragment exactly as the content
|
|
275
|
+
// route serves it — document header included (unless it's an _inline
|
|
276
|
+
// fragment). Used by the `hdoc serve -edit` live preview so the preview of
|
|
277
|
+
// an unsaved buffer matches what a saved page would render as.
|
|
278
|
+
async function render_page(file_path, md_source, logical_path) {
|
|
279
|
+
const { html, frontmatter, read_time } = await render_markdown(
|
|
280
|
+
file_path,
|
|
281
|
+
md_source,
|
|
282
|
+
);
|
|
283
|
+
return logical_path.includes("/_inline/")
|
|
284
|
+
? html
|
|
285
|
+
: wrap_with_document_header(html, frontmatter, logical_path, read_time);
|
|
286
|
+
}
|
|
287
|
+
|
|
274
288
|
async function transform_markdown_and_send_html(req, res, file_path) {
|
|
275
289
|
if (!fs.existsSync(file_path)) return false;
|
|
276
290
|
|
|
@@ -566,6 +580,7 @@ exports.create_content_handler = (ctx) => {
|
|
|
566
580
|
// handle_books_request / handle_library_request are exposed so the editor
|
|
567
581
|
// can register delegating /_books routes that follow a workspace switch.
|
|
568
582
|
render_markdown,
|
|
583
|
+
render_page,
|
|
569
584
|
transform_markdown_and_send_html,
|
|
570
585
|
send_content_file,
|
|
571
586
|
send_file,
|
package/hdoc-edit.js
CHANGED
|
@@ -247,7 +247,9 @@
|
|
|
247
247
|
app.get("/api/events", (req, res) => {
|
|
248
248
|
res.writeHead(200, {
|
|
249
249
|
"Content-Type": "text/event-stream",
|
|
250
|
-
|
|
250
|
+
// no-transform: the compression middleware otherwise buffers
|
|
251
|
+
// the stream and events never reach the client
|
|
252
|
+
"Cache-Control": "no-cache, no-transform",
|
|
251
253
|
Connection: "keep-alive",
|
|
252
254
|
});
|
|
253
255
|
res.write(": connected\n\n");
|
package/hdoc-help.js
CHANGED
|
@@ -29,7 +29,7 @@ Commands
|
|
|
29
29
|
Initializes a new HDocBook project from a template, using runtime input variables
|
|
30
30
|
|
|
31
31
|
- serve
|
|
32
|
-
Starts a local web server on port 3000, serving the content. Supports a -port N to use a different port
|
|
32
|
+
Starts a local web server on port 3000, serving the content. Supports a -port N to use a different port. Right-click page content in the browser to edit the page inline and save back to disk (editing is available from the serving machine only; LAN viewers get read-only preview)
|
|
33
33
|
|
|
34
34
|
- stats
|
|
35
35
|
Returns statistics regarding the book you are working on. Supports a -v switch for verbose output.
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
// Lightweight in-editor validation for the `hdoc serve` Edit Mode (PoC).
|
|
2
|
+
//
|
|
3
|
+
// NOT a replacement for `hdoc validate`: that runs the full build pipeline
|
|
4
|
+
// (hdoc-build.js) over the whole book and stays the source of truth — the
|
|
5
|
+
// Edit Mode "Validate book" action spawns it as a child process. What lives
|
|
6
|
+
// here are the FAST, targeted subsets suited to an editing loop:
|
|
7
|
+
//
|
|
8
|
+
// validate_nav(...) — the navigation-tree checks (path validity, target
|
|
9
|
+
// exists, on-disk case match, British spellings in
|
|
10
|
+
// labels and path segments, nesting depth), per node.
|
|
11
|
+
// validate_page(...) — single-article checks over the page's markdown and
|
|
12
|
+
// its RENDERED html (same pipeline as published):
|
|
13
|
+
// spellcheck, filename rule, H1 count, image alt +
|
|
14
|
+
// existence, link rules incl. same-page anchors and
|
|
15
|
+
// optional external HTTP checks.
|
|
16
|
+
//
|
|
17
|
+
// Check semantics mirror hdoc-validate.js; cross-book links and inter-book
|
|
18
|
+
// anchors need the GitHub-backed machinery, so they are reported as skipped
|
|
19
|
+
// here and left to the full validate.
|
|
20
|
+
|
|
21
|
+
const fs = require("node:fs");
|
|
22
|
+
const path = require("node:path");
|
|
23
|
+
const cheerio = require("cheerio");
|
|
24
|
+
const hdoc = require(path.join(__dirname, "hdoc-module.js"));
|
|
25
|
+
const { dialect_findings } = require(path.join(__dirname, "hdoc-spell.js"));
|
|
26
|
+
|
|
27
|
+
const DOC_FILENAME_RE = /^[a-z]+[-a-z0-9]+$/;
|
|
28
|
+
|
|
29
|
+
// --- shared helpers ---------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
const line_col_of = (text, offset) => {
|
|
32
|
+
let line = 1;
|
|
33
|
+
let last_nl = -1;
|
|
34
|
+
for (let i = 0; i < offset && i < text.length; i++) {
|
|
35
|
+
if (text[i] === "\n") {
|
|
36
|
+
line++;
|
|
37
|
+
last_nl = i;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { line, col: offset - last_nl };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Case-exact check for a source-relative file path. Returns null when every
|
|
44
|
+
// segment matches the on-disk casing (or does not exist at all — existence is
|
|
45
|
+
// its own check), else the offending segment pair.
|
|
46
|
+
const case_mismatch = (root, rel) => {
|
|
47
|
+
let cur = root;
|
|
48
|
+
for (const seg of rel.split("/")) {
|
|
49
|
+
let entries;
|
|
50
|
+
try {
|
|
51
|
+
entries = fs.readdirSync(cur);
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (entries.includes(seg)) {
|
|
56
|
+
cur = path.join(cur, seg);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const ci = entries.find((e) => e.toLowerCase() === seg.toLowerCase());
|
|
60
|
+
return ci ? { expected: ci, got: seg } : null;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Resolve a book link (leading-slash tolerant, no extension) to an existing
|
|
66
|
+
// source file. Returns the source-relative path or null.
|
|
67
|
+
const resolve_link_target = (source_path, link) => {
|
|
68
|
+
const clean = String(link).replace(/^\/+/, "").split("#")[0];
|
|
69
|
+
if (!clean) return null;
|
|
70
|
+
for (const rel of [
|
|
71
|
+
`${clean}.md`,
|
|
72
|
+
`${clean}/index.md`,
|
|
73
|
+
`${clean}.html`,
|
|
74
|
+
`${clean}.htm`,
|
|
75
|
+
`${clean}/index.html`,
|
|
76
|
+
`${clean}/index.htm`,
|
|
77
|
+
]) {
|
|
78
|
+
if (fs.existsSync(path.join(source_path, rel))) return rel;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const redirect_for = (project, link) => {
|
|
84
|
+
const list =
|
|
85
|
+
project && Array.isArray(project.redirects) ? project.redirects : [];
|
|
86
|
+
const norm = link.startsWith("/") ? link : `/${link}`;
|
|
87
|
+
return list.find((r) => {
|
|
88
|
+
const u = String(r.url || "");
|
|
89
|
+
return (u.startsWith("/") ? u : `/${u}`) === norm;
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const project_dictionary = (project) => {
|
|
94
|
+
const v = project && project.validation;
|
|
95
|
+
return v && Array.isArray(v.spellcheckDictionary)
|
|
96
|
+
? v.spellcheckDictionary
|
|
97
|
+
: [];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Per-document spellcheck exclusions ({document_path, words}) for a logical
|
|
101
|
+
// page path (no extension).
|
|
102
|
+
const doc_spell_excludes = (project, logical) => {
|
|
103
|
+
const v = project && project.validation;
|
|
104
|
+
const list = v && Array.isArray(v.exclude_spellcheck) ? v.exclude_spellcheck : [];
|
|
105
|
+
const hit = list.find(
|
|
106
|
+
(e) => String(e.document_path).replace(/^\/+/, "") === logical,
|
|
107
|
+
);
|
|
108
|
+
return hit && Array.isArray(hit.words) ? hit.words : [];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// --- navigation validation ---------------------------------------------------
|
|
112
|
+
|
|
113
|
+
// items: hdocbook navigation items (with ids stamped by TocModel).
|
|
114
|
+
// Returns [{ id, problems: [string] }] for nodes with findings only.
|
|
115
|
+
exports.validate_nav = (items, source_path, docId, project) => {
|
|
116
|
+
const dict = project_dictionary(project);
|
|
117
|
+
const out = [];
|
|
118
|
+
|
|
119
|
+
const spell = (text) =>
|
|
120
|
+
dialect_findings(String(text || ""), dict).map(
|
|
121
|
+
(f) => `British spelling: "${f.word}" should be "${f.suggestion}"`,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const walk = (nodes, depth) => {
|
|
125
|
+
for (const n of nodes) {
|
|
126
|
+
const problems = [];
|
|
127
|
+
|
|
128
|
+
if (depth > 5) {
|
|
129
|
+
problems.push("Navigation is nested deeper than 5 levels");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const p of spell(n.text)) problems.push(`Label: ${p}`);
|
|
133
|
+
|
|
134
|
+
const link = typeof n.link === "string" ? n.link : null;
|
|
135
|
+
if (link) {
|
|
136
|
+
const clean = link.replace(/^\/+/, "");
|
|
137
|
+
if (/[^a-zA-Z0-9\-/]/.test(clean)) {
|
|
138
|
+
problems.push(`Link contains invalid characters: ${link}`);
|
|
139
|
+
}
|
|
140
|
+
if (!(clean === docId || clean.startsWith(`${docId}/`))) {
|
|
141
|
+
problems.push(
|
|
142
|
+
`Link points outside this book (${docId}): ${link}`,
|
|
143
|
+
);
|
|
144
|
+
} else {
|
|
145
|
+
const rel = resolve_link_target(source_path, clean);
|
|
146
|
+
if (!rel) {
|
|
147
|
+
if (!redirect_for(project, clean)) {
|
|
148
|
+
problems.push(`Target file does not exist: ${link}`);
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
const cm = case_mismatch(source_path, rel);
|
|
152
|
+
if (cm) {
|
|
153
|
+
problems.push(
|
|
154
|
+
`Path case does not match disk: "${cm.got}" is "${cm.expected}" on disk`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// British spellings inside the path segments
|
|
159
|
+
const seg_text = clean.split(/[-/]/).join(" ");
|
|
160
|
+
for (const p of spell(seg_text)) problems.push(`Path: ${p}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (problems.length) out.push({ id: n.id, problems });
|
|
165
|
+
if (Array.isArray(n.items)) walk(n.items, depth + 1);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
walk(items, 1);
|
|
169
|
+
return out;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// --- inline (as-you-type) lint ------------------------------------------------
|
|
173
|
+
|
|
174
|
+
// Character ranges of fenced code blocks and inline code spans, so link
|
|
175
|
+
// findings never fire inside code (the build never renders those as links).
|
|
176
|
+
const code_ranges = (md) => {
|
|
177
|
+
const ranges = [];
|
|
178
|
+
const fence_re = /^(```|~~~)[^\n]*\n[\s\S]*?(?:^\1\s*$|(?![\s\S]))/gm;
|
|
179
|
+
let m;
|
|
180
|
+
while ((m = fence_re.exec(md)) !== null) {
|
|
181
|
+
ranges.push([m.index, m.index + m[0].length]);
|
|
182
|
+
}
|
|
183
|
+
const tick_re = /`[^`\n]+`/g;
|
|
184
|
+
while ((m = tick_re.exec(md)) !== null) {
|
|
185
|
+
ranges.push([m.index, m.index + m[0].length]);
|
|
186
|
+
}
|
|
187
|
+
return ranges;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const in_ranges = (ranges, pos) =>
|
|
191
|
+
ranges.some(([a, b]) => pos >= a && pos < b);
|
|
192
|
+
|
|
193
|
+
// Fast, offset-based findings over an UNSAVED markdown buffer, for editor
|
|
194
|
+
// underlines: British spellings (with the US suggestion as a one-click fix)
|
|
195
|
+
// and syntactic/local link problems. No HTTP — external URLs are left to
|
|
196
|
+
// the on-demand page/book validation.
|
|
197
|
+
// args: { md, source_path, docId, project, logical }
|
|
198
|
+
// -> [{ from, to, severity: 'error'|'warning', message, fix? }]
|
|
199
|
+
exports.lint_markdown = (args) => {
|
|
200
|
+
const { md, source_path, docId, project, logical } = args;
|
|
201
|
+
const findings = [];
|
|
202
|
+
|
|
203
|
+
// British spellings (same exclusions as validate)
|
|
204
|
+
const ignore = [
|
|
205
|
+
...project_dictionary(project),
|
|
206
|
+
...doc_spell_excludes(project, logical || ""),
|
|
207
|
+
];
|
|
208
|
+
for (const f of dialect_findings(md, ignore)) {
|
|
209
|
+
findings.push({
|
|
210
|
+
from: f.from,
|
|
211
|
+
to: f.to,
|
|
212
|
+
severity: "error",
|
|
213
|
+
message: `British spelling: ${f.word} should be ${f.suggestion}`,
|
|
214
|
+
fix: f.suggestion,
|
|
215
|
+
word: f.word,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// markdown links/images: [text](target) / 
|
|
220
|
+
const code = code_ranges(md);
|
|
221
|
+
const link_re = /(!?)\[[^\]]*\]\(\s*<?([^)\s>]+)/g;
|
|
222
|
+
let m;
|
|
223
|
+
while ((m = link_re.exec(md)) !== null) {
|
|
224
|
+
if (in_ranges(code, m.index)) continue;
|
|
225
|
+
const is_img = m[1] === "!";
|
|
226
|
+
const target = m[2];
|
|
227
|
+
const from = m.index + m[0].length - target.length;
|
|
228
|
+
const to = from + target.length;
|
|
229
|
+
const err = (message) =>
|
|
230
|
+
findings.push({ from, to, severity: "error", message });
|
|
231
|
+
|
|
232
|
+
if (hdoc.valid_url(target)) {
|
|
233
|
+
const lower = target.toLowerCase();
|
|
234
|
+
if (
|
|
235
|
+
!is_img &&
|
|
236
|
+
(lower.includes("docs.hornbill.com") ||
|
|
237
|
+
lower.includes("docs-internal.hornbill.com"))
|
|
238
|
+
) {
|
|
239
|
+
err(`Hornbill Docs links should not be fully-qualified`);
|
|
240
|
+
}
|
|
241
|
+
continue; // external — checked by page/book validation only
|
|
242
|
+
}
|
|
243
|
+
if (target.startsWith("#")) continue; // anchor — needs a render to verify
|
|
244
|
+
if (target.startsWith("mailto:")) continue;
|
|
245
|
+
|
|
246
|
+
if (!target.startsWith("/")) {
|
|
247
|
+
err(
|
|
248
|
+
`${is_img ? "Image" : "Link"} paths should be root-relative (start with /)`,
|
|
249
|
+
);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const no_hash = target.split("#")[0];
|
|
254
|
+
const segments = no_hash.replace(/^\/+/, "").split("/");
|
|
255
|
+
const is_books = segments[0] === "_books";
|
|
256
|
+
const root = is_books ? segments[1] : segments[0];
|
|
257
|
+
if (!root) continue; // "/" = docs home
|
|
258
|
+
|
|
259
|
+
if (is_img) {
|
|
260
|
+
const rel = no_hash.replace(/^\/+/, "").replace(/^_books\//, "");
|
|
261
|
+
if (!fs.existsSync(path.join(source_path, rel))) {
|
|
262
|
+
err(`Image file does not exist`);
|
|
263
|
+
}
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const ext = path.extname(no_hash).toLowerCase();
|
|
268
|
+
if (is_books && ext === "") {
|
|
269
|
+
err(`Page links should not include _books in the path`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (root !== docId) continue; // cross-book — full validate territory
|
|
273
|
+
if ([".md", ".html", ".htm"].includes(ext)) {
|
|
274
|
+
err(`Links should not include a file extension`);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (ext !== "") continue; // linked resource
|
|
278
|
+
|
|
279
|
+
const clean = no_hash.replace(/^\/+/, "").replace(/^_books\//, "");
|
|
280
|
+
const resolved = resolve_link_target(source_path, clean);
|
|
281
|
+
if (!resolved) {
|
|
282
|
+
if (!redirect_for(project, clean)) err(`Link target does not exist`);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const cm = case_mismatch(source_path, resolved);
|
|
286
|
+
if (cm) {
|
|
287
|
+
err(`Path case does not match disk ("${cm.got}" is "${cm.expected}")`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
findings.sort((a, b) => a.from - b.from);
|
|
292
|
+
return findings;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// --- single-article validation -----------------------------------------------
|
|
296
|
+
|
|
297
|
+
// External URL check: HEAD, falling back to GET on 404/405 (same approach as
|
|
298
|
+
// hdoc-validate.js), shorter retry budget — this is an interactive loop.
|
|
299
|
+
const check_external = async (url) => {
|
|
300
|
+
const opts = {
|
|
301
|
+
method: "HEAD",
|
|
302
|
+
headers: { "user-agent": "hdoc-serve-validate" },
|
|
303
|
+
timeoutMs: 8000,
|
|
304
|
+
redirect: "follow",
|
|
305
|
+
};
|
|
306
|
+
let resp = await hdoc.fetchWithRetry(url, opts, 1);
|
|
307
|
+
if (resp.status === 404 || resp.status === 405) {
|
|
308
|
+
resp = await hdoc.fetchWithRetry(url, { ...opts, method: "GET" }, 1);
|
|
309
|
+
}
|
|
310
|
+
return resp.status;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// args: { source_path, docId, rel (source-relative md path), logical,
|
|
314
|
+
// md, html (rendered page fragment), project, external (bool) }
|
|
315
|
+
// Returns { errors: [], warnings: [], skipped: [] } — strings, prefixed
|
|
316
|
+
// "line:col - " where known.
|
|
317
|
+
exports.validate_page = async (args) => {
|
|
318
|
+
const {
|
|
319
|
+
source_path,
|
|
320
|
+
docId,
|
|
321
|
+
rel,
|
|
322
|
+
logical,
|
|
323
|
+
md,
|
|
324
|
+
html,
|
|
325
|
+
project,
|
|
326
|
+
external,
|
|
327
|
+
} = args;
|
|
328
|
+
const errors = [];
|
|
329
|
+
const warnings = [];
|
|
330
|
+
const skipped = [];
|
|
331
|
+
|
|
332
|
+
// 1) filename convention (mirrors hdoc-build.js; _-prefixed and *_ext.md
|
|
333
|
+
// files never reach here — resolve_page_md refuses them)
|
|
334
|
+
const stem = path.basename(rel).replace(/\.md$/i, "");
|
|
335
|
+
if (!DOC_FILENAME_RE.test(stem) && stem !== "index") {
|
|
336
|
+
errors.push(
|
|
337
|
+
`Filename does not meet naming standards (^[a-z]+[-a-z0-9]+$): ${path.basename(rel)}`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 2) British spellings over the markdown source
|
|
342
|
+
const ignore = [
|
|
343
|
+
...project_dictionary(project),
|
|
344
|
+
...doc_spell_excludes(project, logical),
|
|
345
|
+
];
|
|
346
|
+
for (const f of dialect_findings(md, ignore)) {
|
|
347
|
+
const { line, col } = line_col_of(md, f.from);
|
|
348
|
+
errors.push(
|
|
349
|
+
`${line}:${col} - British spelling: ${f.word} should be ${f.suggestion}`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 3) rendered-output checks
|
|
354
|
+
const $ = cheerio.load(html);
|
|
355
|
+
|
|
356
|
+
// H1 rule (page fragment: the generated document header holds the one
|
|
357
|
+
// legitimate h1; a second one comes from the content)
|
|
358
|
+
const v = project && project.validation ? project.validation : {};
|
|
359
|
+
const h1_excluded =
|
|
360
|
+
Array.isArray(v.exclude_h1_count) &&
|
|
361
|
+
v.exclude_h1_count.some(
|
|
362
|
+
(p) => String(p).replace(/^\/+/, "") === logical,
|
|
363
|
+
);
|
|
364
|
+
const h1_count = $("h1").length;
|
|
365
|
+
if (h1_count > 1 && !h1_excluded) {
|
|
366
|
+
errors.push(
|
|
367
|
+
`Page contains ${h1_count} <h1> headings — only one # heading is allowed per page`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// images
|
|
372
|
+
$("img").each((_i, el) => {
|
|
373
|
+
const src = String($(el).attr("src") || "");
|
|
374
|
+
const alt = $(el).attr("alt");
|
|
375
|
+
if (alt === undefined || String(alt).trim() === "") {
|
|
376
|
+
errors.push(`Image has a missing or empty alt attribute: ${src}`);
|
|
377
|
+
}
|
|
378
|
+
if (!src) return;
|
|
379
|
+
if (hdoc.valid_url(src)) {
|
|
380
|
+
skipped.push(`External image not checked: ${src}`);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (!src.startsWith("/")) {
|
|
384
|
+
errors.push(
|
|
385
|
+
`Root relative image links should start with a forward-slash: ${src}`,
|
|
386
|
+
);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const img_rel = src.replace(/^\/+/, "").replace(/^_books\//, "");
|
|
390
|
+
if (!fs.existsSync(path.join(source_path, img_rel.split("#")[0]))) {
|
|
391
|
+
errors.push(`Image file does not exist: ${src}`);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// links
|
|
396
|
+
const anchors_ok = (hash) => {
|
|
397
|
+
const clean = hash.replace(/^\/?#/, "");
|
|
398
|
+
return $(`div#hb-doc-anchor-${clean.replace(/^hb-doc-anchor-/, "")}`).length > 0 ||
|
|
399
|
+
html.includes(`id="hb-doc-anchor-${clean}"`);
|
|
400
|
+
};
|
|
401
|
+
const externals = [];
|
|
402
|
+
const seen = new Set();
|
|
403
|
+
$("a[href]").each((_i, el) => {
|
|
404
|
+
const href = String($(el).attr("href") || "").trim();
|
|
405
|
+
if (!href || seen.has(href)) return;
|
|
406
|
+
seen.add(href);
|
|
407
|
+
|
|
408
|
+
if (href.startsWith("#") || href.startsWith("/#")) {
|
|
409
|
+
if (!anchors_ok(href)) {
|
|
410
|
+
errors.push(
|
|
411
|
+
`Target hash anchor is not present in page content: ${href}`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const valid = hdoc.valid_url(href);
|
|
418
|
+
if (valid) {
|
|
419
|
+
if (valid.protocol === "mailto:") return;
|
|
420
|
+
const lower = href.toLowerCase();
|
|
421
|
+
if (
|
|
422
|
+
lower.includes("docs.hornbill.com") ||
|
|
423
|
+
lower.includes("docs-internal.hornbill.com")
|
|
424
|
+
) {
|
|
425
|
+
errors.push(
|
|
426
|
+
`Hornbill Docs links should not be fully-qualified: ${href}`,
|
|
427
|
+
);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (external) externals.push(href);
|
|
431
|
+
else skipped.push(`External link not checked: ${href}`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (!href.startsWith("/")) {
|
|
436
|
+
errors.push(
|
|
437
|
+
`Root relative links should start with a forward-slash: ${href}`,
|
|
438
|
+
);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const segments = href.replace(/^\/+/, "").split("/");
|
|
443
|
+
const is_books = segments[0] === "_books";
|
|
444
|
+
const root = is_books ? segments[1] : segments[0];
|
|
445
|
+
if (!root) return; // bare "/" = docs home
|
|
446
|
+
|
|
447
|
+
if (is_books && path.extname(href) === "") {
|
|
448
|
+
errors.push(
|
|
449
|
+
`Root relative page links should not include _books in the path: ${href}`,
|
|
450
|
+
);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (root !== docId) {
|
|
455
|
+
skipped.push(
|
|
456
|
+
`Cross-book link not checked (run a full validate): ${href}`,
|
|
457
|
+
);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const no_hash = href.split("#")[0];
|
|
462
|
+
const ext = path.extname(no_hash).toLowerCase();
|
|
463
|
+
if ([".md", ".html", ".htm"].includes(ext)) {
|
|
464
|
+
errors.push(
|
|
465
|
+
`Relative links should not include a file extension: ${href}`,
|
|
466
|
+
);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (ext !== "") {
|
|
470
|
+
skipped.push(`Linked resource not checked: ${href}`);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const clean = no_hash.replace(/^\/+/, "").replace(/^_books\//, "");
|
|
475
|
+
const target = resolve_link_target(source_path, clean);
|
|
476
|
+
if (!target) {
|
|
477
|
+
if (!redirect_for(project, clean)) {
|
|
478
|
+
errors.push(`Link target does not exist: ${href}`);
|
|
479
|
+
}
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const cm = case_mismatch(source_path, target);
|
|
483
|
+
if (cm) {
|
|
484
|
+
errors.push(
|
|
485
|
+
`Link path case does not match disk ("${cm.got}" is "${cm.expected}"): ${href}`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
if (href.includes("#")) {
|
|
489
|
+
skipped.push(
|
|
490
|
+
`Anchor on another page not checked (run a full validate): ${href}`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// external HTTP checks, concurrent
|
|
496
|
+
if (externals.length) {
|
|
497
|
+
await Promise.all(
|
|
498
|
+
externals.map(async (url) => {
|
|
499
|
+
try {
|
|
500
|
+
const status = await check_external(url);
|
|
501
|
+
if ((status < 200 || status > 299) && status !== 304) {
|
|
502
|
+
warnings.push(
|
|
503
|
+
`External link returned HTTP ${status}: ${url}`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
} catch (e) {
|
|
507
|
+
warnings.push(
|
|
508
|
+
`External link unreachable (${String((e && e.message) || e)}): ${url}`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return { errors, warnings, skipped };
|
|
516
|
+
};
|