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,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"
|
|
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"
|