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.
- package/hdoc-content-routes.js +15 -0
- package/hdoc-help.js +1 -1
- package/hdoc-serve.js +154 -0
- package/package.json +1 -1
- package/ui/js/bootstrap.js +2 -1
- package/ui/js/hdoc-edit-cm.js +29 -0
- package/ui/js/hdoc-edit-inline.js +977 -0
package/hdoc-content-routes.js
CHANGED
|
@@ -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
package/ui/js/bootstrap.js
CHANGED
|
@@ -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();
|