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
package/toolchain.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Single source of truth for tool versions. pandoc and WeasyPrint drive print pagination: bumping either can reflow a locked page count (print.page_count), so bump deliberately (the same goes for the upstream font releases in `fonts`) and re-run every book's page-count check. tests/toolchain.test.js keeps requirements.txt, the Dockerfile, and the actions in sync with this file.",
|
|
3
|
+
"pandoc": "3.12.1",
|
|
4
|
+
"node": "24.21.0",
|
|
5
|
+
"just": "1.58.0",
|
|
6
|
+
"vale": "3.15.1",
|
|
7
|
+
"java": "21",
|
|
8
|
+
"epubcheck": {
|
|
9
|
+
"4.2.6": "3f73c1265cc92e3b53ffda6cd5bbeb58505b2b0c43411c26e74d8df1b193b2c0",
|
|
10
|
+
"5.3.0": "6c07e68584b2e2ce2f89fe06e1246dfead3eb36b46b340e7d93524f29dcff6c5"
|
|
11
|
+
},
|
|
12
|
+
"python": {
|
|
13
|
+
"weasyprint": "70.0",
|
|
14
|
+
"pikepdf": "10.10.0",
|
|
15
|
+
"Pillow": "12.3.0",
|
|
16
|
+
"python-docx": "1.2.0",
|
|
17
|
+
"fonttools": "4.67.0",
|
|
18
|
+
"brotli": "1.2.0"
|
|
19
|
+
},
|
|
20
|
+
"fonts": {
|
|
21
|
+
"source-serif": {
|
|
22
|
+
"url": "https://github.com/adobe-fonts/source-serif/releases/download/4.005R/source-serif-4.005_Desktop.zip",
|
|
23
|
+
"sha256": "549fdb8f9a682bd06944298621404969f6de77c2e422ff3b8244a1dcd6a0c425"
|
|
24
|
+
},
|
|
25
|
+
"source-sans-3": {
|
|
26
|
+
"url": "https://raw.githubusercontent.com/google/fonts/4591e3457ab8be6d70167aa6818922b91e78ab2d/ofl/sourcesans3/SourceSans3%5Bwght%5D.ttf",
|
|
27
|
+
"sha256": "042fe2cc0b933e328410d7acbd0aa6a1873dca5aef81875f4bc214b08825c7b9"
|
|
28
|
+
},
|
|
29
|
+
"source-sans-3-italic": {
|
|
30
|
+
"url": "https://raw.githubusercontent.com/google/fonts/4591e3457ab8be6d70167aa6818922b91e78ab2d/ofl/sourcesans3/SourceSans3-Italic%5Bwght%5D.ttf",
|
|
31
|
+
"sha256": "39e3ab05ccd7cb94907c31005bb5bec1d5432f0b096a2b782976e217a540eb6c"
|
|
32
|
+
},
|
|
33
|
+
"source-code-pro": {
|
|
34
|
+
"url": "https://raw.githubusercontent.com/google/fonts/b6ba0c72868dd42746f8a0a7dd12754663e55a0f/ofl/sourcecodepro/SourceCodePro%5Bwght%5D.ttf",
|
|
35
|
+
"sha256": "b400fc584e10aff25d0e775ce181b4fc1c5ea1b5dc37b81aeb2084375b945790"
|
|
36
|
+
},
|
|
37
|
+
"source-code-pro-italic": {
|
|
38
|
+
"url": "https://raw.githubusercontent.com/google/fonts/b6ba0c72868dd42746f8a0a7dd12754663e55a0f/ofl/sourcecodepro/SourceCodePro-Italic%5Bwght%5D.ttf",
|
|
39
|
+
"sha256": "6db77d25aa7b30eff449305b5c998e475694c74d398421127ea5a60f536413cd"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { GENERATED_FILES, UNIVERSALLY_KNOWN_ACRONYMS } from '../lib/validate.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validate that acronyms are expanded on first use in the book.
|
|
6
|
+
* Reads chapters in index.yml order and checks that each acronym's first
|
|
7
|
+
* occurrence includes a parenthetical expansion nearby.
|
|
8
|
+
*
|
|
9
|
+
* Accepted patterns:
|
|
10
|
+
* - "Full Name ([ACRONYM]{.smallcaps})" — expansion before acronym
|
|
11
|
+
* - "[ACRONYM]{.smallcaps} (Full Name)" — expansion after acronym
|
|
12
|
+
*
|
|
13
|
+
* @param {string[]} indexFiles - Files listed in index.yml (in order)
|
|
14
|
+
* @returns {{errors: string[], warnings: string[]}}
|
|
15
|
+
*/
|
|
16
|
+
export function validateAcronymExpansion(indexFiles) {
|
|
17
|
+
const warnings = [];
|
|
18
|
+
// Track which acronyms we've seen and whether they were expanded
|
|
19
|
+
const seen = new Map(); // acronym -> { file, line, expanded }
|
|
20
|
+
|
|
21
|
+
for (const file of indexFiles) {
|
|
22
|
+
if (!fs.existsSync(file)) continue;
|
|
23
|
+
if (GENERATED_FILES.has(file)) continue;
|
|
24
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
25
|
+
const lines = content.split('\n');
|
|
26
|
+
let inCodeBlock = false;
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < lines.length; i++) {
|
|
29
|
+
const line = lines[i];
|
|
30
|
+
|
|
31
|
+
if (line.trim().startsWith('```')) {
|
|
32
|
+
inCodeBlock = !inCodeBlock;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (inCodeBlock) continue;
|
|
36
|
+
|
|
37
|
+
// Find all [ACRONYM]{.smallcaps} occurrences on this line
|
|
38
|
+
const pattern = /\[([A-Z][A-Za-z0-9+/.]+)\]\{\.smallcaps\}/g;
|
|
39
|
+
let m;
|
|
40
|
+
while ((m = pattern.exec(line)) !== null) {
|
|
41
|
+
const acr = m[0]; // full match e.g. [TLS]{.smallcaps}
|
|
42
|
+
const acronym = m[1]; // just the letters e.g. TLS
|
|
43
|
+
|
|
44
|
+
if (seen.has(acronym)) continue; // already tracked first use
|
|
45
|
+
|
|
46
|
+
// Skip timezone offsets (UTC+1, UTC+5), date formats (YYYY-MM-DD),
|
|
47
|
+
// and degree abbreviations (J.D., M.B.A.) — these aren't expandable acronyms
|
|
48
|
+
if (/^UTC[+-]/.test(acronym)) continue;
|
|
49
|
+
if (/^[YMD]{2,}/.test(acronym)) continue;
|
|
50
|
+
if (/^[A-Z]\.[A-Z]\./.test(acronym)) continue;
|
|
51
|
+
|
|
52
|
+
// Check if there's an expansion nearby on this line
|
|
53
|
+
// Pattern 1: "Full Name ([ACRONYM]{.smallcaps})" — open paren before
|
|
54
|
+
const beforeAcr = line.slice(0, m.index);
|
|
55
|
+
const afterAcr = line.slice(m.index + acr.length);
|
|
56
|
+
// Allow trailing punctuation in the expansion (e.g. a title that ends
|
|
57
|
+
// in "?": "What Comes Next? ([WCN]{.smallcaps})").
|
|
58
|
+
const hasExpansionBefore = /\w+[?!.…]?\s*\($/.test(beforeAcr.trimEnd());
|
|
59
|
+
|
|
60
|
+
// Pattern 2: "[ACRONYM]{.smallcaps} (Full Name)" — open paren after
|
|
61
|
+
// Also handles plurals: "[ACRONYM]{.smallcaps}s (Full Name)"
|
|
62
|
+
const hasExpansionAfter = /^s?\s*\([A-Za-z]/.test(afterAcr);
|
|
63
|
+
|
|
64
|
+
// Pattern 3: Inline expansion near the acronym on the same line
|
|
65
|
+
// e.g., "denial-of-service attack...a [DoS]{.smallcaps} attack"
|
|
66
|
+
// or book title containing expansion text before the acronym
|
|
67
|
+
const hasInlineExpansion = false; // conservative: only parenthetical counts
|
|
68
|
+
|
|
69
|
+
const expanded = hasExpansionBefore || hasExpansionAfter || hasInlineExpansion;
|
|
70
|
+
|
|
71
|
+
seen.set(acronym, { file, line: i + 1, expanded });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Report acronyms not expanded on first use (skip universally known ones)
|
|
77
|
+
for (const [acronym, info] of seen) {
|
|
78
|
+
if (UNIVERSALLY_KNOWN_ACRONYMS.includes(acronym)) continue;
|
|
79
|
+
if (!info.expanded) {
|
|
80
|
+
warnings.push(
|
|
81
|
+
`Acronym "${acronym}" not expanded on first use in ${info.file}:${info.line}`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { errors: [], warnings };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default {
|
|
90
|
+
name: 'acronym-expansion',
|
|
91
|
+
tags: ['style'],
|
|
92
|
+
defaults: {},
|
|
93
|
+
run: (ctx) => validateAcronymExpansion(ctx.indexFiles),
|
|
94
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validate that bold keywords (**Keyword.** Body / **Keyword:** Body) are not
|
|
5
|
+
* used outside of callout divs. Prefer H4 headings for accessibility.
|
|
6
|
+
* @param {string[]} indexFiles - Files listed in index.yml
|
|
7
|
+
* @returns {{errors: string[], warnings: string[]}}
|
|
8
|
+
*/
|
|
9
|
+
export function validateBoldKeywords(indexFiles) {
|
|
10
|
+
const warnings = [];
|
|
11
|
+
// Matches **Text.** or **Text:** at start of line followed by body text
|
|
12
|
+
const pattern = /^\*\*[^*]+[.:]\*\*\s+\S/;
|
|
13
|
+
|
|
14
|
+
for (const file of indexFiles) {
|
|
15
|
+
if (!fs.existsSync(file)) continue;
|
|
16
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
17
|
+
const lines = content.split('\n');
|
|
18
|
+
let inCodeBlock = false;
|
|
19
|
+
let inCallout = false;
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < lines.length; i++) {
|
|
22
|
+
const line = lines[i];
|
|
23
|
+
|
|
24
|
+
if (line.trim().startsWith('```')) {
|
|
25
|
+
inCodeBlock = !inCodeBlock;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (inCodeBlock) continue;
|
|
29
|
+
|
|
30
|
+
if (line.startsWith('::: {.')) {
|
|
31
|
+
inCallout = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (line === ':::') {
|
|
35
|
+
inCallout = false;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!inCallout && pattern.test(line)) {
|
|
40
|
+
const keyword = line.match(/^\*\*(.+?)[.:]\*\*/)?.[1] || '';
|
|
41
|
+
warnings.push(
|
|
42
|
+
`Bold keyword "${keyword}" should be an H4 heading in ${file}:${i + 1}`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { errors: [], warnings };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default {
|
|
52
|
+
name: 'bold-keywords',
|
|
53
|
+
tags: ['style'],
|
|
54
|
+
defaults: {},
|
|
55
|
+
run: (ctx) => validateBoldKeywords(ctx.indexFiles),
|
|
56
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { callouts } from '../lib/callouts.js';
|
|
3
|
+
import { TAKEAWAYS_SUFFIX, allowFor, optionsFor } from '../lib/validate.js';
|
|
4
|
+
|
|
5
|
+
const defaults = {
|
|
6
|
+
// The name a warning uses for each callout class. A class not listed here
|
|
7
|
+
// uses its registry label without the trailing colon, or `.class` if it has
|
|
8
|
+
// no label.
|
|
9
|
+
names: {},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The callout pairs to check, from the registry's `pair_with` fields, in
|
|
14
|
+
* registry order: [[class, partner], ...]. A symmetric pair lists both
|
|
15
|
+
* entries, so each direction is checked.
|
|
16
|
+
* @param {Array<{class: string, pair_with?: string}>} [registry]
|
|
17
|
+
* @returns {Array<[string, string]>}
|
|
18
|
+
*/
|
|
19
|
+
export function calloutPairs(registry = callouts) {
|
|
20
|
+
return registry.filter((c) => c.pair_with).map((c) => [c.class, c.pair_with]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Validate that chapters using a paired callout include its partner. Pairs
|
|
25
|
+
* come from `pair_with` in the callout registry; the business-nonfiction
|
|
26
|
+
* preset pairs .for-managers and .for-ics both ways, so a chapter with one
|
|
27
|
+
* should also have the other.
|
|
28
|
+
* @param {string[]} indexFiles - Files listed in index.yml
|
|
29
|
+
* @param {{names?: Object<string, string>}} [options]
|
|
30
|
+
* @param {Set<string>} [allowed] - Files exempt from the check (validators.allow)
|
|
31
|
+
* @param {Array<{class: string, label?: string, pair_with?: string}>} [registry]
|
|
32
|
+
* @returns {{errors: string[], warnings: string[]}}
|
|
33
|
+
*/
|
|
34
|
+
export function validateCalloutBalance(
|
|
35
|
+
indexFiles,
|
|
36
|
+
options = optionsFor('callout-balance', defaults),
|
|
37
|
+
allowed = allowFor('callout-balance'),
|
|
38
|
+
registry = callouts,
|
|
39
|
+
) {
|
|
40
|
+
const warnings = [];
|
|
41
|
+
const pairs = calloutPairs(registry);
|
|
42
|
+
if (pairs.length === 0) return { errors: [], warnings };
|
|
43
|
+
|
|
44
|
+
const nameOf = (cls) =>
|
|
45
|
+
options.names?.[cls] ?? registry.find((c) => c.class === cls)?.label?.replace(/:$/, '') ?? `.${cls}`;
|
|
46
|
+
|
|
47
|
+
// Files that legitimately use only one side of a pair (validators.allow),
|
|
48
|
+
// e.g. a chapter that introduces each callout in its own section
|
|
49
|
+
const EXEMPT_FILES = [...allowed];
|
|
50
|
+
|
|
51
|
+
for (const file of indexFiles) {
|
|
52
|
+
if (!fs.existsSync(file)) continue;
|
|
53
|
+
if (EXEMPT_FILES.includes(file)) continue;
|
|
54
|
+
if (file.includes(TAKEAWAYS_SUFFIX)) continue;
|
|
55
|
+
|
|
56
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
57
|
+
for (const [cls, partner] of pairs) {
|
|
58
|
+
if (content.includes(`.${cls}`) && !content.includes(`.${partner}`)) {
|
|
59
|
+
warnings.push(
|
|
60
|
+
`${file} has "${nameOf(cls)}" callout but no "${nameOf(partner)}" callout`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { errors: [], warnings };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default {
|
|
70
|
+
name: 'callout-balance',
|
|
71
|
+
tags: ['style'],
|
|
72
|
+
defaults,
|
|
73
|
+
run: (ctx) => validateCalloutBalance(ctx.indexFiles, ctx.options, ctx.allow, ctx.config.callouts),
|
|
74
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validate that Pandoc fenced divs (:::) are properly balanced.
|
|
5
|
+
* Every opening ::: {.class} must have a matching closing :::.
|
|
6
|
+
* Unclosed divs silently swallow content or break rendering.
|
|
7
|
+
* @param {string[]} indexFiles - Files listed in index.yml
|
|
8
|
+
* @returns {{errors: string[], warnings: string[]}}
|
|
9
|
+
*/
|
|
10
|
+
export function validateCalloutDivs(indexFiles) {
|
|
11
|
+
const errors = [];
|
|
12
|
+
|
|
13
|
+
for (const file of indexFiles) {
|
|
14
|
+
if (!fs.existsSync(file)) continue;
|
|
15
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
16
|
+
const lines = content.split('\n');
|
|
17
|
+
let inCodeBlock = false;
|
|
18
|
+
// Stack of opening div line numbers
|
|
19
|
+
const divStack = [];
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < lines.length; i++) {
|
|
22
|
+
const line = lines[i];
|
|
23
|
+
|
|
24
|
+
// Track code blocks (fenced divs inside code are literal text)
|
|
25
|
+
if (/^```/.test(line.trim())) {
|
|
26
|
+
inCodeBlock = !inCodeBlock;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (inCodeBlock) continue;
|
|
30
|
+
|
|
31
|
+
// Opening div: ::: {.class ...} or ::: {custom-style="..."}
|
|
32
|
+
if (/^:::\s*\{/.test(line.trim())) {
|
|
33
|
+
divStack.push(i + 1);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Closing div: ::: alone on a line (possibly with trailing whitespace)
|
|
38
|
+
if (/^:::\s*$/.test(line.trim())) {
|
|
39
|
+
if (divStack.length === 0) {
|
|
40
|
+
errors.push(
|
|
41
|
+
`Unmatched closing ::: in ${file}:${i + 1} (no opening div)`
|
|
42
|
+
);
|
|
43
|
+
} else {
|
|
44
|
+
divStack.pop();
|
|
45
|
+
}
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Any remaining unclosed divs
|
|
51
|
+
for (const openLine of divStack) {
|
|
52
|
+
errors.push(
|
|
53
|
+
`Unclosed fenced div (:::) opened at ${file}:${openLine}`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { errors, warnings: [] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default {
|
|
62
|
+
name: 'callout-divs',
|
|
63
|
+
tags: ['structure'],
|
|
64
|
+
defaults: {},
|
|
65
|
+
run: (ctx) => validateCalloutDivs(ctx.indexFiles),
|
|
66
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { callouts } from '../lib/callouts.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validate that labeled callouts (.for-managers / .for-ics / .pro-tip) don't
|
|
6
|
+
* start with a hardcoded label like "**For managers:**" or "**Pro tip:**". The
|
|
7
|
+
* label is injected by CSS (`.for-managers::before` / `.for-ics::before` /
|
|
8
|
+
* `.pro-tip::before` in src/style.css) for HTML/PDF and by add-div-titles.lua
|
|
9
|
+
* for EPUB, so a hardcoded label inside the callout renders twice
|
|
10
|
+
* ("💡 Pro-Tip: Pro tip: ...") in HTML and PDF.
|
|
11
|
+
* Mirrors the label strings checked by add-div-titles.lua.
|
|
12
|
+
* @param {string[]} indexFiles - Files listed in index.yml
|
|
13
|
+
* @returns {{errors: string[], warnings: string[]}}
|
|
14
|
+
*/
|
|
15
|
+
export function validateRoleCalloutLabels(indexFiles) {
|
|
16
|
+
const errors = [];
|
|
17
|
+
|
|
18
|
+
// Label patterns that duplicate the CSS ::before / Lua filter labels.
|
|
19
|
+
// Matches plain or bold ("**For managers:**", "**For managers**:") forms,
|
|
20
|
+
// including the smallcaps IC variant ("For [IC]{.smallcaps}s:").
|
|
21
|
+
const LABEL_PATTERNS = {
|
|
22
|
+
'for-managers': /^\*{0,2}For managers\*{0,2}\s*:/i,
|
|
23
|
+
'for-ics': /^\*{0,2}For (?:individual contributors|ICs|\[IC\]\{\.smallcaps\}s)\*{0,2}\s*:/i,
|
|
24
|
+
'pro-tip': /^\*{0,2}Pro[ -]?tip\*{0,2}\s*:/i,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Human-readable label per callout class, for the error message.
|
|
28
|
+
const LABEL_NAMES = {
|
|
29
|
+
'for-managers': 'For managers:',
|
|
30
|
+
'for-ics': 'For individual contributors:',
|
|
31
|
+
'pro-tip': 'Pro tip:',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Other labelled callouts from the registry match their own label text.
|
|
35
|
+
// (The TL;DR and common-objection labels are checked elsewhere or never
|
|
36
|
+
// typed by writers, as in the original rule set.)
|
|
37
|
+
const escapeRe = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
38
|
+
for (const c of callouts) {
|
|
39
|
+
if (!c.label || c.class in LABEL_PATTERNS || c.class === 'tldr' || c.class === 'common-objection') continue;
|
|
40
|
+
const bare = c.label.replace(/:$/, '');
|
|
41
|
+
LABEL_PATTERNS[c.class] = new RegExp(`^\\*{0,2}${escapeRe(bare)}\\*{0,2}\\s*:`, 'i');
|
|
42
|
+
LABEL_NAMES[c.class] = c.label;
|
|
43
|
+
}
|
|
44
|
+
const checked = Object.keys(LABEL_PATTERNS).filter((cls) => callouts.some((c) => c.class === cls));
|
|
45
|
+
if (checked.length === 0) return { errors, warnings: [] };
|
|
46
|
+
const LABELLED_OPEN_RE = new RegExp(`^:::\\s*\\{[^}]*\\.(${checked.map(escapeRe).join('|')})\\b`);
|
|
47
|
+
|
|
48
|
+
for (const file of indexFiles) {
|
|
49
|
+
if (!fs.existsSync(file)) continue;
|
|
50
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
51
|
+
const lines = content.split('\n');
|
|
52
|
+
let inCodeBlock = false;
|
|
53
|
+
let pendingCallout = null; // { class, line } awaiting its first content line
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < lines.length; i++) {
|
|
56
|
+
const line = lines[i].trim();
|
|
57
|
+
|
|
58
|
+
if (/^```/.test(line)) {
|
|
59
|
+
inCodeBlock = !inCodeBlock;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (inCodeBlock) continue;
|
|
63
|
+
|
|
64
|
+
const openMatch = line.match(LABELLED_OPEN_RE);
|
|
65
|
+
if (openMatch) {
|
|
66
|
+
pendingCallout = { class: openMatch[1], line: i + 1 };
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!pendingCallout) continue;
|
|
71
|
+
|
|
72
|
+
// Skip blanks and comments between the opening fence and first content
|
|
73
|
+
if (line === '' || /^<!--/.test(line)) continue;
|
|
74
|
+
|
|
75
|
+
// Closing fence or another div before any content
|
|
76
|
+
if (/^:::/.test(line)) {
|
|
77
|
+
pendingCallout = null;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// First content line of the callout
|
|
82
|
+
if (LABEL_PATTERNS[pendingCallout.class].test(line)) {
|
|
83
|
+
const label = LABEL_NAMES[pendingCallout.class];
|
|
84
|
+
errors.push(
|
|
85
|
+
`Callout .${pendingCallout.class} in ${file}:${i + 1} starts with a hardcoded "${label}" label. ` +
|
|
86
|
+
`Remove it — the label is added automatically by CSS ::before (HTML/PDF) and ` +
|
|
87
|
+
`add-div-titles.lua (EPUB), so hardcoding it renders the label twice.`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
pendingCallout = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { errors, warnings: [] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default {
|
|
98
|
+
name: 'callout-labels',
|
|
99
|
+
tags: ['structure'],
|
|
100
|
+
defaults: {},
|
|
101
|
+
run: (ctx) => validateRoleCalloutLabels(ctx.indexFiles),
|
|
102
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { auditChapterLengths, MAX_WORDS, MIN_WORDS } from '../commands/audit-chapter-lengths.js';
|
|
2
|
+
|
|
3
|
+
// Flags content chapters outside a recommended word count range. The audit
|
|
4
|
+
// itself lives in commands/audit-chapter-lengths.js, which also runs on its
|
|
5
|
+
// own (`book-kit audit-chapter-lengths`) with the same defaults.
|
|
6
|
+
export default {
|
|
7
|
+
name: 'chapter-lengths',
|
|
8
|
+
tags: ['style'],
|
|
9
|
+
defaults: {
|
|
10
|
+
min_words: MIN_WORDS,
|
|
11
|
+
max_words: MAX_WORDS,
|
|
12
|
+
},
|
|
13
|
+
run: (ctx) => ({ errors: [], warnings: auditChapterLengths(undefined, ctx.options).warnings }),
|
|
14
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { isNonChapter } from '../lib/structure.js';
|
|
3
|
+
import { config, isExcluded, REQUIRED_CALLOUT } from '../lib/validate.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Validate a chapter file
|
|
7
|
+
* @param {string} chapter - Path to chapter file
|
|
8
|
+
* @param {string[]} indexFiles - Files listed in index.yml
|
|
9
|
+
* @returns {{errors: string[], warnings: string[]}} Validation results
|
|
10
|
+
*/
|
|
11
|
+
export function validateChapter(chapter, indexFiles) {
|
|
12
|
+
const errors = [];
|
|
13
|
+
const warnings = [];
|
|
14
|
+
|
|
15
|
+
// Check if chapter is in index.yml
|
|
16
|
+
if (!indexFiles.includes(chapter)) {
|
|
17
|
+
errors.push(`Chapter not found in index.yml: ${chapter}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Read chapter contents
|
|
21
|
+
const contents = fs.readFileSync(chapter, 'utf8');
|
|
22
|
+
|
|
23
|
+
// Check for TODO markers
|
|
24
|
+
if (contents.includes('TODO')) {
|
|
25
|
+
errors.push(`Chapter contains TODO: ${chapter}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check for the required opening callout (TL;DR by default). Structural
|
|
29
|
+
// pages and takeaways collections aren't chapters, so they're exempt.
|
|
30
|
+
if (REQUIRED_CALLOUT && !isNonChapter(chapter, config) && !contents.includes(`.${REQUIRED_CALLOUT}`)) {
|
|
31
|
+
errors.push(`Chapter missing ${REQUIRED_CALLOUT === 'tldr' ? 'TL;DR' : `.${REQUIRED_CALLOUT} callout`}: ${chapter}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { errors, warnings };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Runs validateChapter over every chapter under paths.src that isn't in
|
|
38
|
+
// lint.exclude. The per-chapter results are concatenated into one, which
|
|
39
|
+
// prints in the same order as reporting each chapter separately only because
|
|
40
|
+
// validateChapter emits errors and never warnings; keep it that way, or report
|
|
41
|
+
// per chapter again.
|
|
42
|
+
export default {
|
|
43
|
+
name: 'chapter',
|
|
44
|
+
tags: ['structure'],
|
|
45
|
+
defaults: {},
|
|
46
|
+
run(ctx) {
|
|
47
|
+
const errors = [];
|
|
48
|
+
const warnings = [];
|
|
49
|
+
for (const chapter of ctx.chapters) {
|
|
50
|
+
if (isExcluded(chapter)) continue;
|
|
51
|
+
const result = validateChapter(chapter, ctx.indexFiles);
|
|
52
|
+
errors.push(...result.errors);
|
|
53
|
+
warnings.push(...result.warnings);
|
|
54
|
+
}
|
|
55
|
+
return { errors, warnings };
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { collectAllAnchors } from '../lib/validate.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validate that internal cross-references (#anchor) resolve to actual headings.
|
|
6
|
+
* @param {string[]} indexFiles - Files listed in index.yml
|
|
7
|
+
* @returns {{errors: string[], warnings: string[]}}
|
|
8
|
+
*/
|
|
9
|
+
export function validateCrossReferences(indexFiles) {
|
|
10
|
+
const warnings = [];
|
|
11
|
+
const anchors = collectAllAnchors(indexFiles);
|
|
12
|
+
|
|
13
|
+
for (const file of indexFiles) {
|
|
14
|
+
if (!fs.existsSync(file)) continue;
|
|
15
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
16
|
+
const lines = content.split('\n');
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < lines.length; i++) {
|
|
19
|
+
const line = lines[i];
|
|
20
|
+
// Skip code blocks
|
|
21
|
+
if (line.trim().startsWith('```')) continue;
|
|
22
|
+
|
|
23
|
+
// Find all internal cross-references: [text](#anchor)
|
|
24
|
+
const refPattern = /\]\(#([a-z][a-z0-9-]*)\)/g;
|
|
25
|
+
let match;
|
|
26
|
+
while ((match = refPattern.exec(line)) !== null) {
|
|
27
|
+
const anchor = match[1];
|
|
28
|
+
if (!anchors.has(anchor)) {
|
|
29
|
+
warnings.push(
|
|
30
|
+
`Broken cross-reference "#${anchor}" in ${file}:${i + 1}`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { errors: [], warnings };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default {
|
|
41
|
+
name: 'cross-references',
|
|
42
|
+
tags: ['structure'],
|
|
43
|
+
defaults: {},
|
|
44
|
+
run: (ctx) => validateCrossReferences(ctx.indexFiles),
|
|
45
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { optionsFor } from '../lib/validate.js';
|
|
3
|
+
|
|
4
|
+
const defaults = {
|
|
5
|
+
// Words that are legitimately repeated in English prose and must not be
|
|
6
|
+
// flagged as accidental doubling (e.g. "the fact that that happened").
|
|
7
|
+
// Matching ignores case.
|
|
8
|
+
allowlist: ['had', 'that'],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Validate that no word is accidentally repeated back-to-back ("the the",
|
|
13
|
+
* "and and") — a classic typo that survives spellcheck. Matches a run of
|
|
14
|
+
* letters immediately followed by the same run (case-insensitive), skipping a
|
|
15
|
+
* small allowlist of legitimate repeats (the `allowlist` option). Inline code,
|
|
16
|
+
* fenced code blocks, and YAML frontmatter are skipped.
|
|
17
|
+
* @param {string[]} indexFiles - Files listed in index.yml
|
|
18
|
+
* @param {{allowlist?: string[]}} [options]
|
|
19
|
+
* @returns {{errors: string[], warnings: string[]}}
|
|
20
|
+
*/
|
|
21
|
+
export function validateDoubledWords(indexFiles, options = optionsFor('doubled-words', defaults)) {
|
|
22
|
+
const errors = [];
|
|
23
|
+
const DOUBLED_WORD_ALLOWLIST = new Set((options.allowlist ?? []).map((w) => String(w).toLowerCase()));
|
|
24
|
+
|
|
25
|
+
for (const file of indexFiles) {
|
|
26
|
+
if (!fs.existsSync(file)) continue;
|
|
27
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
28
|
+
const lines = content.split('\n');
|
|
29
|
+
let inCodeBlock = false;
|
|
30
|
+
let inFrontmatter = false;
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < lines.length; i++) {
|
|
33
|
+
const line = lines[i];
|
|
34
|
+
|
|
35
|
+
if (i === 0 && line.trim() === '---') {
|
|
36
|
+
inFrontmatter = true;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (inFrontmatter && line.trim() === '---') {
|
|
40
|
+
inFrontmatter = false;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (inFrontmatter) continue;
|
|
44
|
+
|
|
45
|
+
if (line.trim().startsWith('```')) {
|
|
46
|
+
inCodeBlock = !inCodeBlock;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (inCodeBlock) continue;
|
|
50
|
+
|
|
51
|
+
// Same letter-run twice in a row, separated only by spaces/tabs.
|
|
52
|
+
const doubled = /\b([A-Za-z]+)([ \t]+)\1\b/gi;
|
|
53
|
+
let m;
|
|
54
|
+
while ((m = doubled.exec(line)) !== null) {
|
|
55
|
+
if (DOUBLED_WORD_ALLOWLIST.has(m[1].toLowerCase())) continue;
|
|
56
|
+
|
|
57
|
+
// Skip if inside inline code (odd number of backticks before match)
|
|
58
|
+
const before = line.slice(0, m.index);
|
|
59
|
+
if ((before.match(/`/g) || []).length % 2 === 1) continue;
|
|
60
|
+
|
|
61
|
+
errors.push(
|
|
62
|
+
`Doubled word "${m[1]} ${m[1]}" in ${file}:${i + 1}: "...${line.slice(Math.max(0, m.index - 15), m.index + m[0].length + 15).trim()}..."`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { errors, warnings: [] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default {
|
|
72
|
+
name: 'doubled-words',
|
|
73
|
+
tags: ['structure'],
|
|
74
|
+
defaults,
|
|
75
|
+
run: (ctx) => validateDoubledWords(ctx.indexFiles, ctx.options),
|
|
76
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { headingToAnchor } from '../lib/validate.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validate no duplicate H2 headings across production chapters.
|
|
6
|
+
* Duplicate H2 headings cause anchor ID collisions in the combined output.
|
|
7
|
+
* @param {string[]} indexFiles - Files listed in index.yml
|
|
8
|
+
* @returns {{errors: string[], warnings: string[]}}
|
|
9
|
+
*/
|
|
10
|
+
export function validateDuplicateHeadings(indexFiles) {
|
|
11
|
+
const warnings = [];
|
|
12
|
+
// Map anchor -> [files where it appears]
|
|
13
|
+
const anchorSources = new Map();
|
|
14
|
+
|
|
15
|
+
for (const file of indexFiles) {
|
|
16
|
+
if (!fs.existsSync(file)) continue;
|
|
17
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
18
|
+
|
|
19
|
+
for (const line of content.split('\n')) {
|
|
20
|
+
// Only check H2 headings (chapters)
|
|
21
|
+
if (/^##\s/.test(line)) {
|
|
22
|
+
const anchor = headingToAnchor(line);
|
|
23
|
+
if (!anchor) continue;
|
|
24
|
+
|
|
25
|
+
if (!anchorSources.has(anchor)) {
|
|
26
|
+
anchorSources.set(anchor, []);
|
|
27
|
+
}
|
|
28
|
+
anchorSources.get(anchor).push(file);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// "Key takeaways" is intentionally repeated across section key-takeaways files
|
|
34
|
+
const ALLOWED_DUPLICATES = ['key-takeaways'];
|
|
35
|
+
|
|
36
|
+
for (const [anchor, files] of anchorSources) {
|
|
37
|
+
if (files.length > 1 && !ALLOWED_DUPLICATES.includes(anchor)) {
|
|
38
|
+
warnings.push(
|
|
39
|
+
`Duplicate H2 anchor "#${anchor}" in: ${files.join(', ')}`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { errors: [], warnings };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default {
|
|
48
|
+
name: 'duplicate-headings',
|
|
49
|
+
tags: ['structure'],
|
|
50
|
+
defaults: {},
|
|
51
|
+
run: (ctx) => validateDuplicateHeadings(ctx.indexFiles),
|
|
52
|
+
};
|