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,73 @@
1
+ import fs from 'node:fs';
2
+
3
+ /**
4
+ * Validate that em-dashes are used instead of spaced hyphens.
5
+ * Detects patterns like "word - word" which should be "word—word" or "word — word".
6
+ * Skips code blocks, YAML frontmatter, and markdown list markers.
7
+ * @param {string[]} indexFiles - Files listed in index.yml
8
+ * @returns {{errors: string[], warnings: string[]}}
9
+ */
10
+ export function validateEmDashes(indexFiles) {
11
+ const warnings = [];
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
+ let inFrontmatter = false;
19
+
20
+ for (let i = 0; i < lines.length; i++) {
21
+ const line = lines[i];
22
+
23
+ // Track YAML frontmatter
24
+ if (i === 0 && line.trim() === '---') {
25
+ inFrontmatter = true;
26
+ continue;
27
+ }
28
+ if (inFrontmatter && line.trim() === '---') {
29
+ inFrontmatter = false;
30
+ continue;
31
+ }
32
+ if (inFrontmatter) continue;
33
+
34
+ // Track code blocks
35
+ if (line.trim().startsWith('```')) {
36
+ inCodeBlock = !inCodeBlock;
37
+ continue;
38
+ }
39
+ if (inCodeBlock) continue;
40
+
41
+ // Skip list markers (- item), horizontal rules (---), and table separators
42
+ if (/^\s*[-*+]\s/.test(line)) continue;
43
+ if (/^-{3,}$/.test(line.trim())) continue;
44
+ if (/^\|/.test(line.trim())) continue;
45
+
46
+ // Detect spaced hyphens: "word - word" (not "e.g. - " list continuation)
47
+ // Match: letter/closing-paren/quote, space(s), single hyphen, space(s), letter/opening-quote
48
+ const spacedHyphen = /(?<=[a-zA-Z)'""])\s+-\s+(?=[a-zA-Z('""])/g;
49
+ let m;
50
+ while ((m = spacedHyphen.exec(line)) !== null) {
51
+ // Skip if inside inline code
52
+ const before = line.slice(0, m.index);
53
+ if ((before.match(/`/g) || []).length % 2 === 1) continue;
54
+
55
+ // Skip if inside a URL
56
+ if (/https?:\/\/\S*$/.test(before)) continue;
57
+
58
+ warnings.push(
59
+ `Spaced hyphen (should be em-dash?) in ${file}:${i + 1}: "...${line.slice(Math.max(0, m.index - 15), m.index + m[0].length + 15).trim()}..."`
60
+ );
61
+ }
62
+ }
63
+ }
64
+
65
+ return { errors: [], warnings };
66
+ }
67
+
68
+ export default {
69
+ name: 'em-dashes',
70
+ tags: ['style'],
71
+ defaults: {},
72
+ run: (ctx) => validateEmDashes(ctx.indexFiles),
73
+ };
@@ -0,0 +1,46 @@
1
+ import fs from 'node:fs';
2
+
3
+ /**
4
+ * Validate that no smallcaps spans have escaped brackets.
5
+ * A backslash before the opening bracket (e.g., \[AM]{.smallcaps}) prevents
6
+ * pandoc from rendering the span, producing raw markup in the output.
7
+ * @param {string[]} indexFiles - Files listed in index.yml
8
+ * @returns {{errors: string[], warnings: string[]}}
9
+ */
10
+ export function validateEscapedSmallcaps(indexFiles) {
11
+ const errors = [];
12
+ const pattern = /\\\[[^\]]*?\]\{\.smallcaps\}/g;
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
+
20
+ for (let i = 0; i < lines.length; i++) {
21
+ const line = lines[i];
22
+
23
+ if (line.trim().startsWith('```')) {
24
+ inCodeBlock = !inCodeBlock;
25
+ continue;
26
+ }
27
+ if (inCodeBlock) continue;
28
+
29
+ let m;
30
+ while ((m = pattern.exec(line)) !== null) {
31
+ errors.push(
32
+ `Escaped smallcaps ${m[0]} in ${file}:${i + 1} — remove the backslash`
33
+ );
34
+ }
35
+ }
36
+ }
37
+
38
+ return { errors, warnings: [] };
39
+ }
40
+
41
+ export default {
42
+ name: 'escaped-smallcaps',
43
+ tags: ['style'],
44
+ defaults: {},
45
+ run: (ctx) => validateEscapedSmallcaps(ctx.indexFiles),
46
+ };
@@ -0,0 +1,78 @@
1
+ import fs from 'node:fs';
2
+
3
+ /**
4
+ * Validate that footnote references and definitions match.
5
+ * Every [^label] reference must have a [^label]: definition, and vice versa.
6
+ * Mismatches break footnote rendering in PDF and EPUB.
7
+ * @param {string[]} indexFiles - Files listed in index.yml
8
+ * @returns {{errors: string[], warnings: string[]}}
9
+ */
10
+ export function validateFootnotes(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
+ const refs = new Map(); // label -> first line number
19
+ const defs = new Map(); // label -> line number
20
+
21
+ for (let i = 0; i < lines.length; i++) {
22
+ const line = lines[i];
23
+
24
+ if (/^```/.test(line.trim())) {
25
+ inCodeBlock = !inCodeBlock;
26
+ continue;
27
+ }
28
+ if (inCodeBlock) continue;
29
+
30
+ // Footnote definitions: [^label]: at start of line
31
+ const defMatch = line.match(/^\[\^([^\]]+)\]:/);
32
+ if (defMatch) {
33
+ defs.set(defMatch[1], i + 1);
34
+ }
35
+
36
+ // Footnote references: [^label] not followed by :
37
+ // Must be careful not to match definitions
38
+ const refPattern = /\[\^([^\]]+)\](?!:)/g;
39
+ let m;
40
+ while ((m = refPattern.exec(line)) !== null) {
41
+ // Skip if this is actually a definition line (starts with [^)
42
+ if (/^\[\^/.test(line)) continue;
43
+
44
+ const label = m[1];
45
+ if (!refs.has(label)) {
46
+ refs.set(label, i + 1);
47
+ }
48
+ }
49
+ }
50
+
51
+ // Check for references without definitions
52
+ for (const [label, lineNum] of refs) {
53
+ if (!defs.has(label)) {
54
+ errors.push(
55
+ `Footnote reference [^${label}] without definition in ${file}:${lineNum}`
56
+ );
57
+ }
58
+ }
59
+
60
+ // Check for definitions without references (orphaned footnotes)
61
+ for (const [label, lineNum] of defs) {
62
+ if (!refs.has(label)) {
63
+ errors.push(
64
+ `Orphaned footnote definition [^${label}]: without reference in ${file}:${lineNum}`
65
+ );
66
+ }
67
+ }
68
+ }
69
+
70
+ return { errors, warnings: [] };
71
+ }
72
+
73
+ export default {
74
+ name: 'footnotes',
75
+ tags: ['structure'],
76
+ defaults: {},
77
+ run: (ctx) => validateFootnotes(ctx.indexFiles),
78
+ };
@@ -0,0 +1,58 @@
1
+ import fs from 'node:fs';
2
+
3
+ /**
4
+ * Validate heading hierarchy — no skipping levels (e.g., H2 → H4 without H3).
5
+ * Skipped heading levels break document outline and PDF/EPUB table of contents.
6
+ * @param {string[]} indexFiles - Files listed in index.yml
7
+ * @returns {{errors: string[], warnings: string[]}}
8
+ */
9
+ export function validateHeadingHierarchy(indexFiles) {
10
+ const warnings = [];
11
+
12
+ for (const file of indexFiles) {
13
+ if (!fs.existsSync(file)) continue;
14
+ const content = fs.readFileSync(file, 'utf8');
15
+ const lines = content.split('\n');
16
+ let inCodeBlock = false;
17
+ let prevLevel = 0;
18
+
19
+ for (let i = 0; i < lines.length; i++) {
20
+ const line = lines[i];
21
+
22
+ if (/^```/.test(line.trim())) {
23
+ inCodeBlock = !inCodeBlock;
24
+ continue;
25
+ }
26
+ if (inCodeBlock) continue;
27
+
28
+ const headingMatch = line.match(/^(#{1,6})\s+/);
29
+ if (!headingMatch) continue;
30
+
31
+ const level = headingMatch[1].length;
32
+
33
+ // First heading in file sets the baseline
34
+ if (prevLevel === 0) {
35
+ prevLevel = level;
36
+ continue;
37
+ }
38
+
39
+ // Going deeper by more than one level is a hierarchy violation
40
+ if (level > prevLevel + 1) {
41
+ warnings.push(
42
+ `Heading hierarchy skip: H${prevLevel} → H${level} in ${file}:${i + 1}`
43
+ );
44
+ }
45
+
46
+ prevLevel = level;
47
+ }
48
+ }
49
+
50
+ return { errors: [], warnings };
51
+ }
52
+
53
+ export default {
54
+ name: 'heading-hierarchy',
55
+ tags: ['structure'],
56
+ defaults: {},
57
+ run: (ctx) => validateHeadingHierarchy(ctx.indexFiles),
58
+ };
@@ -0,0 +1,103 @@
1
+ import fs from 'node:fs';
2
+ import { allowFor, optionsFor } from '../lib/validate.js';
3
+
4
+ const defaults = {
5
+ // More openers to flag: regular-expression sources, matched case-insensitively
6
+ // against each paragraph line (anchor with ^ to match only at its start).
7
+ // The warning quotes the source.
8
+ extra_patterns: [],
9
+ };
10
+
11
+ // An extra_patterns entry as a case-insensitive RegExp, naming the config key
12
+ // when the source doesn't parse.
13
+ function compilePattern(source) {
14
+ try {
15
+ return new RegExp(source, 'i');
16
+ } catch (error) {
17
+ throw new Error(`validators.options.hypothetical-hooks.extra_patterns: ${error.message}`, { cause: error });
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Validate that paragraphs don't start with formulaic hypothetical-scenario
23
+ * openers ("Picture this:", "Imagine...", etc.) that sound AI-generated.
24
+ * Reports warnings for any matches found.
25
+ * @param {string[]} indexFiles - Files listed in index.yml
26
+ * @param {{extra_patterns?: string[]}} [options]
27
+ * @param {Set<string>} [allowed] - "file:line" openers exempt (validators.allow)
28
+ * @returns {{errors: string[], warnings: string[]}}
29
+ */
30
+ export function validateHypotheticalHooks(
31
+ indexFiles,
32
+ options = optionsFor('hypothetical-hooks', defaults),
33
+ allowed = allowFor('hypothetical-hooks'),
34
+ ) {
35
+ const warnings = [];
36
+ // Patterns that flag formulaic hypothetical openers
37
+ const patterns = [
38
+ { regex: /^Picture this:/i, label: 'Picture this:' },
39
+ { regex: /^Imagine\b/i, label: 'Imagine...' },
40
+ { regex: /^Consider a\b/i, label: 'Consider a...' },
41
+ { regex: /^Here'?s a scenario/i, label: "Here's a scenario..." },
42
+ { regex: /^Now picture\b/i, label: 'Now picture...' },
43
+ { regex: /^Let'?s say\b/i, label: "Let's say..." },
44
+ ...(options.extra_patterns ?? []).map((source) => ({ regex: compilePattern(source), label: source })),
45
+ ];
46
+
47
+ // Allowlist for intentional uses (analogies, non-workplace metaphors).
48
+ // To exempt a flagged opener, add its "src/chapter.md:line" (the paragraph
49
+ // start line the checker reports). Prune entries when chapters are renamed or
50
+ // removed so the set can't silently drift (cf. the RHETORICAL_ALLOWLIST).
51
+ const HOOK_ALLOWLIST = allowed;
52
+
53
+ for (const file of indexFiles) {
54
+ if (!fs.existsSync(file)) continue;
55
+ const content = fs.readFileSync(file, 'utf8');
56
+ const lines = content.split('\n');
57
+ let inCodeBlock = false;
58
+ let inCallout = false;
59
+
60
+ for (let i = 0; i < lines.length; i++) {
61
+ const line = lines[i];
62
+
63
+ if (line.trim().startsWith('```')) {
64
+ inCodeBlock = !inCodeBlock;
65
+ continue;
66
+ }
67
+ if (inCodeBlock) continue;
68
+
69
+ if (line.startsWith('::: {.')) {
70
+ inCallout = true;
71
+ continue;
72
+ }
73
+ if (line === ':::') {
74
+ inCallout = false;
75
+ continue;
76
+ }
77
+
78
+ // Skip headings, blank lines, list items, and HTML comments
79
+ if (!line.trim() || line.startsWith('#') || line.startsWith('-') || line.startsWith('<!--')) continue;
80
+ if (inCallout) continue;
81
+
82
+ for (const { regex, label } of patterns) {
83
+ if (regex.test(line.trim())) {
84
+ const location = `${file}:${i + 1}`;
85
+ if (!HOOK_ALLOWLIST.has(location)) {
86
+ warnings.push(
87
+ `Formulaic hypothetical opener "${label}" in ${location} — rephrase to avoid AI-sounding hooks`
88
+ );
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ return { errors: [], warnings };
96
+ }
97
+
98
+ export default {
99
+ name: 'hypothetical-hooks',
100
+ tags: ['style'],
101
+ defaults,
102
+ run: (ctx) => validateHypotheticalHooks(ctx.indexFiles, ctx.options, ctx.allow),
103
+ };
@@ -0,0 +1,28 @@
1
+ import fs from 'node:fs';
2
+
3
+ /**
4
+ * Validate index.yml ↔ disk integrity: every file listed in index.yml must
5
+ * exist on disk. The reverse direction — an on-disk, non-excluded chapter that
6
+ * is missing from index.yml — is already caught by validateChapter, so between
7
+ * the two, both directions of the orphan check are covered.
8
+ * @param {string[]} indexFiles - Files listed in index.yml
9
+ * @returns {{errors: string[], warnings: string[]}}
10
+ */
11
+ export function validateIndexIntegrity(indexFiles) {
12
+ const errors = [];
13
+ for (const file of indexFiles) {
14
+ if (!fs.existsSync(file)) {
15
+ errors.push(
16
+ `index.yml lists a file that does not exist on disk: ${file}`
17
+ );
18
+ }
19
+ }
20
+ return { errors, warnings: [] };
21
+ }
22
+
23
+ export default {
24
+ name: 'index-integrity',
25
+ tags: ['structure'],
26
+ defaults: {},
27
+ run: (ctx) => validateIndexIntegrity(ctx.indexFiles),
28
+ };
@@ -0,0 +1,73 @@
1
+ // The kit's built-in validators, in run order. Each module's default export
2
+ // has the same shape as a book's plugin: { name, tags, defaults, run(ctx) },
3
+ // where run returns { errors, warnings }. `tags` is ['structure'] for the
4
+ // neutral checks the kit defaults leave on, or ['style'] for the house-style
5
+ // checks defaults/book.config.yml turns off.
6
+ //
7
+ // Order matters: it's the order findings print in. `chapter` runs first.
8
+
9
+ import chapter from './chapter.js';
10
+ import sectionIntros from './section-intros.js';
11
+ import sectionIntroNoTldr from './section-intro-no-tldr.js';
12
+ import sectionIntroHasLead from './section-intro-has-lead.js';
13
+ import sectionIntroFinalLead from './section-intro-final-lead.js';
14
+ import noTransitionTodos from './no-transition-todos.js';
15
+ import noTodoMarkers from './no-todo-markers.js';
16
+ import noBareUrlLinkText from './no-bare-url-link-text.js';
17
+ import minOpenerLength from './min-opener-length.js';
18
+ import chapterLengths from './chapter-lengths.js';
19
+ import crossReferences from './cross-references.js';
20
+ import indexIntegrity from './index-integrity.js';
21
+ import smallcaps from './smallcaps.js';
22
+ import escapedSmallcaps from './escaped-smallcaps.js';
23
+ import calloutBalance from './callout-balance.js';
24
+ import tldrLength from './tldr-length.js';
25
+ import duplicateHeadings from './duplicate-headings.js';
26
+ import acronymExpansion from './acronym-expansion.js';
27
+ import emDashes from './em-dashes.js';
28
+ import numberRangeDashes from './number-range-dashes.js';
29
+ import quotePunctuation from './quote-punctuation.js';
30
+ import doubledWords from './doubled-words.js';
31
+ import calloutDivs from './callout-divs.js';
32
+ import calloutLabels from './callout-labels.js';
33
+ import footnotes from './footnotes.js';
34
+ import headingHierarchy from './heading-hierarchy.js';
35
+ import sentenceStarters from './sentence-starters.js';
36
+ import listPunctuation from './list-punctuation.js';
37
+ import boldKeywords from './bold-keywords.js';
38
+ import hypotheticalHooks from './hypothetical-hooks.js';
39
+ import paragraphLength from './paragraph-length.js';
40
+
41
+ export const BUILTIN_VALIDATORS = [
42
+ chapter,
43
+ sectionIntros,
44
+ sectionIntroNoTldr,
45
+ sectionIntroHasLead,
46
+ sectionIntroFinalLead,
47
+ noTransitionTodos,
48
+ noTodoMarkers,
49
+ noBareUrlLinkText,
50
+ minOpenerLength,
51
+ chapterLengths,
52
+ crossReferences,
53
+ indexIntegrity,
54
+ smallcaps,
55
+ escapedSmallcaps,
56
+ calloutBalance,
57
+ tldrLength,
58
+ duplicateHeadings,
59
+ acronymExpansion,
60
+ emDashes,
61
+ numberRangeDashes,
62
+ quotePunctuation,
63
+ doubledWords,
64
+ calloutDivs,
65
+ calloutLabels,
66
+ footnotes,
67
+ headingHierarchy,
68
+ sentenceStarters,
69
+ listPunctuation,
70
+ boldKeywords,
71
+ hypotheticalHooks,
72
+ paragraphLength,
73
+ ];
@@ -0,0 +1,123 @@
1
+ import fs from 'node:fs';
2
+
3
+ /**
4
+ * Validate that bullet list items have consistent terminal punctuation.
5
+ * Within a single list, items should either all end with periods or all
6
+ * lack them — mixing looks unprofessional in print.
7
+ * @param {string[]} indexFiles - Files listed in index.yml
8
+ * @returns {{errors: string[], warnings: string[]}}
9
+ */
10
+ export function validateListPunctuation(indexFiles) {
11
+ const warnings = [];
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
+ let currentList = []; // [{line, endsPeriod}]
19
+ let listStartLine = 0;
20
+ let inListContinuation = false;
21
+ let lastItemText;
22
+
23
+ const checkList = () => {
24
+ if (currentList.length < 2) {
25
+ currentList = [];
26
+ return;
27
+ }
28
+
29
+ const withPeriod = currentList.filter(item => item.endsPeriod).length;
30
+ const withoutPeriod = currentList.length - withPeriod;
31
+
32
+ // Only warn if there's a genuine mix (not all same)
33
+ // Exclude single-sentence continuation lists: where only the LAST item
34
+ // has a period (grammatically, these are one sentence broken across bullets)
35
+ if (withPeriod > 0 && withoutPeriod > 0) {
36
+ const lastHasPeriod = currentList[currentList.length - 1].endsPeriod;
37
+ const onlyLastHasPeriod = lastHasPeriod && withPeriod === 1;
38
+ if (!onlyLastHasPeriod) {
39
+ warnings.push(
40
+ `Inconsistent list punctuation in ${file}:${listStartLine} ` +
41
+ `(${withPeriod} items end with period, ${withoutPeriod} don't)`
42
+ );
43
+ }
44
+ }
45
+ currentList = [];
46
+ };
47
+
48
+ for (let i = 0; i < lines.length; i++) {
49
+ const line = lines[i];
50
+
51
+ if (/^```/.test(line.trim())) {
52
+ inCodeBlock = !inCodeBlock;
53
+ checkList();
54
+ inListContinuation = false;
55
+ continue;
56
+ }
57
+ if (inCodeBlock) continue;
58
+
59
+ // Detect bullet list items (- or * or + followed by space)
60
+ const bulletMatch = line.match(/^(\s*[-*+])\s+(.*)/);
61
+ if (bulletMatch) {
62
+ // If this is a new list (previous line wasn't a list item or continuation)
63
+ if (currentList.length === 0) {
64
+ listStartLine = i + 1;
65
+ }
66
+
67
+ lastItemText = bulletMatch[2].trim();
68
+ inListContinuation = true;
69
+
70
+ // Don't evaluate yet — item might continue on next line
71
+ currentList.push({ line: i + 1, endsPeriod: false, text: lastItemText });
72
+ continue;
73
+ }
74
+
75
+ // Continuation of a list item (indented text after a bullet)
76
+ if (inListContinuation && /^\s{2,}\S/.test(line)) {
77
+ lastItemText = line.trim();
78
+ // Update the last item's text
79
+ if (currentList.length > 0) {
80
+ currentList[currentList.length - 1].text = lastItemText;
81
+ }
82
+ continue;
83
+ }
84
+
85
+ // End of list (blank line or non-indented non-list text)
86
+ if (inListContinuation) {
87
+ // Finalize punctuation detection for all items
88
+ for (const item of currentList) {
89
+ const text = item.text;
90
+ // Check if text ends with sentence-ending punctuation (period, !, ?)
91
+ // Strip trailing markdown formatting (**, `, etc.) and quotes first
92
+ const cleaned = text.replace(/[*_`\]}\s"'"')\]]+$/, '');
93
+ item.endsPeriod = /[.!?]$/.test(cleaned);
94
+ }
95
+ checkList();
96
+ inListContinuation = false;
97
+ }
98
+
99
+ // Non-list content: reset
100
+ if (line.trim() === '' && !inListContinuation) {
101
+ continue;
102
+ }
103
+ }
104
+
105
+ // Finalize any remaining list
106
+ if (inListContinuation && currentList.length > 0) {
107
+ for (const item of currentList) {
108
+ const cleaned = item.text.replace(/[*_`\]}\s"'"')\]]+$/, '');
109
+ item.endsPeriod = /[.!?]$/.test(cleaned);
110
+ }
111
+ checkList();
112
+ }
113
+ }
114
+
115
+ return { errors: [], warnings };
116
+ }
117
+
118
+ export default {
119
+ name: 'list-punctuation',
120
+ tags: ['style'],
121
+ defaults: {},
122
+ run: (ctx) => validateListPunctuation(ctx.indexFiles),
123
+ };
@@ -0,0 +1,87 @@
1
+ import fs from 'node:fs';
2
+ import { isNonChapter } from '../lib/structure.js';
3
+ import { config, getIndexFiles, isExcluded, optionsFor } from '../lib/validate.js';
4
+
5
+ const defaults = {
6
+ // Minimum words in the first two paragraphs after the TL;DR, combined.
7
+ min_words: 60,
8
+ };
9
+
10
+ /**
11
+ * Validate that every production chapter's opening (first two paragraphs
12
+ * after the TL;DR block, combined) is at least `min_words` (60) words. Short punchy
13
+ * hook-then-context patterns are fine — what we're guarding against is
14
+ * accidentally truncated or skeletal openers.
15
+ * @param {string[]} [chapters] - Files listed in index.yml (read from it by default)
16
+ * @param {{min_words?: number}} [options]
17
+ * @returns {{errors: string[], warnings: string[]}} Validation results
18
+ */
19
+ export function validateMinOpenerLength(chapters = getIndexFiles(), options = optionsFor('min-opener-length', defaults)) {
20
+ const errors = [];
21
+ const warnings = [];
22
+ const MIN_WORDS = options.min_words;
23
+
24
+ for (const file of chapters) {
25
+ if (!fs.existsSync(file)) continue;
26
+ if (isExcluded(file)) continue;
27
+ // Skip key-takeaway aggregator chapters (they're all-takeaways, no prose).
28
+ // Skip structural pages, takeaways collections, and drafts: not narrative.
29
+ if (isNonChapter(file, config)) continue;
30
+
31
+ const content = fs.readFileSync(file, 'utf8');
32
+ // Find the end of the TL;DR block (if any) and then the first two non-blank
33
+ // prose paragraphs after it.
34
+ const tldrEnd = content.search(/^:::\s*$/m);
35
+ let rest;
36
+ if (tldrEnd !== -1 && /:::\s*\{\.tldr/m.test(content.slice(0, tldrEnd))) {
37
+ rest = content.slice(tldrEnd).replace(/^:::\s*$/m, '').trimStart();
38
+ } else {
39
+ // No TL;DR: use the whole body below the first heading.
40
+ const afterHeading = content.replace(/^#+[^\n]*\n/m, '');
41
+ rest = afterHeading.trimStart();
42
+ }
43
+
44
+ const isProse = (t) => {
45
+ if (!t) return false;
46
+ if (/^#/.test(t)) return false;
47
+ if (/^:::/.test(t)) return false;
48
+ if (/^```/.test(t)) return false;
49
+ if (/^\|/.test(t)) return false;
50
+ if (/^[-*+]\s/.test(t)) return false;
51
+ // HTML linter-control comments (<!-- vale ... -->, <!-- textlint-... -->)
52
+ // sit between paragraphs but aren't prose — don't let them occupy an
53
+ // opener slot and crowd out the real hook.
54
+ if (/^<!--/.test(t)) return false;
55
+ // A leading blockquote is a valid hook device (e.g. a quoted email the
56
+ // chapter then reacts to), so it counts as opener content — see the word
57
+ // cleanup below, which strips the `>` markers before counting.
58
+ return true;
59
+ };
60
+
61
+ const paragraphs = rest.split(/\n{2,}/).map((p) => p.trim()).filter(isProse);
62
+ const opener = paragraphs.slice(0, 2).join(' ');
63
+ if (!opener) continue;
64
+
65
+ const words = opener
66
+ .replace(/\[[^\]]*\]\([^)]*\)/g, '$1') // strip link URLs
67
+ .replace(/^\s*>\s?/gm, '') // strip blockquote markers
68
+ .replace(/[*_`]/g, '')
69
+ .split(/\s+/)
70
+ .filter(Boolean);
71
+ if (words.length < MIN_WORDS) {
72
+ warnings.push(
73
+ `${file}: first two post-TL;DR paragraphs total only ${words.length} words ` +
74
+ `(minimum ${MIN_WORDS}). Chapter may have a truncated or skeletal opener.`
75
+ );
76
+ }
77
+ }
78
+
79
+ return { errors, warnings };
80
+ }
81
+
82
+ export default {
83
+ name: 'min-opener-length',
84
+ tags: ['style'],
85
+ defaults,
86
+ run: (ctx) => validateMinOpenerLength(ctx.indexFiles, ctx.options),
87
+ };