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-serve.js CHANGED
@@ -7,6 +7,11 @@
7
7
  const hdoc = require(path.join(__dirname, "hdoc-module.js"));
8
8
  const { create_content_handler, build_nav_inline, resolve_within_root } =
9
9
  require(path.join(__dirname, "hdoc-content-routes.js"));
10
+ const { TocModel } = require(path.join(__dirname, "hdoc-toc.js"));
11
+ const serve_validate = require(
12
+ path.join(__dirname, "hdoc-serve-validate.js"),
13
+ );
14
+ const { spawn } = require("node:child_process");
10
15
 
11
16
  let port = 3000;
12
17
  let docId;
@@ -202,6 +207,7 @@
202
207
  }
203
208
  try {
204
209
  fs.writeFileSync(resolved.abs, body.content, "utf8");
210
+ note_write(resolved.abs, body.content);
205
211
  } catch (e) {
206
212
  return res
207
213
  .status(500)
@@ -215,6 +221,635 @@
215
221
  });
216
222
  });
217
223
 
224
+ // --- Edit suite (PoC): nav + files management -------------------
225
+ //
226
+ // Mirrors the /api/toc and /api/files surface of `hdoc edit`,
227
+ // backed by the same TocModel, so the viewer's Edit Mode can
228
+ // manage the left-hand nav (Contents) and browse/manage the
229
+ // on-disk pages (Files). All routes sit under /_edit and are
230
+ // therefore loopback-gated by the middleware above.
231
+ //
232
+ // The model is created lazily on first use and re-read from disk
233
+ // on every GET /_edit/toc (the tree fetch that precedes any run
234
+ // of edits), so external hdocbook.json edits are picked up. It is
235
+ // deliberately NOT reloaded before mutations: freshly assigned
236
+ // node ids only persist on the first structural save, and a
237
+ // reload in between would regenerate them and orphan the client.
238
+ let toc = null;
239
+ const get_toc = () => {
240
+ if (!toc) {
241
+ toc = new TocModel(global_source_path, docId);
242
+ // suppress our own hdocbook.json writes in the watcher
243
+ toc.on_persist = note_write;
244
+ }
245
+ return toc;
246
+ };
247
+ const toc_op = (res, fn) => {
248
+ try {
249
+ res.json(fn(get_toc()));
250
+ } catch (e) {
251
+ res.status(400).json({ error: String((e && e.message) || e) });
252
+ }
253
+ };
254
+
255
+ app.get("/_edit/toc", (req, res) =>
256
+ toc_op(res, (t) => {
257
+ t.reload();
258
+ return t.to_dto();
259
+ }),
260
+ );
261
+
262
+ app.post("/_edit/toc/move", (req, res) => {
263
+ const b = req.body || {};
264
+ toc_op(res, (t) => {
265
+ if (!b.id) throw new Error("Missing id");
266
+ t.move(b.id, b.parentId ?? null, b.index);
267
+ return t.to_dto();
268
+ });
269
+ });
270
+
271
+ app.post("/_edit/toc/rename", (req, res) => {
272
+ const b = req.body || {};
273
+ toc_op(res, (t) => {
274
+ if (!b.id) throw new Error("Missing id");
275
+ if (typeof b.text !== "string") throw new Error("Missing text");
276
+ t.rename(b.id, b.text);
277
+ return t.to_dto();
278
+ });
279
+ });
280
+
281
+ app.post("/_edit/toc/draft", (req, res) => {
282
+ const b = req.body || {};
283
+ toc_op(res, (t) => {
284
+ if (!b.id) throw new Error("Missing id");
285
+ t.set_draft(b.id, !!b.draft);
286
+ return t.to_dto();
287
+ });
288
+ });
289
+
290
+ // Add a nav leaf linking to a page (optionally creating the file).
291
+ app.post("/_edit/toc/create", (req, res) => {
292
+ const b = req.body || {};
293
+ toc_op(res, (t) => {
294
+ if (typeof b.link !== "string" || !b.link) {
295
+ throw new Error("Missing link");
296
+ }
297
+ if (b.createFile) {
298
+ const rel = `${b.link.replace(/^\/+/, "")}.md`;
299
+ const title = (b.text || "New Page").trim() || "New Page";
300
+ try {
301
+ const created = t.create_file(rel, `# ${title}\n`);
302
+ note_write(
303
+ path.join(global_source_path, created),
304
+ `# ${title}\n`,
305
+ );
306
+ } catch (e) {
307
+ // An existing file is fine — the leaf just links to it.
308
+ if (!/already exists/i.test(String(e && e.message))) throw e;
309
+ }
310
+ }
311
+ t.create_leaf(b.parentId ?? null, b.index, b.text ?? "", b.link);
312
+ return t.to_dto();
313
+ });
314
+ });
315
+
316
+ app.post("/_edit/toc/add-section", (req, res) => {
317
+ const b = req.body || {};
318
+ toc_op(res, (t) => {
319
+ t.add_section(b.parentId ?? null, b.index, b.text ?? "");
320
+ return t.to_dto();
321
+ });
322
+ });
323
+
324
+ app.post("/_edit/toc/update", (req, res) => {
325
+ const b = req.body || {};
326
+ toc_op(res, (t) => {
327
+ if (typeof b.id !== "string" || !b.id) throw new Error("Missing id");
328
+ t.update_node(b.id, {
329
+ text: b.text,
330
+ link: b.link,
331
+ expand: b.expand,
332
+ newId: b.newId,
333
+ });
334
+ return t.to_dto();
335
+ });
336
+ });
337
+
338
+ // Remove a nav node; { deleteFile: true } also deletes its page file.
339
+ app.delete("/_edit/toc/:id", (req, res) => {
340
+ const b = req.body || {};
341
+ toc_op(res, (t) => {
342
+ const node = t.remove_node(req.params.id);
343
+ let fileDeleted = false;
344
+ if (b.deleteFile && typeof node.link === "string" && node.link) {
345
+ const r = t._resolve_file(node.link);
346
+ if (r.file && r.fileExists) {
347
+ t.delete_file(r.file);
348
+ fileDeleted = true;
349
+ }
350
+ }
351
+ return { tree: t.to_dto(), fileDeleted };
352
+ });
353
+ });
354
+
355
+ // Upload an image/asset (raw bytes) to a path inside the book —
356
+ // mirrors `hdoc edit` PUT /api/upload. resolve_upload restricts to
357
+ // image extensions and sandboxes inside the book folder.
358
+ app.put(
359
+ "/_edit/upload",
360
+ express.raw({ type: () => true, limit: "25mb" }),
361
+ (req, res) => {
362
+ toc_op(res, (t) => {
363
+ const { abs, rel } = t.resolve_upload(
364
+ String(req.query.path || ""),
365
+ );
366
+ if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
367
+ throw new Error("Empty upload body");
368
+ }
369
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
370
+ fs.writeFileSync(abs, req.body);
371
+ note_write(abs, req.body);
372
+ return { ok: true, file: rel, bytes: req.body.length };
373
+ });
374
+ },
375
+ );
376
+
377
+ // Pages/assets on disk (decoupled from nav; "_" folders excluded).
378
+ app.get("/_edit/files", (req, res) =>
379
+ toc_op(res, (t) => t.list_files()),
380
+ );
381
+
382
+ app.post("/_edit/files", (req, res) => {
383
+ const b = req.body || {};
384
+ toc_op(res, (t) => {
385
+ const content_txt =
386
+ typeof b.content === "string" ? b.content : "";
387
+ const created = t.create_file(b.path, content_txt);
388
+ note_write(path.join(global_source_path, created), content_txt);
389
+ return { created, ...t.list_files() };
390
+ });
391
+ });
392
+
393
+ app.post("/_edit/files/folder", (req, res) => {
394
+ const b = req.body || {};
395
+ toc_op(res, (t) => ({
396
+ created: t.create_folder(b.path),
397
+ ...t.list_files(),
398
+ }));
399
+ });
400
+
401
+ app.delete("/_edit/files", (req, res) => {
402
+ const b = req.body || {};
403
+ toc_op(res, (t) => ({
404
+ deleted: t.delete_file(b.path),
405
+ ...t.list_files(),
406
+ }));
407
+ });
408
+
409
+ // --- live filesystem sync (SSE + recursive watcher) -----------------
410
+ // Mirrors `hdoc edit`: connected clients get {type:'structure'} when
411
+ // hdocbook.json changes externally (the TOC model is reloaded first)
412
+ // and {type:'fs', path, exists} for other files. Our own writes are
413
+ // suppressed by content hash. fs.watch({recursive}) is Windows/macOS;
414
+ // on Linux this degrades to a console warning (same as hdoc edit).
415
+ const sse_clients = new Set();
416
+ const recent_writes = new Map(); // absPath -> { hash, time }
417
+
418
+ const note_write = (abs, data) => {
419
+ recent_writes.set(abs, { hash: sha1(data), time: Date.now() });
420
+ const cutoff = Date.now() - 10000;
421
+ for (const [k, v] of recent_writes) {
422
+ if (v.time < cutoff) recent_writes.delete(k);
423
+ }
424
+ };
425
+
426
+ const broadcast = (evt) => {
427
+ const data = `data: ${JSON.stringify(evt)}\n\n`;
428
+ for (const c of sse_clients) {
429
+ try {
430
+ c.write(data);
431
+ } catch {
432
+ /* client gone */
433
+ }
434
+ }
435
+ };
436
+
437
+ const hdocbook_json_abs = path.join(
438
+ global_source_path,
439
+ docId,
440
+ "hdocbook.json",
441
+ );
442
+
443
+ const handle_fs_change = (rel) => {
444
+ const abs = path.join(global_source_path, rel);
445
+ let exists = false;
446
+ let buf = null;
447
+ try {
448
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
449
+ exists = true;
450
+ buf = fs.readFileSync(abs);
451
+ }
452
+ } catch {
453
+ /* transient */
454
+ }
455
+ if (exists && buf) {
456
+ const note = recent_writes.get(abs);
457
+ if (
458
+ note &&
459
+ note.hash === sha1(buf) &&
460
+ Date.now() - note.time < 5000
461
+ ) {
462
+ return; // our own write
463
+ }
464
+ }
465
+ if (abs === hdocbook_json_abs) {
466
+ if (exists && toc) {
467
+ try {
468
+ toc.reload();
469
+ } catch (e) {
470
+ console.error("TOC reload failed:", e.message);
471
+ }
472
+ }
473
+ broadcast({ type: "structure" });
474
+ return;
475
+ }
476
+ broadcast({ type: "fs", path: rel, exists });
477
+ };
478
+
479
+ const watch_debounce = new Map();
480
+ try {
481
+ const watch_root = path.join(global_source_path, docId);
482
+ fs.watch(watch_root, { recursive: true }, (_event, filename) => {
483
+ if (!filename) return;
484
+ const rel = `${docId}/${String(filename).split(path.sep).join("/")}`;
485
+ clearTimeout(watch_debounce.get(rel));
486
+ watch_debounce.set(
487
+ rel,
488
+ setTimeout(() => {
489
+ watch_debounce.delete(rel);
490
+ handle_fs_change(rel);
491
+ }, 120),
492
+ );
493
+ });
494
+ } catch (e) {
495
+ console.error("File watching unavailable:", e.message);
496
+ }
497
+
498
+ app.get("/_edit/events", (req, res) => {
499
+ res.writeHead(200, {
500
+ "Content-Type": "text/event-stream",
501
+ // no-transform: the compression middleware otherwise buffers
502
+ // the stream and events never reach the client
503
+ "Cache-Control": "no-cache, no-transform",
504
+ Connection: "keep-alive",
505
+ });
506
+ res.write(": connected\n\n");
507
+ sse_clients.add(res);
508
+ const hb = setInterval(() => {
509
+ try {
510
+ res.write(": hb\n\n");
511
+ } catch {
512
+ /* ignore */
513
+ }
514
+ }, 25000);
515
+ req.on("close", () => {
516
+ clearInterval(hb);
517
+ sse_clients.delete(res);
518
+ });
519
+ });
520
+
521
+ // --- custom spellcheck dictionary -----------------------------------
522
+ // Persists into hdocbook-project.json validation.spellcheckDictionary
523
+ // (same store `hdoc edit` and `hdoc validate` use), preserving the
524
+ // file's indent style. The inline lint reads the project file fresh
525
+ // on every pass, so the word stops being flagged immediately.
526
+ app.get("/_edit/dictionary", (req, res) => {
527
+ const proj = fresh_project();
528
+ const v = proj && proj.validation;
529
+ res.json({
530
+ words:
531
+ v && Array.isArray(v.spellcheckDictionary)
532
+ ? v.spellcheckDictionary
533
+ : [],
534
+ });
535
+ });
536
+
537
+ app.post("/_edit/dictionary", (req, res) => {
538
+ const word = String((req.body || {}).word || "").trim();
539
+ if (!word) return res.status(400).json({ error: "Missing word" });
540
+ try {
541
+ const proj_path = path.join(
542
+ global_source_path,
543
+ "hdocbook-project.json",
544
+ );
545
+ const raw = fs.readFileSync(proj_path, "utf8");
546
+ const m = raw.match(/\n(\t+| +)\S/);
547
+ const indent = m ? (m[1].includes("\t") ? "\t" : m[1].length) : 2;
548
+ const proj = JSON.parse(raw);
549
+ if (!proj.validation) proj.validation = {};
550
+ if (!Array.isArray(proj.validation.spellcheckDictionary)) {
551
+ proj.validation.spellcheckDictionary = [];
552
+ }
553
+ const list = proj.validation.spellcheckDictionary;
554
+ if (!list.some((x) => x.toLowerCase() === word.toLowerCase())) {
555
+ list.push(word);
556
+ list.sort((a, b) => a.localeCompare(b));
557
+ }
558
+ fs.writeFileSync(
559
+ proj_path,
560
+ JSON.stringify(proj, null, indent) +
561
+ (raw.endsWith("\n") ? "\n" : ""),
562
+ "utf8",
563
+ );
564
+ res.json({ ok: true, words: list });
565
+ } catch (e) {
566
+ res.status(400).json({ error: String((e && e.message) || e) });
567
+ }
568
+ });
569
+
570
+ // --- AI writing assistance (Anthropic Messages API, native fetch) ---
571
+ // Same contract as `hdoc edit` /api/assist: streams the edited
572
+ // Markdown back as plain text; key from ANTHROPIC_API_KEY (never
573
+ // reaches the browser); model/limits from hdocbook-project.json
574
+ // "ai" — read fresh per request rather than at startup.
575
+ const AI_SYSTEM = [
576
+ "You are a writing assistant embedded in the Hornbill HDocBook editor.",
577
+ "You help authors edit technical documentation written in Markdown.",
578
+ "",
579
+ "House style:",
580
+ "- Use US / International English spelling and vocabulary.",
581
+ "- Be clear, concise and direct; prefer active voice and present tense.",
582
+ "- Preserve the author's meaning and intent. Never invent facts, features or details.",
583
+ "",
584
+ "Markdown and HDocBook rules:",
585
+ "- Preserve all Markdown structure: headings, lists, tables, links, images, code fences and inline `code`.",
586
+ "- Never change the contents of fenced code blocks or inline code spans.",
587
+ "- Preserve HDocBook markup verbatim, including [[INCLUDE ...]], [[File:...]] and wiki-style directives.",
588
+ "- Do not change link or image URLs.",
589
+ "",
590
+ "Output rules:",
591
+ "- Return only the edited Markdown for the supplied text — no preamble, explanation, commentary or surrounding code fence.",
592
+ ].join("\n");
593
+
594
+ const AI_ACTIONS = {
595
+ rewrite:
596
+ "Rewrite the supplied text to improve clarity, flow and readability while preserving its meaning and all Markdown structure.",
597
+ grammar:
598
+ "Correct only grammar, spelling and punctuation in the supplied text. Make the minimum changes necessary; do not otherwise rephrase or restructure it.",
599
+ tighten:
600
+ "Make the supplied text more concise and direct without losing information. Remove redundancy and wordiness.",
601
+ };
602
+
603
+ app.post("/_edit/assist", async (req, res) => {
604
+ const apiKey = process.env.ANTHROPIC_API_KEY;
605
+ if (!apiKey) {
606
+ return res.status(503).json({
607
+ error:
608
+ "ANTHROPIC_API_KEY is not set. Set it in the environment running `hdoc serve` to enable AI writing assistance.",
609
+ });
610
+ }
611
+
612
+ const body = req.body || {};
613
+ const text = typeof body.text === "string" ? body.text : "";
614
+ const action =
615
+ typeof body.action === "string" ? body.action : "rewrite";
616
+ const instruction =
617
+ typeof body.instruction === "string"
618
+ ? body.instruction.trim()
619
+ : "";
620
+ if (!text.trim()) {
621
+ return res.status(400).json({ error: "No text to edit" });
622
+ }
623
+
624
+ const proj = fresh_project();
625
+ const ai_cfg =
626
+ proj && proj.ai && typeof proj.ai === "object" ? proj.ai : {};
627
+ const model =
628
+ typeof ai_cfg.model === "string" && ai_cfg.model
629
+ ? ai_cfg.model
630
+ : "claude-sonnet-4-6";
631
+ const max_tokens = Number.isFinite(ai_cfg.maxTokens)
632
+ ? ai_cfg.maxTokens
633
+ : 4096;
634
+
635
+ const task =
636
+ action === "custom"
637
+ ? instruction || "Improve the supplied text."
638
+ : AI_ACTIONS[action] || AI_ACTIONS.rewrite;
639
+
640
+ let system = AI_SYSTEM;
641
+ const dict =
642
+ proj &&
643
+ proj.validation &&
644
+ Array.isArray(proj.validation.spellcheckDictionary)
645
+ ? proj.validation.spellcheckDictionary
646
+ : [];
647
+ if (dict.length) {
648
+ system += `\n\nThe following are correctly-spelled domain terms; keep them as-is: ${dict.join(", ")}.`;
649
+ }
650
+
651
+ const user_text = `Task: ${task}\n\nHere is the Markdown to edit:\n\n<text>\n${text}\n</text>\n\nReturn only the edited Markdown.`;
652
+
653
+ let upstream;
654
+ try {
655
+ upstream = await fetch("https://api.anthropic.com/v1/messages", {
656
+ method: "POST",
657
+ headers: {
658
+ "content-type": "application/json",
659
+ "x-api-key": apiKey,
660
+ "anthropic-version": "2023-06-01",
661
+ },
662
+ body: JSON.stringify({
663
+ model,
664
+ max_tokens,
665
+ stream: true,
666
+ system: [
667
+ {
668
+ type: "text",
669
+ text: system,
670
+ cache_control: { type: "ephemeral" },
671
+ },
672
+ ],
673
+ messages: [{ role: "user", content: user_text }],
674
+ }),
675
+ });
676
+ } catch (e) {
677
+ return res.status(502).json({
678
+ error: `Failed to reach the Anthropic API: ${String((e && e.message) || e)}`,
679
+ });
680
+ }
681
+
682
+ if (!upstream.ok || !upstream.body) {
683
+ let detail = `Anthropic API error (HTTP ${upstream.status})`;
684
+ try {
685
+ const err = await upstream.json();
686
+ if (err && err.error && err.error.message) {
687
+ detail = err.error.message;
688
+ }
689
+ } catch {
690
+ /* non-JSON error body */
691
+ }
692
+ return res
693
+ .status(upstream.status === 401 ? 401 : 502)
694
+ .json({ error: detail });
695
+ }
696
+
697
+ res.writeHead(200, {
698
+ "Content-Type": "text/plain; charset=utf-8",
699
+ "Cache-Control": "no-cache",
700
+ });
701
+ const reader = upstream.body.getReader();
702
+ const decoder = new TextDecoder();
703
+ let buf = "";
704
+ try {
705
+ for (;;) {
706
+ const { done, value } = await reader.read();
707
+ if (done) break;
708
+ buf += decoder.decode(value, { stream: true });
709
+ let nl;
710
+ while ((nl = buf.indexOf("\n")) >= 0) {
711
+ const line = buf.slice(0, nl).trim();
712
+ buf = buf.slice(nl + 1);
713
+ if (!line.startsWith("data:")) continue;
714
+ const payload = line.slice(5).trim();
715
+ if (!payload) continue;
716
+ try {
717
+ const evt = JSON.parse(payload);
718
+ if (
719
+ evt.type === "content_block_delta" &&
720
+ evt.delta &&
721
+ evt.delta.type === "text_delta"
722
+ ) {
723
+ res.write(evt.delta.text);
724
+ }
725
+ } catch {
726
+ /* partial frame split across chunks */
727
+ }
728
+ }
729
+ }
730
+ } catch (e) {
731
+ console.error("AI assist stream error:", (e && e.message) || e);
732
+ }
733
+ res.end();
734
+ });
735
+
736
+ // --- validation ---------------------------------------------------
737
+ //
738
+ // Three tiers:
739
+ // nav — fast in-process checks over the navigation tree
740
+ // page — fast in-process checks over one article (md + rendered
741
+ // html), optional external-link HTTP checks
742
+ // book — the REAL `hdoc validate`, spawned as a child process
743
+ // (full build pipeline; wipes/rebuilds _work). One at a
744
+ // time; may run for minutes on a large book.
745
+
746
+ const fresh_project = () => {
747
+ try {
748
+ return JSON.parse(
749
+ fs.readFileSync(
750
+ path.join(global_source_path, "hdocbook-project.json"),
751
+ "utf8",
752
+ ),
753
+ );
754
+ } catch {
755
+ return hdocbook_project;
756
+ }
757
+ };
758
+
759
+ app.post("/_edit/validate/nav", (req, res) => {
760
+ toc_op(res, (t) => {
761
+ t.reload();
762
+ return {
763
+ nodes: serve_validate.validate_nav(
764
+ t.items,
765
+ global_source_path,
766
+ docId,
767
+ fresh_project(),
768
+ ),
769
+ };
770
+ });
771
+ });
772
+
773
+ app.post("/_edit/validate/page", async (req, res) => {
774
+ const body = req.body || {};
775
+ const resolved = resolve_page_md(body.path);
776
+ if (!resolved) {
777
+ return res
778
+ .status(404)
779
+ .json({ error: "No markdown source found for this page" });
780
+ }
781
+ try {
782
+ const md = fs.readFileSync(resolved.abs, "utf8");
783
+ const html = await content.render_page(
784
+ resolved.abs,
785
+ md,
786
+ resolved.logical,
787
+ );
788
+ const result = await serve_validate.validate_page({
789
+ source_path: global_source_path,
790
+ docId,
791
+ rel: resolved.rel,
792
+ logical: resolved.logical,
793
+ md,
794
+ html,
795
+ project: fresh_project(),
796
+ external: body.external !== false,
797
+ });
798
+ res.json({ file: resolved.rel, ...result });
799
+ } catch (e) {
800
+ res.status(500).json({ error: String((e && e.message) || e) });
801
+ }
802
+ });
803
+
804
+ let validate_child = null;
805
+ app.post("/_edit/validate/book", (req, res) => {
806
+ if (validate_child) {
807
+ return res
808
+ .status(409)
809
+ .json({ error: "A validation run is already in progress" });
810
+ }
811
+ const body = req.body || {};
812
+ const args = [path.join(__dirname, "hdoc.js"), "validate", "--quiet"];
813
+ if (body.externalLinks === false) args.push("--no-links");
814
+ const child = spawn(process.execPath, args, {
815
+ cwd: global_source_path,
816
+ windowsHide: true,
817
+ env: process.env,
818
+ });
819
+ validate_child = child;
820
+ let out = "";
821
+ const cap = (buf) => {
822
+ out += String(buf);
823
+ // keep the tail — validation output is what matters and a huge
824
+ // build log would bloat the response
825
+ if (out.length > 512 * 1024) out = out.slice(-512 * 1024);
826
+ };
827
+ child.stdout.on("data", cap);
828
+ child.stderr.on("data", cap);
829
+ const timer = setTimeout(
830
+ () => {
831
+ cap("\n[Timed out after 10 minutes — killed]");
832
+ child.kill("SIGKILL");
833
+ },
834
+ 10 * 60 * 1000,
835
+ );
836
+ child.on("close", (code) => {
837
+ clearTimeout(timer);
838
+ validate_child = null;
839
+ // strip ANSI color codes and spinner control chars
840
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI stripping
841
+ const clean = out
842
+ .replace(/\[[0-9;]*m/g, "")
843
+ .replace(/[\r][^\n]*\r/g, "");
844
+ res.json({ code, output: clean });
845
+ });
846
+ child.on("error", (e) => {
847
+ clearTimeout(timer);
848
+ validate_child = null;
849
+ res.status(500).json({ error: String((e && e.message) || e) });
850
+ });
851
+ });
852
+
218
853
  // Render an UNSAVED buffer to the final page fragment (document
219
854
  // header included) through the shared pipeline, for live preview.
220
855
  app.post("/_edit/preview", async (req, res) => {
@@ -234,13 +869,35 @@
234
869
  body.content,
235
870
  resolved.logical,
236
871
  );
237
- res.json({ html });
872
+ // inline lint rides the same debounced round trip: British
873
+ // spellings + syntactic/local link problems as editor
874
+ // underline ranges (no HTTP checks here)
875
+ let findings = [];
876
+ try {
877
+ findings = serve_validate.lint_markdown({
878
+ md: body.content,
879
+ source_path: global_source_path,
880
+ docId,
881
+ project: fresh_project(),
882
+ logical: resolved.logical,
883
+ });
884
+ } catch (e) {
885
+ console.error("Inline lint failed:", (e && e.message) || e);
886
+ }
887
+ res.json({ html, findings });
238
888
  } catch (e) {
239
889
  res.status(500).json({ error: String((e && e.message) || e) });
240
890
  }
241
891
  });
242
892
  }
243
893
 
894
+ // en-US dictionary for the typo spell-check worker
895
+ // (ui/js/hdoc-edit-spell.js) — same files `hdoc edit` serves.
896
+ app.use(
897
+ "/dict",
898
+ express.static(path.join(__dirname, "editor", "dist", "dict")),
899
+ );
900
+
244
901
  // Local preview serves exactly one book — skip the viewer's library home
245
902
  // and land straight in the book.
246
903
  app.get("/", (req, res) => {
package/hdoc-toc.js CHANGED
@@ -118,12 +118,17 @@ class TocModel {
118
118
  }
119
119
 
120
120
  // Resolve a leaf link (path without extension) to a source-relative file.
121
+ // Links may carry a leading slash (both "/docId/page" and "docId/page"
122
+ // appear in real books) — the returned rel is normalized to the
123
+ // slash-free, forward-slash form list_files() produces, or the linked
124
+ // flags and file labels built from it never match.
121
125
  _resolve_file(link) {
122
- const rel = `${link}.md`;
126
+ const clean = String(link).replace(/^\/+/, "");
127
+ const rel = `${clean}.md`;
123
128
  if (fs.existsSync(path.join(this.source_path, rel))) {
124
129
  return { file: rel, fileExists: true };
125
130
  }
126
- const rel_index = path.join(link, "index.md");
131
+ const rel_index = `${clean}/index.md`;
127
132
  if (fs.existsSync(path.join(this.source_path, rel_index))) {
128
133
  return { file: rel_index, fileExists: true };
129
134
  }