hdoc-tools 0.57.2 → 0.57.3

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-spell.js ADDED
@@ -0,0 +1,61 @@
1
+ // Dialect spell-check: flags British spellings and suggests the US form, using
2
+ // the same `american-british-english-translator` the build uses (hdoc-validate),
3
+ // so the editor and `hdoc validate` agree. Returns character-offset findings
4
+ // suitable for editor underlines.
5
+
6
+ let translator = null;
7
+
8
+ function line_starts(text) {
9
+ const starts = [0];
10
+ for (let i = 0; i < text.length; i++) {
11
+ if (text[i] === "\n") starts.push(i + 1);
12
+ }
13
+ return starts;
14
+ }
15
+
16
+ function escape_regex(s) {
17
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18
+ }
19
+
20
+ // Returns [{ from, to, word, suggestion, type: 'dialect' }] for British spellings,
21
+ // skipping any word in `ignore` (case-insensitive).
22
+ function dialect_findings(text, ignore = []) {
23
+ if (!translator) translator = require("american-british-english-translator");
24
+ const out = translator.translate(text, { british: true, spelling: true });
25
+ const ignore_set = new Set(ignore.map((w) => String(w).toLowerCase()));
26
+ const starts = line_starts(text);
27
+ const findings = [];
28
+
29
+ for (const line_key of Object.keys(out)) {
30
+ const line_no = Number.parseInt(line_key, 10);
31
+ if (!line_no) continue;
32
+ const line_start = starts[line_no - 1];
33
+ if (line_start === undefined) continue;
34
+ const line_end = line_no < starts.length ? starts[line_no] : text.length;
35
+ const line_text = text.slice(line_start, line_end);
36
+
37
+ for (const entry of out[line_key]) {
38
+ for (const word of Object.keys(entry)) {
39
+ if (ignore_set.has(word.toLowerCase())) continue;
40
+ const suggestion = entry[word]?.details;
41
+ if (!suggestion) continue;
42
+ const re = new RegExp(`\\b${escape_regex(word)}\\b`, "gi");
43
+ let m;
44
+ // eslint-disable-next-line no-cond-assign
45
+ while ((m = re.exec(line_text)) !== null) {
46
+ const from = line_start + m.index;
47
+ findings.push({
48
+ from,
49
+ to: from + m[0].length,
50
+ word: m[0],
51
+ suggestion,
52
+ type: "dialect",
53
+ });
54
+ }
55
+ }
56
+ }
57
+ }
58
+ return findings;
59
+ }
60
+
61
+ exports.dialect_findings = dialect_findings;
package/hdoc-toc.js ADDED
@@ -0,0 +1,442 @@
1
+ // In-memory TOC working model for `hdoc edit`.
2
+ //
3
+ // Owns the book's navigation tree (hdocbook.json -> navigation.items) and the
4
+ // node-identity scheme. Each node gets a permanent, opaque, per-book-unique id
5
+ // (see README of the editor effort): identity is separated from location
6
+ // (link/slug) and from order (1.2.3 numbering is derived at render time only).
7
+ //
8
+ // Identity strategy (Phase 1): on load we assign an id to every node that lacks
9
+ // one, in memory. The model is the single source of truth for the editing
10
+ // session, so ids stay stable across /api/toc reads. The first structural save
11
+ // persists these ids back into hdocbook.json (lazy one-time migration).
12
+ //
13
+ // hdocbook.json is read with fs.readFileSync + JSON.parse (never require) because
14
+ // the editor mutates it and require() would cache a stale copy.
15
+
16
+ const fs = require("node:fs");
17
+ const path = require("node:path");
18
+ const crypto = require("node:crypto");
19
+
20
+ // Lowercase, URL-safe, no look-alike characters (drops 0/o/1/l/i). 30 symbols.
21
+ const ID_ALPHABET = "23456789abcdefghjkmnpqrstvwxyz";
22
+ const ID_LENGTH = 8;
23
+ // Largest multiple of the alphabet size that fits in a byte, for unbiased
24
+ // rejection sampling (256 -> 240 = 8 * 30).
25
+ const ID_REJECT_THRESHOLD =
26
+ Math.floor(256 / ID_ALPHABET.length) * ID_ALPHABET.length;
27
+
28
+ // Recognised image/asset extensions (for the Files browser + uploads).
29
+ const IMAGE_EXTS = [
30
+ ".png",
31
+ ".jpg",
32
+ ".jpeg",
33
+ ".gif",
34
+ ".svg",
35
+ ".webp",
36
+ ".bmp",
37
+ ".ico",
38
+ ".avif",
39
+ ];
40
+
41
+ // Classify a file by name for the Files tree: page (.md), image, or other.
42
+ function kind_of(name) {
43
+ const lower = name.toLowerCase();
44
+ if (lower.endsWith(".md")) return "page";
45
+ if (IMAGE_EXTS.some((e) => lower.endsWith(e))) return "image";
46
+ return "other";
47
+ }
48
+
49
+ function random_id_char() {
50
+ // Rejection-sample a byte to avoid modulo bias across the 30-symbol alphabet.
51
+ for (;;) {
52
+ const b = crypto.randomBytes(1)[0];
53
+ if (b < ID_REJECT_THRESHOLD) return ID_ALPHABET[b % ID_ALPHABET.length];
54
+ }
55
+ }
56
+
57
+ // Generate an opaque id not present in `used` (a Set), and reserve it.
58
+ function generate_id(used) {
59
+ for (let attempt = 0; attempt < 10000; attempt++) {
60
+ let id = "";
61
+ for (let i = 0; i < ID_LENGTH; i++) id += random_id_char();
62
+ if (!used.has(id)) {
63
+ used.add(id);
64
+ return id;
65
+ }
66
+ }
67
+ throw new Error("Unable to generate a unique node id");
68
+ }
69
+
70
+ class TocModel {
71
+ constructor(source_path, docId) {
72
+ this.source_path = source_path;
73
+ this.docId = docId;
74
+ this.hdocbook_path = path.join(source_path, docId, "hdocbook.json");
75
+ // True when we assigned ids that are not yet written to disk. The first
76
+ // save will persist them (handled by a future write endpoint).
77
+ this.ids_pending_persist = false;
78
+ // Optional hook: called as (absPath, content) after we write hdocbook.json,
79
+ // so a file watcher can suppress our own writes.
80
+ this.on_persist = null;
81
+ this.load();
82
+ }
83
+
84
+ // Re-read hdocbook.json from disk (e.g. after an external edit), keeping any
85
+ // existing ids and assigning ids to new nodes.
86
+ reload() {
87
+ this.load();
88
+ }
89
+
90
+ load() {
91
+ const raw = JSON.parse(fs.readFileSync(this.hdocbook_path, "utf8"));
92
+ this.raw = raw;
93
+ const nav =
94
+ raw.navigation && Array.isArray(raw.navigation.items)
95
+ ? raw.navigation.items
96
+ : [];
97
+ this.items = nav;
98
+
99
+ // Pass 1: collect ids already present so generated ones never collide.
100
+ this.used_ids = new Set();
101
+ this._walk(nav, (n) => {
102
+ if (typeof n.id === "string" && n.id) this.used_ids.add(n.id);
103
+ });
104
+ // Pass 2: assign opaque ids to any node that lacks one.
105
+ this._walk(nav, (n) => {
106
+ if (!(typeof n.id === "string" && n.id)) {
107
+ n.id = generate_id(this.used_ids);
108
+ this.ids_pending_persist = true;
109
+ }
110
+ });
111
+ }
112
+
113
+ _walk(nodes, fn) {
114
+ for (const n of nodes) {
115
+ fn(n);
116
+ if (Array.isArray(n.items)) this._walk(n.items, fn);
117
+ }
118
+ }
119
+
120
+ // Resolve a leaf link (path without extension) to a source-relative file.
121
+ _resolve_file(link) {
122
+ const rel = `${link}.md`;
123
+ if (fs.existsSync(path.join(this.source_path, rel))) {
124
+ return { file: rel, fileExists: true };
125
+ }
126
+ const rel_index = path.join(link, "index.md");
127
+ if (fs.existsSync(path.join(this.source_path, rel_index))) {
128
+ return { file: rel_index, fileExists: true };
129
+ }
130
+ // Report the expected path even when missing, so the UI can flag it.
131
+ return { file: rel, fileExists: false };
132
+ }
133
+
134
+ // Find a node anywhere in the tree by its opaque id (or null).
135
+ find_node(id) {
136
+ let found = null;
137
+ this._walk(this.items, (n) => {
138
+ if (n.id === id) found = n;
139
+ });
140
+ return found;
141
+ }
142
+
143
+ // Resolve a node id to its source file. Returns null if the id is unknown.
144
+ // For a branch (or a leaf with no link) abs/file are null and exists is false.
145
+ resolve_file(id) {
146
+ const node = this.find_node(id);
147
+ if (!node) return null;
148
+ const link = typeof node.link === "string" ? node.link : null;
149
+ if (!link) {
150
+ return { node, link: null, file: null, abs: null, exists: false };
151
+ }
152
+ const r = this._resolve_file(link);
153
+ return {
154
+ node,
155
+ link,
156
+ file: r.file,
157
+ abs: path.join(this.source_path, r.file),
158
+ exists: r.fileExists,
159
+ };
160
+ }
161
+
162
+ // --- structural mutations (Phase 1b: nav-only — links/files never move) ---
163
+
164
+ // Locate a node by id: returns { node, parent (array), index } or null.
165
+ _locate(id) {
166
+ const search = (arr) => {
167
+ for (let i = 0; i < arr.length; i++) {
168
+ if (arr[i].id === id) return { node: arr[i], parent: arr, index: i };
169
+ if (Array.isArray(arr[i].items)) {
170
+ const found = search(arr[i].items);
171
+ if (found) return found;
172
+ }
173
+ }
174
+ return null;
175
+ };
176
+ return search(this.items);
177
+ }
178
+
179
+ // Is targetId the node itself or one of its descendants?
180
+ _contains(node, targetId) {
181
+ if (node.id === targetId) return true;
182
+ if (Array.isArray(node.items)) {
183
+ return node.items.some((c) => this._contains(c, targetId));
184
+ }
185
+ return false;
186
+ }
187
+
188
+ // Reorder/reparent a node. parentId null => top level. index is the insertion
189
+ // position in the destination AFTER the node has been removed from its old
190
+ // spot. Only navigation order/nesting changes; link/items/draft are preserved.
191
+ move(id, parentId, index) {
192
+ const loc = this._locate(id);
193
+ if (!loc) throw new Error(`Unknown node id: ${id}`);
194
+
195
+ let dest;
196
+ if (parentId === null || parentId === undefined) {
197
+ dest = this.items;
198
+ } else {
199
+ const parent = this.find_node(parentId);
200
+ if (!parent) throw new Error(`Unknown parent id: ${parentId}`);
201
+ if (!Array.isArray(parent.items)) {
202
+ throw new Error("Target node is a leaf and cannot contain children");
203
+ }
204
+ if (this._contains(loc.node, parentId)) {
205
+ throw new Error("Cannot move a node into its own subtree");
206
+ }
207
+ dest = parent.items;
208
+ }
209
+
210
+ loc.parent.splice(loc.index, 1);
211
+ let idx = typeof index === "number" ? index : dest.length;
212
+ idx = Math.max(0, Math.min(idx, dest.length));
213
+ dest.splice(idx, 0, loc.node);
214
+ this.persist();
215
+ }
216
+
217
+ // Rename a node's display label (text). No effect on link/slug or file.
218
+ rename(id, text) {
219
+ const node = this.find_node(id);
220
+ if (!node) throw new Error(`Unknown node id: ${id}`);
221
+ node.text = text;
222
+ this.persist();
223
+ }
224
+
225
+ // Set a node's draft flag.
226
+ set_draft(id, draft) {
227
+ const node = this.find_node(id);
228
+ if (!node) throw new Error(`Unknown node id: ${id}`);
229
+ node.draft = !!draft;
230
+ this.persist();
231
+ }
232
+
233
+ // Write the working model back to hdocbook.json in its native format
234
+ // (2-space indent, LF, no trailing newline). This is also where in-memory
235
+ // opaque ids first land on disk (the lazy one-time migration).
236
+ persist() {
237
+ if (!this.raw.navigation) this.raw.navigation = {};
238
+ this.raw.navigation.items = this.items;
239
+ const json = JSON.stringify(this.raw, null, 2);
240
+ fs.writeFileSync(this.hdocbook_path, json, "utf8");
241
+ this.ids_pending_persist = false;
242
+ if (this.on_persist) this.on_persist(this.hdocbook_path, json);
243
+ }
244
+
245
+ // --- pages on disk (decoupled from nav) ---
246
+
247
+ // Validate a source-relative path: it must live inside the book's docId
248
+ // folder, not traverse upward, and not touch special "_" folders/files.
249
+ // `exts` limits the allowed file extension (default .md for pages; pass null
250
+ // to allow any, or a list for assets). Returns { rel (normalised), abs }.
251
+ _safe_rel(rel, { dir = false, exts = [".md"] } = {}) {
252
+ if (typeof rel !== "string" || !rel.trim()) throw new Error("Missing path");
253
+ const norm = rel.split("\\").join("/").replace(/^\/+/, "");
254
+ const segs = norm.split("/").filter((s) => s.length > 0);
255
+ if (segs[0] !== this.docId) {
256
+ throw new Error(`Path must be inside the book folder: ${this.docId}`);
257
+ }
258
+ if (segs.includes("..") || segs.includes(".")) {
259
+ throw new Error("Invalid path");
260
+ }
261
+ if (segs.some((s) => s.startsWith("_"))) {
262
+ throw new Error("Cannot operate inside special (_) folders");
263
+ }
264
+ if (!dir && exts) {
265
+ const lower = norm.toLowerCase();
266
+ if (!exts.some((e) => lower.endsWith(e))) {
267
+ throw new Error(`File type not allowed (expected: ${exts.join(", ")})`);
268
+ }
269
+ }
270
+ const root = path.join(this.source_path, this.docId);
271
+ const abs = path.join(this.source_path, norm);
272
+ const within = path.relative(root, abs);
273
+ if (within.startsWith("..") || path.isAbsolute(within)) {
274
+ throw new Error("Path escapes the book");
275
+ }
276
+ return { rel: norm, abs };
277
+ }
278
+
279
+ // Source-relative files referenced by some nav leaf's link.
280
+ linked_files() {
281
+ const set = new Set();
282
+ this._walk(this.items, (n) => {
283
+ if (typeof n.link === "string" && n.link) {
284
+ const r = this._resolve_file(n.link);
285
+ if (r.file) set.add(r.file);
286
+ }
287
+ });
288
+ return set;
289
+ }
290
+
291
+ // Every file on disk under the book (excluding "_" folders), each tagged with
292
+ // its kind (page/image/other). Pages also carry a linked flag (in nav or not).
293
+ list_files() {
294
+ const linked = this.linked_files();
295
+ const root = path.join(this.source_path, this.docId);
296
+ const files = [];
297
+ const dirs = [];
298
+ const walk = (absDir) => {
299
+ for (const e of fs.readdirSync(absDir, { withFileTypes: true })) {
300
+ if (e.name.startsWith("_")) continue; // skip special folders/files
301
+ const abs = path.join(absDir, e.name);
302
+ const rel = path.relative(this.source_path, abs).split(path.sep).join("/");
303
+ if (e.isDirectory()) {
304
+ dirs.push(rel);
305
+ walk(abs);
306
+ } else {
307
+ const kind = kind_of(e.name);
308
+ files.push({
309
+ file: rel,
310
+ kind,
311
+ linked: kind === "page" ? linked.has(rel) : false,
312
+ });
313
+ }
314
+ }
315
+ };
316
+ if (fs.existsSync(root)) walk(root);
317
+ files.sort((a, b) => a.file.localeCompare(b.file));
318
+ dirs.sort((a, b) => a.localeCompare(b));
319
+ return { docId: this.docId, dirs, files };
320
+ }
321
+
322
+ create_file(rel, content = "") {
323
+ const { abs, rel: r } = this._safe_rel(rel, { dir: false });
324
+ if (fs.existsSync(abs)) throw new Error("A file already exists at that path");
325
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
326
+ fs.writeFileSync(abs, content, "utf8");
327
+ return r;
328
+ }
329
+
330
+ create_folder(rel) {
331
+ const { abs, rel: r } = this._safe_rel(rel, { dir: true });
332
+ fs.mkdirSync(abs, { recursive: true });
333
+ return r;
334
+ }
335
+
336
+ // Delete any file (page, image, or other asset) inside the book.
337
+ delete_file(rel) {
338
+ const { abs, rel: r } = this._safe_rel(rel, { dir: false, exts: null });
339
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
340
+ throw new Error("File not found");
341
+ }
342
+ fs.unlinkSync(abs);
343
+ return r;
344
+ }
345
+
346
+ // Validate + return the absolute path for an uploaded image/asset.
347
+ resolve_upload(rel) {
348
+ return this._safe_rel(rel, { dir: false, exts: IMAGE_EXTS });
349
+ }
350
+
351
+ // --- nav create / remove (Phase 1c) ---
352
+
353
+ _dest_array(parentId) {
354
+ if (parentId === null || parentId === undefined) return this.items;
355
+ const parent = this.find_node(parentId);
356
+ if (!parent) throw new Error(`Unknown parent id: ${parentId}`);
357
+ if (!Array.isArray(parent.items)) {
358
+ throw new Error("Target node is a leaf and cannot contain children");
359
+ }
360
+ return parent.items;
361
+ }
362
+
363
+ _insert(dest, index, node) {
364
+ let idx = typeof index === "number" ? index : dest.length;
365
+ idx = Math.max(0, Math.min(idx, dest.length));
366
+ dest.splice(idx, 0, node);
367
+ }
368
+
369
+ // Add a nav leaf that links to an existing page.
370
+ create_leaf(parentId, index, text, link) {
371
+ const dest = this._dest_array(parentId);
372
+ const node = { id: generate_id(this.used_ids), text: text || "", link };
373
+ this._insert(dest, index, node);
374
+ this.persist();
375
+ return node.id;
376
+ }
377
+
378
+ // Add an empty nav section (branch container).
379
+ add_section(parentId, index, text) {
380
+ const dest = this._dest_array(parentId);
381
+ const node = { id: generate_id(this.used_ids), text: text || "", items: [] };
382
+ this._insert(dest, index, node);
383
+ this.persist();
384
+ return node.id;
385
+ }
386
+
387
+ // Remove a nav node (unlink). Returns the removed node so the caller can
388
+ // optionally delete its backing file.
389
+ remove_node(id) {
390
+ const loc = this._locate(id);
391
+ if (!loc) throw new Error(`Unknown node id: ${id}`);
392
+ loc.parent.splice(loc.index, 1);
393
+ this.persist();
394
+ return loc.node;
395
+ }
396
+
397
+ // Normalized tree for the editor UI. Numbering is derived from sibling
398
+ // position (1, 1.1, 1.2, 2, ...) and never stored.
399
+ to_dto() {
400
+ const walk = (nodes, prefix) =>
401
+ nodes.map((n, i) => {
402
+ const number = prefix ? `${prefix}.${i + 1}` : `${i + 1}`;
403
+ const base = {
404
+ id: n.id,
405
+ text: typeof n.text === "string" ? n.text : "",
406
+ number,
407
+ draft: n.draft === true,
408
+ };
409
+ if (Array.isArray(n.items)) {
410
+ return {
411
+ ...base,
412
+ type: "branch",
413
+ expand: n.expand === true,
414
+ children: walk(n.items, number),
415
+ };
416
+ }
417
+ const link = typeof n.link === "string" ? n.link : null;
418
+ const resolved = link
419
+ ? this._resolve_file(link)
420
+ : { file: null, fileExists: false };
421
+ return {
422
+ ...base,
423
+ type: "leaf",
424
+ link,
425
+ file: resolved.file,
426
+ fileExists: resolved.fileExists,
427
+ };
428
+ });
429
+
430
+ return {
431
+ docId: this.docId,
432
+ title: typeof this.raw.title === "string" ? this.raw.title : "",
433
+ idsPendingPersist: this.ids_pending_persist,
434
+ tree: walk(this.items, ""),
435
+ };
436
+ }
437
+ }
438
+
439
+ exports.TocModel = TocModel;
440
+ exports.generate_id = generate_id;
441
+ exports.ID_ALPHABET = ID_ALPHABET;
442
+ exports.ID_LENGTH = ID_LENGTH;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "hdoc-tools",
3
- "version": "0.57.2",
3
+ "version": "0.57.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "hdoc-tools",
9
- "version": "0.57.2",
9
+ "version": "0.57.3",
10
10
  "hasInstallScript": true,
11
11
  "license": "ISC",
12
12
  "dependencies": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hdoc-tools",
3
- "version": "0.57.2",
3
+ "version": "0.57.3",
4
4
  "description": "Hornbill HDocBook Development Support Tool",
5
5
  "main": "hdoc.js",
6
6
  "bin": {
@@ -23,7 +23,9 @@
23
23
  "hdoc-mermaid.js",
24
24
  "hdoc-module.js",
25
25
  "hdoc-serve.js",
26
+ "hdoc-spell.js",
26
27
  "hdoc-stats.js",
28
+ "hdoc-toc.js",
27
29
  "hdoc-validate.js",
28
30
  "hdoc-validate-config.js",
29
31
  "hdoc-ver.js",