hdoc-tools 0.63.0 → 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.
@@ -271,6 +271,20 @@ exports.create_content_handler = (ctx) => {
271
271
  return `${header}\n${body}`;
272
272
  }
273
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);
286
+ }
287
+
274
288
  async function transform_markdown_and_send_html(req, res, file_path) {
275
289
  if (!fs.existsSync(file_path)) return false;
276
290
 
@@ -566,6 +580,7 @@ exports.create_content_handler = (ctx) => {
566
580
  // handle_books_request / handle_library_request are exposed so the editor
567
581
  // can register delegating /_books routes that follow a workspace switch.
568
582
  render_markdown,
583
+ render_page,
569
584
  transform_markdown_and_send_html,
570
585
  send_content_file,
571
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,154 @@
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
+
95
244
  // Local preview serves exactly one book — skip the viewer's library home
96
245
  // and land straight in the book.
97
246
  app.get("/", (req, res) => {
@@ -147,12 +296,17 @@
147
296
  content.send_content_resource_404(req, res);
148
297
  });
149
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.
150
301
  const server = app.listen(port, "0.0.0.0", () => {
151
302
  const addr = server.address();
152
303
  if (!addr) return;
153
304
 
154
305
  console.log("Server listening at http://127.0.0.1:%s", addr.port);
155
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
+ );
156
310
 
157
311
  const _vars = ["{{DOC_ID}}", "{{BUILD_NUMBER}}", "{{BUILD_DATE}}"];
158
312
  console.log("Server Vars:");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hdoc-tools",
3
- "version": "0.63.0",
3
+ "version": "0.64.0",
4
4
  "description": "Hornbill HDocBook Development Support Tool",
5
5
  "main": "hdoc.js",
6
6
  "bin": {
@@ -81,7 +81,8 @@ function loadJS(arrFiles,callback)
81
81
  "js/webcomponents/hdocApprove.js",
82
82
  "js/doc.hornbill.js",
83
83
  "js/highlightjs/highlight.pack.js",
84
- "js/highlightjs-badge.js"
84
+ "js/highlightjs-badge.js",
85
+ "js/hdoc-edit-inline.js" //-- LOCAL PREVIEW PATCH: inline edit for `hdoc serve` (no-op unless the server enables it) - keep on live-site re-sync
85
86
  ],function()
86
87
  {
87
88
  intialiseApp();