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-serve.js CHANGED
@@ -3,9 +3,15 @@
3
3
  const compression = require("compression");
4
4
  const fs = require("node:fs");
5
5
  const path = require("node:path");
6
+ const crypto = require("node:crypto");
6
7
  const hdoc = require(path.join(__dirname, "hdoc-module.js"));
7
8
  const { create_content_handler, build_nav_inline, resolve_within_root } =
8
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");
9
15
 
10
16
  let port = 3000;
11
17
  let docId;
@@ -92,6 +98,806 @@
92
98
  });
93
99
  content.register(app);
94
100
 
101
+ // --- Inline edit mode (always on, loopback callers only) ---
102
+ //
103
+ // Mounts a minimal read/write/preview API consumed by the viewer's
104
+ // LOCAL PREVIEW PATCH script (ui/js/hdoc-edit-inline.js): right-click
105
+ // on page content opens a slide-over panel with the page's raw
106
+ // markdown; edits live-preview through the SAME render pipeline as
107
+ // published output, and Save writes the file back with an etag-based
108
+ // conflict check (mirrors hdoc-edit.js /api/pagefile). The server
109
+ // stays bound to 0.0.0.0 so read-only preview remains shareable on
110
+ // the LAN, but anything that can touch the book source must not be
111
+ // LAN-reachable — so every /_edit route is gated per request to
112
+ // loopback callers. LAN visitors probe /_edit/mode, get
113
+ // {enabled:false}, and the viewer never shows the edit UI.
114
+ {
115
+ const is_loopback = (req) => {
116
+ const a = req.socket.remoteAddress || "";
117
+ return a === "127.0.0.1" || a === "::1" || a === "::ffff:127.0.0.1";
118
+ };
119
+
120
+ const sha1 = (data) =>
121
+ crypto.createHash("sha1").update(Buffer.from(data)).digest("hex");
122
+
123
+ // Resolve a logical (extensionless, book-relative) page path to its
124
+ // backing markdown file, exactly as handle_books_request would:
125
+ // <path>.md first, then <path>/index.md. Only files inside the book
126
+ // content folder (<source_path>/<docId>/) are editable.
127
+ const resolve_page_md = (logical) => {
128
+ const clean = String(logical || "")
129
+ .split("?")[0]
130
+ .split("#")[0]
131
+ .replace(/\.(html|htm|md)$/i, "")
132
+ .replace(/^\/+/, "")
133
+ .replace(/\/+$/, "");
134
+ if (clean !== docId && !clean.startsWith(`${docId}/`)) return null;
135
+ const base = resolve_within_root(global_source_path, clean);
136
+ if (base === null) return null;
137
+ for (const abs of [`${base}.md`, path.join(base, "index.md")]) {
138
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
139
+ return {
140
+ abs,
141
+ rel: path
142
+ .relative(global_source_path, abs)
143
+ .split(path.sep)
144
+ .join("/"),
145
+ logical: clean,
146
+ };
147
+ }
148
+ }
149
+ return null;
150
+ };
151
+
152
+ app.use(express.json({ limit: "5mb" }));
153
+
154
+ // Probe: tells the viewer the edit UI should be shown. Answers
155
+ // {enabled:false} (rather than 403) for LAN callers so the viewer
156
+ // quietly stays read-only.
157
+ app.get("/_edit/mode", (req, res) => {
158
+ res.json({ enabled: is_loopback(req) });
159
+ });
160
+
161
+ // Every other /_edit route (source read, save, preview) is
162
+ // loopback-only — hard 403 for anything else.
163
+ app.use("/_edit", (req, res, next) => {
164
+ if (!is_loopback(req)) {
165
+ return res.status(403).json({ error: "loopback only" });
166
+ }
167
+ next();
168
+ });
169
+
170
+ // Raw markdown source of a page, with an etag for optimistic
171
+ // concurrency on save.
172
+ app.get("/_edit/source", (req, res) => {
173
+ const resolved = resolve_page_md(req.query.path);
174
+ if (!resolved) {
175
+ return res
176
+ .status(404)
177
+ .json({ error: "No markdown source found for this page" });
178
+ }
179
+ const content_txt = fs.readFileSync(resolved.abs, "utf8");
180
+ res.json({
181
+ file: resolved.rel,
182
+ content: content_txt,
183
+ etag: sha1(content_txt),
184
+ });
185
+ });
186
+
187
+ // Save the buffer back to disk. Refuses to overwrite a file that
188
+ // changed since it was loaded (baseEtag mismatch → 409).
189
+ app.put("/_edit/source", (req, res) => {
190
+ const resolved = resolve_page_md(req.query.path);
191
+ if (!resolved) {
192
+ return res
193
+ .status(404)
194
+ .json({ error: "No markdown source found for this page" });
195
+ }
196
+ const body = req.body || {};
197
+ if (typeof body.content !== "string") {
198
+ return res.status(400).json({ error: "Missing content" });
199
+ }
200
+ if (body.baseEtag) {
201
+ const current = fs.readFileSync(resolved.abs, "utf8");
202
+ if (sha1(current) !== body.baseEtag) {
203
+ return res
204
+ .status(409)
205
+ .json({ error: "conflict", etag: sha1(current) });
206
+ }
207
+ }
208
+ try {
209
+ fs.writeFileSync(resolved.abs, body.content, "utf8");
210
+ note_write(resolved.abs, body.content);
211
+ } catch (e) {
212
+ return res
213
+ .status(500)
214
+ .json({ error: String((e && e.message) || e) });
215
+ }
216
+ res.json({
217
+ ok: true,
218
+ file: resolved.rel,
219
+ bytes: Buffer.byteLength(body.content, "utf8"),
220
+ etag: sha1(body.content),
221
+ });
222
+ });
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
+
853
+ // Render an UNSAVED buffer to the final page fragment (document
854
+ // header included) through the shared pipeline, for live preview.
855
+ app.post("/_edit/preview", async (req, res) => {
856
+ const body = req.body || {};
857
+ const resolved = resolve_page_md(body.path);
858
+ if (!resolved) {
859
+ return res
860
+ .status(404)
861
+ .json({ error: "No markdown source found for this page" });
862
+ }
863
+ if (typeof body.content !== "string") {
864
+ return res.status(400).json({ error: "Missing content" });
865
+ }
866
+ try {
867
+ const html = await content.render_page(
868
+ resolved.abs,
869
+ body.content,
870
+ resolved.logical,
871
+ );
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 });
888
+ } catch (e) {
889
+ res.status(500).json({ error: String((e && e.message) || e) });
890
+ }
891
+ });
892
+ }
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
+
95
901
  // Local preview serves exactly one book — skip the viewer's library home
96
902
  // and land straight in the book.
97
903
  app.get("/", (req, res) => {
@@ -147,12 +953,17 @@
147
953
  content.send_content_resource_404(req, res);
148
954
  });
149
955
 
956
+ // Preview stays shareable on the LAN (0.0.0.0); the /_edit write
957
+ // routes above are gated per request to loopback callers instead.
150
958
  const server = app.listen(port, "0.0.0.0", () => {
151
959
  const addr = server.address();
152
960
  if (!addr) return;
153
961
 
154
962
  console.log("Server listening at http://127.0.0.1:%s", addr.port);
155
963
  console.log(`Document source path is: ${source_path}`);
964
+ console.log(
965
+ "Inline edit is available from this machine only (right-click page content in the browser).",
966
+ );
156
967
 
157
968
  const _vars = ["{{DOC_ID}}", "{{BUILD_NUMBER}}", "{{BUILD_DATE}}"];
158
969
  console.log("Server Vars:");