book-kit 0.0.0-stage → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +72 -0
  3. package/README.md +251 -2
  4. package/bin/book-kit.js +115 -0
  5. package/commands/_emoji_io.py +32 -0
  6. package/commands/audit-chapter-lengths.js +244 -0
  7. package/commands/check-epub-size.js +68 -0
  8. package/commands/check-pdf-page-count.js +162 -0
  9. package/commands/composite-kindle-emoji.py +85 -0
  10. package/commands/composite-print-emoji.py +98 -0
  11. package/commands/conventions.js +76 -0
  12. package/commands/convert-pdfx.sh +194 -0
  13. package/commands/download-icc-profile.sh +105 -0
  14. package/commands/fonts.py +212 -0
  15. package/commands/generate-reference-docx.py +344 -0
  16. package/commands/kindle-fonts.js +173 -0
  17. package/commands/links-to-footnotes.js +366 -0
  18. package/commands/markdownlint.js +25 -0
  19. package/commands/minify-css.js +43 -0
  20. package/commands/optimize-images.js +73 -0
  21. package/commands/parity.sh +150 -0
  22. package/commands/postprocess-epub.js +541 -0
  23. package/commands/prep-print-src.js +77 -0
  24. package/commands/prose.js +38 -0
  25. package/commands/release-files.js +89 -0
  26. package/commands/reorder-print-frontmatter.js +79 -0
  27. package/commands/strip-page-rules.js +1168 -0
  28. package/commands/strip-property-rules.js +93 -0
  29. package/commands/update-key-takeaways.js +314 -0
  30. package/commands/validate.js +283 -0
  31. package/configs/markdownlint-cli2.jsonc +98 -0
  32. package/configs/textlintrc.json +22 -0
  33. package/css/theme/01-tokens.css +83 -0
  34. package/css/theme/02-fonts.css +79 -0
  35. package/css/theme/03-prose.css +53 -0
  36. package/css/theme/04-base.css +226 -0
  37. package/css/theme/05-components.css +546 -0
  38. package/css/theme/06-utilities.css +40 -0
  39. package/css/theme/07-ereader.css +245 -0
  40. package/css/theme/08-epub.css +865 -0
  41. package/css/theme/09-back-matter.css +47 -0
  42. package/css/theme/10-dark.css +302 -0
  43. package/css/theme/11-print.css +1229 -0
  44. package/css/theme.css +38 -0
  45. package/defaults/book.config.yml +318 -0
  46. package/defaults/pandoc-base.yml +19 -0
  47. package/defaults/pandoc-docx.yml +6 -0
  48. package/defaults/pandoc-epub-fonts.yml +6 -0
  49. package/defaults/pandoc-epub.yml +15 -0
  50. package/defaults/pandoc-kindle.yml +6 -0
  51. package/emoji/1f382.png +0 -0
  52. package/emoji/1f389.png +0 -0
  53. package/emoji/1f440.png +0 -0
  54. package/emoji/1f44d.png +0 -0
  55. package/emoji/1f44f.png +0 -0
  56. package/emoji/1f454.png +0 -0
  57. package/emoji/1f4a1.png +0 -0
  58. package/emoji/1f4bb.png +0 -0
  59. package/emoji/1f602.png +0 -0
  60. package/emoji/1f603.png +0 -0
  61. package/emoji/1f604.png +0 -0
  62. package/emoji/1f60a.png +0 -0
  63. package/emoji/1f60f.png +0 -0
  64. package/emoji/1f64c.png +0 -0
  65. package/emoji/1f64f.png +0 -0
  66. package/emoji/1f914.png +0 -0
  67. package/emoji/1f923.png +0 -0
  68. package/emoji/2714.png +0 -0
  69. package/emoji/274c.png +0 -0
  70. package/filters/add-div-titles.lua +196 -0
  71. package/filters/arc-notice.lua +36 -0
  72. package/filters/body-emoji-images.lua +48 -0
  73. package/filters/callout-classes.lua +40 -0
  74. package/filters/lib/book_kit.lua +110 -0
  75. package/filters/revision.lua +17 -0
  76. package/filters/strip-comments.lua +40 -0
  77. package/filters/strip-emoji-kindle.lua +134 -0
  78. package/fonts/LICENSE-source-code-pro.md +93 -0
  79. package/fonts/LICENSE-source-sans.md +93 -0
  80. package/fonts/LICENSE-source-serif.md +93 -0
  81. package/fonts/recipe.json +41 -0
  82. package/just/book-kit.just +229 -0
  83. package/lib/acronyms.js +47 -0
  84. package/lib/callouts.js +17 -0
  85. package/lib/config.js +211 -0
  86. package/lib/is-main.js +21 -0
  87. package/lib/lint.js +148 -0
  88. package/lib/pandoc.js +148 -0
  89. package/lib/paragraphs.js +173 -0
  90. package/lib/revision.js +35 -0
  91. package/lib/structure.js +39 -0
  92. package/lib/textlint-github-formatter.js +58 -0
  93. package/lib/validate.js +202 -0
  94. package/package.json +98 -4
  95. package/presets/business-nonfiction/CONVENTIONS.md +35 -0
  96. package/presets/business-nonfiction/preset.yml +128 -0
  97. package/profiles/pdfx-def.ps +42 -0
  98. package/requirements.txt +14 -0
  99. package/rules/no-ai-like-patterns.js +209 -0
  100. package/rules/no-assumptive.js +76 -0
  101. package/rules/no-bare-acronyms.js +129 -0
  102. package/rules/no-cliches.js +544 -0
  103. package/rules/no-cultural-idioms.js +422 -0
  104. package/rules/no-vague-intensifiers.js +94 -0
  105. package/templates/template-epub.html +79 -0
  106. package/templates/template.html +73 -0
  107. package/toolchain.json +42 -0
  108. package/validators/acronym-expansion.js +94 -0
  109. package/validators/bold-keywords.js +56 -0
  110. package/validators/callout-balance.js +74 -0
  111. package/validators/callout-divs.js +66 -0
  112. package/validators/callout-labels.js +102 -0
  113. package/validators/chapter-lengths.js +14 -0
  114. package/validators/chapter.js +57 -0
  115. package/validators/cross-references.js +45 -0
  116. package/validators/doubled-words.js +76 -0
  117. package/validators/duplicate-headings.js +52 -0
  118. package/validators/em-dashes.js +73 -0
  119. package/validators/escaped-smallcaps.js +46 -0
  120. package/validators/footnotes.js +78 -0
  121. package/validators/heading-hierarchy.js +58 -0
  122. package/validators/hypothetical-hooks.js +103 -0
  123. package/validators/index-integrity.js +28 -0
  124. package/validators/index.js +73 -0
  125. package/validators/list-punctuation.js +123 -0
  126. package/validators/min-opener-length.js +87 -0
  127. package/validators/no-bare-url-link-text.js +38 -0
  128. package/validators/no-todo-markers.js +49 -0
  129. package/validators/no-transition-todos.js +34 -0
  130. package/validators/number-range-dashes.js +72 -0
  131. package/validators/paragraph-length.js +62 -0
  132. package/validators/quote-punctuation.js +85 -0
  133. package/validators/section-intro-final-lead.js +90 -0
  134. package/validators/section-intro-has-lead.js +41 -0
  135. package/validators/section-intro-no-tldr.js +37 -0
  136. package/validators/section-intros.js +33 -0
  137. package/validators/sentence-starters.js +132 -0
  138. package/validators/smallcaps.js +67 -0
  139. package/validators/tldr-length.js +58 -0
@@ -0,0 +1,38 @@
1
+ import fs from 'node:fs';
2
+ import { getIndexFiles } from '../lib/validate.js';
3
+
4
+ /**
5
+ * Validate that link text is not a bare URL. Link text should describe the
6
+ * target; bare-URL link text hurts screen-reader users and print rendering.
7
+ * @param {string[]} [chapters] - Files listed in index.yml (read from it by default)
8
+ * @returns {{errors: string[], warnings: string[]}} Validation results
9
+ */
10
+ export function validateNoBareUrlLinkText(chapters = getIndexFiles()) {
11
+ const errors = [];
12
+ const warnings = [];
13
+ // [https://example.com](https://...) — text identical to URL.
14
+ const bareLinkRe = /\[(https?:\/\/[^\]]+)\]\(([^)]+)\)/g;
15
+
16
+ for (const file of chapters) {
17
+ if (!fs.existsSync(file)) continue;
18
+ const content = fs.readFileSync(file, 'utf8');
19
+ const lines = content.split('\n');
20
+ lines.forEach((line, i) => {
21
+ for (const m of line.matchAll(bareLinkRe)) {
22
+ errors.push(
23
+ `${file}:${i + 1} uses a bare URL as link text: "${m[0]}". ` +
24
+ `Describe the link's target instead; screen readers read the URL aloud, and print turns it into a footnote anyway.`
25
+ );
26
+ }
27
+ });
28
+ }
29
+
30
+ return { errors, warnings };
31
+ }
32
+
33
+ export default {
34
+ name: 'no-bare-url-link-text',
35
+ tags: ['structure'],
36
+ defaults: {},
37
+ run: (ctx) => validateNoBareUrlLinkText(ctx.indexFiles),
38
+ };
@@ -0,0 +1,49 @@
1
+ import fs from 'node:fs';
2
+ import { getIndexFiles } from '../lib/validate.js';
3
+
4
+ /**
5
+ * Validate that no production chapter contains any unresolved TODO/FIXME/XXX
6
+ * markers. Broader guard than validateNoTransitionTodos; catches stray
7
+ * drafting notes before they ship.
8
+ * @param {string[]} [chapters] - Files listed in index.yml (read from it by default)
9
+ * @returns {{errors: string[], warnings: string[]}} Validation results
10
+ */
11
+ export function validateNoTodoMarkers(chapters = getIndexFiles()) {
12
+ const errors = [];
13
+ const warnings = [];
14
+ // Exempt the TRANSITION variant (handled by validateNoTransitionTodos) so
15
+ // the two validators produce one error per marker, not two.
16
+ const markerRe = /\b(TODO(?!:\s*TRANSITION)|FIXME|XXX)(:|\b)/i;
17
+
18
+ for (const file of chapters) {
19
+ if (!fs.existsSync(file)) continue;
20
+ const content = fs.readFileSync(file, 'utf8');
21
+ const lines = content.split('\n');
22
+ lines.forEach((line, i) => {
23
+ // Skip code blocks and inline code (rough heuristic).
24
+ if (/^\s{4,}/.test(line)) return;
25
+ if (/^\s*```/.test(line)) return;
26
+ // Strip {.revision} spans: deliberate placeholders the build fills in
27
+ // (e.g. [TODO: number]{.revision}), not unfinished writing.
28
+ const stripped = line
29
+ .replace(/\[[^\]]*\]\{\.revision\b[^}]*\}/g, '')
30
+ .replace(/`[^`]*`/g, '');
31
+ const m = stripped.match(markerRe);
32
+ if (m) {
33
+ errors.push(
34
+ `${file}:${i + 1} contains unresolved "${m[1]}" marker: ` +
35
+ `${line.trim().slice(0, 120)}`
36
+ );
37
+ }
38
+ });
39
+ }
40
+
41
+ return { errors, warnings };
42
+ }
43
+
44
+ export default {
45
+ name: 'no-todo-markers',
46
+ tags: ['structure'],
47
+ defaults: {},
48
+ run: (ctx) => validateNoTodoMarkers(ctx.indexFiles),
49
+ };
@@ -0,0 +1,34 @@
1
+ import fs from 'node:fs';
2
+ import { getIndexFiles } from '../lib/validate.js';
3
+
4
+ /**
5
+ * Validate that no production chapter contains a `TODO: TRANSITION` marker.
6
+ * A writer (or a drafting tool) leaves one where a transition between
7
+ * sections is still owed; it must be resolved before production builds.
8
+ * @param {string[]} [chapters] - Files listed in index.yml (read from it by default)
9
+ * @returns {{errors: string[], warnings: string[]}} Validation results
10
+ */
11
+ export function validateNoTransitionTodos(chapters = getIndexFiles()) {
12
+ const errors = [];
13
+ const warnings = [];
14
+
15
+ for (const file of chapters) {
16
+ if (!fs.existsSync(file)) continue;
17
+ const content = fs.readFileSync(file, 'utf8');
18
+ if (/TODO:\s*TRANSITION/i.test(content)) {
19
+ errors.push(
20
+ `${file} contains an unresolved "TODO: TRANSITION" marker. ` +
21
+ `Write the transition, or remove the marker, before merging.`
22
+ );
23
+ }
24
+ }
25
+
26
+ return { errors, warnings };
27
+ }
28
+
29
+ export default {
30
+ name: 'no-transition-todos',
31
+ tags: ['style'],
32
+ defaults: {},
33
+ run: (ctx) => validateNoTransitionTodos(ctx.indexFiles),
34
+ };
@@ -0,0 +1,72 @@
1
+ import fs from 'node:fs';
2
+
3
+ /**
4
+ * Validate that numeric ranges use an en dash (–), not a hyphen (CMOS 6.78):
5
+ * "9–5", "25–30", "50–60%", not "9-5". A hyphenated digit-hyphen-digit run in
6
+ * prose is almost always a range typed with the wrong dash.
7
+ *
8
+ * Matches a standalone `\d{1,4}-\d{1,4}` — the surrounding negative look-arounds
9
+ * (`[\w./-]`) exclude version numbers (`1.2-3`), ISBNs (`979-8-…`), slashed
10
+ * sequences, and hyphenated identifiers. Inline code, fenced code blocks, URLs,
11
+ * and YAML frontmatter are skipped. (Comma/`$`-bearing ranges like
12
+ * `$10,000-$15,000` are out of scope — the digit-hyphen-digit run is broken by
13
+ * the comma or symbol.)
14
+ * @param {string[]} indexFiles - Files listed in index.yml
15
+ * @returns {{errors: string[], warnings: string[]}}
16
+ */
17
+ export function validateNumberRangeDashes(indexFiles) {
18
+ const errors = [];
19
+
20
+ for (const file of indexFiles) {
21
+ if (!fs.existsSync(file)) continue;
22
+ const content = fs.readFileSync(file, 'utf8');
23
+ const lines = content.split('\n');
24
+ let inCodeBlock = false;
25
+ let inFrontmatter = false;
26
+
27
+ for (let i = 0; i < lines.length; i++) {
28
+ const line = lines[i];
29
+
30
+ if (i === 0 && line.trim() === '---') {
31
+ inFrontmatter = true;
32
+ continue;
33
+ }
34
+ if (inFrontmatter && line.trim() === '---') {
35
+ inFrontmatter = false;
36
+ continue;
37
+ }
38
+ if (inFrontmatter) continue;
39
+
40
+ if (line.trim().startsWith('```')) {
41
+ inCodeBlock = !inCodeBlock;
42
+ continue;
43
+ }
44
+ if (inCodeBlock) continue;
45
+
46
+ const range = /(?<![\w./-])\d{1,4}-\d{1,4}(?![\w./-])/g;
47
+ let m;
48
+ while ((m = range.exec(line)) !== null) {
49
+ const before = line.slice(0, m.index);
50
+
51
+ // Skip if inside inline code (odd number of backticks before match)
52
+ if ((before.match(/`/g) || []).length % 2 === 1) continue;
53
+
54
+ // Skip if inside a URL
55
+ if (/https?:\/\/\S*$/.test(before)) continue;
56
+
57
+ errors.push(
58
+ `Number range should use an en dash (–), not a hyphen, in ${file}:${i + 1}: "...${line.slice(Math.max(0, m.index - 15), m.index + m[0].length + 15).trim()}..."`
59
+ );
60
+ }
61
+ }
62
+ }
63
+
64
+ return { errors, warnings: [] };
65
+ }
66
+
67
+ export default {
68
+ name: 'number-range-dashes',
69
+ tags: ['style'],
70
+ defaults: {},
71
+ run: (ctx) => validateNumberRangeDashes(ctx.indexFiles),
72
+ };
@@ -0,0 +1,62 @@
1
+ import fs from 'node:fs';
2
+ import { extractParagraphs, isParagraphLengthExempt, MAX_PARAGRAPH_WORDS } from '../lib/paragraphs.js';
3
+ import { GENERATED_FILES, allowFor, optionsFor } from '../lib/validate.js';
4
+
5
+ const defaults = {
6
+ // Longest prose paragraph allowed, in words.
7
+ max_words: MAX_PARAGRAPH_WORDS,
8
+ };
9
+
10
+ /**
11
+ * Validate paragraph lengths for skim-ability.
12
+ *
13
+ * Long paragraphs are walls of text; skimmers (and readers with ADHD) bounce
14
+ * off them. Flags any prose paragraph — body text or callout text — over
15
+ * the `max_words` option (default MAX_PARAGRAPH_WORDS, 120, which flags only
16
+ * the longest few percent of paragraphs in typical prose). Generated key-takeaways chapters, front/back matter, and scratch
17
+ * files are exempt (see isParagraphLengthExempt in lib/paragraphs.js).
18
+ *
19
+ * @param {string[]} indexFiles - Chapter files from index.yml
20
+ * @param {{max_words?: number}} [options]
21
+ * @param {Set<string>} [allowed] - "file:line" paragraphs exempt (validators.allow)
22
+ * @returns {{errors: string[], warnings: string[]}} Validation results
23
+ */
24
+ export function validateParagraphLength(
25
+ indexFiles,
26
+ options = optionsFor('paragraph-length', defaults),
27
+ allowed = allowFor('paragraph-length'),
28
+ ) {
29
+ const errors = [];
30
+ const MAX_WORDS = options.max_words;
31
+
32
+ // Intentional exceptions — paragraphs that genuinely resist splitting
33
+ // (one sustained argument that weakens when broken). Keep this near-empty;
34
+ // splitting is almost always the right call.
35
+ // Format: "file:line" using the paragraph start line.
36
+ const PARAGRAPH_LENGTH_ALLOWLIST = allowed;
37
+
38
+ for (const file of indexFiles) {
39
+ if (!fs.existsSync(file)) continue;
40
+ if (isParagraphLengthExempt(file)) continue;
41
+ if (GENERATED_FILES.has(file)) continue; // generated pages (lint.generated) aren't prose
42
+ const content = fs.readFileSync(file, 'utf8');
43
+
44
+ for (const paragraph of extractParagraphs(content)) {
45
+ if (paragraph.words <= MAX_WORDS) continue;
46
+ const location = `${file}:${paragraph.line}`;
47
+ if (PARAGRAPH_LENGTH_ALLOWLIST.has(location)) continue;
48
+ errors.push(
49
+ `Paragraph too long (${paragraph.words} words, max ${MAX_WORDS}) in ${location} — split it up for skim-ability`
50
+ );
51
+ }
52
+ }
53
+
54
+ return { errors, warnings: [] };
55
+ }
56
+
57
+ export default {
58
+ name: 'paragraph-length',
59
+ tags: ['style'],
60
+ defaults,
61
+ run: (ctx) => validateParagraphLength(ctx.indexFiles, ctx.options, ctx.allow),
62
+ };
@@ -0,0 +1,85 @@
1
+ import fs from 'node:fs';
2
+
3
+ /**
4
+ * Validate quote punctuation against two rules:
5
+ *
6
+ * 1. Periods and commas belong INSIDE the closing quote (US convention):
7
+ * `left".` must read `left."` and `"fit",` must read `"fit,"`.
8
+ * Flagged as: a *letter* + closing quote (straight " or curly ") + `.`/`,`.
9
+ * Requiring a letter before the quote deliberately excludes inch/foot
10
+ * measurements (`6".`, `0.75"`) — a digit, not a letter, precedes the quote.
11
+ *
12
+ * 2. No comma after a quoted question or exclamation (Chicago style):
13
+ * `"…ready?", she asked` must read `"…ready?" she asked`.
14
+ * Flagged as: `?` or `!` + closing quote + `,`.
15
+ *
16
+ * Inline code, fenced code blocks, and YAML frontmatter are skipped.
17
+ * @param {string[]} indexFiles - Files listed in index.yml
18
+ * @returns {{errors: string[], warnings: string[]}}
19
+ */
20
+ export function validateQuotePunctuation(indexFiles) {
21
+ const errors = [];
22
+
23
+ for (const file of indexFiles) {
24
+ if (!fs.existsSync(file)) continue;
25
+ const content = fs.readFileSync(file, 'utf8');
26
+ const lines = content.split('\n');
27
+ let inCodeBlock = false;
28
+ let inFrontmatter = false;
29
+
30
+ for (let i = 0; i < lines.length; i++) {
31
+ const line = lines[i];
32
+
33
+ // Track YAML frontmatter
34
+ if (i === 0 && line.trim() === '---') {
35
+ inFrontmatter = true;
36
+ continue;
37
+ }
38
+ if (inFrontmatter && line.trim() === '---') {
39
+ inFrontmatter = false;
40
+ continue;
41
+ }
42
+ if (inFrontmatter) continue;
43
+
44
+ // Track fenced code blocks
45
+ if (line.trim().startsWith('```')) {
46
+ inCodeBlock = !inCodeBlock;
47
+ continue;
48
+ }
49
+ if (inCodeBlock) continue;
50
+
51
+ const isInInlineCode = (index) =>
52
+ ((line.slice(0, index).match(/`/g) || []).length % 2) === 1;
53
+ const context = (index, len) =>
54
+ line.slice(Math.max(0, index - 15), index + len + 15).trim();
55
+
56
+ // Rule 1: letter, closing quote (straight or curly), then period/comma.
57
+ const leaked = /[A-Za-z]["”][.,]/g;
58
+ let m;
59
+ while ((m = leaked.exec(line)) !== null) {
60
+ if (isInInlineCode(m.index)) continue;
61
+ errors.push(
62
+ `Quote punctuation: period/comma belongs inside the closing quote in ${file}:${i + 1}: "...${context(m.index, m[0].length)}..."`
63
+ );
64
+ }
65
+
66
+ // Rule 2: comma after a quoted question/exclamation (drop it, Chicago style).
67
+ const questionComma = /[?!]["”],/g;
68
+ while ((m = questionComma.exec(line)) !== null) {
69
+ if (isInInlineCode(m.index)) continue;
70
+ errors.push(
71
+ `Quote punctuation: drop the comma after a quoted question/exclamation in ${file}:${i + 1}: "...${context(m.index, m[0].length)}..."`
72
+ );
73
+ }
74
+ }
75
+ }
76
+
77
+ return { errors, warnings: [] };
78
+ }
79
+
80
+ export default {
81
+ name: 'quote-punctuation',
82
+ tags: ['style'],
83
+ defaults: {},
84
+ run: (ctx) => validateQuotePunctuation(ctx.indexFiles),
85
+ };
@@ -0,0 +1,90 @@
1
+ import fs from 'node:fs';
2
+ import { getSectionIntros } from '../lib/validate.js';
3
+
4
+ /**
5
+ * Validate that the final paragraph of each narrative section intro is inside
6
+ * a `.lead` fenced div, matching the lead styling of the rest of the intro.
7
+ * In HTML, `section.section-opener > p` already applies lead styling to direct
8
+ * paragraphs, but EPUB and DOCX rely on the explicit `.lead` div
9
+ * (`custom-style="lead"`), so a trailing plain paragraph renders as body text
10
+ * there while the surrounding intro renders as lead. Only the section-opener
11
+ * preamble (before the first H2) is checked.
12
+ * @returns {{errors: string[], warnings: string[]}} Validation results
13
+ */
14
+ export function validateSectionIntroFinalLead() {
15
+ const errors = [];
16
+ const warnings = [];
17
+ const intros = getSectionIntros();
18
+
19
+ for (const { section, file } of intros) {
20
+ const content = fs.readFileSync(file, 'utf8');
21
+ // Only check narrative section intros: those whose first heading is an H1.
22
+ // Front-matter files (copyright, dedication) and the back cover don't serve
23
+ // as narrative bridges between sections.
24
+ const firstHeading = content.split('\n').find((l) => /^#+\s/.test(l.trim()));
25
+ if (!firstHeading || !/^#\s/.test(firstHeading.trim())) continue;
26
+ if (section === 'Back cover') continue;
27
+
28
+ // Only check the section-opener preamble (before the first H2)
29
+ const h2Index = content.search(/^## /m);
30
+ const preamble = h2Index !== -1 ? content.slice(0, h2Index) : content;
31
+ const lines = preamble.split('\n');
32
+
33
+ // Walk the preamble tracking fenced divs to find the innermost div class
34
+ // (if any) containing the last content line.
35
+ let inCodeBlock = false;
36
+ const divStack = [];
37
+ let lastContentLine = null;
38
+ let lastContentDivClass = null;
39
+
40
+ for (let i = 0; i < lines.length; i++) {
41
+ const line = lines[i].trim();
42
+
43
+ if (/^```/.test(line)) {
44
+ inCodeBlock = !inCodeBlock;
45
+ continue;
46
+ }
47
+ if (inCodeBlock) continue;
48
+
49
+ // Opening div: ::: {.class ...}
50
+ const openMatch = line.match(/^:::\s*\{[^}]*\.([\w-]+)/);
51
+ if (openMatch) {
52
+ divStack.push(openMatch[1]);
53
+ continue;
54
+ }
55
+ // Opening div without a class: ::: {custom-style="..."} or ::: {#id}
56
+ if (/^:::\s*\{/.test(line)) {
57
+ divStack.push(null);
58
+ continue;
59
+ }
60
+ // Closing div
61
+ if (/^:::\s*$/.test(line)) {
62
+ divStack.pop();
63
+ continue;
64
+ }
65
+
66
+ // Skip blanks, headings, and HTML comments
67
+ if (line === '' || /^#+\s/.test(line) || /^<!--/.test(line)) continue;
68
+
69
+ lastContentLine = i + 1;
70
+ lastContentDivClass = divStack.length > 0 ? divStack[divStack.length - 1] : null;
71
+ }
72
+
73
+ if (lastContentLine !== null && lastContentDivClass !== 'lead') {
74
+ errors.push(
75
+ `Section intro "${section}" in ${file}:${lastContentLine} ends with a paragraph outside a .lead div. ` +
76
+ `Wrap the final paragraph in ::: {.lead custom-style="lead"} so it matches the lead styling ` +
77
+ `of the rest of the section intro in EPUB and DOCX output.`
78
+ );
79
+ }
80
+ }
81
+
82
+ return { errors, warnings };
83
+ }
84
+
85
+ export default {
86
+ name: 'section-intro-final-lead',
87
+ tags: ['style'],
88
+ defaults: {},
89
+ run: () => validateSectionIntroFinalLead(),
90
+ };
@@ -0,0 +1,41 @@
1
+ import fs from 'node:fs';
2
+ import { getSectionIntros } from '../lib/validate.js';
3
+
4
+ /**
5
+ * Validate that section intro files use {.section-opener} on their H1 heading.
6
+ * The .section-opener class triggers page-level CSS that applies lead styling
7
+ * to all direct paragraphs and lists, replacing the need for individual .lead divs.
8
+ * @returns {{errors: string[], warnings: string[]}} Validation results
9
+ */
10
+ export function validateSectionIntroHasLead() {
11
+ const errors = [];
12
+ const warnings = [];
13
+ const intros = getSectionIntros();
14
+
15
+ for (const { section, file } of intros) {
16
+ const content = fs.readFileSync(file, 'utf8');
17
+ // Only check narrative section intros: those whose first heading is an H1.
18
+ // Front-matter files (copyright, dedication) and the back cover don't serve
19
+ // as narrative bridges between sections.
20
+ const firstHeading = content.split('\n').find((l) => /^#+\s/.test(l.trim()));
21
+ if (!firstHeading || !/^#\s/.test(firstHeading.trim())) continue;
22
+ if (section === 'Back cover') continue;
23
+
24
+ const hasSectionOpener = /^#\s.*\{[^}]*\.section-opener[^}]*\}/m.test(content);
25
+ if (!hasSectionOpener) {
26
+ errors.push(
27
+ `Section intro "${section}" in ${file} is missing {.section-opener} on its H1 heading. ` +
28
+ `Add {.section-opener} to the H1 for page-level lead styling.`
29
+ );
30
+ }
31
+ }
32
+
33
+ return { errors, warnings };
34
+ }
35
+
36
+ export default {
37
+ name: 'section-intro-has-lead',
38
+ tags: ['style'],
39
+ defaults: {},
40
+ run: () => validateSectionIntroHasLead(),
41
+ };
@@ -0,0 +1,37 @@
1
+ import fs from 'node:fs';
2
+ import { getSectionIntros } from '../lib/validate.js';
3
+
4
+ /**
5
+ * Validate that section intro files do not contain TL;DR blocks in the section-opener
6
+ * portion (before the first H2). TL;DRs under H2 sub-chapters within the file are allowed.
7
+ * Section intros use {.section-opener} on their H1 instead of TL;DR blocks.
8
+ * @returns {{errors: string[], warnings: string[]}} Validation results
9
+ */
10
+ export function validateSectionIntroNoTldr() {
11
+ const errors = [];
12
+ const warnings = [];
13
+ const intros = getSectionIntros();
14
+
15
+ for (const { section, file } of intros) {
16
+ const content = fs.readFileSync(file, 'utf8');
17
+ // Only check the portion before the first H2 heading
18
+ const h2Index = content.search(/^## /m);
19
+ const preamble = h2Index !== -1 ? content.slice(0, h2Index) : content;
20
+ const hasTldr = /^:::\s*\{\.tldr\b/m.test(preamble);
21
+ if (hasTldr) {
22
+ errors.push(
23
+ `Section intro "${section}" in ${file} contains a TL;DR block in the section-opener preamble. ` +
24
+ `Section intros should use {.section-opener} on their H1, not .tldr blocks.`
25
+ );
26
+ }
27
+ }
28
+
29
+ return { errors, warnings };
30
+ }
31
+
32
+ export default {
33
+ name: 'section-intro-no-tldr',
34
+ tags: ['style'],
35
+ defaults: {},
36
+ run: () => validateSectionIntroNoTldr(),
37
+ };
@@ -0,0 +1,33 @@
1
+ import fs from 'node:fs';
2
+ import { getSectionIntros } from '../lib/validate.js';
3
+
4
+ /**
5
+ * Validate that section intro files use H1 headings (not H2 or lower).
6
+ * Section intros must use # (H1) to appear as top-level entries in the PDF table of contents.
7
+ * @returns {{errors: string[], warnings: string[]}} Validation results
8
+ */
9
+ export function validateSectionIntros() {
10
+ const errors = [];
11
+ const warnings = [];
12
+ const intros = getSectionIntros();
13
+
14
+ for (const { section, file } of intros) {
15
+ const firstLine = fs.readFileSync(file, 'utf8').split('\n')[0] || '';
16
+ const match = firstLine.match(/^(#+)\s/);
17
+ if (match && match[1].length > 1) {
18
+ errors.push(
19
+ `Section intro "${section}" in ${file} uses H${match[1].length} heading instead of H1. ` +
20
+ `Section intros must use # (H1) to appear in the PDF table of contents.`
21
+ );
22
+ }
23
+ }
24
+
25
+ return { errors, warnings };
26
+ }
27
+
28
+ export default {
29
+ name: 'section-intros',
30
+ tags: ['style'],
31
+ defaults: {},
32
+ run: () => validateSectionIntros(),
33
+ };
@@ -0,0 +1,132 @@
1
+ import fs from 'node:fs';
2
+ import { allowFor, optionsFor } from '../lib/validate.js';
3
+
4
+ const defaults = {
5
+ // Consecutive sentences with the same first word that trigger a warning.
6
+ threshold: 3,
7
+ };
8
+
9
+ /**
10
+ * Validate sentence starter variety within paragraphs.
11
+ * Warns when `threshold` (3) or more consecutive sentences in a paragraph start with the same word.
12
+ * Repetitive openings make prose feel monotonous and AI-generated.
13
+ * @param {string[]} indexFiles - Files listed in index.yml
14
+ * @param {{threshold?: number}} [options]
15
+ * @param {Set<string>} [allowed] - "file:line" paragraphs exempt (validators.allow)
16
+ * @returns {{errors: string[], warnings: string[]}}
17
+ */
18
+ export function validateSentenceStarters(
19
+ indexFiles,
20
+ options = optionsFor('sentence-starters', defaults),
21
+ allowed = allowFor('sentence-starters'),
22
+ ) {
23
+ const warnings = [];
24
+ const THRESHOLD = options.threshold; // consecutive sentences with same starter
25
+
26
+ // Intentional rhetorical patterns (parallel structure, escalation, question lists)
27
+ // Format: "file:line" using the paragraph start line reported by the checker
28
+ const RHETORICAL_ALLOWLIST = allowed;
29
+
30
+ for (const file of indexFiles) {
31
+ if (!fs.existsSync(file)) continue;
32
+ const content = fs.readFileSync(file, 'utf8');
33
+ const lines = content.split('\n');
34
+ let inCodeBlock = false;
35
+ let inCallout = false;
36
+ let paragraphLines = [];
37
+ let paragraphStartLine = 0;
38
+
39
+ const checkParagraph = () => {
40
+ if (paragraphLines.length === 0) return;
41
+ const text = paragraphLines.join(' ');
42
+
43
+ // Split into sentences (simple heuristic: period/!/? followed by space and uppercase)
44
+ const sentences = text.split(/(?<=[.!?])\s+(?=[A-Z"'*[])/).filter(s => s.trim());
45
+ if (sentences.length < THRESHOLD) return;
46
+
47
+ // Check for consecutive same-word starters
48
+ let streak = 1;
49
+ let prevStarter = '';
50
+ for (let i = 0; i < sentences.length; i++) {
51
+ // Extract first word (skip markdown formatting: **, *, [)
52
+ const starterMatch = sentences[i].match(/^[*_[\]]*([A-Za-z]+)/);
53
+ if (!starterMatch) {
54
+ streak = 1;
55
+ prevStarter = '';
56
+ continue;
57
+ }
58
+ const starter = starterMatch[1].toLowerCase();
59
+
60
+ if (starter === prevStarter && starter.length > 1) {
61
+ streak++;
62
+ if (streak >= THRESHOLD) {
63
+ const location = `${file}:${paragraphStartLine}`;
64
+ if (!RHETORICAL_ALLOWLIST.has(location)) {
65
+ warnings.push(
66
+ `${streak} consecutive sentences start with "${starterMatch[1]}" in ${location}`
67
+ );
68
+ }
69
+ break;
70
+ }
71
+ } else {
72
+ streak = 1;
73
+ }
74
+ prevStarter = starter;
75
+ }
76
+ };
77
+
78
+ for (let i = 0; i < lines.length; i++) {
79
+ const line = lines[i];
80
+
81
+ if (/^```/.test(line.trim())) {
82
+ inCodeBlock = !inCodeBlock;
83
+ checkParagraph();
84
+ paragraphLines = [];
85
+ continue;
86
+ }
87
+ if (inCodeBlock) continue;
88
+
89
+ // Skip callout divs (their content is intentionally structured)
90
+ if (/^:::\s*\{/.test(line.trim())) {
91
+ checkParagraph();
92
+ paragraphLines = [];
93
+ inCallout = true;
94
+ continue;
95
+ }
96
+ if (/^:::\s*$/.test(line.trim())) {
97
+ inCallout = false;
98
+ paragraphLines = [];
99
+ continue;
100
+ }
101
+ if (inCallout) continue;
102
+
103
+ // Skip headings, list items, and blank lines
104
+ if (/^#{1,6}\s/.test(line) || /^\s*[-*+]\s/.test(line) || /^\s*\d+\.\s/.test(line)) {
105
+ checkParagraph();
106
+ paragraphLines = [];
107
+ continue;
108
+ }
109
+
110
+ if (line.trim() === '') {
111
+ checkParagraph();
112
+ paragraphLines = [];
113
+ continue;
114
+ }
115
+
116
+ if (paragraphLines.length === 0) {
117
+ paragraphStartLine = i + 1;
118
+ }
119
+ paragraphLines.push(line.trim());
120
+ }
121
+ checkParagraph();
122
+ }
123
+
124
+ return { errors: [], warnings };
125
+ }
126
+
127
+ export default {
128
+ name: 'sentence-starters',
129
+ tags: ['style'],
130
+ defaults,
131
+ run: (ctx) => validateSentenceStarters(ctx.indexFiles, ctx.options, ctx.allow),
132
+ };