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,366 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { isMain } from '../lib/is-main.js';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+
11
+ // Helper function to generate backup directory name with timestamp
12
+ function generateBackupDirName(srcDirectory) {
13
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/T/, '_').slice(0, -5);
14
+ return `${srcDirectory}_backup_${timestamp}`;
15
+ }
16
+
17
+ class LinksToFootnotes {
18
+ // Regex pattern for markdown links: [text](url)
19
+ // Capture groups: 1=link text, 2=url (with optional angle brackets), 3=trailing punctuation
20
+ static LINK_PATTERN = /\[((?:[^[\]]|\[[^\]]*\])+)\]\((<[^>]+>|[^)]+)\)([).,;:!?]*)/g;
21
+
22
+ // Regex pattern for footnote references that appear BEFORE sentence-ending punctuation
23
+ // Used to reorder "text[^1]." to "text.[^1]"
24
+ // Capture groups: 1=footnote reference, 2=punctuation
25
+ static FOOTNOTE_BEFORE_PUNCT_PATTERN = /(\[\^[^\]]+\])([.!?])/g;
26
+
27
+ // Regex pattern for heading attribute blocks containing an explicit ID, e.g., "{#id .class}"
28
+ // Uses \s\{ to require whitespace before {, so inline spans like [URL]{.smallcaps} aren't matched
29
+ static HEADING_ID_ATTR_PATTERN = /\s\{[^}]*#([A-Za-z0-9_:-]+)[^}]*\}\s*$/;
30
+
31
+ // Regex pattern for any trailing heading attribute block, e.g., "{.class}" or "{epub:type=appendix}"
32
+ static HEADING_ATTR_PATTERN = /\s\{[^}]+\}\s*$/;
33
+
34
+ constructor(srcDirectory, createBackup = true, preserveInternalLinks = false, linkFootnoteUrls = false) {
35
+ this.srcDirectory = srcDirectory;
36
+ this.createBackup = createBackup;
37
+ this.preserveInternalLinks = preserveInternalLinks;
38
+ // E-readers can follow a link in a footnote, so the Kindle build keeps the
39
+ // URLs there clickable; print gets plain text, since paper can't.
40
+ this.linkFootnoteUrls = linkFootnoteUrls;
41
+ this.globalFootnoteCounter = 1;
42
+ this.anchorMap = {};
43
+ }
44
+
45
+ async processAllFiles() {
46
+ const files = fs.readdirSync(this.srcDirectory)
47
+ .filter(f => f.endsWith('.md'))
48
+ .sort()
49
+ .map(f => path.join(this.srcDirectory, f));
50
+
51
+ console.log(`Found ${files.length} markdown files to process`);
52
+
53
+ // First pass: build anchor mapping
54
+ this.buildAnchorMap(files);
55
+
56
+ // Second pass: process files
57
+ for (const filePath of files) {
58
+ this.processFile(filePath);
59
+ }
60
+
61
+ console.log('Finished processing all files');
62
+ }
63
+
64
+ // Helper method to pluralize a word based on count
65
+ pluralize(count, singular, plural = null) {
66
+ plural = plural || `${singular}s`;
67
+ return count === 1 ? singular : plural;
68
+ }
69
+
70
+ // Parse a raw heading string to extract the custom ID and cleaned title.
71
+ // Uses \s\{ (not \s*\{) to require whitespace before {, distinguishing
72
+ // heading attributes like {#id} from inline span attributes like [URL]{.smallcaps}.
73
+ parseHeading(rawHeading) {
74
+ const idMatch = rawHeading.match(LinksToFootnotes.HEADING_ID_ATTR_PATTERN);
75
+ if (idMatch) {
76
+ const customId = idMatch[1];
77
+ const title = rawHeading.replace(LinksToFootnotes.HEADING_ID_ATTR_PATTERN, '').trim();
78
+ return { customId, title };
79
+ }
80
+ const title = rawHeading.replace(LinksToFootnotes.HEADING_ATTR_PATTERN, '').trim();
81
+ return { customId: null, title };
82
+ }
83
+
84
+ buildAnchorMap(files) {
85
+ console.log('Building anchor map from headings...');
86
+
87
+ // Track anchor usage to handle duplicates with Pandoc's numbering scheme
88
+ const anchorCounts = {};
89
+
90
+ for (const filePath of files) {
91
+ const content = fs.readFileSync(filePath, 'utf8');
92
+ let currentChapter = null;
93
+
94
+ for (const line of content.split('\n')) {
95
+ // Match headings at levels 1-4
96
+ const match = line.match(/^(#{1,4})\s+(.+)$/);
97
+ if (!match) continue;
98
+
99
+ const level = match[1].length;
100
+ const { customId, title } = this.parseHeading(match[2].trim());
101
+
102
+ let anchor = customId || this.generateAnchor(title);
103
+ anchor = this.handleDuplicateAnchor(anchor, anchorCounts);
104
+
105
+ if (level <= 2) currentChapter = title;
106
+
107
+ this.anchorMap[anchor] = {
108
+ title,
109
+ chapter: level <= 2 ? title : currentChapter,
110
+ level,
111
+ };
112
+ }
113
+ }
114
+
115
+ console.log(` Built map with ${Object.keys(this.anchorMap).length} anchors`);
116
+ }
117
+
118
+ handleDuplicateAnchor(anchor, anchorCounts) {
119
+ // Track how many times we've seen this anchor
120
+ if (anchorCounts[anchor] > 0) {
121
+ // This is a duplicate - append the count like Pandoc does
122
+ const numberedAnchor = `${anchor}-${anchorCounts[anchor]}`;
123
+ anchorCounts[anchor]++;
124
+ return numberedAnchor;
125
+ } else {
126
+ // First occurrence
127
+ anchorCounts[anchor] = 1;
128
+ return anchor;
129
+ }
130
+ }
131
+
132
+ generateAnchor(title) {
133
+ // Approximate Pandoc's auto_identifiers algorithm:
134
+ // 1. Strip Markdown formatting (attribute blocks, spans, links, images, code, emphasis).
135
+ // 2. Normalize punctuation (some chars to hyphens).
136
+ // 3. Lowercase, collapse whitespace to hyphens, and trim extra hyphens.
137
+
138
+ let stripped = title;
139
+
140
+ // Remove trailing attribute block, e.g., "{#id .class}"
141
+ stripped = stripped.replace(LinksToFootnotes.HEADING_ATTR_PATTERN, '');
142
+
143
+ // Inline spans: "[text]{.class}" -> "text"
144
+ stripped = stripped.replace(/\[([^\]]+)\]\{[^}]*\}/g, '$1');
145
+
146
+ // Images: "![alt](url)" -> "alt"
147
+ stripped = stripped.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1');
148
+
149
+ // Links: "[text](url)" -> "text"
150
+ stripped = stripped.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
151
+
152
+ // Inline code: "`code`" -> "code"
153
+ stripped = stripped.replace(/`([^`]*)`/g, '$1');
154
+
155
+ // Emphasis markers: "*text*", "_text_", "~text~", etc. -> "text"
156
+ stripped = stripped.replace(/[*_~^]+/g, '');
157
+
158
+ return stripped
159
+ .toLowerCase()
160
+ .replace(/[^\w\s-]/g, '') // Strip punctuation (matches Pandoc auto_identifiers: apostrophes, colons, quotes etc. are removed, not hyphenated)
161
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
162
+ .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
163
+ .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
164
+ }
165
+
166
+ // A footnote's URL: an autolink when the reader can tap it, else plain text.
167
+ // Pandoc's Markdown doesn't autolink bare URLs, so plain text stays plain.
168
+ formatFootnoteUrl(url) {
169
+ return this.linkFootnoteUrls ? `<${url}>` : url;
170
+ }
171
+
172
+ internalLink(url) {
173
+ return url.startsWith('#');
174
+ }
175
+
176
+ formatInternalLink(linkText, anchor) {
177
+ // Remove leading # from anchor
178
+ anchor = anchor.replace(/^#/, '');
179
+
180
+ // Look up the anchor in our map
181
+ const info = this.anchorMap[anchor];
182
+
183
+ if (!info) {
184
+ // If we can't find the anchor, fall back to the old behavior (footnote)
185
+ return null;
186
+ }
187
+
188
+ // When preserving internal links (e.g., Kindle EPUB), keep the markdown
189
+ // link so Pandoc converts it to a clickable internal navigation link.
190
+ // Kindle KF8 supports intra-book links, unlike printed PDF.
191
+ if (this.preserveInternalLinks) {
192
+ return `[${linkText}](#${anchor})`;
193
+ }
194
+
195
+ // For PDF: strip the link syntax and return just the link text
196
+ // (printed books can't have clickable links)
197
+ return linkText;
198
+ }
199
+
200
+ processFile(filePath) {
201
+ console.log(`Processing ${path.basename(filePath)}...`);
202
+
203
+ const content = fs.readFileSync(filePath, 'utf8');
204
+ const originalContent = content;
205
+
206
+ // Process content line by line to handle footnotes differently
207
+ const lines = content.split('\n');
208
+ const footnotes = [];
209
+ let parentheticalCount = 0;
210
+
211
+ let inFencedCodeBlock = false;
212
+
213
+ const processedLines = lines.map(line => {
214
+ // Track fenced code blocks (``` or ~~~)
215
+ if (/^(`{3,}|~{3,})/.test(line)) {
216
+ inFencedCodeBlock = !inFencedCodeBlock;
217
+ return line;
218
+ }
219
+
220
+ // Skip lines inside fenced code blocks
221
+ if (inFencedCodeBlock) {
222
+ return line;
223
+ }
224
+
225
+ // Check if this line is a footnote definition
226
+ if (line.match(/^\[?\^[^\]]+\]:/)) {
227
+ // Within footnote definitions, convert links to parentheticals
228
+ return line.replace(LinksToFootnotes.LINK_PATTERN, (match, linkText, url, trailingPunct) => {
229
+ // Remove angle brackets if present
230
+ url = url.replace(/^<|>$/g, '');
231
+
232
+ // Convert all links to parentheticals in footnotes
233
+ parentheticalCount++;
234
+ return `${linkText} (${this.formatFootnoteUrl(url)})${trailingPunct}`;
235
+ });
236
+ } else {
237
+ // Mask inline code spans so links inside them aren't converted.
238
+ // Replace each `...` span with a placeholder, apply link conversion,
239
+ // then restore the original code spans.
240
+ const codeSpans = [];
241
+ const masked = line.replace(/`[^`]+`/g, (m) => {
242
+ codeSpans.push(m);
243
+ return `\x00CODE${codeSpans.length - 1}\x00`;
244
+ });
245
+
246
+ // Outside footnote definitions, handle links
247
+ const processed = masked.replace(LinksToFootnotes.LINK_PATTERN, (match, linkText, url, trailingPunct) => {
248
+ // Remove angle brackets if present
249
+ url = url.replace(/^<|>$/g, '');
250
+
251
+ // Check if this is an internal link
252
+ if (this.internalLink(url)) {
253
+ // Try to format as an internal reference
254
+ const formatted = this.formatInternalLink(linkText, url);
255
+
256
+ if (formatted) {
257
+ return `${formatted}${trailingPunct}`;
258
+ } else {
259
+ // If we can't resolve it, create a footnote (fallback)
260
+ const footnoteId = this.globalFootnoteCounter++;
261
+ footnotes.push({ id: footnoteId, url });
262
+ // Footnote references should appear AFTER punctuation
263
+ return `${linkText}${trailingPunct}[^${footnoteId}]`;
264
+ }
265
+ } else {
266
+ // External link - create footnote
267
+ // Footnote references should appear AFTER punctuation
268
+ const footnoteId = this.globalFootnoteCounter++;
269
+ footnotes.push({ id: footnoteId, url });
270
+ return `${linkText}${trailingPunct}[^${footnoteId}]`;
271
+ }
272
+ });
273
+
274
+ // Restore inline code spans (NUL-delimited placeholders)
275
+ // eslint-disable-next-line no-control-regex
276
+ return processed.replace(/\x00CODE(\d+)\x00/g, (_, i) => codeSpans[i]);
277
+ }
278
+ });
279
+
280
+ let newContent = processedLines.join('\n');
281
+
282
+ // Fix existing footnote references where they appear before punctuation
283
+ // e.g., "text[^1]." should become "text.[^1]"
284
+ // This ensures consistent formatting for all footnotes (both existing and newly created)
285
+ let footnoteReorderCount = 0;
286
+ newContent = newContent.replace(LinksToFootnotes.FOOTNOTE_BEFORE_PUNCT_PATTERN, (match, footnoteRef, punct) => {
287
+ footnoteReorderCount++;
288
+ return `${punct}${footnoteRef}`;
289
+ });
290
+
291
+ if (footnoteReorderCount > 0) {
292
+ console.log(` Reordered ${footnoteReorderCount} footnote ${this.pluralize(footnoteReorderCount, 'reference')} to appear after punctuation`);
293
+ }
294
+
295
+ // Add footnote definitions at the end of the file if we found any links
296
+ if (footnotes.length > 0) {
297
+ console.log(` Found ${footnotes.length} ${this.pluralize(footnotes.length, 'link')} to convert to footnotes`);
298
+
299
+ // Add footnotes at the end
300
+ newContent += '\n\n';
301
+ for (const footnote of footnotes) {
302
+ newContent += `[^${footnote.id}]: ${this.formatFootnoteUrl(footnote.url)}\n`;
303
+ }
304
+ }
305
+
306
+ // Report parenthetical conversions
307
+ if (parentheticalCount > 0) {
308
+ console.log(` Converted ${parentheticalCount} ${this.pluralize(parentheticalCount, 'link')} within footnotes to parentheticals`);
309
+ }
310
+
311
+ // Only write if content changed
312
+ if (newContent !== originalContent) {
313
+ fs.writeFileSync(filePath, newContent);
314
+ console.log(' Updated successfully');
315
+ } else {
316
+ console.log(' No changes needed');
317
+ }
318
+ }
319
+ }
320
+
321
+ // Script execution
322
+ if (isMain(import.meta.url)) {
323
+ (async () => {
324
+ const args = process.argv.slice(2);
325
+
326
+ if (args.length < 1 || args.length > 4) {
327
+ console.error(`Usage: ${path.basename(process.argv[1])} <source_directory> [--no-backup] [--preserve-internal-links] [--link-footnote-urls]`);
328
+ console.error(`Example: ${path.basename(process.argv[1])} src`);
329
+ console.error(`Example: ${path.basename(process.argv[1])} src --no-backup`);
330
+ console.error(`Example: ${path.basename(process.argv[1])} src --no-backup --preserve-internal-links`);
331
+ console.error(`Example: ${path.basename(process.argv[1])} src --no-backup --preserve-internal-links --link-footnote-urls`);
332
+ process.exit(1);
333
+ }
334
+
335
+ const srcDirectory = args[0];
336
+ const flags = args.slice(1);
337
+ const createBackup = !flags.includes('--no-backup');
338
+ const preserveInternalLinks = flags.includes('--preserve-internal-links');
339
+ const linkFootnoteUrls = flags.includes('--link-footnote-urls');
340
+
341
+ if (!fs.existsSync(srcDirectory)) {
342
+ console.error(`Error: Directory '${srcDirectory}' does not exist`);
343
+ process.exit(1);
344
+ }
345
+
346
+ // Create a backup directory only if requested
347
+ if (createBackup) {
348
+ const backupDir = generateBackupDirName(srcDirectory);
349
+ console.log(`Creating backup in ${backupDir}...`);
350
+ fs.cpSync(srcDirectory, backupDir, { recursive: true });
351
+ console.log('Backup created successfully');
352
+
353
+ // Process files
354
+ const converter = new LinksToFootnotes(srcDirectory, createBackup, preserveInternalLinks, linkFootnoteUrls);
355
+ await converter.processAllFiles();
356
+
357
+ console.log(`\nDone! Original files backed up to ${backupDir}`);
358
+ } else {
359
+ // Process files
360
+ const converter = new LinksToFootnotes(srcDirectory, createBackup, preserveInternalLinks, linkFootnoteUrls);
361
+ await converter.processAllFiles();
362
+
363
+ console.log('\nDone! No backup created (--no-backup specified)');
364
+ }
365
+ })();
366
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+
3
+ // book-kit markdownlint [markdownlint-cli2 options] [globs]
4
+ //
5
+ // Markdown lint with markdownlint-cli2 and the book's resolved config
6
+ // (lib/lint.js): the kit's base config with lint.markdownlint merged in.
7
+ // Globs default to every chapter in paths.src.
8
+
9
+ import { spawnSync } from 'node:child_process';
10
+ import { loadConfig } from '../lib/config.js';
11
+ import { namesFiles, prepareMarkdownlint } from '../lib/lint.js';
12
+
13
+ const config = loadConfig();
14
+ const configFile = prepareMarkdownlint(config);
15
+
16
+ // Arguments pass through in order; the chapters glob is added only when none names files.
17
+ const args = process.argv.slice(2);
18
+
19
+ const result = spawnSync(
20
+ 'npx',
21
+ ['markdownlint-cli2', '--config', configFile, ...(namesFiles(args, config.root) ? [] : [`${config.paths.src}/*.md`]), ...args],
22
+ { stdio: 'inherit', cwd: config.root },
23
+ );
24
+ if (result.error) throw result.error;
25
+ process.exit(result.status ?? 1);
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Minify CSS files using cssnano.
5
+ * Runs AFTER strip-page-rules.js so variable resolution isn't disrupted.
6
+ *
7
+ * Usage: book-kit minify-css [file1.css] [file2.css] ...
8
+ * If no files given, minifies dist/style-epub.css and dist/style-kindle.css
9
+ */
10
+
11
+ import { readFileSync, writeFileSync } from 'node:fs';
12
+ import postcss from 'postcss';
13
+ import cssnano from 'cssnano';
14
+
15
+ const files = process.argv.slice(2).length > 0
16
+ ? process.argv.slice(2)
17
+ : ['dist/style-epub.css', 'dist/style-kindle.css'];
18
+
19
+ const processor = postcss([
20
+ cssnano({ preset: ['default', { discardComments: { removeAll: true } }] })
21
+ ]);
22
+
23
+ let totalSaved = 0;
24
+
25
+ for (const file of files) {
26
+ try {
27
+ const input = readFileSync(file, 'utf8');
28
+ const result = await processor.process(input, { from: file, to: file });
29
+ writeFileSync(file, result.css);
30
+ const saved = input.length - result.css.length;
31
+ totalSaved += saved;
32
+ const pct = ((saved / input.length) * 100).toFixed(1);
33
+ console.log(` ${file}: ${input.length} → ${result.css.length} bytes (−${pct}%)`);
34
+ } catch (e) {
35
+ if (e.code === 'ENOENT') {
36
+ console.log(` ${file}: skipped (not found)`);
37
+ } else {
38
+ throw e;
39
+ }
40
+ }
41
+ }
42
+
43
+ console.log(`Minified ${files.length} files, saved ${totalSaved} bytes total`);
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Optimize images for EPUB/Kindle builds using sharp.
5
+ * - Losslessly recompresses PNGs (emoji and img/)
6
+ * - Reports size savings, and exits non-zero if any image failed
7
+ *
8
+ * Usage: book-kit optimize-images
9
+ */
10
+
11
+ import sharp from 'sharp';
12
+ import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
13
+ import { join, extname } from 'node:path';
14
+ import { pathFor } from '../lib/config.js';
15
+
16
+ const imgDir = pathFor('img');
17
+
18
+ // PNG only. The cover JPEG is deliberately excluded: sharp/libvips writes JPEGs
19
+ // without the JFIF APP0 marker (they start FF D8 FF DB, not FF D8 FF E0), and
20
+ // pandoc's image reader needs that marker — without it the EPUB build warns
21
+ // "could not determine image size" and embeds the cover with no dimensions.
22
+ // Optimize the cover out-of-band with a JFIF-preserving encoder instead, e.g.:
23
+ // magick cover.jpeg -strip -interlace none -quality 85 cover.jpeg
24
+ const targets = [
25
+ { dir: join(imgDir, 'emoji'), glob: '.png' },
26
+ { dir: imgDir, glob: '.png' },
27
+ ];
28
+
29
+ let totalBefore = 0;
30
+ let totalAfter = 0;
31
+ let filesProcessed = 0;
32
+
33
+ for (const { dir, glob } of targets) {
34
+ let files;
35
+ try {
36
+ files = readdirSync(dir).filter(f => extname(f).toLowerCase() === glob);
37
+ } catch {
38
+ continue;
39
+ }
40
+
41
+ for (const file of files) {
42
+ const filePath = join(dir, file);
43
+ const before = statSync(filePath).size;
44
+
45
+ try {
46
+ const buffer = readFileSync(filePath);
47
+ const optimized = await sharp(buffer)
48
+ .png({ compressionLevel: 9, adaptiveFiltering: true, effort: 10 })
49
+ .toBuffer();
50
+
51
+ if (optimized.length < before) {
52
+ writeFileSync(filePath, optimized);
53
+ const after = optimized.length;
54
+ const pct = ((1 - after / before) * 100).toFixed(1);
55
+ console.log(` ${file}: ${before} → ${after} bytes (−${pct}%)`);
56
+ totalBefore += before;
57
+ totalAfter += after;
58
+ } else {
59
+ console.log(` ${file}: already optimal (${before} bytes)`);
60
+ totalBefore += before;
61
+ totalAfter += before;
62
+ }
63
+ filesProcessed++;
64
+ } catch (e) {
65
+ console.error(` ${file}: error — ${e.message}`);
66
+ process.exitCode = 1;
67
+ }
68
+ }
69
+ }
70
+
71
+ const saved = totalBefore - totalAfter;
72
+ const pct = totalBefore > 0 ? ((saved / totalBefore) * 100).toFixed(1) : '0.0';
73
+ console.log(`\nOptimized ${filesProcessed} images, saved ${saved} bytes (${pct}%)`);
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env bash
2
+ # Compare two builds of a book format by format and fail on any real
3
+ # difference. Use it to prove a change that shouldn't touch the output (a kit
4
+ # upgrade, a toolchain bump, moving a book onto the kit) really doesn't.
5
+ #
6
+ # book-kit parity BEFORE_DIST AFTER_DIST [WORK_DIR]
7
+ #
8
+ # WORK_DIR keeps the unpacked files for inspection; it must be new or empty,
9
+ # so a typo can't wipe a real directory. Without it, a temp dir is used and
10
+ # removed afterwards.
11
+ #
12
+ # Compares every output both directories have, named from book.config.yml:
13
+ # <basename>.epub, -kindle.epub, -arc.epub, -kindle-arc.epub, .html, .docx,
14
+ # .pdf, and <basename><output.pdfx_suffix>.pdf. A file in one directory but
15
+ # not the other fails. Differences that carry no content are ignored:
16
+ # - EPUB/DOCX build timestamps (dcterms:modified, dcterms:created)
17
+ # - PNG bytes when the decoded pixels match (re-encoded, not a new image)
18
+ # - Ghostscript's random six-letter font-subset prefixes
19
+ # PDFs are checked for page count (and print.page_count, if locked), extracted
20
+ # text, embedded fonts, and every page's rendering; the PDF/X also for its
21
+ # PDF/X-1a:2001 marker and output intent.
22
+ #
23
+ # Needs unzip, poppler (pdfinfo, pdftotext, pdffonts, pdftoppm), and a python3
24
+ # with Pillow and pikepdf. Run from the book (book-kit runs commands there).
25
+
26
+ set -uo pipefail
27
+ export LC_ALL=C
28
+
29
+ usage="usage: book-kit parity BEFORE_DIST AFTER_DIST [WORK_DIR]"
30
+ BEFORE=${1:?$usage}
31
+ AFTER=${2:?$usage}
32
+ if [ -n "${3:-}" ]; then
33
+ WORK=$3
34
+ if [ -e "$WORK" ] && [ -n "$(ls -A "$WORK" 2>/dev/null)" ]; then
35
+ echo "book-kit parity: $WORK isn't empty; pass a new or empty WORK_DIR" >&2
36
+ exit 2
37
+ fi
38
+ mkdir -p "$WORK"
39
+ else
40
+ WORK=$(mktemp -d)
41
+ trap 'rm -rf "$WORK"' EXIT
42
+ fi
43
+ BK=(node "$(dirname "$0")/../bin/book-kit.js")
44
+ NAME=$("${BK[@]}" config output.basename)
45
+ PDFX_SUFFIX=$("${BK[@]}" config output.pdfx_suffix)
46
+ fail=0
47
+
48
+ bad() { echo " ✗ $*"; fail=1; }
49
+ ok() { echo " ✓ $*"; }
50
+
51
+ same_pixels() {
52
+ python3 - "$1" "$2" <<'PY'
53
+ import sys
54
+ from PIL import Image, ImageChops
55
+ a, b = (Image.open(p) for p in sys.argv[1:3])
56
+ same = a.size == b.size and ImageChops.difference(a.convert("RGBA"), b.convert("RGBA")).getbbox(alpha_only=False) is None
57
+ sys.exit(0 if same else 1)
58
+ PY
59
+ }
60
+
61
+ strip_timestamps() {
62
+ sed -E \
63
+ -e 's|<meta property="dcterms:modified">[^<]*</meta>|<meta property="dcterms:modified"/>|' \
64
+ -e 's#<dcterms:(created|modified)([^>]*)>[^<]*</dcterms:[a-z]+>#<dcterms:\1\2/>#g' "$1"
65
+ }
66
+
67
+ # Unzip both sides and compare member by member.
68
+ compare_zip() {
69
+ local file=$1 dir="$WORK/$1"
70
+ mkdir -p "$dir/before" "$dir/after"
71
+ if ! { unzip -q "$BEFORE/$file" -d "$dir/before" && unzip -q "$AFTER/$file" -d "$dir/after"; }; then bad "can't unzip"; return; fi
72
+ (cd "$dir/before" && find . -type f | sort) > "$dir/files.before"
73
+ (cd "$dir/after" && find . -type f | sort) > "$dir/files.after"
74
+ if ! diff -u "$dir/files.before" "$dir/files.after"; then bad "member lists differ"; return; fi
75
+ local n=0 reencoded=0 f
76
+ while read -r f; do
77
+ cmp -s "$dir/before/$f" "$dir/after/$f" && continue
78
+ case "$f" in
79
+ *.png)
80
+ if same_pixels "$dir/before/$f" "$dir/after/$f"; then reencoded=$((reencoded + 1)); continue; fi ;;
81
+ *.opf|*core.xml)
82
+ if diff -q <(strip_timestamps "$dir/before/$f") <(strip_timestamps "$dir/after/$f") >/dev/null; then continue; fi ;;
83
+ esac
84
+ bad "$f differs"; n=$((n + 1))
85
+ case "$f" in *.png|*.jpg|*.jpeg|*.gif|*.woff2|*.ttf|*.otf) ;; *) diff -u "$dir/before/$f" "$dir/after/$f" | head -20 ;; esac
86
+ done < "$dir/files.before"
87
+ [ "$n" -eq 0 ] && ok "$(wc -l < "$dir/files.before" | tr -d ' ') members match (ignoring timestamps; $reencoded PNGs re-encoded with identical pixels)"
88
+ }
89
+
90
+ fonts() {
91
+ pdffonts "$1" | tail -n +3 | awk '{ sub(/^[A-Z]{6}\+/, "", $1); print $1, $(NF-4), $(NF-3) }' | sort | uniq -c
92
+ }
93
+
94
+ compare_pdf() {
95
+ local file=$1 dir="$WORK/$1"
96
+ mkdir -p "$dir/before" "$dir/after"
97
+ local pb pa
98
+ pb=$(pdfinfo "$BEFORE/$file" | awk '/^Pages:/ { print $2 }')
99
+ pa=$(pdfinfo "$AFTER/$file" | awk '/^Pages:/ { print $2 }')
100
+ if [ "$pb" = "$pa" ]; then ok "$pa pages in both"; else bad "page count: $pb before, $pa after"; fi
101
+ if "${BK[@]}" config print.page_count 2>/dev/null | grep -qvx null; then
102
+ if "${BK[@]}" check-pdf-page-count "$AFTER/$file" >/dev/null; then ok "matches print.page_count"; else bad "doesn't match print.page_count"; fi
103
+ fi
104
+ pdftotext -layout "$BEFORE/$file" "$dir/before.txt"; pdftotext -layout "$AFTER/$file" "$dir/after.txt"
105
+ if diff -q "$dir/before.txt" "$dir/after.txt" >/dev/null; then ok "text identical"; else bad "text differs"; diff -u "$dir/before.txt" "$dir/after.txt" | head -20; fi
106
+ if diff -q <(fonts "$BEFORE/$file") <(fonts "$AFTER/$file") >/dev/null; then ok "same fonts"; else bad "fonts differ"; diff <(fonts "$BEFORE/$file") <(fonts "$AFTER/$file"); fi
107
+ local unembedded
108
+ unembedded=$(pdffonts "$AFTER/$file" | tail -n +3 | awk '$(NF-4) == "no"' | wc -l | tr -d ' ')
109
+ if [ "$unembedded" -eq 0 ]; then ok "all fonts embedded"; else bad "$unembedded fonts not embedded"; fi
110
+ # Every page at low resolution: enough to catch a layout, image, or color
111
+ # change without diffing hundreds of full-size bitmaps.
112
+ pdftoppm -r 40 -gray -png "$BEFORE/$file" "$dir/before/p" &
113
+ pdftoppm -r 40 -gray -png "$AFTER/$file" "$dir/after/p" &
114
+ wait
115
+ local d=0 page
116
+ for page in "$dir"/before/*.png; do
117
+ cmp -s "$page" "$dir/after/$(basename "$page")" || { d=$((d + 1)); echo " page differs: $(basename "$page")"; }
118
+ done
119
+ if [ "$d" -eq 0 ]; then ok "every page renders identically"; else bad "$d pages render differently"; fi
120
+ }
121
+
122
+ check_pdfx() {
123
+ if python3 - "$1" <<'PY'
124
+ import sys
125
+ import pikepdf
126
+ pdf = pikepdf.open(sys.argv[1])
127
+ version = str(pdf.trailer.Info.get("/GTS_PDFXVersion", ""))
128
+ sys.exit(0 if version == "PDF/X-1a:2001" and pdf.Root.get("/OutputIntents") else 1)
129
+ PY
130
+ then ok "PDF/X-1a:2001 with an output intent"; else bad "not marked PDF/X-1a:2001 with an output intent"; fi
131
+ }
132
+
133
+ compared=0
134
+ for file in "$NAME.epub" "$NAME-kindle.epub" "$NAME-arc.epub" "$NAME-kindle-arc.epub" "$NAME.html" "$NAME.docx" "$NAME.pdf" "$NAME$PDFX_SUFFIX.pdf"; do
135
+ if [ ! -f "$BEFORE/$file" ] && [ ! -f "$AFTER/$file" ]; then continue; fi
136
+ echo "### $file"
137
+ if [ ! -f "$BEFORE/$file" ] || [ ! -f "$AFTER/$file" ]; then bad "only in $([ -f "$BEFORE/$file" ] && echo before || echo after)"; continue; fi
138
+ compared=$((compared + 1))
139
+ case "$file" in
140
+ *.epub|*.docx) compare_zip "$file" ;;
141
+ *.html) if cmp -s "$BEFORE/$file" "$AFTER/$file"; then ok "identical"; else bad "differs"; diff <(tr '>' '\n' < "$BEFORE/$file") <(tr '>' '\n' < "$AFTER/$file") | head -20; fi ;;
142
+ "$NAME$PDFX_SUFFIX.pdf") compare_pdf "$file"; check_pdfx "$AFTER/$file" ;;
143
+ *.pdf) compare_pdf "$file" ;;
144
+ esac
145
+ done
146
+
147
+ echo
148
+ if [ "$compared" -eq 0 ]; then echo "Parity: no outputs to compare in $BEFORE and $AFTER."; exit 1; fi
149
+ if [ "$fail" -eq 0 ]; then echo "Parity: all $compared outputs match."; else echo "Parity: FAILED (see ✗ above)."; fi
150
+ exit "$fail"