hdoc-tools 0.62.5 → 0.64.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.
Files changed (74) hide show
  1. package/editor/dist/assets/{index-BtxvGZHW.js → index-Dknn5g5A.js} +5 -4
  2. package/editor/dist/index.html +13 -13
  3. package/hdoc-build.js +5 -3
  4. package/hdoc-content-routes.js +151 -4
  5. package/hdoc-help.js +1 -1
  6. package/hdoc-serve.js +180 -3
  7. package/package.json +1 -1
  8. package/ui/css/theme-default/styles/base.css +86 -21
  9. package/ui/css/theme-default/styles/components/api-doc.css +6 -3
  10. package/ui/css/theme-default/styles/components/content.css +140 -39
  11. package/ui/css/theme-default/styles/components/custom-block.css +1 -1
  12. package/ui/css/theme-default/styles/components/htl-doc.css +243 -64
  13. package/ui/css/theme-default/styles/components/htl-library.css +152 -0
  14. package/ui/css/theme-default/styles/components/htl-search.css +267 -0
  15. package/ui/css/theme-default/styles/components/sidebar.css +59 -16
  16. package/ui/css/theme-default/styles/fonts.css +17 -3
  17. package/ui/css/theme-default/styles/htldoc.layouts.css +756 -87
  18. package/ui/css/theme-default/styles/vars.css +40 -35
  19. package/ui/favicon.svg +64 -0
  20. package/ui/images/hornbill-logo-full-reversed.svg +92 -0
  21. package/ui/images/hornbill-logo-full.svg +92 -0
  22. package/ui/images/hug_library.jpg +0 -0
  23. package/ui/images/mcp-catalog.svg +9 -0
  24. package/ui/images/products/hornbill-square.svg +1 -0
  25. package/ui/index.html +1106 -342
  26. package/ui/js/bootstrap.js +90 -0
  27. package/ui/js/doc.hornbill.js +3156 -751
  28. package/ui/js/hb.vue.js +121 -0
  29. package/ui/js/hdoc-edit-cm.js +29 -0
  30. package/ui/js/hdoc-edit-inline.js +977 -0
  31. package/ui/js/highlightjs/highlight.pack.js +1513 -2
  32. package/ui/js/highlightjs/styles/vs2015-accessible.css +141 -0
  33. package/ui/js/highlightjs-badge.js +41 -58
  34. package/ui/js/mermaid.min.js +1447 -1295
  35. package/ui/js/webcomponents/hdocApprove.js +115 -0
  36. package/ui/js/highlightjs/styles/brown-paper.css +0 -64
  37. package/ui/js/highlightjs/styles/brown-papersq.png +0 -0
  38. package/ui/js/highlightjs/styles/codepen-embed.css +0 -60
  39. package/ui/js/highlightjs/styles/color-brewer.css +0 -71
  40. package/ui/js/highlightjs/styles/darcula.css +0 -77
  41. package/ui/js/highlightjs/styles/dark.css +0 -63
  42. package/ui/js/highlightjs/styles/darkula.css +0 -6
  43. package/ui/js/highlightjs/styles/default.css +0 -99
  44. package/ui/js/highlightjs/styles/dracula.css +0 -76
  45. package/ui/js/highlightjs/styles/far.css +0 -71
  46. package/ui/js/highlightjs/styles/foundation.css +0 -88
  47. package/ui/js/highlightjs/styles/github-gist.css +0 -71
  48. package/ui/js/highlightjs/styles/github-mm.css +0 -71
  49. package/ui/js/highlightjs/styles/github.css +0 -99
  50. package/ui/js/highlightjs/styles/googlecode.css +0 -89
  51. package/ui/js/highlightjs/styles/grayscale.css +0 -101
  52. package/ui/js/highlightjs/styles/idea.css +0 -97
  53. package/ui/js/highlightjs/styles/ir-black.css +0 -73
  54. package/ui/js/highlightjs/styles/kavadocs.css +0 -71
  55. package/ui/js/highlightjs/styles/kavadocsdark.css +0 -120
  56. package/ui/js/highlightjs/styles/kimbie.dark.css +0 -74
  57. package/ui/js/highlightjs/styles/kimbie.light.css +0 -74
  58. package/ui/js/highlightjs/styles/magula.css +0 -70
  59. package/ui/js/highlightjs/styles/mono-blue.css +0 -59
  60. package/ui/js/highlightjs/styles/monokai-sublime.css +0 -83
  61. package/ui/js/highlightjs/styles/monokai.css +0 -70
  62. package/ui/js/highlightjs/styles/obsidian.css +0 -88
  63. package/ui/js/highlightjs/styles/paraiso-dark.css +0 -72
  64. package/ui/js/highlightjs/styles/paraiso-light.css +0 -72
  65. package/ui/js/highlightjs/styles/railscasts.css +0 -106
  66. package/ui/js/highlightjs/styles/rainbow.css +0 -85
  67. package/ui/js/highlightjs/styles/solarized-dark.css +0 -84
  68. package/ui/js/highlightjs/styles/solarized-light.css +0 -84
  69. package/ui/js/highlightjs/styles/sunburst.css +0 -102
  70. package/ui/js/highlightjs/styles/twilight.css +0 -97
  71. package/ui/js/highlightjs/styles/vs.css +0 -68
  72. package/ui/js/highlightjs/styles/vs2015.css +0 -117
  73. package/ui/js/highlightjs/styles/xcode.css +0 -104
  74. package/ui/js/highlightjs/styles/zenburn.css +0 -80
@@ -63,8 +63,8 @@ Error generating stack: `+e.message+`
63
63
  }
64
64
  } catch (e) {}
65
65
  <\/script>
66
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
67
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
66
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.css" integrity="sha384-Bk5cbLkZQ5raZ0+H2/+VbfYx3WpvxvQK4zqXZr7sYODuaX7bKXoSOnipQxkaS8sv" crossorigin="anonymous">
67
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
68
68
  <link rel="stylesheet" href="/_ui/css/theme-default/styles/fonts.css">
69
69
  <link rel="stylesheet" href="/_ui/css/theme-default/styles/vars.css">
70
70
  <link rel="stylesheet" href="/_ui/css/theme-default/styles/base.css">
@@ -74,7 +74,7 @@ Error generating stack: `+e.message+`
74
74
  <link rel="stylesheet" href="/_ui/css/theme-default/styles/components/content.css">
75
75
  <link rel="stylesheet" href="/_ui/css/theme-default/styles/components/custom-block.css">
76
76
  <link rel="stylesheet" href="/_ui/css/theme-default/styles/components/api-doc.css">
77
- <link rel="stylesheet" href="/_ui/js/highlightjs/styles/vs2015.css">
77
+ <link rel="stylesheet" href="/_ui/js/highlightjs/styles/vs2015-accessible.css">
78
78
  <style>html,body{margin:0;}</style>
79
79
  </head>
80
80
  <body>
@@ -98,7 +98,8 @@ Error generating stack: `+e.message+`
98
98
  try {
99
99
  if (window.hljs) {
100
100
  var blocks = root.querySelectorAll('pre>code');
101
- for (var i = 0; i < blocks.length; i++) window.hljs.highlightBlock(blocks[i]);
101
+ // highlight.js v11 API (highlightBlock was removed in v11)
102
+ for (var i = 0; i < blocks.length; i++) window.hljs.highlightElement(blocks[i]);
102
103
  }
103
104
  if (window.highlightJsBadge) {
104
105
  window.highlightJsBadge({ contentSelector: '#hdoc-preview-root' });
@@ -1,14 +1,14 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>hdoc edit</title>
8
- <script type="module" crossorigin src="/assets/index-BtxvGZHW.js"></script>
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>hdoc edit</title>
8
+ <script type="module" crossorigin src="/assets/index-Dknn5g5A.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-Blewr90z.css">
10
- </head>
11
- <body>
12
- <div id="root"></div>
13
- </body>
14
- </html>
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
package/hdoc-build.js CHANGED
@@ -48,9 +48,11 @@
48
48
  "pdf-header-non-git.html",
49
49
  );
50
50
 
51
- // Mermaid theme SINGLE source shared with the client-side renderer
52
- // (ui/js/doc.hornbill.js fetches the same file). Emitted into each queued
53
- // diagram as YAML frontmatter; inline JSON is valid YAML, so no conversion.
51
+ // Mermaid theme for the PDF SVG bake. Emitted into each queued diagram as
52
+ // YAML frontmatter; inline JSON is valid YAML, so no conversion. NOTE: the
53
+ // viewer no longer fetches this file since the ui/ sync with the live
54
+ // htdocs viewer it embeds the same values as HB_MERMAID_CONFIG in
55
+ // ui/js/doc.hornbill.js. Keep the two in step when changing the theme.
54
56
  const mermaid_theme_path = path.resolve(
55
57
  __dirname,
56
58
  "ui",
@@ -21,6 +21,14 @@ const stream = require("node:stream");
21
21
  const hdoc = require(path.join(__dirname, "hdoc-module.js"));
22
22
  const mdfm = require("markdown-it-front-matter");
23
23
 
24
+ // Document header template baked around rendered markdown, so the local
25
+ // preview shows the same header (breadcrumbs/title/type/reading time) as
26
+ // published output. Package-static, so read once.
27
+ const doc_header_template = fs.readFileSync(
28
+ path.join(__dirname, "templates", "doc-header-non-git.html"),
29
+ "utf8",
30
+ );
31
+
24
32
  // Escape a Mermaid definition for safe embedding as the text content of a
25
33
  // <pre> element. The viewer's client-side Mermaid plugin reads the element's
26
34
  // textContent and renders it in the browser, so we only need HTML-text safety
@@ -192,18 +200,95 @@ exports.create_content_handler = (ctx) => {
192
200
  const tips = require(`${__dirname}/custom_modules/tips.js`);
193
201
  md.use(tips, { links: true });
194
202
 
195
- const html = md.render(md_txt.toString());
203
+ // Wrap h2/h3 sections in anchor-id <div>s exactly as the build pipeline
204
+ // does (hdoc.wrapAndExtract) — the theme CSS targets that structure, and
205
+ // it also yields the reading time for the document header.
206
+ const rendered = md.render(md_txt.toString());
207
+ const extracted = hdoc.wrapAndExtract(rendered, ["h1"]);
208
+ const html = extracted.html;
196
209
 
197
210
  const frontmatter = frontmatter_content.length
198
211
  ? hdoc.parse_yaml(frontmatter_content)
199
212
  : null;
200
- return { html, frontmatter };
213
+ return { html, frontmatter, read_time: extracted.readTimeMins };
214
+ }
215
+
216
+ // Wrap rendered markdown with the same document header the build pipeline
217
+ // bakes into published pages (templates/doc-header-non-git.html): breadcrumb
218
+ // trail, first-<h1> title, doc type and reading time. Without this the viewer
219
+ // shows the raw body with the <h1> inline, which is not what publishing
220
+ // produces. logical_path is the extensionless book-relative request path
221
+ // (e.g. "my-book/some/page") used for the breadcrumb lookup.
222
+ function wrap_with_document_header(html, frontmatter, logical_path, read_time) {
223
+ let body = html;
224
+
225
+ // Pull the first <h1> out of the body — the header owns the title, exactly
226
+ // as hdoc-build does for published output.
227
+ let title = "";
228
+ const h1_match = body.match(/<h1[^>]*>([\s\S]*?)<\/h1>/);
229
+ if (h1_match) {
230
+ title = h1_match[1].trim();
231
+ body = body.replace(h1_match[0], "");
232
+ } else if (frontmatter?.title) {
233
+ title = frontmatter.title;
234
+ }
235
+
236
+ const reading_time = frontmatter?.["reading-time"] ?? read_time ?? 0;
237
+
238
+ // Breadcrumbs from the live nav (config re-read per request), emitted with
239
+ // the same <li> shape hdoc-build writes. Last crumb (the page itself) is
240
+ // dropped, matching the build pipeline.
241
+ let bc_tags = "\n";
242
+ try {
243
+ const bc = hdoc.build_breadcrumbs(
244
+ get_book_config().navigation.items,
245
+ ).bc;
246
+ const crumbs = bc[logical_path] || bc[`/${logical_path}`];
247
+ if (crumbs) {
248
+ for (let i = 0; i < crumbs.length - 1; i++) {
249
+ if (crumbs[i].link) {
250
+ const bc_link = crumbs[i].link.startsWith("/")
251
+ ? crumbs[i].link
252
+ : `/${crumbs[i].link}`;
253
+ bc_tags += `\t\t\t\t<li class="mt-0 nav-bar-item"><a href="${bc_link}" class="ps-0 pe-0 text-decoration-none">${crumbs[i].text}</a></li>\n`;
254
+ } else {
255
+ bc_tags += `\t\t\t\t<li class="mt-0 nav-bar-item">${crumbs[i].text}</li>\n`;
256
+ }
257
+ }
258
+ }
259
+ } catch {
260
+ // Bad/missing navigation is validate's problem to report, not the
261
+ // preview's — render the page with an empty trail.
262
+ }
263
+ bc_tags += "\t\t\t";
264
+
265
+ const header = doc_header_template
266
+ .replaceAll("{{title}}", title)
267
+ .replaceAll("{{doc-type}}", frontmatter?.type || "Article")
268
+ .replaceAll("{{reading-time}}", String(reading_time))
269
+ .replaceAll("{{breadcrumbs}}", bc_tags);
270
+
271
+ return `${header}\n${body}`;
272
+ }
273
+
274
+ // Render markdown source to the final page fragment exactly as the content
275
+ // route serves it — document header included (unless it's an _inline
276
+ // fragment). Used by the `hdoc serve -edit` live preview so the preview of
277
+ // an unsaved buffer matches what a saved page would render as.
278
+ async function render_page(file_path, md_source, logical_path) {
279
+ const { html, frontmatter, read_time } = await render_markdown(
280
+ file_path,
281
+ md_source,
282
+ );
283
+ return logical_path.includes("/_inline/")
284
+ ? html
285
+ : wrap_with_document_header(html, frontmatter, logical_path, read_time);
201
286
  }
202
287
 
203
288
  async function transform_markdown_and_send_html(req, res, file_path) {
204
289
  if (!fs.existsSync(file_path)) return false;
205
290
 
206
- const { html, frontmatter } = await render_markdown(
291
+ const { html, frontmatter, read_time } = await render_markdown(
207
292
  file_path,
208
293
  fs.readFileSync(file_path).toString(),
209
294
  );
@@ -215,8 +300,20 @@ exports.create_content_handler = (ctx) => {
215
300
  res.setHeader("X-frontmatter", base64);
216
301
  }
217
302
 
303
+ // Inline (_inline/) fragments are embedded elsewhere and get no header,
304
+ // same as the build pipeline.
305
+ const logical_path = req.url
306
+ .replace(/^\/_books\//, "")
307
+ .split("?")[0]
308
+ .split("#")[0]
309
+ .replace(/\.(html|htm|md)$/i, "")
310
+ .replace(/\/+$/, "");
311
+ const out = logical_path.includes("/_inline/")
312
+ ? html
313
+ : wrap_with_document_header(html, frontmatter, logical_path, read_time);
314
+
218
315
  res.setHeader("Content-Type", "text/html");
219
- res.send(html);
316
+ res.send(out);
220
317
  return true;
221
318
  }
222
319
 
@@ -409,11 +506,22 @@ exports.create_content_handler = (ctx) => {
409
506
 
410
507
  function handle_library_request(req, res) {
411
508
  const book_config = get_book_config();
509
+ // Mirror the fields the production library.json carries so the viewer's
510
+ // library home can group/describe the book. audience is an array in
511
+ // hdocbook.json but a plain string in the production library feed.
412
512
  const library = {
413
513
  books: [
414
514
  {
415
515
  docId: book_config.docId,
516
+ version: book_config.version,
517
+ audience: Array.isArray(book_config.audience)
518
+ ? book_config.audience[0]
519
+ : book_config.audience,
416
520
  title: book_config.title,
521
+ description: book_config.description || "",
522
+ productFamilyId: book_config.productFamily,
523
+ tags: book_config.tags || [],
524
+ publicSource: book_config.publicSource,
417
525
  nav_inline: nav_inline,
418
526
  },
419
527
  ],
@@ -422,9 +530,47 @@ exports.create_content_handler = (ctx) => {
422
530
  res.send(JSON.stringify(library, null, 3));
423
531
  }
424
532
 
533
+ // The production viewer needs a valid products.json (its init dereferences
534
+ // data.products). Fetch the real product family list once in the background
535
+ // (same helper the build pipeline uses) and serve it; if docs.hornbill.com
536
+ // is unreachable, serve an empty list — the library page then groups the
537
+ // book under "Other" instead of failing to initialise.
538
+ let products_cache = null;
539
+ const products_fetch = hdoc
540
+ .load_product_families()
541
+ .then((p) => {
542
+ if (p.success) products_cache = p.prod_families;
543
+ else
544
+ console.log(
545
+ "[WARNING] Could not fetch product families from docs.hornbill.com — serving an empty product list to the viewer",
546
+ );
547
+ })
548
+ .catch(() => {});
549
+
550
+ async function handle_products_request(req, res) {
551
+ await products_fetch;
552
+ res.setHeader("Content-Type", "application/json");
553
+ res.send(JSON.stringify(products_cache || { products: [] }));
554
+ }
555
+
556
+ // Endpoints the production viewer probes but that have no local backing
557
+ // service (docs sign-in session, MCP catalog, full-text search). A JSON 404
558
+ // makes the viewer's fetchJsonFile resolve null and degrade gracefully;
559
+ // without these the SPA catch-all would answer with index.html instead.
560
+ function handle_unavailable_api(req, res) {
561
+ res.status(404);
562
+ res.setHeader("Content-Type", "application/json");
563
+ res.send(
564
+ JSON.stringify({ error: "Not available in the hdoc local preview" }),
565
+ );
566
+ }
567
+
425
568
  function register(app) {
569
+ app.get("/_books/products.json", handle_products_request);
426
570
  app.get("/_books/library.json", handle_library_request);
427
571
  app.get("/_books/*splat", handle_books_request);
572
+ app.get("/_api/{*splat}", handle_unavailable_api);
573
+ app.get("/_search", handle_unavailable_api);
428
574
  }
429
575
 
430
576
  return {
@@ -434,6 +580,7 @@ exports.create_content_handler = (ctx) => {
434
580
  // handle_books_request / handle_library_request are exposed so the editor
435
581
  // can register delegating /_books routes that follow a workspace switch.
436
582
  render_markdown,
583
+ render_page,
437
584
  transform_markdown_and_send_html,
438
585
  send_content_file,
439
586
  send_file,
package/hdoc-help.js CHANGED
@@ -29,7 +29,7 @@ Commands
29
29
  Initializes a new HDocBook project from a template, using runtime input variables
30
30
 
31
31
  - serve
32
- Starts a local web server on port 3000, serving the content. Supports a -port N to use a different port
32
+ Starts a local web server on port 3000, serving the content. Supports a -port N to use a different port. Right-click page content in the browser to edit the page inline and save back to disk (editing is available from the serving machine only; LAN viewers get read-only preview)
33
33
 
34
34
  - stats
35
35
  Returns statistics regarding the book you are working on. Supports a -v switch for verbose output.
package/hdoc-serve.js CHANGED
@@ -3,6 +3,7 @@
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"));
@@ -92,6 +93,160 @@
92
93
  });
93
94
  content.register(app);
94
95
 
96
+ // --- Inline edit mode (always on, loopback callers only) ---
97
+ //
98
+ // Mounts a minimal read/write/preview API consumed by the viewer's
99
+ // LOCAL PREVIEW PATCH script (ui/js/hdoc-edit-inline.js): right-click
100
+ // on page content opens a slide-over panel with the page's raw
101
+ // markdown; edits live-preview through the SAME render pipeline as
102
+ // published output, and Save writes the file back with an etag-based
103
+ // conflict check (mirrors hdoc-edit.js /api/pagefile). The server
104
+ // stays bound to 0.0.0.0 so read-only preview remains shareable on
105
+ // the LAN, but anything that can touch the book source must not be
106
+ // LAN-reachable — so every /_edit route is gated per request to
107
+ // loopback callers. LAN visitors probe /_edit/mode, get
108
+ // {enabled:false}, and the viewer never shows the edit UI.
109
+ {
110
+ const is_loopback = (req) => {
111
+ const a = req.socket.remoteAddress || "";
112
+ return a === "127.0.0.1" || a === "::1" || a === "::ffff:127.0.0.1";
113
+ };
114
+
115
+ const sha1 = (data) =>
116
+ crypto.createHash("sha1").update(Buffer.from(data)).digest("hex");
117
+
118
+ // Resolve a logical (extensionless, book-relative) page path to its
119
+ // backing markdown file, exactly as handle_books_request would:
120
+ // <path>.md first, then <path>/index.md. Only files inside the book
121
+ // content folder (<source_path>/<docId>/) are editable.
122
+ const resolve_page_md = (logical) => {
123
+ const clean = String(logical || "")
124
+ .split("?")[0]
125
+ .split("#")[0]
126
+ .replace(/\.(html|htm|md)$/i, "")
127
+ .replace(/^\/+/, "")
128
+ .replace(/\/+$/, "");
129
+ if (clean !== docId && !clean.startsWith(`${docId}/`)) return null;
130
+ const base = resolve_within_root(global_source_path, clean);
131
+ if (base === null) return null;
132
+ for (const abs of [`${base}.md`, path.join(base, "index.md")]) {
133
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
134
+ return {
135
+ abs,
136
+ rel: path
137
+ .relative(global_source_path, abs)
138
+ .split(path.sep)
139
+ .join("/"),
140
+ logical: clean,
141
+ };
142
+ }
143
+ }
144
+ return null;
145
+ };
146
+
147
+ app.use(express.json({ limit: "5mb" }));
148
+
149
+ // Probe: tells the viewer the edit UI should be shown. Answers
150
+ // {enabled:false} (rather than 403) for LAN callers so the viewer
151
+ // quietly stays read-only.
152
+ app.get("/_edit/mode", (req, res) => {
153
+ res.json({ enabled: is_loopback(req) });
154
+ });
155
+
156
+ // Every other /_edit route (source read, save, preview) is
157
+ // loopback-only — hard 403 for anything else.
158
+ app.use("/_edit", (req, res, next) => {
159
+ if (!is_loopback(req)) {
160
+ return res.status(403).json({ error: "loopback only" });
161
+ }
162
+ next();
163
+ });
164
+
165
+ // Raw markdown source of a page, with an etag for optimistic
166
+ // concurrency on save.
167
+ app.get("/_edit/source", (req, res) => {
168
+ const resolved = resolve_page_md(req.query.path);
169
+ if (!resolved) {
170
+ return res
171
+ .status(404)
172
+ .json({ error: "No markdown source found for this page" });
173
+ }
174
+ const content_txt = fs.readFileSync(resolved.abs, "utf8");
175
+ res.json({
176
+ file: resolved.rel,
177
+ content: content_txt,
178
+ etag: sha1(content_txt),
179
+ });
180
+ });
181
+
182
+ // Save the buffer back to disk. Refuses to overwrite a file that
183
+ // changed since it was loaded (baseEtag mismatch → 409).
184
+ app.put("/_edit/source", (req, res) => {
185
+ const resolved = resolve_page_md(req.query.path);
186
+ if (!resolved) {
187
+ return res
188
+ .status(404)
189
+ .json({ error: "No markdown source found for this page" });
190
+ }
191
+ const body = req.body || {};
192
+ if (typeof body.content !== "string") {
193
+ return res.status(400).json({ error: "Missing content" });
194
+ }
195
+ if (body.baseEtag) {
196
+ const current = fs.readFileSync(resolved.abs, "utf8");
197
+ if (sha1(current) !== body.baseEtag) {
198
+ return res
199
+ .status(409)
200
+ .json({ error: "conflict", etag: sha1(current) });
201
+ }
202
+ }
203
+ try {
204
+ fs.writeFileSync(resolved.abs, body.content, "utf8");
205
+ } catch (e) {
206
+ return res
207
+ .status(500)
208
+ .json({ error: String((e && e.message) || e) });
209
+ }
210
+ res.json({
211
+ ok: true,
212
+ file: resolved.rel,
213
+ bytes: Buffer.byteLength(body.content, "utf8"),
214
+ etag: sha1(body.content),
215
+ });
216
+ });
217
+
218
+ // Render an UNSAVED buffer to the final page fragment (document
219
+ // header included) through the shared pipeline, for live preview.
220
+ app.post("/_edit/preview", async (req, res) => {
221
+ const body = req.body || {};
222
+ const resolved = resolve_page_md(body.path);
223
+ if (!resolved) {
224
+ return res
225
+ .status(404)
226
+ .json({ error: "No markdown source found for this page" });
227
+ }
228
+ if (typeof body.content !== "string") {
229
+ return res.status(400).json({ error: "Missing content" });
230
+ }
231
+ try {
232
+ const html = await content.render_page(
233
+ resolved.abs,
234
+ body.content,
235
+ resolved.logical,
236
+ );
237
+ res.json({ html });
238
+ } catch (e) {
239
+ res.status(500).json({ error: String((e && e.message) || e) });
240
+ }
241
+ });
242
+ }
243
+
244
+ // Local preview serves exactly one book — skip the viewer's library home
245
+ // and land straight in the book.
246
+ app.get("/", (req, res) => {
247
+ res.redirect(`/${docId}`);
248
+ });
249
+
95
250
  // Catch all
96
251
  app.get("/{*splat}", (req, res) => {
97
252
 
@@ -110,9 +265,26 @@
110
265
 
111
266
  // If the file exists, send it.
112
267
  if (fs.existsSync(ui_file_path)) {
113
- // Stream the large Mermaid bundle (skips per-request variable
114
- // expansion over ~4.5MB) and let send_file set a long cache header.
115
- if (path.basename(ui_file_path) === "mermaid.min.js") {
268
+ // Only text assets go through send_content_file (which reads the
269
+ // file as a string for variable expansion) binary assets (fonts,
270
+ // images) are corrupted by that path, so stream them verbatim.
271
+ // mermaid.min.js is also streamed (skips per-request variable
272
+ // expansion over ~4.5MB, and send_file sets its cache header).
273
+ const text_exts = new Set([
274
+ ".html",
275
+ ".htm",
276
+ ".css",
277
+ ".js",
278
+ ".json",
279
+ ".svg",
280
+ ".txt",
281
+ ".md",
282
+ ".map",
283
+ ]);
284
+ if (
285
+ path.basename(ui_file_path) === "mermaid.min.js" ||
286
+ !text_exts.has(path.extname(ui_file_path).toLowerCase())
287
+ ) {
116
288
  content.send_file(req, res, ui_file_path);
117
289
  return;
118
290
  }
@@ -124,12 +296,17 @@
124
296
  content.send_content_resource_404(req, res);
125
297
  });
126
298
 
299
+ // Preview stays shareable on the LAN (0.0.0.0); the /_edit write
300
+ // routes above are gated per request to loopback callers instead.
127
301
  const server = app.listen(port, "0.0.0.0", () => {
128
302
  const addr = server.address();
129
303
  if (!addr) return;
130
304
 
131
305
  console.log("Server listening at http://127.0.0.1:%s", addr.port);
132
306
  console.log(`Document source path is: ${source_path}`);
307
+ console.log(
308
+ "Inline edit is available from this machine only (right-click page content in the browser).",
309
+ );
133
310
 
134
311
  const _vars = ["{{DOC_ID}}", "{{BUILD_NUMBER}}", "{{BUILD_DATE}}"];
135
312
  console.log("Server Vars:");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hdoc-tools",
3
- "version": "0.62.5",
3
+ "version": "0.64.0",
4
4
  "description": "Hornbill HDocBook Development Support Tool",
5
5
  "main": "hdoc.js",
6
6
  "bin": {