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