hdoc-tools 0.64.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-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
- "Cache-Control": "no-cache",
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");
@@ -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) / ![alt](src)
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
+ };