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