hdoc-tools 0.47.5 → 0.50.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.
@@ -0,0 +1,134 @@
1
+ (() => {
2
+ const fs = require("node:fs");
3
+ const path = require("node:path");
4
+
5
+ // Replicates the URL normalisation logic from populate_index so page keys match.
6
+ function to_page_path(file) {
7
+ let p = file.relative_path.replaceAll("\\", "/");
8
+ if (
9
+ p.endsWith("/index.md") ||
10
+ p.endsWith("/index.html") ||
11
+ p.endsWith("/index.htm")
12
+ ) {
13
+ p = p.substring(0, p.lastIndexOf("/"));
14
+ }
15
+ return `/${p.replace(path.extname(file.relative_path), "")}`;
16
+ }
17
+
18
+ // Groups index_records into one entry per page, preserving section order.
19
+ function build_pages(index_records) {
20
+ const pages = new Map();
21
+ for (const file of index_records) {
22
+ if (file.inline) continue;
23
+ const page_path = to_page_path(file);
24
+ if (!pages.has(page_path)) {
25
+ pages.set(page_path, {
26
+ page_path,
27
+ title: file.index_html.fm_props.title || "",
28
+ keywords: file.keywords || "",
29
+ status: file.status || "release",
30
+ lastmod: file.lastmod || null,
31
+ sections: [],
32
+ });
33
+ }
34
+ const text = (file.index_html.text || "").trim();
35
+ if (text) pages.get(page_path).sections.push(text);
36
+ }
37
+ return Array.from(pages.values());
38
+ }
39
+
40
+ // Returns an ISO 8601 string, or null if the value is absent or unparseable.
41
+ function safe_iso(value) {
42
+ if (!value) return null;
43
+ const d = new Date(value);
44
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
45
+ }
46
+
47
+ exports.populate_onyx_index = (
48
+ work_path_content,
49
+ doc_id,
50
+ book_config,
51
+ index_records,
52
+ base_url = "",
53
+ verbose = false,
54
+ ) => {
55
+ const response = {
56
+ success: false,
57
+ document_count: 0,
58
+ error: "",
59
+ };
60
+
61
+ const pages = build_pages(index_records);
62
+ const canonical_base = base_url.replace(/\/$/, "");
63
+ const metadata_entries = [];
64
+
65
+ for (const page of pages) {
66
+ // e.g. onyx/getting-started/index.txt or onyx/reference/api.txt
67
+ const txt_rel = `_onyx${page.page_path}.txt`;
68
+ const txt_abs = path.join(work_path_content, txt_rel);
69
+
70
+ fs.mkdirSync(path.dirname(txt_abs), { recursive: true });
71
+
72
+ // Plain-text body: title, keywords hint, then each section separated by
73
+ // a blank line so Onyx's chunker sees natural paragraph boundaries.
74
+ const lines = [];
75
+ if (page.title) lines.push(page.title, "");
76
+ if (page.keywords) lines.push(`Keywords: ${page.keywords}`, "");
77
+ for (const section of page.sections) {
78
+ lines.push(section, "");
79
+ }
80
+
81
+ try {
82
+ fs.writeFileSync(txt_abs, lines.join("\n").trimEnd(), "utf8");
83
+ } catch (e) {
84
+ response.error = `Failed to write ${txt_rel}: ${e.message}`;
85
+ return response;
86
+ }
87
+
88
+ if (verbose) console.log(`Onyx: wrote ${txt_rel}`);
89
+
90
+ const entry = {
91
+ filename: txt_rel.replaceAll("\\", "/"),
92
+ link: canonical_base
93
+ ? `${canonical_base}${page.page_path}`
94
+ : page.page_path,
95
+ primary_owners: [],
96
+ secondary_owners: [],
97
+ metadata: {
98
+ book_id: doc_id,
99
+ product_family: book_config.productFamily || "",
100
+ audience: Array.isArray(book_config.audience)
101
+ ? book_config.audience.join(",")
102
+ : "",
103
+ tags: Array.isArray(book_config.tags)
104
+ ? book_config.tags.join(",")
105
+ : "",
106
+ status: page.status,
107
+ },
108
+ };
109
+ const updated_at = safe_iso(page.lastmod);
110
+ if (updated_at) entry.doc_updated_at = updated_at;
111
+ metadata_entries.push(entry);
112
+
113
+ response.document_count++;
114
+ }
115
+
116
+ const meta_path = path.join(work_path_content, ".onyx_metadata.json");
117
+ try {
118
+ fs.writeFileSync(
119
+ meta_path,
120
+ JSON.stringify(metadata_entries, null, 2),
121
+ "utf8",
122
+ );
123
+ } catch (e) {
124
+ response.error = `Failed to write .onyx_metadata.json: ${e.message}`;
125
+ return response;
126
+ }
127
+
128
+ response.success = true;
129
+ console.log(
130
+ `\nOnyx Index Build Complete: ${response.document_count} document records created.`,
131
+ );
132
+ return response;
133
+ };
134
+ })();
package/hdoc-build.js CHANGED
@@ -5,8 +5,10 @@
5
5
  const path = require("node:path");
6
6
  const puppeteer = require("puppeteer");
7
7
  const hdoc_validate = require(path.join(__dirname, "hdoc-validate.js"));
8
+ const hdoc_validate_config = require(path.join(__dirname, "hdoc-validate-config.js"));
8
9
  const hdoc = require(path.join(__dirname, "hdoc-module.js"));
9
10
  const hdoc_build_db = require(path.join(__dirname, "hdoc-build-db.js"));
11
+ const hdoc_build_onyx = require(path.join(__dirname, "hdoc-build-onyx.js"));
10
12
  const hdoc_build_pdf = require(path.join(__dirname, "hdoc-build-pdf.js"));
11
13
  const hdoc_index = require(path.join(__dirname, "hdoc-db.js"));
12
14
  const archiver = require("archiver");
@@ -976,6 +978,17 @@
976
978
  );
977
979
  process.exit(1);
978
980
  }
981
+
982
+ const project_config_errors = hdoc_validate_config.validate_project(hdocbook_project);
983
+ if (project_config_errors.length > 0) {
984
+ console.log("\r\n-----------------------");
985
+ console.log(" Validation Output ");
986
+ console.log("-----------------------");
987
+ for (const err of project_config_errors) console.error(`- ${err}`);
988
+ console.error(`\r\n${project_config_errors.length} Configuration Error${project_config_errors.length !== 1 ? 's' : ''} Found in hdocbook-project.json`);
989
+ process.exit(1);
990
+ }
991
+
979
992
  doc_id = hdocbook_project.docId;
980
993
 
981
994
  if (
@@ -1006,7 +1019,12 @@
1006
1019
  const work_path = path.join(source_path, "_work");
1007
1020
  const work_hdocbook_path = path.join(work_path, doc_id, "hdocbook.json");
1008
1021
 
1009
- hdocbook_config = require(hdocbook_path);
1022
+ try {
1023
+ hdocbook_config = require(hdocbook_path);
1024
+ } catch (e) {
1025
+ console.error(`File not found: ${doc_id}/hdocbook.json\n`);
1026
+ process.exit(1);
1027
+ }
1010
1028
  if (build_version !== "") {
1011
1029
  if (build_version.match(regex_version)) {
1012
1030
  hdocbook_config.version = build_version;
@@ -1017,6 +1035,16 @@
1017
1035
  }
1018
1036
  }
1019
1037
 
1038
+ const book_config_errors = hdoc_validate_config.validate_book(hdocbook_config, doc_id);
1039
+ if (book_config_errors.length > 0) {
1040
+ console.log("\r\n-----------------------");
1041
+ console.log(" Validation Output ");
1042
+ console.log("-----------------------");
1043
+ for (const err of book_config_errors) console.error(`- ${err}`);
1044
+ console.error(`\r\n${book_config_errors.length} Configuration Error${book_config_errors.length !== 1 ? 's' : ''} Found in ${doc_id}/hdocbook.json`);
1045
+ process.exit(1);
1046
+ }
1047
+
1020
1048
  if (!hdocbook_config.version.match(regex_version)) {
1021
1049
  console.error(
1022
1050
  `ERROR: Version number does not match required format - ${hdocbook_config.version}\n`,
package/hdoc-help.js CHANGED
@@ -35,6 +35,7 @@ Commands
35
35
  - Use the '--set-version 1.2.3' argument to set the version number of the built book.
36
36
  - Use the '--no-color' argument to remove any color control characters from the output.
37
37
  - Use the '--no-links' argument to skip link output to CLI during validation.
38
+ - Use the '--quiet' argument to suppress most console output, and only output validation errors if they are found.
38
39
 
39
40
  - bump
40
41
  Updates the semantic version number of the current book. If no options are specified, then the default of patch is applied:
@@ -0,0 +1,321 @@
1
+ (() => {
2
+ const path = require("node:path");
3
+ const regex_doc_id = /^[a-z][a-z0-9-]+$/;
4
+ const regex_version = /^[0-9]{1,3}[.][0-9]{1,3}[.][0-9]{1,6}$/;
5
+
6
+ const hdocbook_schema = require(path.join(__dirname, "schemas", "hdocbook.schema.json"));
7
+ const valid_audience = hdocbook_schema.properties.audience.items.enum;
8
+ const valid_product_families = hdocbook_schema.properties.productFamily.enum;
9
+
10
+ const project_schema = require(path.join(__dirname, "schemas", "hdocbook-project.schema.json"));
11
+ const project_top_keys = Object.keys(project_schema.properties);
12
+ const project_pdf_keys = Object.keys(project_schema.properties.pdfGeneration.properties);
13
+ const project_validation_keys = Object.keys(project_schema.properties.validation.properties);
14
+ const project_redirect_keys = Object.keys(project_schema.properties.redirects.items.properties);
15
+ const redirect_valid_codes = project_schema.properties.redirects.items.properties.code.enum;
16
+
17
+ // Reports any keys in obj that are not in the allowed set.
18
+ const check_extra_keys = (obj, allowed, path, file, errors) => {
19
+ for (const key of Object.keys(obj)) {
20
+ if (!allowed.includes(key)) {
21
+ errors.push(`${file}: "${path ? path + '.' : ''}${key}" is not a recognised property`);
22
+ }
23
+ }
24
+ };
25
+
26
+ const validate_nav_item = (item, path, errors, depth) => {
27
+ if (typeof item !== 'object' || Array.isArray(item) || item === null) {
28
+ errors.push(`hdocbook.json: "${path}" must be an object`);
29
+ return;
30
+ }
31
+ check_extra_keys(item, ['text', 'link', 'expand', 'draft', 'items'], path, 'hdocbook.json', errors);
32
+ if (!item.text || typeof item.text !== 'string') {
33
+ errors.push(`hdocbook.json: "${path}.text" is required and must be a non-empty string`);
34
+ }
35
+ if (item.link !== undefined && typeof item.link !== 'string') {
36
+ errors.push(`hdocbook.json: "${path}.link" must be a string`);
37
+ }
38
+ if (item.expand !== undefined && typeof item.expand !== 'boolean') {
39
+ errors.push(`hdocbook.json: "${path}.expand" must be a boolean`);
40
+ }
41
+ if (item.draft !== undefined && typeof item.draft !== 'boolean') {
42
+ errors.push(`hdocbook.json: "${path}.draft" must be a boolean`);
43
+ }
44
+ if (item.items !== undefined) {
45
+ if (!Array.isArray(item.items)) {
46
+ errors.push(`hdocbook.json: "${path}.items" must be an array`);
47
+ } else if (depth >= 3) {
48
+ errors.push(`hdocbook.json: "${path}.items" exceeds the maximum navigation nesting depth of 3 levels`);
49
+ } else {
50
+ for (let i = 0; i < item.items.length; i++) {
51
+ validate_nav_item(item.items[i], `${path}.items[${i}]`, errors, depth + 1);
52
+ }
53
+ }
54
+ }
55
+ };
56
+
57
+ // Validates the structure of hdocbook-project.json
58
+ const validate_project = (config) => {
59
+ const errors = [];
60
+ const file = 'hdocbook-project.json';
61
+
62
+ check_extra_keys(config, project_top_keys, '', file, errors);
63
+
64
+ if (!config.docId || typeof config.docId !== 'string') {
65
+ errors.push(`${file}: "docId" is required and must be a string`);
66
+ } else if (!regex_doc_id.test(config.docId)) {
67
+ errors.push(`${file}: "docId" must use kebab-case (lowercase letters, numbers, hyphens): "${config.docId}"`);
68
+ }
69
+
70
+ if (config.pdfGeneration !== undefined) {
71
+ if (typeof config.pdfGeneration !== 'object' || Array.isArray(config.pdfGeneration)) {
72
+ errors.push(`${file}: "pdfGeneration" must be an object`);
73
+ } else {
74
+ check_extra_keys(config.pdfGeneration, project_pdf_keys, 'pdfGeneration', file, errors);
75
+ if (config.pdfGeneration.enable === undefined) {
76
+ errors.push(`${file}: "pdfGeneration.enable" is required`);
77
+ } else if (typeof config.pdfGeneration.enable !== 'boolean') {
78
+ errors.push(`${file}: "pdfGeneration.enable" must be a boolean`);
79
+ }
80
+ if (config.pdfGeneration.exclude_paths !== undefined) {
81
+ if (!Array.isArray(config.pdfGeneration.exclude_paths)) {
82
+ errors.push(`${file}: "pdfGeneration.exclude_paths" must be an array`);
83
+ } else {
84
+ for (let i = 0; i < config.pdfGeneration.exclude_paths.length; i++) {
85
+ if (typeof config.pdfGeneration.exclude_paths[i] !== 'string') {
86
+ errors.push(`${file}: "pdfGeneration.exclude_paths[${i}]" must be a string`);
87
+ }
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ if (config.validation !== undefined) {
95
+ if (typeof config.validation !== 'object' || Array.isArray(config.validation)) {
96
+ errors.push(`${file}: "validation" must be an object`);
97
+ } else {
98
+ check_extra_keys(config.validation, project_validation_keys, 'validation', file, errors);
99
+ if (config.validation.exclude_links !== undefined) {
100
+ if (!Array.isArray(config.validation.exclude_links)) {
101
+ errors.push(`${file}: "validation.exclude_links" must be an array`);
102
+ } else {
103
+ for (let i = 0; i < config.validation.exclude_links.length; i++) {
104
+ if (typeof config.validation.exclude_links[i] !== 'string') {
105
+ errors.push(`${file}: "validation.exclude_links[${i}]" must be a string`);
106
+ }
107
+ }
108
+ }
109
+ }
110
+ if (config.validation.exclude_spellcheck !== undefined) {
111
+ if (!Array.isArray(config.validation.exclude_spellcheck)) {
112
+ errors.push(`${file}: "validation.exclude_spellcheck" must be an array`);
113
+ } else {
114
+ for (let i = 0; i < config.validation.exclude_spellcheck.length; i++) {
115
+ const item = config.validation.exclude_spellcheck[i];
116
+ if (typeof item !== 'object' || Array.isArray(item) || item === null) {
117
+ errors.push(`${file}: "validation.exclude_spellcheck[${i}]" must be an object`);
118
+ } else {
119
+ check_extra_keys(item, ['document_path', 'words'], `validation.exclude_spellcheck[${i}]`, file, errors);
120
+ if (!item.document_path || typeof item.document_path !== 'string') {
121
+ errors.push(`${file}: "validation.exclude_spellcheck[${i}].document_path" is required and must be a non-empty string`);
122
+ }
123
+ if (!Array.isArray(item.words)) {
124
+ errors.push(`${file}: "validation.exclude_spellcheck[${i}].words" is required and must be an array`);
125
+ } else {
126
+ for (let j = 0; j < item.words.length; j++) {
127
+ if (typeof item.words[j] !== 'string') {
128
+ errors.push(`${file}: "validation.exclude_spellcheck[${i}].words[${j}]" must be a string`);
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ if (config.validation.exclude_h1_count !== undefined) {
137
+ if (!Array.isArray(config.validation.exclude_h1_count)) {
138
+ errors.push(`${file}: "validation.exclude_h1_count" must be an array`);
139
+ } else {
140
+ for (let i = 0; i < config.validation.exclude_h1_count.length; i++) {
141
+ if (typeof config.validation.exclude_h1_count[i] !== 'string') {
142
+ errors.push(`${file}: "validation.exclude_h1_count[${i}]" must be a string`);
143
+ }
144
+ }
145
+ }
146
+ }
147
+ if (config.validation.external_link_warnings !== undefined &&
148
+ typeof config.validation.external_link_warnings !== 'boolean') {
149
+ errors.push(`${file}: "validation.external_link_warnings" must be a boolean`);
150
+ }
151
+ }
152
+ }
153
+
154
+ if (config.redirects !== undefined) {
155
+ if (!Array.isArray(config.redirects)) {
156
+ errors.push(`${file}: "redirects" must be an array`);
157
+ } else {
158
+ for (let i = 0; i < config.redirects.length; i++) {
159
+ const r = config.redirects[i];
160
+ if (typeof r !== 'object' || Array.isArray(r) || r === null) {
161
+ errors.push(`${file}: "redirects[${i}]" must be an object`);
162
+ } else {
163
+ check_extra_keys(r, project_redirect_keys, `redirects[${i}]`, file, errors);
164
+ if (!r.url || typeof r.url !== 'string') {
165
+ errors.push(`${file}: "redirects[${i}].url" is required and must be a non-empty string`);
166
+ }
167
+ if (r.code === undefined) {
168
+ errors.push(`${file}: "redirects[${i}].code" is required`);
169
+ } else if (!redirect_valid_codes.includes(r.code)) {
170
+ errors.push(`${file}: "redirects[${i}].code" must be ${redirect_valid_codes.join(', ')} (got ${r.code})`);
171
+ }
172
+ if (r.location !== undefined && typeof r.location !== 'string') {
173
+ errors.push(`${file}: "redirects[${i}].location" must be a string`);
174
+ }
175
+ if (r.skip_location_validation !== undefined && typeof r.skip_location_validation !== 'boolean') {
176
+ errors.push(`${file}: "redirects[${i}].skip_location_validation" must be a boolean`);
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ return errors;
184
+ };
185
+
186
+ // Validates the structure of hdocbook.json
187
+ const validate_book = (config, expected_doc_id) => {
188
+ const errors = [];
189
+ const file = 'hdocbook.json';
190
+
191
+ check_extra_keys(config,
192
+ ['docId', 'title', 'description', 'publicSource', 'version', 'productFamily',
193
+ 'coverImage', 'tags', 'audience', 'languages', 'readingTime', 'navigation', 'inline'],
194
+ '', file, errors);
195
+
196
+ if (!config.docId || typeof config.docId !== 'string') {
197
+ errors.push(`${file}: "docId" is required and must be a string`);
198
+ } else {
199
+ if (!regex_doc_id.test(config.docId)) {
200
+ errors.push(`${file}: "docId" must use kebab-case (lowercase letters, numbers, hyphens): "${config.docId}"`);
201
+ }
202
+ if (expected_doc_id && config.docId !== expected_doc_id) {
203
+ errors.push(`${file}: "docId" value "${config.docId}" does not match the folder name and the docId in hdocbook-project.json ("${expected_doc_id}")`);
204
+ }
205
+ }
206
+
207
+ if (!config.title || typeof config.title !== 'string') {
208
+ errors.push(`${file}: "title" is required and must be a non-empty string`);
209
+ }
210
+
211
+ if (!config.version || typeof config.version !== 'string') {
212
+ errors.push(`${file}: "version" is required and must be a string`);
213
+ } else if (!regex_version.test(config.version)) {
214
+ errors.push(`${file}: "version" must be in X.Y.Z format (got "${config.version}")`);
215
+ }
216
+
217
+ if (!config.productFamily || typeof config.productFamily !== 'string') {
218
+ errors.push(`${file}: "productFamily" is required and must be a string`);
219
+ } else if (!valid_product_families.includes(config.productFamily)) {
220
+ errors.push(`${file}: "productFamily" value "${config.productFamily}" is not recognised. Valid values: ${valid_product_families.join(', ')}`);
221
+ }
222
+
223
+ if (!config.audience || !Array.isArray(config.audience) || config.audience.length === 0) {
224
+ errors.push(`${file}: "audience" is required and must be a non-empty array`);
225
+ } else {
226
+ for (let i = 0; i < config.audience.length; i++) {
227
+ if (typeof config.audience[i] !== 'string') {
228
+ errors.push(`${file}: "audience[${i}]" must be a string`);
229
+ } else if (!valid_audience.includes(config.audience[i])) {
230
+ errors.push(`${file}: "audience[${i}]" value "${config.audience[i]}" is not recognised. Valid values: ${valid_audience.join(', ')}`);
231
+ }
232
+ }
233
+ }
234
+
235
+ if (!config.navigation || typeof config.navigation !== 'object' || Array.isArray(config.navigation)) {
236
+ errors.push(`${file}: "navigation" is required and must be an object`);
237
+ } else {
238
+ check_extra_keys(config.navigation, ['items'], 'navigation', file, errors);
239
+ if (!Array.isArray(config.navigation.items)) {
240
+ errors.push(`${file}: "navigation.items" is required and must be an array`);
241
+ } else if (config.navigation.items.length === 0) {
242
+ errors.push(`${file}: "navigation.items" must not be empty`);
243
+ } else {
244
+ for (let i = 0; i < config.navigation.items.length; i++) {
245
+ validate_nav_item(config.navigation.items[i], `navigation.items[${i}]`, errors, 1);
246
+ }
247
+ }
248
+ }
249
+
250
+ if (config.description !== undefined && typeof config.description !== 'string') {
251
+ errors.push(`${file}: "description" must be a string`);
252
+ }
253
+
254
+ if (config.publicSource !== undefined && config.publicSource !== '') {
255
+ if (typeof config.publicSource !== 'string') {
256
+ errors.push(`${file}: "publicSource" must be a string`);
257
+ } else if (config.publicSource.toLowerCase() === '--publicsource--') {
258
+ errors.push(`${file}: "publicSource" is still set to its default template value`);
259
+ } else if (
260
+ !config.publicSource.startsWith('https://github.com') &&
261
+ !config.publicSource.startsWith('https://api.github.com')
262
+ ) {
263
+ errors.push(`${file}: "publicSource" must be a GitHub URL starting with https://github.com or https://api.github.com (got "${config.publicSource}")`);
264
+ }
265
+ }
266
+
267
+ if (config.coverImage !== undefined && typeof config.coverImage !== 'string') {
268
+ errors.push(`${file}: "coverImage" must be a string`);
269
+ }
270
+
271
+ if (config.tags !== undefined) {
272
+ if (!Array.isArray(config.tags)) {
273
+ errors.push(`${file}: "tags" must be an array`);
274
+ } else {
275
+ for (let i = 0; i < config.tags.length; i++) {
276
+ if (typeof config.tags[i] !== 'string') {
277
+ errors.push(`${file}: "tags[${i}]" must be a string`);
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ if (config.languages !== undefined) {
284
+ if (!Array.isArray(config.languages)) {
285
+ errors.push(`${file}: "languages" must be an array`);
286
+ } else {
287
+ for (let i = 0; i < config.languages.length; i++) {
288
+ if (typeof config.languages[i] !== 'string') {
289
+ errors.push(`${file}: "languages[${i}]" must be a string`);
290
+ }
291
+ }
292
+ }
293
+ }
294
+
295
+ if (config.inline !== undefined) {
296
+ if (!Array.isArray(config.inline)) {
297
+ errors.push(`${file}: "inline" must be an array`);
298
+ } else {
299
+ for (let i = 0; i < config.inline.length; i++) {
300
+ const item = config.inline[i];
301
+ if (typeof item !== 'object' || Array.isArray(item) || item === null) {
302
+ errors.push(`${file}: "inline[${i}]" must be an object`);
303
+ } else {
304
+ check_extra_keys(item, ['title', 'link'], `inline[${i}]`, file, errors);
305
+ if (!item.title || typeof item.title !== 'string') {
306
+ errors.push(`${file}: "inline[${i}].title" is required and must be a non-empty string`);
307
+ }
308
+ if (!item.link || typeof item.link !== 'string') {
309
+ errors.push(`${file}: "inline[${i}].link" is required and must be a non-empty string`);
310
+ }
311
+ }
312
+ }
313
+ }
314
+ }
315
+
316
+ return errors;
317
+ };
318
+
319
+ exports.validate_project = validate_project;
320
+ exports.validate_book = validate_book;
321
+ })();
package/hdoc.js CHANGED
@@ -13,69 +13,98 @@
13
13
 
14
14
  let console_color = true;
15
15
 
16
- console.log = (...args) => {
17
- if (process.env.GITHUB_ACTIONS !== 'true') {
18
- // If not running in GitHub Actions, send args to the original console.log
19
- originalConsoleLog(...args);
20
-
21
- } else {
22
- // If running in GitHub Actions, escape % and \ characters so printf doesn't throw an error
23
- const escapedArgs = args.map(arg => {
24
- if (typeof arg === 'string') {
25
- return arg.replace(/%/g, '%%').replace(/\\/g, '\\\\');
26
- }
27
- return arg;
28
- });
29
- // Use the original console.log with escaped arguments
30
- originalConsoleLog(...escapedArgs);
31
- }
32
- };
16
+ // Quiet mode: suppress all output, show a spinner, print only errors (or success) at exit
17
+ const _quietMode = process.argv.includes('--quiet') && process.argv[2] === 'validate';
18
+ const _quietErrors = [];
19
+
20
+ if (_quietMode) {
21
+ const _spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
22
+ let _spinnerIdx = 0;
23
+ const _spinnerInterval = setInterval(() => {
24
+ process.stderr.write(`\r${_spinnerFrames[_spinnerIdx++ % _spinnerFrames.length]} Validating...`);
25
+ }, 100);
26
+ _spinnerInterval.unref(); // Don't prevent natural process exit
33
27
 
34
- if (process.env.GITHUB_ACTIONS === 'true') {
35
- console.log("\nRunning in GitHub Actions environment\n");
28
+ console.log = () => {};
29
+ console.info = () => {};
30
+ console.error = (...args) => {
31
+ _quietErrors.push(args.map(a => (typeof a === 'string' ? a : String(a))).join(' '));
32
+ };
33
+
34
+ process.on('exit', (code) => {
35
+ clearInterval(_spinnerInterval);
36
+ process.stderr.write('\r\x1b[K');
37
+ if (_quietErrors.length > 0) {
38
+ for (const e of _quietErrors) originalConsoleError(`${e}\n`);
39
+ } else if (code === 0) {
40
+ originalConsoleLog('No validation errors found');
41
+ }
42
+ });
36
43
  } else {
37
- console.log("\nRunning in non-GitHub Actions environment\n");
38
- }
44
+ console.log = (...args) => {
45
+ if (process.env.GITHUB_ACTIONS !== 'true') {
46
+ // If not running in GitHub Actions, send args to the original console.log
47
+ originalConsoleLog(...args);
39
48
 
40
- console.info = (...args) => {
49
+ } else {
50
+ // If running in GitHub Actions, escape % and \ characters so printf doesn't throw an error
51
+ const escapedArgs = args.map(arg => {
52
+ if (typeof arg === 'string') {
53
+ return arg.replace(/%/g, '%%').replace(/\\/g, '\\\\');
54
+ }
55
+ return arg;
56
+ });
57
+ // Use the original console.log with escaped arguments
58
+ originalConsoleLog(...escapedArgs);
59
+ }
60
+ };
41
61
 
42
- if (process.env.GITHUB_ACTIONS !== 'true') {
43
- // If not running in GitHub Actions, send args to the original console.log
44
- if (console_color) originalConsoleInfo(`\x1b[33m${args}\x1b[0m`);
45
- else originalConsoleInfo(...args);
62
+ if (process.env.GITHUB_ACTIONS === 'true') {
63
+ console.log("\nRunning in GitHub Actions environment\n");
46
64
  } else {
47
- // If running in GitHub Actions, escape % and \ characters so printf doesn't throw an error
48
- const escapedArgs = args.map(arg => {
49
- if (typeof arg === 'string') {
50
- return arg.replace(/%/g, '%%').replace(/\\/g, '\\\\');
51
- }
52
- return arg;
53
- });
54
- // Use the original console.log with escaped arguments
55
- originalConsoleInfo(...escapedArgs);
65
+ console.log("\nRunning in non-GitHub Actions environment\n");
56
66
  }
57
67
 
58
- };
68
+ console.info = (...args) => {
59
69
 
60
- console.error = (...args) => {
70
+ if (process.env.GITHUB_ACTIONS !== 'true') {
71
+ // If not running in GitHub Actions, send args to the original console.log
72
+ if (console_color) originalConsoleInfo(`\x1b[33m${args}\x1b[0m`);
73
+ else originalConsoleInfo(...args);
74
+ } else {
75
+ // If running in GitHub Actions, escape % and \ characters so printf doesn't throw an error
76
+ const escapedArgs = args.map(arg => {
77
+ if (typeof arg === 'string') {
78
+ return arg.replace(/%/g, '%%').replace(/\\/g, '\\\\');
79
+ }
80
+ return arg;
81
+ });
82
+ // Use the original console.log with escaped arguments
83
+ originalConsoleInfo(...escapedArgs);
84
+ }
61
85
 
62
- if (process.env.GITHUB_ACTIONS !== 'true') {
63
- // If not running in GitHub Actions, send args to the original console.log
64
- if (console_color) originalConsoleError(`\x1b[33m${args}\x1b[0m`);
65
- else originalConsoleError(...args);
66
- } else {
86
+ };
67
87
 
68
- // If running in GitHub Actions, escape % and \ characters so printf doesn't throw an error
69
- const escapedArgs = args.map(arg => {
70
- if (typeof arg === 'string') {
71
- return arg.replace(/%/g, '%%').replace(/\\/g, '\\\\');
72
- }
73
- return arg;
74
- });
75
- // Use the original console.log with escaped arguments
76
- originalConsoleError(...escapedArgs);
77
- }
78
- };
88
+ console.error = (...args) => {
89
+
90
+ if (process.env.GITHUB_ACTIONS !== 'true') {
91
+ // If not running in GitHub Actions, send args to the original console.log
92
+ if (console_color) originalConsoleError(`\x1b[33m${args}\x1b[0m`);
93
+ else originalConsoleError(...args);
94
+ } else {
95
+
96
+ // If running in GitHub Actions, escape % and \ characters so printf doesn't throw an error
97
+ const escapedArgs = args.map(arg => {
98
+ if (typeof arg === 'string') {
99
+ return arg.replace(/%/g, '%%').replace(/\\/g, '\\\\');
100
+ }
101
+ return arg;
102
+ });
103
+ // Use the original console.log with escaped arguments
104
+ originalConsoleError(...escapedArgs);
105
+ }
106
+ };
107
+ }
79
108
 
80
109
  const getHdocPackageVersion = (packagePath) => {
81
110
  if (fs.existsSync(packagePath)) {
@@ -105,6 +134,7 @@
105
134
  let gen_exclude = false;
106
135
  let bump_type = "patch"; // To generate spellcheck exclusions for all files
107
136
  let output_links = true;
137
+ let onyx_index = false;
108
138
 
109
139
  // Get options from command args
110
140
  for (let x = 0; x < process.argv.length; x++) {
@@ -151,6 +181,10 @@
151
181
  console_color = false;
152
182
  } else if (process.argv[x].toLowerCase() === "--no-links") {
153
183
  output_links = false;
184
+ } else if (process.argv[x].toLowerCase() === "--onyx") {
185
+ onyx_index = true;
186
+ } else if (process.argv[x].toLowerCase() === "--quiet") {
187
+ // handled at startup via _quietMode
154
188
  }
155
189
  }
156
190
  source_path = hdoc.true_case_path_sync(source_path);
@@ -186,6 +220,7 @@
186
220
  gen_exclude,
187
221
  build_version,
188
222
  output_links,
223
+ onyx_index,
189
224
  );
190
225
  } else if (command.toLowerCase() === "createdocs") {
191
226
  const creator = require(path.join(__dirname, "hdoc-create.js"));
@@ -200,6 +235,7 @@
200
235
  gen_exclude,
201
236
  build_version,
202
237
  output_links,
238
+ onyx_index,
203
239
  );
204
240
  } else if (command.toLowerCase() === "stats") {
205
241
  const stats = require(path.join(__dirname, "hdoc-stats.js"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hdoc-tools",
3
- "version": "0.47.5",
3
+ "version": "0.50.0",
4
4
  "description": "Hornbill HDocBook Development Support Tool",
5
5
  "main": "hdoc.js",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "hdoc-db.js",
12
12
  "hdoc-build.js",
13
13
  "hdoc-build-db.js",
14
+ "hdoc-build-onyx.js",
14
15
  "hdoc-build-pdf.js",
15
16
  "hdoc-bump.js",
16
17
  "hdoc-create.js",
@@ -20,10 +21,12 @@
20
21
  "hdoc-serve.js",
21
22
  "hdoc-stats.js",
22
23
  "hdoc-validate.js",
24
+ "hdoc-validate-config.js",
23
25
  "hdoc-ver.js",
24
26
  "validateNodeVer.js",
25
27
  "ui",
26
28
  "custom_modules",
29
+ "schemas",
27
30
  "templates",
28
31
  "templates/init/.npmignore",
29
32
  "templates/init/gitignore",
@@ -0,0 +1,102 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "hdocbook-project.schema.json",
4
+ "title": "HDocBook Project Configuration",
5
+ "description": "Schema for hdocbook-project.json - project-level build and validation configuration",
6
+ "type": "object",
7
+ "required": ["docId"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "docId": {
11
+ "type": "string",
12
+ "description": "The document ID. Must match the book folder name and use kebab-case.",
13
+ "pattern": "^[a-z][a-z0-9-]+$"
14
+ },
15
+ "pdfGeneration": {
16
+ "type": "object",
17
+ "description": "Controls PDF generation during the book build.",
18
+ "required": ["enable"],
19
+ "additionalProperties": false,
20
+ "properties": {
21
+ "enable": {
22
+ "type": "boolean",
23
+ "description": "Whether to generate PDFs for page content during the build."
24
+ },
25
+ "exclude_paths": {
26
+ "type": "array",
27
+ "description": "Paths to exclude from PDF generation. Supports a trailing * wildcard.",
28
+ "items": { "type": "string" }
29
+ }
30
+ }
31
+ },
32
+ "validation": {
33
+ "type": "object",
34
+ "description": "Controls book validation behaviour.",
35
+ "additionalProperties": false,
36
+ "properties": {
37
+ "exclude_links": {
38
+ "type": "array",
39
+ "description": "Links to skip during link validation. Supports a trailing * wildcard.",
40
+ "items": { "type": "string" }
41
+ },
42
+ "exclude_spellcheck": {
43
+ "type": "array",
44
+ "description": "Per-document spellcheck word exclusions.",
45
+ "items": {
46
+ "type": "object",
47
+ "required": ["document_path", "words"],
48
+ "additionalProperties": false,
49
+ "properties": {
50
+ "document_path": {
51
+ "type": "string",
52
+ "description": "Root-relative path to the document, without file extension."
53
+ },
54
+ "words": {
55
+ "type": "array",
56
+ "description": "Words to exclude from the British English spellcheck for this document.",
57
+ "items": { "type": "string" }
58
+ }
59
+ }
60
+ }
61
+ },
62
+ "exclude_h1_count": {
63
+ "type": "array",
64
+ "description": "Root-relative paths of documents allowed to have more than one H1 tag.",
65
+ "items": { "type": "string" }
66
+ },
67
+ "external_link_warnings": {
68
+ "type": "boolean",
69
+ "description": "When true, external link failures are reported as warnings rather than errors."
70
+ }
71
+ }
72
+ },
73
+ "redirects": {
74
+ "type": "array",
75
+ "description": "Permanent redirects for pages that have moved or been deleted.",
76
+ "items": {
77
+ "type": "object",
78
+ "required": ["url", "code"],
79
+ "additionalProperties": false,
80
+ "properties": {
81
+ "url": {
82
+ "type": "string",
83
+ "description": "The path being redirected from."
84
+ },
85
+ "code": {
86
+ "type": "integer",
87
+ "description": "HTTP redirect code. 301 = moved permanently, 308 = permanent redirect, 410 = gone.",
88
+ "enum": [301, 308, 410]
89
+ },
90
+ "location": {
91
+ "type": "string",
92
+ "description": "Destination path for 301/308 redirects. Not applicable for 410."
93
+ },
94
+ "skip_location_validation": {
95
+ "type": "boolean",
96
+ "description": "Skip validation of the redirect destination path."
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,146 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "hdocbook.schema.json",
4
+ "title": "HDocBook Configuration",
5
+ "description": "Schema for hdocbook.json - book metadata, navigation, and publishing configuration",
6
+ "type": "object",
7
+ "required": ["docId", "title", "description", "version", "productFamily", "audience", "navigation"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "docId": {
11
+ "type": "string",
12
+ "description": "The unique document ID. Must match the folder name that contains this file, using kebab-case.",
13
+ "pattern": "^[a-z][a-z0-9-]+$"
14
+ },
15
+ "title": {
16
+ "type": "string",
17
+ "description": "The full title of the book as presented without other context."
18
+ },
19
+ "description": {
20
+ "type": "string",
21
+ "description": "A short description of the book's purpose and contents."
22
+ },
23
+ "publicSource": {
24
+ "type": "string",
25
+ "description": "URL to the public GitHub repository for this book. Omit or leave empty for private books.",
26
+ "pattern": "^https://(github\\.com|api\\.github\\.com)/"
27
+ },
28
+ "version": {
29
+ "type": "string",
30
+ "description": "Book version in X.Y.Z format. Controls automated publishing — increment to trigger a new publish.",
31
+ "pattern": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,6}$"
32
+ },
33
+ "productFamily": {
34
+ "type": "string",
35
+ "description": "The ID of the product family this book belongs to.",
36
+ "enum": [
37
+ "esp",
38
+ "com.hornbill.docmanager",
39
+ "com.hornbill.boardmanager",
40
+ "com.hornbill.customermanager",
41
+ "com.hornbill.grc",
42
+ "com.hornbill.itom",
43
+ "com.hornbill.livechat",
44
+ "com.hornbill.projectmanager",
45
+ "com.hornbill.servicemanager",
46
+ "com.hornbill.suppliermanager",
47
+ "com.hornbill.timesheetmanager",
48
+ "com.hornbill.collaboration",
49
+ "appdev",
50
+ "hdocs"
51
+ ]
52
+ },
53
+ "coverImage": {
54
+ "type": "string",
55
+ "description": "Root-relative path to a cover image used for gallery and social sharing. Optional."
56
+ },
57
+ "tags": {
58
+ "type": "array",
59
+ "description": "Tags associated with this book.",
60
+ "items": { "type": "string" }
61
+ },
62
+ "audience": {
63
+ "type": "array",
64
+ "description": "Target audiences for this book. At least one value is required.",
65
+ "minItems": 1,
66
+ "items": {
67
+ "type": "string",
68
+ "enum": [
69
+ "public",
70
+ "private",
71
+ "private.cloud",
72
+ "private.dev.platform",
73
+ "private.dev.apps",
74
+ "private.hdocs",
75
+ "private.elearning"
76
+ ]
77
+ }
78
+ },
79
+ "languages": {
80
+ "type": "array",
81
+ "description": "Language codes for the languages available in this book (e.g. 'en').",
82
+ "items": { "type": "string" }
83
+ },
84
+ "readingTime": {
85
+ "type": "number",
86
+ "description": "Total estimated reading time in minutes. Calculated and written automatically during build — do not set manually."
87
+ },
88
+ "navigation": {
89
+ "type": "object",
90
+ "description": "Defines the navigation tree displayed alongside book content.",
91
+ "required": ["items"],
92
+ "additionalProperties": false,
93
+ "properties": {
94
+ "items": {
95
+ "type": "array",
96
+ "description": "Top-level navigation items. Maximum three levels of nesting are supported.",
97
+ "minItems": 1,
98
+ "items": { "$ref": "#/$defs/navigationItem" }
99
+ }
100
+ }
101
+ },
102
+ "inline": {
103
+ "type": "array",
104
+ "description": "Inline content sources — content embedded from other books.",
105
+ "items": {
106
+ "type": "object",
107
+ "required": ["title", "link"],
108
+ "additionalProperties": false,
109
+ "properties": {
110
+ "title": { "type": "string" },
111
+ "link": { "type": "string" }
112
+ }
113
+ }
114
+ }
115
+ },
116
+ "$defs": {
117
+ "navigationItem": {
118
+ "type": "object",
119
+ "required": ["text"],
120
+ "additionalProperties": false,
121
+ "properties": {
122
+ "text": {
123
+ "type": "string",
124
+ "description": "Display text for the navigation item."
125
+ },
126
+ "link": {
127
+ "type": "string",
128
+ "description": "Path to the content page. Ignored when 'items' is present."
129
+ },
130
+ "expand": {
131
+ "type": "boolean",
132
+ "description": "Whether this item is expanded by default."
133
+ },
134
+ "draft": {
135
+ "type": "boolean",
136
+ "description": "When true, this item is excluded from published output."
137
+ },
138
+ "items": {
139
+ "type": "array",
140
+ "description": "Child navigation items.",
141
+ "items": { "$ref": "#/$defs/navigationItem" }
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }