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 +3 -1
- package/hdoc-serve-validate.js +516 -0
- package/hdoc-serve.js +658 -1
- package/hdoc-toc.js +7 -2
- package/package.json +2 -1
- package/ui/index.html +5 -2
- package/ui/js/bootstrap.js +2 -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 +16 -16
- package/ui/js/hdoc-edit-inline.js +676 -4
- package/ui/js/hdoc-edit-spell.js +17 -0
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
|
-
|
|
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
|
|
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 =
|
|
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
|
}
|