book-kit 0.0.0-stage → 0.6.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.
Files changed (139) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +72 -0
  3. package/README.md +251 -2
  4. package/bin/book-kit.js +115 -0
  5. package/commands/_emoji_io.py +32 -0
  6. package/commands/audit-chapter-lengths.js +244 -0
  7. package/commands/check-epub-size.js +68 -0
  8. package/commands/check-pdf-page-count.js +162 -0
  9. package/commands/composite-kindle-emoji.py +85 -0
  10. package/commands/composite-print-emoji.py +98 -0
  11. package/commands/conventions.js +76 -0
  12. package/commands/convert-pdfx.sh +194 -0
  13. package/commands/download-icc-profile.sh +105 -0
  14. package/commands/fonts.py +212 -0
  15. package/commands/generate-reference-docx.py +344 -0
  16. package/commands/kindle-fonts.js +173 -0
  17. package/commands/links-to-footnotes.js +366 -0
  18. package/commands/markdownlint.js +25 -0
  19. package/commands/minify-css.js +43 -0
  20. package/commands/optimize-images.js +73 -0
  21. package/commands/parity.sh +150 -0
  22. package/commands/postprocess-epub.js +541 -0
  23. package/commands/prep-print-src.js +77 -0
  24. package/commands/prose.js +38 -0
  25. package/commands/release-files.js +89 -0
  26. package/commands/reorder-print-frontmatter.js +79 -0
  27. package/commands/strip-page-rules.js +1168 -0
  28. package/commands/strip-property-rules.js +93 -0
  29. package/commands/update-key-takeaways.js +314 -0
  30. package/commands/validate.js +283 -0
  31. package/configs/markdownlint-cli2.jsonc +98 -0
  32. package/configs/textlintrc.json +22 -0
  33. package/css/theme/01-tokens.css +83 -0
  34. package/css/theme/02-fonts.css +79 -0
  35. package/css/theme/03-prose.css +53 -0
  36. package/css/theme/04-base.css +226 -0
  37. package/css/theme/05-components.css +546 -0
  38. package/css/theme/06-utilities.css +40 -0
  39. package/css/theme/07-ereader.css +245 -0
  40. package/css/theme/08-epub.css +865 -0
  41. package/css/theme/09-back-matter.css +47 -0
  42. package/css/theme/10-dark.css +302 -0
  43. package/css/theme/11-print.css +1229 -0
  44. package/css/theme.css +38 -0
  45. package/defaults/book.config.yml +318 -0
  46. package/defaults/pandoc-base.yml +19 -0
  47. package/defaults/pandoc-docx.yml +6 -0
  48. package/defaults/pandoc-epub-fonts.yml +6 -0
  49. package/defaults/pandoc-epub.yml +15 -0
  50. package/defaults/pandoc-kindle.yml +6 -0
  51. package/emoji/1f382.png +0 -0
  52. package/emoji/1f389.png +0 -0
  53. package/emoji/1f440.png +0 -0
  54. package/emoji/1f44d.png +0 -0
  55. package/emoji/1f44f.png +0 -0
  56. package/emoji/1f454.png +0 -0
  57. package/emoji/1f4a1.png +0 -0
  58. package/emoji/1f4bb.png +0 -0
  59. package/emoji/1f602.png +0 -0
  60. package/emoji/1f603.png +0 -0
  61. package/emoji/1f604.png +0 -0
  62. package/emoji/1f60a.png +0 -0
  63. package/emoji/1f60f.png +0 -0
  64. package/emoji/1f64c.png +0 -0
  65. package/emoji/1f64f.png +0 -0
  66. package/emoji/1f914.png +0 -0
  67. package/emoji/1f923.png +0 -0
  68. package/emoji/2714.png +0 -0
  69. package/emoji/274c.png +0 -0
  70. package/filters/add-div-titles.lua +196 -0
  71. package/filters/arc-notice.lua +36 -0
  72. package/filters/body-emoji-images.lua +48 -0
  73. package/filters/callout-classes.lua +40 -0
  74. package/filters/lib/book_kit.lua +110 -0
  75. package/filters/revision.lua +17 -0
  76. package/filters/strip-comments.lua +40 -0
  77. package/filters/strip-emoji-kindle.lua +134 -0
  78. package/fonts/LICENSE-source-code-pro.md +93 -0
  79. package/fonts/LICENSE-source-sans.md +93 -0
  80. package/fonts/LICENSE-source-serif.md +93 -0
  81. package/fonts/recipe.json +41 -0
  82. package/just/book-kit.just +229 -0
  83. package/lib/acronyms.js +47 -0
  84. package/lib/callouts.js +17 -0
  85. package/lib/config.js +211 -0
  86. package/lib/is-main.js +21 -0
  87. package/lib/lint.js +148 -0
  88. package/lib/pandoc.js +148 -0
  89. package/lib/paragraphs.js +173 -0
  90. package/lib/revision.js +35 -0
  91. package/lib/structure.js +39 -0
  92. package/lib/textlint-github-formatter.js +58 -0
  93. package/lib/validate.js +202 -0
  94. package/package.json +98 -4
  95. package/presets/business-nonfiction/CONVENTIONS.md +35 -0
  96. package/presets/business-nonfiction/preset.yml +128 -0
  97. package/profiles/pdfx-def.ps +42 -0
  98. package/requirements.txt +14 -0
  99. package/rules/no-ai-like-patterns.js +209 -0
  100. package/rules/no-assumptive.js +76 -0
  101. package/rules/no-bare-acronyms.js +129 -0
  102. package/rules/no-cliches.js +544 -0
  103. package/rules/no-cultural-idioms.js +422 -0
  104. package/rules/no-vague-intensifiers.js +94 -0
  105. package/templates/template-epub.html +79 -0
  106. package/templates/template.html +73 -0
  107. package/toolchain.json +42 -0
  108. package/validators/acronym-expansion.js +94 -0
  109. package/validators/bold-keywords.js +56 -0
  110. package/validators/callout-balance.js +74 -0
  111. package/validators/callout-divs.js +66 -0
  112. package/validators/callout-labels.js +102 -0
  113. package/validators/chapter-lengths.js +14 -0
  114. package/validators/chapter.js +57 -0
  115. package/validators/cross-references.js +45 -0
  116. package/validators/doubled-words.js +76 -0
  117. package/validators/duplicate-headings.js +52 -0
  118. package/validators/em-dashes.js +73 -0
  119. package/validators/escaped-smallcaps.js +46 -0
  120. package/validators/footnotes.js +78 -0
  121. package/validators/heading-hierarchy.js +58 -0
  122. package/validators/hypothetical-hooks.js +103 -0
  123. package/validators/index-integrity.js +28 -0
  124. package/validators/index.js +73 -0
  125. package/validators/list-punctuation.js +123 -0
  126. package/validators/min-opener-length.js +87 -0
  127. package/validators/no-bare-url-link-text.js +38 -0
  128. package/validators/no-todo-markers.js +49 -0
  129. package/validators/no-transition-todos.js +34 -0
  130. package/validators/number-range-dashes.js +72 -0
  131. package/validators/paragraph-length.js +62 -0
  132. package/validators/quote-punctuation.js +85 -0
  133. package/validators/section-intro-final-lead.js +90 -0
  134. package/validators/section-intro-has-lead.js +41 -0
  135. package/validators/section-intro-no-tldr.js +37 -0
  136. package/validators/section-intros.js +33 -0
  137. package/validators/sentence-starters.js +132 -0
  138. package/validators/smallcaps.js +67 -0
  139. package/validators/tldr-length.js +58 -0
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Strip CSS `@property` at-rules from the inlined stylesheet of the print HTML
5
+ * before it reaches WeasyPrint.
6
+ *
7
+ * Why: Tailwind v4 emits many `@property --tw-*` declarations (the CSS
8
+ * Properties and Values API) to type its utility custom properties. WeasyPrint
9
+ * doesn't implement that API, so it logs a `WARNING: Unknown rule <AtRule
10
+ * @property …>` for every one — hundreds of lines that bury any real warning.
11
+ * The rules only add type/inheritance/default metadata to custom properties;
12
+ * the properties still resolve as plain `var()` values without them, and the
13
+ * static print output doesn't depend on that metadata. Removing them is purely
14
+ * cosmetic for the rendered PDF and clears the log noise.
15
+ *
16
+ * Print-only: runs against the standalone print HTML in the `pdf` recipe. The
17
+ * HTML build keeps `@property` (browsers support it); the EPUB/Kindle builds
18
+ * already drop it via strip-page-rules.js.
19
+ *
20
+ * `@property` bodies contain only declarations (no nested rules), so the blocks
21
+ * are flat — but this still brace-counts to stay correct if that ever changes.
22
+ *
23
+ * Usage: book-kit strip-property-rules <file.html>
24
+ */
25
+
26
+ import { readFileSync, writeFileSync } from 'node:fs';
27
+
28
+ const file = process.argv[2];
29
+ if (!file) {
30
+ console.error('Usage: book-kit strip-property-rules <file.html>');
31
+ process.exit(1);
32
+ }
33
+
34
+ /**
35
+ * Remove every `@property …{ … }` block from a CSS string, counting braces so
36
+ * the match ends at the rule's own closing brace.
37
+ * @param {string} css
38
+ * @returns {{ css: string, removed: number }}
39
+ */
40
+ function stripPropertyRules(css) {
41
+ let out = '';
42
+ let removed = 0;
43
+ let i = 0;
44
+
45
+ while (i < css.length) {
46
+ const at = css.indexOf('@property', i);
47
+ if (at === -1) {
48
+ out += css.slice(i);
49
+ break;
50
+ }
51
+
52
+ // Copy everything up to the at-rule, then find its opening brace.
53
+ out += css.slice(i, at);
54
+ const open = css.indexOf('{', at);
55
+ if (open === -1) {
56
+ // Malformed/truncated rule — keep the rest verbatim and stop.
57
+ out += css.slice(at);
58
+ break;
59
+ }
60
+
61
+ // Walk to the matching close brace.
62
+ let depth = 0;
63
+ let j = open;
64
+ for (; j < css.length; j++) {
65
+ if (css[j] === '{') depth++;
66
+ else if (css[j] === '}' && --depth === 0) break;
67
+ }
68
+
69
+ removed++;
70
+ i = j + 1; // resume after the closing brace, dropping the block
71
+ }
72
+
73
+ return { css: out, removed };
74
+ }
75
+
76
+ const html = readFileSync(file, 'utf8');
77
+
78
+ // Operate only on inlined <style> blocks (pandoc's embed-resources output).
79
+ let total = 0;
80
+ const updated = html.replace(
81
+ /(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi,
82
+ (_match, openTag, body, closeTag) => {
83
+ const { css, removed } = stripPropertyRules(body);
84
+ total += removed;
85
+ return openTag + css + closeTag;
86
+ },
87
+ );
88
+
89
+ if (total > 0) {
90
+ writeFileSync(file, updated);
91
+ }
92
+
93
+ console.log(`Stripped ${total} @property rule(s) from ${file}`);
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Update key takeaways by combining TL;DRs from chapters into section key
5
+ * takeaway files: for each `# Section` comment in index.yml, collect its
6
+ * chapters' TL;DRs into src/<section><structure.takeaways_suffix>.md.
7
+ * Hand-written text around the generated block is preserved.
8
+ *
9
+ * Usage: book-kit update-key-takeaways
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { loadConfigOrDefaults } from '../lib/config.js';
16
+ import { isMain } from '../lib/is-main.js';
17
+
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = path.dirname(__filename);
20
+
21
+ const config = loadConfigOrDefaults();
22
+
23
+ // Sections that don't get a takeaways chapter (structure.takeaways_skip_sections)
24
+ const SKIP_SECTIONS = config.structure.takeaways_skip_sections;
25
+ const TAKEAWAYS_SUFFIX = config.structure.takeaways_suffix;
26
+
27
+ /**
28
+ * Parse sections from index.yml content
29
+ * @param {string} indexContent - Raw index.yml file content
30
+ * @returns {{sections: string[], sectionFiles: Object.<string, string[]>}}
31
+ */
32
+ function parseSections(indexContent) {
33
+ const sections = [];
34
+ const sectionFiles = {};
35
+ let currentSection = null;
36
+
37
+ for (const line of indexContent.split('\n')) {
38
+ // Match section comment lines
39
+ const commentMatch = line.match(/^\s*#\s+(.+)$/);
40
+ if (commentMatch) {
41
+ const sectionName = commentMatch[1].trim();
42
+ // Skip sections we don't want to process
43
+ if (!SKIP_SECTIONS.includes(sectionName)) {
44
+ currentSection = sectionName;
45
+ if (!sections.includes(currentSection)) {
46
+ sections.push(currentSection);
47
+ sectionFiles[currentSection] = [];
48
+ }
49
+ } else {
50
+ currentSection = null;
51
+ }
52
+ continue;
53
+ }
54
+
55
+ // Match file entries under current section
56
+ const fileMatch = line.match(/^\s*-\s+(.+\.md)\s*$/);
57
+ if (fileMatch && currentSection) {
58
+ const filePath = fileMatch[1].trim();
59
+ sectionFiles[currentSection].push(filePath);
60
+ }
61
+ }
62
+
63
+ return { sections, sectionFiles };
64
+ }
65
+
66
+ /**
67
+ * Extract TL;DR content from chapter file
68
+ * @param {string} filePath - Path to chapter file
69
+ * @returns {{text: string, disabledRules: string[]}|null} TL;DR content and disabled textlint rules, or null
70
+ */
71
+ function extractTldr(filePath) {
72
+ if (!fs.existsSync(filePath)) {
73
+ return null;
74
+ }
75
+
76
+ const content = fs.readFileSync(filePath, 'utf8');
77
+
78
+ if (!content.includes('.tldr')) {
79
+ return null;
80
+ }
81
+
82
+ // Extract content from tldr div block
83
+ const tldrMatch = content.match(/::: \{\.tldr[^}]*\}\n(.*?)\n:::/s);
84
+ if (tldrMatch) {
85
+ const text = tldrMatch[1].trim();
86
+
87
+ // Collect file-level textlint-disable rules so intentional
88
+ // rule violations (e.g. clichés used as examples) stay suppressed
89
+ // when the TL;DR is embedded in a key-takeaways file.
90
+ const disabledRules = [];
91
+ const disableMatch = content.match(/^<!--\s*textlint-disable\s+([^>]+?)(?:\s*)-->/m);
92
+ if (disableMatch) {
93
+ disabledRules.push(...disableMatch[1].trim().split(/,\s*/));
94
+ }
95
+
96
+ return { text, disabledRules };
97
+ }
98
+
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * Convert section name to filename-safe format
104
+ * @param {string} sectionName - Section name
105
+ * @returns {string} Filename-safe section name
106
+ */
107
+ function sectionToFilename(sectionName) {
108
+ return sectionName
109
+ .toLowerCase()
110
+ .replace(/[^a-z0-9\s-]/g, '') // Remove special characters
111
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
112
+ .replace(/-+/g, '-') // Replace multiple hyphens with single
113
+ .trim();
114
+ }
115
+
116
+ // Phrases that work inside a chapter's TL;DR ("this chapter shows you…",
117
+ // "read on to learn…") but dangle when the TL;DR is lifted into a section's
118
+ // key takeaways list, where there is no "this chapter" to point at.
119
+ const DEICTIC_PATTERNS = [
120
+ /\bthis chapter\b/i,
121
+ /\bread on\b/i,
122
+ /\bkeep reading\b/i,
123
+ /\bthe pages? that follows?\b/i,
124
+ /\bwhat follows\b/i
125
+ ];
126
+
127
+ /**
128
+ * Strip sentences that reference the chapter itself ("This chapter shows
129
+ * you…", "Read on to learn…") so the remaining text stands alone as a key
130
+ * takeaway. TL;DRs keep their hook sentence in place; only the generated
131
+ * takeaways drop it.
132
+ * @param {string} text - TL;DR text
133
+ * @returns {string} Text with chapter-deictic sentences removed
134
+ */
135
+ function stripChapterReferences(text) {
136
+ if (!DEICTIC_PATTERNS.some((pattern) => pattern.test(text))) {
137
+ return text;
138
+ }
139
+
140
+ const sentences = text.split(/(?<=[.!?])\s+/);
141
+ const kept = sentences.filter(
142
+ (sentence) => !DEICTIC_PATTERNS.some((pattern) => pattern.test(sentence))
143
+ );
144
+
145
+ // If every sentence references the chapter, keep the original rather than
146
+ // emit an empty takeaway — but flag it so the TL;DR gets fixed upstream.
147
+ if (kept.length === 0) {
148
+ console.warn(`Warning: TL;DR is entirely chapter-deictic; keeping as-is: "${text.slice(0, 80)}…"`);
149
+ return text;
150
+ }
151
+
152
+ const result = kept.join(' ');
153
+ if (DEICTIC_PATTERNS.some((pattern) => pattern.test(result))) {
154
+ console.warn(`Warning: chapter reference survived stripping: "${result.slice(0, 80)}…"`);
155
+ }
156
+ return result;
157
+ }
158
+
159
+ /**
160
+ * Generate key takeaways content
161
+ * @param {Array<{text: string, disabledRules: string[]}>} tldrs - Array of TL;DR objects
162
+ * @returns {string} Formatted key takeaways markdown
163
+ */
164
+ function generateKeyTakeaways(tldrs) {
165
+ // Collect all disabled textlint rules across TL;DRs in this section
166
+ const allDisabledRules = [...new Set(tldrs.flatMap(t => t.disabledRules))];
167
+
168
+ let contents = '';
169
+ if (allDisabledRules.length > 0) {
170
+ contents += `<!-- textlint-disable ${allDisabledRules.join(', ')} -->\n\n`;
171
+ }
172
+ contents += '::: {.takeaways custom-style="takeaways"}\n\n### Key takeaways\n\n';
173
+ contents += tldrs.map(t => `- ${stripChapterReferences(t.text)}`).join('\n');
174
+ contents += '\n\n:::\n';
175
+ return contents;
176
+ }
177
+
178
+ /**
179
+ * Collect TL;DRs for each section
180
+ * @param {string[]} sections - Array of section names
181
+ * @param {Object.<string, string[]>} sectionFiles - Map of section name to file paths
182
+ * @returns {Object.<string, Array<{text: string, disabledRules: string[]}>>} Map of section name to TL;DRs
183
+ */
184
+ function collectTldrs(sections, sectionFiles) {
185
+ const sectionTldrs = {};
186
+
187
+ for (const section of sections) {
188
+ sectionTldrs[section] = [];
189
+
190
+ const files = sectionFiles[section] || [];
191
+ for (const chapter of files) {
192
+ const tldr = extractTldr(chapter);
193
+ if (tldr) {
194
+ sectionTldrs[section].push(tldr);
195
+ } else if (fs.existsSync(chapter)) {
196
+ console.log(`No TL;DR in ${chapter}`);
197
+ }
198
+ }
199
+ }
200
+
201
+ return sectionTldrs;
202
+ }
203
+
204
+ /**
205
+ * Extract hand-written content before and after the takeaways block.
206
+ * Returns { before, after } strings, either of which may be empty.
207
+ * @param {string} filePath - Path to existing key takeaways file
208
+ * @returns {{before: string, after: string}} Content surrounding the takeaways block
209
+ */
210
+ function extractBridgingContent(filePath) {
211
+ if (!fs.existsSync(filePath)) {
212
+ return { before: '', after: '' };
213
+ }
214
+
215
+ const content = fs.readFileSync(filePath, 'utf8');
216
+
217
+ // Find the takeaways block boundaries, accounting for optional
218
+ // textlint-disable comments that precede it.
219
+ const takeawaysStart = content.search(/(?:<!--\s*textlint-disable[^>]*-->\s*\n*)?::: \{\.takeaways/);
220
+ const takeawaysEnd = content.indexOf('\n:::\n', takeawaysStart);
221
+
222
+ if (takeawaysStart === -1 || takeawaysEnd === -1) {
223
+ return { before: '', after: '' };
224
+ }
225
+
226
+ // "before" is everything up to the takeaways block (trimmed)
227
+ let before = content.slice(0, takeawaysStart).trim();
228
+
229
+ // "after" is everything following the closing ::: and optional
230
+ // textlint-enable comments (trimmed)
231
+ let after = content.slice(takeawaysEnd + '\n:::\n'.length).trim();
232
+ // Strip leading textlint-enable comment from "after" since the
233
+ // generated block already handles it via allDisabledRules
234
+ after = after.replace(/^<!--\s*textlint-enable[^>]*-->\s*\n*/, '').trim();
235
+
236
+ return { before, after };
237
+ }
238
+
239
+ /**
240
+ * Generate key takeaways files for each section
241
+ * @param {Object.<string, string[]>} sectionTldrs - Map of section name to TL;DRs
242
+ * @param {string} outputDir - Output directory for generated files
243
+ * @returns {string[]} Array of generated file paths
244
+ */
245
+ function generateFiles(sectionTldrs, outputDir = config.paths.src) {
246
+ const generatedFiles = [];
247
+
248
+ for (const [section, tldrs] of Object.entries(sectionTldrs)) {
249
+ if (tldrs.length === 0) {
250
+ continue;
251
+ }
252
+
253
+ const filename = sectionToFilename(section);
254
+ const filePath = path.join(outputDir, `${filename}${TAKEAWAYS_SUFFIX}.md`);
255
+
256
+ // Preserve any hand-written bridging content around the block
257
+ const { before, after } = extractBridgingContent(filePath);
258
+ const takeawaysBlock = generateKeyTakeaways(tldrs);
259
+
260
+ let contents = '';
261
+ if (before) {
262
+ contents += before + '\n\n';
263
+ }
264
+ contents += takeawaysBlock;
265
+ if (after) {
266
+ contents += '\n' + after + '\n';
267
+ }
268
+
269
+ fs.writeFileSync(filePath, contents);
270
+ console.log(`Generated ${filePath} with ${tldrs.length} takeaways`);
271
+ generatedFiles.push(filePath);
272
+ }
273
+
274
+ return generatedFiles;
275
+ }
276
+
277
+ /**
278
+ * Main function to update key takeaways by combining TL;DRs
279
+ * @param {string} indexPath - Path to index.yml file
280
+ * @param {string} outputDir - Output directory for generated files
281
+ * @returns {string[]} Array of generated file paths
282
+ */
283
+ function updateKeyTakeaways(indexPath = config.paths.index, outputDir = config.paths.src) {
284
+ // Read the index.yml file
285
+ const indexContent = fs.readFileSync(indexPath, 'utf8');
286
+
287
+ // Parse sections from comments in index.yml
288
+ const { sections, sectionFiles } = parseSections(indexContent);
289
+
290
+ // Collect TL;DRs for each section
291
+ const sectionTldrs = collectTldrs(sections, sectionFiles);
292
+
293
+ // Generate key takeaways files
294
+ return generateFiles(sectionTldrs, outputDir);
295
+ }
296
+
297
+ // Export for testing
298
+ export {
299
+ parseSections,
300
+ extractTldr,
301
+ extractBridgingContent,
302
+ stripChapterReferences,
303
+ sectionToFilename,
304
+ generateKeyTakeaways,
305
+ collectTldrs,
306
+ generateFiles,
307
+ updateKeyTakeaways,
308
+ SKIP_SECTIONS
309
+ };
310
+
311
+ // Run if executed directly
312
+ if (isMain(import.meta.url)) {
313
+ updateKeyTakeaways();
314
+ }
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env node
2
+
3
+ // book-kit validate: run the book's validators and print what they find.
4
+ //
5
+ // Validators come from three places, run in this order:
6
+ // 1. the kit's built-ins (validators/, in BUILTIN_VALIDATORS order)
7
+ // 2. preset validators: every *.js file in <preset>/validators/, for each
8
+ // preset the book extends, in merge order, files sorted by name
9
+ // 3. the book's plugins (validators.plugins in book.config.yml)
10
+ //
11
+ // All three share one shape: an ES module whose default export is
12
+ // { name, tags?, defaults?, run(ctx) }, where run (sync or async) returns
13
+ // { errors, warnings }. ctx has the resolved config, the index.yml file list
14
+ // (indexFiles), the chapter files under paths.src (chapters), the validator's
15
+ // options (its defaults deep-merged with validators.options.<name>), and its
16
+ // intentional exceptions (allow, a Set from validators.allow.<name>).
17
+ //
18
+ // book-kit validate run every validator
19
+ // book-kit validate --list print the built-in validator names
20
+ // book-kit validate --list --verbose name, severity, tags, and source of every validator
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { createRequire } from 'node:module';
25
+ import { pathToFileURL } from 'node:url';
26
+ import { isMain } from '../lib/is-main.js';
27
+ import { config, getChapters, getIndexFiles, optionsFor } from '../lib/validate.js';
28
+ import { BUILTIN_VALIDATORS } from '../validators/index.js';
29
+
30
+ /**
31
+ * The ctx a validator's run() gets.
32
+ * @param {{name: string, defaults?: object}} validator
33
+ * @param {{config: object, indexFiles: string[], chapters: string[]}} base
34
+ * @returns {{config: object, indexFiles: string[], chapters: string[], options: object, allow: Set<string>}}
35
+ */
36
+ function contextFor(validator, base) {
37
+ return {
38
+ ...base,
39
+ options: optionsFor(validator.name, validator.defaults ?? {}, base.config),
40
+ allow: new Set(base.config.validators.allow?.[validator.name] ?? []),
41
+ };
42
+ }
43
+
44
+ // The built-ins other than `chapter`, as [name, fn] pairs where fn takes the
45
+ // index.yml file list. Kept for callers of the pre-module API.
46
+ const VALIDATORS = BUILTIN_VALIDATORS
47
+ .filter((validator) => validator.name !== 'chapter')
48
+ .map((validator) => [
49
+ validator.name,
50
+ (indexFiles) => validator.run(contextFor(validator, { config, indexFiles, chapters: getChapters() })),
51
+ ]);
52
+
53
+ /**
54
+ * Apply a validator's configured severity to its results. Severity per name
55
+ * comes from validators.severity in book.config.yml: "error" promotes
56
+ * warnings to errors, "warn" demotes errors to warnings, and "on" (or no
57
+ * entry) passes results through as written. ("off" skips the validator
58
+ * before it runs.)
59
+ * @param {string} name - Validator name (a validators.severity key)
60
+ * @param {{errors?: string[], warnings?: string[]}} result
61
+ * @param {object} [cfg] - Resolved config
62
+ * @returns {{errors: string[], warnings: string[]}}
63
+ */
64
+ function applySeverity(name, { errors = [], warnings = [] }, cfg = config) {
65
+ const severity = cfg.validators.severity?.[name];
66
+ if (severity === 'error') return { errors: [...errors, ...warnings], warnings: [] };
67
+ if (severity === 'warn') return { errors: [], warnings: [...errors, ...warnings] };
68
+ return { errors, warnings };
69
+ }
70
+
71
+ /**
72
+ * Import a validator module and check its shape.
73
+ * @param {string} file - Absolute path to the module
74
+ * @param {string} what - How to name it in an error
75
+ */
76
+ async function importValidator(file, what) {
77
+ const mod = await import(pathToFileURL(file).href);
78
+ const validator = mod.default;
79
+ if (!validator?.name || typeof validator.run !== 'function') {
80
+ throw new Error(`${what} must export default { name, run(ctx) }`);
81
+ }
82
+ return validator;
83
+ }
84
+
85
+ /**
86
+ * Where a validators.plugins entry lives. A path (starting with "." or "/",
87
+ * or ending in .js or .mjs) is relative to the book root; anything else is an
88
+ * npm package, resolved from the book root as Node would.
89
+ * @param {string} spec - The validators.plugins entry
90
+ * @param {string} root - Book root
91
+ * @returns {string} Absolute path to the module
92
+ */
93
+ function resolvePlugin(spec, root) {
94
+ if (spec.startsWith('.') || spec.startsWith('/') || /\.m?js$/.test(spec)) {
95
+ return path.resolve(root, spec);
96
+ }
97
+ try {
98
+ return createRequire(path.join(root, 'package.json')).resolve(spec);
99
+ } catch (error) {
100
+ if (error.code !== 'MODULE_NOT_FOUND') throw error;
101
+ throw new Error(
102
+ `Validator plugin "${spec}" isn't installed in the book (npm install ${spec}), ` +
103
+ `or use a path like ./validators/${spec}.js`,
104
+ { cause: error },
105
+ );
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Load the validators shipped by the presets the book extends: every *.js
111
+ * file in <preset>/validators/, presets in merge order, files sorted.
112
+ * @param {object} [cfg] - Resolved config (its non-enumerable `presets`)
113
+ * @returns {Promise<Array<{validator: object, source: string}>>}
114
+ */
115
+ async function loadPresetValidators(cfg = config) {
116
+ const entries = [];
117
+ for (const { name, dir } of cfg.presets ?? []) {
118
+ const validatorsDir = path.join(dir, 'validators');
119
+ if (!fs.existsSync(validatorsDir)) continue;
120
+ for (const file of fs.readdirSync(validatorsDir).filter((f) => f.endsWith('.js')).sort()) {
121
+ const validator = await importValidator(path.join(validatorsDir, file), `Validator ${file} in preset ${name}`);
122
+ entries.push({ validator, source: `preset ${name}` });
123
+ }
124
+ }
125
+ return entries;
126
+ }
127
+
128
+ /**
129
+ * Load the book's own validators (validators.plugins).
130
+ * @param {object} [cfg] - Resolved config
131
+ * @returns {Promise<Array<{validator: object, source: string}>>}
132
+ */
133
+ async function loadPlugins(cfg = config) {
134
+ const entries = [];
135
+ for (const spec of cfg.validators.plugins ?? []) {
136
+ const validator = await importValidator(resolvePlugin(spec, cfg.root), `Validator plugin ${spec}`);
137
+ entries.push({ validator, source: `plugin ${spec}` });
138
+ }
139
+ return entries;
140
+ }
141
+
142
+ /**
143
+ * Every validator in run order: built-ins, preset validators, plugins.
144
+ * @param {{plugins?: object[], cfg?: object}} [options] - Injected plugin
145
+ * objects replace validators.plugins (for tests)
146
+ * @returns {Promise<Array<{validator: object, source: string}>>}
147
+ */
148
+ async function allValidators({ plugins, cfg = config } = {}) {
149
+ return [
150
+ ...BUILTIN_VALIDATORS.map((validator) => ({ validator, source: 'kit' })),
151
+ ...(await loadPresetValidators(cfg)),
152
+ ...(plugins ? plugins.map((validator) => ({ validator, source: 'plugin' })) : await loadPlugins(cfg)),
153
+ ];
154
+ }
155
+
156
+ /**
157
+ * Run every validator and print the results.
158
+ * @param {{plugins?: Array<{name: string, run: Function}>}} [options]
159
+ * @returns {Promise<number>} Exit status: 1 if any errors, else 0
160
+ */
161
+ async function runValidation({ plugins } = {}) {
162
+ const chapters = getChapters();
163
+ const indexFiles = getIndexFiles();
164
+ const validators = await allValidators({ plugins });
165
+
166
+ const allErrors = [];
167
+ const allWarnings = [];
168
+ const report = ({ errors, warnings }) => {
169
+ for (const error of errors) {
170
+ console.log(error);
171
+ allErrors.push(error);
172
+ }
173
+ for (const warning of warnings) {
174
+ console.log(`Warning: ${warning}`);
175
+ allWarnings.push(warning);
176
+ }
177
+ };
178
+
179
+ for (const { validator } of validators) {
180
+ if (config.validators.severity?.[validator.name] === 'off') continue;
181
+ const ctx = contextFor(validator, { config, indexFiles, chapters });
182
+ report(applySeverity(validator.name, await validator.run(ctx)));
183
+ }
184
+
185
+ if (allErrors.length === 0) {
186
+ console.log('✓ All chapters validated successfully');
187
+ } else {
188
+ console.log(`\n✗ Found ${allErrors.length} error(s)`);
189
+ }
190
+
191
+ if (allWarnings.length > 0) {
192
+ console.log(`ℹ ${allWarnings.length} warning(s)`);
193
+ }
194
+
195
+ return allErrors.length > 0 ? 1 : 0;
196
+ }
197
+
198
+ /**
199
+ * The `--list` output. Plain: one built-in name per line. Verbose: every
200
+ * validator (presets' and plugins' too) in aligned columns of name,
201
+ * effective severity (error, warn, off, or "default" for on or unset),
202
+ * tags, and source (kit, preset <name>, or plugin <spec>).
203
+ * @param {{verbose?: boolean, cfg?: object}} [options]
204
+ * @returns {Promise<string>}
205
+ */
206
+ async function listValidators({ verbose = false, cfg = config } = {}) {
207
+ if (!verbose) return BUILTIN_VALIDATORS.map((validator) => validator.name).join('\n');
208
+ const rows = (await allValidators({ cfg })).map(({ validator, source }) => {
209
+ const severity = cfg.validators.severity?.[validator.name];
210
+ return [
211
+ validator.name,
212
+ ['error', 'warn', 'off'].includes(severity) ? severity : 'default',
213
+ (validator.tags ?? []).join(',') || '-',
214
+ source,
215
+ ];
216
+ });
217
+ const widths = [0, 1, 2].map((i) => Math.max(...rows.map((row) => row[i].length)));
218
+ return rows.map((row) => row.map((cell, i) => (i < 3 ? cell.padEnd(widths[i]) : cell)).join(' ')).join('\n');
219
+ }
220
+
221
+ // Built-in validator functions, re-exported for tests and older callers.
222
+ export { validateChapter } from '../validators/chapter.js';
223
+ export { validateSectionIntros } from '../validators/section-intros.js';
224
+ export { validateSectionIntroNoTldr } from '../validators/section-intro-no-tldr.js';
225
+ export { validateSectionIntroHasLead } from '../validators/section-intro-has-lead.js';
226
+ export { validateSectionIntroFinalLead } from '../validators/section-intro-final-lead.js';
227
+ export { validateNoTransitionTodos } from '../validators/no-transition-todos.js';
228
+ export { validateNoTodoMarkers } from '../validators/no-todo-markers.js';
229
+ export { validateMinOpenerLength } from '../validators/min-opener-length.js';
230
+ export { validateNoBareUrlLinkText } from '../validators/no-bare-url-link-text.js';
231
+ export { validateCrossReferences } from '../validators/cross-references.js';
232
+ export { validateIndexIntegrity } from '../validators/index-integrity.js';
233
+ export { validateSmallcaps } from '../validators/smallcaps.js';
234
+ export { validateEscapedSmallcaps } from '../validators/escaped-smallcaps.js';
235
+ export { validateCalloutBalance } from '../validators/callout-balance.js';
236
+ export { validateTldrLength } from '../validators/tldr-length.js';
237
+ export { validateDuplicateHeadings } from '../validators/duplicate-headings.js';
238
+ export { validateAcronymExpansion } from '../validators/acronym-expansion.js';
239
+ export { validateEmDashes } from '../validators/em-dashes.js';
240
+ export { validateNumberRangeDashes } from '../validators/number-range-dashes.js';
241
+ export { validateQuotePunctuation } from '../validators/quote-punctuation.js';
242
+ export { validateDoubledWords } from '../validators/doubled-words.js';
243
+ export { validateCalloutDivs } from '../validators/callout-divs.js';
244
+ export { validateRoleCalloutLabels } from '../validators/callout-labels.js';
245
+ export { validateFootnotes } from '../validators/footnotes.js';
246
+ export { validateHeadingHierarchy } from '../validators/heading-hierarchy.js';
247
+ export { validateSentenceStarters } from '../validators/sentence-starters.js';
248
+ export { validateListPunctuation } from '../validators/list-punctuation.js';
249
+ export { validateBoldKeywords } from '../validators/bold-keywords.js';
250
+ export { validateHypotheticalHooks } from '../validators/hypothetical-hooks.js';
251
+ export { validateParagraphLength } from '../validators/paragraph-length.js';
252
+ export {
253
+ getChapters,
254
+ getIndexFiles,
255
+ getSectionIntros,
256
+ isExcluded,
257
+ headingToAnchor,
258
+ collectAllAnchors,
259
+ EXCLUDE_PATTERNS,
260
+ KNOWN_ACRONYMS,
261
+ UNIVERSALLY_KNOWN_ACRONYMS,
262
+ } from '../lib/validate.js';
263
+ export { BUILTIN_VALIDATORS };
264
+ export {
265
+ runValidation,
266
+ applySeverity,
267
+ listValidators,
268
+ loadPlugins,
269
+ loadPresetValidators,
270
+ resolvePlugin,
271
+ VALIDATORS,
272
+ };
273
+
274
+ // Run if executed directly
275
+ if (isMain(import.meta.url)) {
276
+ if (process.argv.includes('--list')) {
277
+ const verbose = process.argv.includes('--verbose') || process.argv.includes('-v');
278
+ console.log(await listValidators({ verbose }));
279
+ process.exit(0);
280
+ }
281
+ const exitCode = await runValidation();
282
+ process.exit(exitCode);
283
+ }