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.
- package/editor/dist/assets/{index-BtxvGZHW.js → index-Dknn5g5A.js} +5 -4
- package/editor/dist/index.html +13 -13
- package/hdoc-build.js +5 -3
- package/hdoc-content-routes.js +151 -4
- package/hdoc-help.js +1 -1
- package/hdoc-serve.js +180 -3
- package/package.json +1 -1
- package/ui/css/theme-default/styles/base.css +86 -21
- package/ui/css/theme-default/styles/components/api-doc.css +6 -3
- package/ui/css/theme-default/styles/components/content.css +140 -39
- package/ui/css/theme-default/styles/components/custom-block.css +1 -1
- package/ui/css/theme-default/styles/components/htl-doc.css +243 -64
- package/ui/css/theme-default/styles/components/htl-library.css +152 -0
- package/ui/css/theme-default/styles/components/htl-search.css +267 -0
- package/ui/css/theme-default/styles/components/sidebar.css +59 -16
- package/ui/css/theme-default/styles/fonts.css +17 -3
- package/ui/css/theme-default/styles/htldoc.layouts.css +756 -87
- package/ui/css/theme-default/styles/vars.css +40 -35
- package/ui/favicon.svg +64 -0
- package/ui/images/hornbill-logo-full-reversed.svg +92 -0
- package/ui/images/hornbill-logo-full.svg +92 -0
- package/ui/images/hug_library.jpg +0 -0
- package/ui/images/mcp-catalog.svg +9 -0
- package/ui/images/products/hornbill-square.svg +1 -0
- package/ui/index.html +1106 -342
- package/ui/js/bootstrap.js +90 -0
- package/ui/js/doc.hornbill.js +3156 -751
- package/ui/js/hb.vue.js +121 -0
- package/ui/js/hdoc-edit-cm.js +29 -0
- package/ui/js/hdoc-edit-inline.js +977 -0
- package/ui/js/highlightjs/highlight.pack.js +1513 -2
- package/ui/js/highlightjs/styles/vs2015-accessible.css +141 -0
- package/ui/js/highlightjs-badge.js +41 -58
- package/ui/js/mermaid.min.js +1447 -1295
- package/ui/js/webcomponents/hdocApprove.js +115 -0
- package/ui/js/highlightjs/styles/brown-paper.css +0 -64
- package/ui/js/highlightjs/styles/brown-papersq.png +0 -0
- package/ui/js/highlightjs/styles/codepen-embed.css +0 -60
- package/ui/js/highlightjs/styles/color-brewer.css +0 -71
- package/ui/js/highlightjs/styles/darcula.css +0 -77
- package/ui/js/highlightjs/styles/dark.css +0 -63
- package/ui/js/highlightjs/styles/darkula.css +0 -6
- package/ui/js/highlightjs/styles/default.css +0 -99
- package/ui/js/highlightjs/styles/dracula.css +0 -76
- package/ui/js/highlightjs/styles/far.css +0 -71
- package/ui/js/highlightjs/styles/foundation.css +0 -88
- package/ui/js/highlightjs/styles/github-gist.css +0 -71
- package/ui/js/highlightjs/styles/github-mm.css +0 -71
- package/ui/js/highlightjs/styles/github.css +0 -99
- package/ui/js/highlightjs/styles/googlecode.css +0 -89
- package/ui/js/highlightjs/styles/grayscale.css +0 -101
- package/ui/js/highlightjs/styles/idea.css +0 -97
- package/ui/js/highlightjs/styles/ir-black.css +0 -73
- package/ui/js/highlightjs/styles/kavadocs.css +0 -71
- package/ui/js/highlightjs/styles/kavadocsdark.css +0 -120
- package/ui/js/highlightjs/styles/kimbie.dark.css +0 -74
- package/ui/js/highlightjs/styles/kimbie.light.css +0 -74
- package/ui/js/highlightjs/styles/magula.css +0 -70
- package/ui/js/highlightjs/styles/mono-blue.css +0 -59
- package/ui/js/highlightjs/styles/monokai-sublime.css +0 -83
- package/ui/js/highlightjs/styles/monokai.css +0 -70
- package/ui/js/highlightjs/styles/obsidian.css +0 -88
- package/ui/js/highlightjs/styles/paraiso-dark.css +0 -72
- package/ui/js/highlightjs/styles/paraiso-light.css +0 -72
- package/ui/js/highlightjs/styles/railscasts.css +0 -106
- package/ui/js/highlightjs/styles/rainbow.css +0 -85
- package/ui/js/highlightjs/styles/solarized-dark.css +0 -84
- package/ui/js/highlightjs/styles/solarized-light.css +0 -84
- package/ui/js/highlightjs/styles/sunburst.css +0 -102
- package/ui/js/highlightjs/styles/twilight.css +0 -97
- package/ui/js/highlightjs/styles/vs.css +0 -68
- package/ui/js/highlightjs/styles/vs2015.css +0 -117
- package/ui/js/highlightjs/styles/xcode.css +0 -104
- 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.
|
|
67
|
-
<link
|
|
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
|
-
|
|
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' });
|
package/editor/dist/index.html
CHANGED
|
@@ -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-
|
|
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
|
|
52
|
-
//
|
|
53
|
-
//
|
|
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",
|
package/hdoc-content-routes.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
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:");
|