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
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
+ };