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.
- package/LICENSE +21 -0
- package/NOTICE.md +72 -0
- package/README.md +251 -2
- package/bin/book-kit.js +115 -0
- package/commands/_emoji_io.py +32 -0
- package/commands/audit-chapter-lengths.js +244 -0
- package/commands/check-epub-size.js +68 -0
- package/commands/check-pdf-page-count.js +162 -0
- package/commands/composite-kindle-emoji.py +85 -0
- package/commands/composite-print-emoji.py +98 -0
- package/commands/conventions.js +76 -0
- package/commands/convert-pdfx.sh +194 -0
- package/commands/download-icc-profile.sh +105 -0
- package/commands/fonts.py +212 -0
- package/commands/generate-reference-docx.py +344 -0
- package/commands/kindle-fonts.js +173 -0
- package/commands/links-to-footnotes.js +366 -0
- package/commands/markdownlint.js +25 -0
- package/commands/minify-css.js +43 -0
- package/commands/optimize-images.js +73 -0
- package/commands/parity.sh +150 -0
- package/commands/postprocess-epub.js +541 -0
- package/commands/prep-print-src.js +77 -0
- package/commands/prose.js +38 -0
- package/commands/release-files.js +89 -0
- package/commands/reorder-print-frontmatter.js +79 -0
- package/commands/strip-page-rules.js +1168 -0
- package/commands/strip-property-rules.js +93 -0
- package/commands/update-key-takeaways.js +314 -0
- package/commands/validate.js +283 -0
- package/configs/markdownlint-cli2.jsonc +98 -0
- package/configs/textlintrc.json +22 -0
- package/css/theme/01-tokens.css +83 -0
- package/css/theme/02-fonts.css +79 -0
- package/css/theme/03-prose.css +53 -0
- package/css/theme/04-base.css +226 -0
- package/css/theme/05-components.css +546 -0
- package/css/theme/06-utilities.css +40 -0
- package/css/theme/07-ereader.css +245 -0
- package/css/theme/08-epub.css +865 -0
- package/css/theme/09-back-matter.css +47 -0
- package/css/theme/10-dark.css +302 -0
- package/css/theme/11-print.css +1229 -0
- package/css/theme.css +38 -0
- package/defaults/book.config.yml +318 -0
- package/defaults/pandoc-base.yml +19 -0
- package/defaults/pandoc-docx.yml +6 -0
- package/defaults/pandoc-epub-fonts.yml +6 -0
- package/defaults/pandoc-epub.yml +15 -0
- package/defaults/pandoc-kindle.yml +6 -0
- package/emoji/1f382.png +0 -0
- package/emoji/1f389.png +0 -0
- package/emoji/1f440.png +0 -0
- package/emoji/1f44d.png +0 -0
- package/emoji/1f44f.png +0 -0
- package/emoji/1f454.png +0 -0
- package/emoji/1f4a1.png +0 -0
- package/emoji/1f4bb.png +0 -0
- package/emoji/1f602.png +0 -0
- package/emoji/1f603.png +0 -0
- package/emoji/1f604.png +0 -0
- package/emoji/1f60a.png +0 -0
- package/emoji/1f60f.png +0 -0
- package/emoji/1f64c.png +0 -0
- package/emoji/1f64f.png +0 -0
- package/emoji/1f914.png +0 -0
- package/emoji/1f923.png +0 -0
- package/emoji/2714.png +0 -0
- package/emoji/274c.png +0 -0
- package/filters/add-div-titles.lua +196 -0
- package/filters/arc-notice.lua +36 -0
- package/filters/body-emoji-images.lua +48 -0
- package/filters/callout-classes.lua +40 -0
- package/filters/lib/book_kit.lua +110 -0
- package/filters/revision.lua +17 -0
- package/filters/strip-comments.lua +40 -0
- package/filters/strip-emoji-kindle.lua +134 -0
- package/fonts/LICENSE-source-code-pro.md +93 -0
- package/fonts/LICENSE-source-sans.md +93 -0
- package/fonts/LICENSE-source-serif.md +93 -0
- package/fonts/recipe.json +41 -0
- package/just/book-kit.just +229 -0
- package/lib/acronyms.js +47 -0
- package/lib/callouts.js +17 -0
- package/lib/config.js +211 -0
- package/lib/is-main.js +21 -0
- package/lib/lint.js +148 -0
- package/lib/pandoc.js +148 -0
- package/lib/paragraphs.js +173 -0
- package/lib/revision.js +35 -0
- package/lib/structure.js +39 -0
- package/lib/textlint-github-formatter.js +58 -0
- package/lib/validate.js +202 -0
- package/package.json +98 -4
- package/presets/business-nonfiction/CONVENTIONS.md +35 -0
- package/presets/business-nonfiction/preset.yml +128 -0
- package/profiles/pdfx-def.ps +42 -0
- package/requirements.txt +14 -0
- package/rules/no-ai-like-patterns.js +209 -0
- package/rules/no-assumptive.js +76 -0
- package/rules/no-bare-acronyms.js +129 -0
- package/rules/no-cliches.js +544 -0
- package/rules/no-cultural-idioms.js +422 -0
- package/rules/no-vague-intensifiers.js +94 -0
- package/templates/template-epub.html +79 -0
- package/templates/template.html +73 -0
- package/toolchain.json +42 -0
- package/validators/acronym-expansion.js +94 -0
- package/validators/bold-keywords.js +56 -0
- package/validators/callout-balance.js +74 -0
- package/validators/callout-divs.js +66 -0
- package/validators/callout-labels.js +102 -0
- package/validators/chapter-lengths.js +14 -0
- package/validators/chapter.js +57 -0
- package/validators/cross-references.js +45 -0
- package/validators/doubled-words.js +76 -0
- package/validators/duplicate-headings.js +52 -0
- package/validators/em-dashes.js +73 -0
- package/validators/escaped-smallcaps.js +46 -0
- package/validators/footnotes.js +78 -0
- package/validators/heading-hierarchy.js +58 -0
- package/validators/hypothetical-hooks.js +103 -0
- package/validators/index-integrity.js +28 -0
- package/validators/index.js +73 -0
- package/validators/list-punctuation.js +123 -0
- package/validators/min-opener-length.js +87 -0
- package/validators/no-bare-url-link-text.js +38 -0
- package/validators/no-todo-markers.js +49 -0
- package/validators/no-transition-todos.js +34 -0
- package/validators/number-range-dashes.js +72 -0
- package/validators/paragraph-length.js +62 -0
- package/validators/quote-punctuation.js +85 -0
- package/validators/section-intro-final-lead.js +90 -0
- package/validators/section-intro-has-lead.js +41 -0
- package/validators/section-intro-no-tldr.js +37 -0
- package/validators/section-intros.js +33 -0
- package/validators/sentence-starters.js +132 -0
- package/validators/smallcaps.js +67 -0
- 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
|
+
}
|